From 4b7f8565980479e9751a0900672a5a6589abdd4b Mon Sep 17 00:00:00 2001 From: Denis Akimov Date: Mon, 14 Sep 2026 11:47:39 -0700 Subject: [PATCH 01/12] feat(services): exit when the supervisor does Stdin EOF is each worker's usual "my parent is gone" signal, but it only arrives once every holder of the pipe closes it, and an Electron helper that outlives the app inherits that descriptor. A first PAIR instance survived its own Electron, held the worker ports, and crash-looped the next instance's workers to "restart budget exhausted", which left the UI with an empty node list. Watching the original parent pid directly is what actually guarantees no orphan is left holding a port. Signed-off-by: Denis Akimov --- services/lmstudio-proxy/main.go | 7 + services/nvpair-cluster-manager/main.go | 7 + services/nvpair-engine-manager/main.go | 7 + services/nvpair-errors/main.go | 7 + services/nvpair-job-scheduler/main.go | 7 + services/nvpair-manual-nodes/main.go | 7 + services/nvpair-node-info/main.go | 7 + services/nvpair-node-scanner/main.go | 7 + services/nvpair-node-settings/main.go | 7 + services/nvpair-ui-broker/main.go | 7 + services/nvpair-workload-manager/main.go | 7 + services/ollama-proxy/main.go | 7 + services/shared/parentwatch/parentwatch.go | 123 ++++++++++++++++++ .../shared/parentwatch/parentwatch_test.go | 86 ++++++++++++ 14 files changed, 293 insertions(+) create mode 100644 services/shared/parentwatch/parentwatch.go create mode 100644 services/shared/parentwatch/parentwatch_test.go diff --git a/services/lmstudio-proxy/main.go b/services/lmstudio-proxy/main.go index 846ac9cb..14640319 100644 --- a/services/lmstudio-proxy/main.go +++ b/services/lmstudio-proxy/main.go @@ -16,6 +16,7 @@ import ( "nvpair-shared/applog" "nvpair-shared/clustertrust" + "nvpair-shared/parentwatch" ) func main() { @@ -51,6 +52,12 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Stdin EOF is this process's usual "my parent is gone" signal, but it is + // only delivered once EVERY holder of the pipe closes it -- and an Electron + // helper that outlives the app inherits that descriptor. Watching the parent + // directly is what actually guarantees no orphan is left holding a port. + defer parentwatch.Start("lmstudio-proxy", cancel)() + sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { diff --git a/services/nvpair-cluster-manager/main.go b/services/nvpair-cluster-manager/main.go index 0860b3e9..b0919c6a 100644 --- a/services/nvpair-cluster-manager/main.go +++ b/services/nvpair-cluster-manager/main.go @@ -15,6 +15,7 @@ import ( "syscall" "nvpair-shared/applog" + "nvpair-shared/parentwatch" ) func main() { @@ -49,6 +50,12 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Stdin EOF is this process's usual "my parent is gone" signal, but it is + // only delivered once EVERY holder of the pipe closes it -- and an Electron + // helper that outlives the app inherits that descriptor. Watching the parent + // directly is what actually guarantees no orphan is left holding a port. + defer parentwatch.Start("nvpair-cluster-manager", cancel)() + sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { diff --git a/services/nvpair-engine-manager/main.go b/services/nvpair-engine-manager/main.go index 4a9b5765..34650754 100644 --- a/services/nvpair-engine-manager/main.go +++ b/services/nvpair-engine-manager/main.go @@ -20,6 +20,7 @@ import ( "nvpair-shared/appdir" "nvpair-shared/applog" "nvpair-shared/clustertrust" + "nvpair-shared/parentwatch" ) // bundledManifests are the default engine manifests compiled into the @@ -65,6 +66,12 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Stdin EOF is this process's usual "my parent is gone" signal, but it is + // only delivered once EVERY holder of the pipe closes it -- and an Electron + // helper that outlives the app inherits that descriptor. Watching the parent + // directly is what actually guarantees no orphan is left holding a port. + defer parentwatch.Start("nvpair-engine-manager", cancel)() + sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { diff --git a/services/nvpair-errors/main.go b/services/nvpair-errors/main.go index 14202698..f485d7ed 100644 --- a/services/nvpair-errors/main.go +++ b/services/nvpair-errors/main.go @@ -16,6 +16,7 @@ import ( "nvpair-shared/applog" "nvpair-shared/clustertrust" + "nvpair-shared/parentwatch" ) func main() { @@ -52,6 +53,12 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Stdin EOF is this process's usual "my parent is gone" signal, but it is + // only delivered once EVERY holder of the pipe closes it -- and an Electron + // helper that outlives the app inherits that descriptor. Watching the parent + // directly is what actually guarantees no orphan is left holding a port. + defer parentwatch.Start("nvpair-errors", cancel)() + sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { diff --git a/services/nvpair-job-scheduler/main.go b/services/nvpair-job-scheduler/main.go index 357a060f..267a4a99 100644 --- a/services/nvpair-job-scheduler/main.go +++ b/services/nvpair-job-scheduler/main.go @@ -16,6 +16,7 @@ import ( "time" "nvpair-shared/applog" + "nvpair-shared/parentwatch" ) // defaultInterval is the scheduler's default recompute cadence (spec §4). @@ -52,6 +53,12 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Stdin EOF is this process's usual "my parent is gone" signal, but it is + // only delivered once EVERY holder of the pipe closes it -- and an Electron + // helper that outlives the app inherits that descriptor. Watching the parent + // directly is what actually guarantees no orphan is left holding a port. + defer parentwatch.Start("nvpair-job-scheduler", cancel)() + sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { diff --git a/services/nvpair-manual-nodes/main.go b/services/nvpair-manual-nodes/main.go index 3334b5ab..c3b57749 100644 --- a/services/nvpair-manual-nodes/main.go +++ b/services/nvpair-manual-nodes/main.go @@ -16,6 +16,7 @@ import ( "nvpair-shared/applog" "nvpair-shared/clustertrust" + "nvpair-shared/parentwatch" ) func main() { @@ -68,6 +69,12 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Stdin EOF is this process's usual "my parent is gone" signal, but it is + // only delivered once EVERY holder of the pipe closes it -- and an Electron + // helper that outlives the app inherits that descriptor. Watching the parent + // directly is what actually guarantees no orphan is left holding a port. + defer parentwatch.Start("nvpair-manual-nodes", cancel)() + sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { diff --git a/services/nvpair-node-info/main.go b/services/nvpair-node-info/main.go index 0b1f34c0..c126755e 100644 --- a/services/nvpair-node-info/main.go +++ b/services/nvpair-node-info/main.go @@ -24,6 +24,7 @@ import ( "nvpair-shared/clustertrust" "nvpair-shared/nodeid" "nvpair-shared/noderec" + "nvpair-shared/parentwatch" "nvpair-shared/splitlisten" ) @@ -517,6 +518,12 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Stdin EOF is this process's usual "my parent is gone" signal, but it is + // only delivered once EVERY holder of the pipe closes it -- and an Electron + // helper that outlives the app inherits that descriptor. Watching the parent + // directly is what actually guarantees no orphan is left holding a port. + defer parentwatch.Start("nvpair-node-info", cancel)() + // Keep membership converging on its own when cluster-gated. Every other gate // here is refreshed by the request handler, but the TLS personality is chosen // during the handshake — which happens BEFORE any handler runs — so without an diff --git a/services/nvpair-node-scanner/main.go b/services/nvpair-node-scanner/main.go index 9c8d9b09..15633a74 100644 --- a/services/nvpair-node-scanner/main.go +++ b/services/nvpair-node-scanner/main.go @@ -16,6 +16,7 @@ import ( "nvpair-shared/applog" "nvpair-shared/clustertrust" + "nvpair-shared/parentwatch" ) func main() { @@ -72,6 +73,12 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Stdin EOF is this process's usual "my parent is gone" signal, but it is + // only delivered once EVERY holder of the pipe closes it -- and an Electron + // helper that outlives the app inherits that descriptor. Watching the parent + // directly is what actually guarantees no orphan is left holding a port. + defer parentwatch.Start("nvpair-node-scanner", cancel)() + sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { diff --git a/services/nvpair-node-settings/main.go b/services/nvpair-node-settings/main.go index 10976fb1..1ec9d555 100644 --- a/services/nvpair-node-settings/main.go +++ b/services/nvpair-node-settings/main.go @@ -17,6 +17,7 @@ import ( "nvpair-shared/appdir" "nvpair-shared/applog" + "nvpair-shared/parentwatch" ) // defaultSettingsPath returns the canonical on-disk location for @@ -68,6 +69,12 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Stdin EOF is this process's usual "my parent is gone" signal, but it is + // only delivered once EVERY holder of the pipe closes it -- and an Electron + // helper that outlives the app inherits that descriptor. Watching the parent + // directly is what actually guarantees no orphan is left holding a port. + defer parentwatch.Start("nvpair-node-settings", cancel)() + sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { diff --git a/services/nvpair-ui-broker/main.go b/services/nvpair-ui-broker/main.go index ced0d783..3c45cf0d 100644 --- a/services/nvpair-ui-broker/main.go +++ b/services/nvpair-ui-broker/main.go @@ -18,6 +18,7 @@ import ( "nvpair-shared/appdir" "nvpair-shared/applog" + "nvpair-shared/parentwatch" ) func main() { @@ -236,6 +237,12 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Stdin EOF is this process's usual "my parent is gone" signal, but it is + // only delivered once EVERY holder of the pipe closes it -- and an Electron + // helper that outlives the app inherits that descriptor. Watching the parent + // directly is what actually guarantees no orphan is left holding a port. + defer parentwatch.Start("nvpair-ui-broker", cancel)() + sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { diff --git a/services/nvpair-workload-manager/main.go b/services/nvpair-workload-manager/main.go index 24712bf7..8f65ce37 100644 --- a/services/nvpair-workload-manager/main.go +++ b/services/nvpair-workload-manager/main.go @@ -17,6 +17,7 @@ import ( "nvpair-shared/applog" "nvpair-shared/nodeid" + "nvpair-shared/parentwatch" ) // defaultPort is the fixed inter-node HTTP port (spec §7.2) the local events @@ -71,6 +72,12 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Stdin EOF is this process's usual "my parent is gone" signal, but it is + // only delivered once EVERY holder of the pipe closes it -- and an Electron + // helper that outlives the app inherits that descriptor. Watching the parent + // directly is what actually guarantees no orphan is left holding a port. + defer parentwatch.Start("nvpair-workload-manager", cancel)() + sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { diff --git a/services/ollama-proxy/main.go b/services/ollama-proxy/main.go index afd147dd..b8cfc6ec 100644 --- a/services/ollama-proxy/main.go +++ b/services/ollama-proxy/main.go @@ -17,6 +17,7 @@ import ( "nvpair-shared/applog" "nvpair-shared/clustertrust" + "nvpair-shared/parentwatch" ) type aliasAddressFlags []string @@ -62,6 +63,12 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Stdin EOF is this process's usual "my parent is gone" signal, but it is + // only delivered once EVERY holder of the pipe closes it -- and an Electron + // helper that outlives the app inherits that descriptor. Watching the parent + // directly is what actually guarantees no orphan is left holding a port. + defer parentwatch.Start("ollama-proxy", cancel)() + sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { diff --git a/services/shared/parentwatch/parentwatch.go b/services/shared/parentwatch/parentwatch.go new file mode 100644 index 00000000..92b89612 --- /dev/null +++ b/services/shared/parentwatch/parentwatch.go @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +// Package parentwatch ends a subprocess when the process that started it goes +// away, so a crashed or force-quit parent cannot leave a service running. +// +// Why this exists rather than relying on stdin closing: +// +// Every service here already reads JSON-RPC from stdin and treats EOF as "my +// parent is gone". That is the right signal and it usually works, but it is not +// sufficient, because EOF only arrives when the LAST holder of the pipe's write +// end closes it. An Electron app spawns helper processes that inherit the open +// descriptors of the main process; when the main process dies but a helper +// lingers, the pipe stays open, no EOF is ever delivered, and the whole service +// tree keeps running. +// +// That is not hypothetical. It stranded a full 12-process tree for over an hour: +// the app was quit, its helpers outlived it, the broker never saw EOF, and the +// orphans went on holding ports 14318-14323. The next launch could not bind +// them, so four workers crash-looped until the supervisor gave up, and the UI +// showed an empty node list because the cluster-manager it needed was one of +// them. Nothing in the logs said "orphan"; it looked like a cluster fault. +// +// Watching the parent directly has none of that fragility. It does not care how +// many descriptors are open or who inherited them: on Unix an orphan is +// reparented (to init/launchd), so a changed parent pid IS the death of the +// original parent, observed from the child. +package parentwatch + +import ( + "log/slog" + "os" + "time" +) + +// pollInterval is how often the parent is checked. +// +// One second: an orphan holding a port blocks the next launch, so minutes of +// lag would be user-visible, while the check itself is a getppid() call -- +// cheaper than the logging one line of output costs. +const pollInterval = time.Second + +// exitGrace is how long a graceful shutdown gets before the process is ended +// outright. +// +// Graceful-only is not enough here. The broker that stranded the tree ignored +// SIGTERM as well as EOF -- its shutdown path was waiting on a parent that no +// longer existed -- so a watchdog that merely asks nicely can leave exactly the +// orphan it was added to prevent. Five seconds is longer than any clean +// shutdown on this path takes and far shorter than a user waits before +// launching again. +const exitGrace = 5 * time.Second + +// Start watches the calling process's parent and runs onOrphaned once, in its +// own goroutine, when that parent goes away. It returns a stop function. +// +// The original parent pid is captured at call time and compared, rather than +// testing for pid 1. A service started by a launcher that is ALREADY init -- +// nohup, a launchd job, a detached test harness -- would look orphaned from its +// first instant under a pid-1 test and exit immediately. Comparing against the +// pid we actually started under makes "my parent changed" mean what it says. +// +// A process whose parent is init at startup is therefore never watched: it has +// no parent to outlive, and there is nothing to detect. +func Start(name string, shutdown func()) (stop func()) { + return start(name, os.Getppid(), pollInterval, exitGrace, shutdown, os.Exit) +} + +// start is Start with the parent pid and interval injected, so a test can drive +// it without spawning real processes. +func start( + name string, + originalPPID int, + interval, grace time.Duration, + shutdown func(), + exit func(int), +) func() { + done := make(chan struct{}) + if originalPPID <= 1 { + // Already parentless (or unknowable). Nothing to watch for. + slog.Debug("parent watch not started; no parent to outlive", "service", name) + return func() {} + } + + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-ticker.C: + if os.Getppid() == originalPPID { + continue + } + slog.Warn("parent process is gone; shutting down to avoid orphaning", + "service", name, "originalParent", originalPPID, "now", os.Getppid()) + shutdown() + // Backstop: a shutdown that wedges must not become the orphan + // this exists to prevent. Exit 0 -- being orphaned is not a + // failure of this process, and a non-zero code would read as a + // crash to whatever restarts it. + select { + case <-done: + case <-time.After(grace): + slog.Warn("graceful shutdown did not finish; exiting", + "service", name, "grace", grace) + exit(0) + } + return + } + } + }() + + var stopped bool + return func() { + if stopped { + return + } + stopped = true + close(done) + } +} diff --git a/services/shared/parentwatch/parentwatch_test.go b/services/shared/parentwatch/parentwatch_test.go new file mode 100644 index 00000000..6a378252 --- /dev/null +++ b/services/shared/parentwatch/parentwatch_test.go @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package parentwatch + +import ( + "os" + "testing" + "time" +) + +// The whole point: a parent that goes away must take its children with it. +// os.Getppid() will not equal a pid we invent, so this is exactly the +// "my parent changed" condition an orphan observes. +func TestFiresWhenTheParentIsGone(t *testing.T) { + fired := make(chan struct{}) + stop := start("test", os.Getppid()+100000, time.Millisecond, time.Hour, func() { close(fired) }, func(int) {}) + defer stop() + + select { + case <-fired: + case <-time.After(2 * time.Second): + t.Fatal("parent went away and nothing fired; the process would be orphaned") + } +} + +func TestQuietWhileTheParentIsAlive(t *testing.T) { + fired := make(chan struct{}) + stop := start("test", os.Getppid(), time.Millisecond, time.Hour, func() { close(fired) }, func(int) {}) + defer stop() + + select { + case <-fired: + t.Fatal("shut down while the parent was still alive") + case <-time.After(150 * time.Millisecond): + } +} + +// A service launched by something that is already init -- nohup, a launchd job, +// a detached harness -- has no parent to outlive. Testing for pid 1 instead of +// comparing against the pid we started under would make those exit instantly. +func TestDoesNotWatchWhenThereIsNoParentToOutlive(t *testing.T) { + for _, ppid := range []int{1, 0, -1} { + fired := make(chan struct{}) + stop := start("test", ppid, time.Millisecond, time.Hour, func() { close(fired) }, func(int) {}) + select { + case <-fired: + t.Errorf("started with ppid %d and shut itself down; it has no parent to lose", ppid) + case <-time.After(50 * time.Millisecond): + } + stop() + } +} + +func TestStopIsIdempotentAndHaltsTheWatch(t *testing.T) { + fired := make(chan struct{}, 1) + stop := start("test", os.Getppid()+100000, 20*time.Millisecond, time.Hour, func() { fired <- struct{}{} }, func(int) {}) + stop() + stop() // must not panic on a double close + + select { + case <-fired: + t.Fatal("fired after stop") + case <-time.After(100 * time.Millisecond): + } +} + +// A shutdown that never finishes is how the original orphan survived: it +// ignored SIGTERM too. The watchdog must end the process itself rather than +// wait forever on a graceful path that is not coming. +func TestExitsWhenGracefulShutdownWedges(t *testing.T) { + exited := make(chan int, 1) + stop := start("test", os.Getppid()+100000, time.Millisecond, 20*time.Millisecond, + func() { /* wedged: never completes */ }, + func(code int) { exited <- code }) + defer stop() + + select { + case code := <-exited: + if code != 0 { + t.Errorf("exit code %d; being orphaned is not a crash", code) + } + case <-time.After(2 * time.Second): + t.Fatal("shutdown wedged and the process was left running -- the orphan this prevents") + } +} From 8ab975645c1c3196388dbd5bc954fc1e7db42164 Mon Sep 17 00:00:00 2001 From: Denis Akimov Date: Mon, 14 Sep 2026 11:47:39 -0700 Subject: [PATCH 02/12] feat(mlx-pool): one model per process with LRU eviction mlx_lm.server holds exactly one model and offers no unload, so a pool of single-model child processes is what makes eviction possible at all: ending a child is the unload. A model serving a request is refcounted and never evicted. Signed-off-by: Denis Akimov --- services/mlx-pool/README.md | 88 ++++++ services/mlx-pool/bench/switch_ab.py | 82 ++++++ services/mlx-pool/go.mod | 7 + services/mlx-pool/main.go | 166 +++++++++++ services/mlx-pool/pool.go | 409 +++++++++++++++++++++++++++ services/mlx-pool/pool_test.go | 237 ++++++++++++++++ services/mlx-pool/proc_unix.go | 30 ++ services/mlx-pool/proc_windows.go | 24 ++ services/mlx-pool/server.go | 307 ++++++++++++++++++++ 9 files changed, 1350 insertions(+) create mode 100644 services/mlx-pool/README.md create mode 100644 services/mlx-pool/bench/switch_ab.py create mode 100644 services/mlx-pool/go.mod create mode 100644 services/mlx-pool/main.go create mode 100644 services/mlx-pool/pool.go create mode 100644 services/mlx-pool/pool_test.go create mode 100644 services/mlx-pool/proc_unix.go create mode 100644 services/mlx-pool/proc_windows.go create mode 100644 services/mlx-pool/server.go diff --git a/services/mlx-pool/README.md b/services/mlx-pool/README.md new file mode 100644 index 00000000..314f094e --- /dev/null +++ b/services/mlx-pool/README.md @@ -0,0 +1,88 @@ + + +# mlx-pool + +An OpenAI-compatible front end that keeps **up to N** `mlx_lm.server` processes +alive and routes each request to the one holding the requested model, evicting +the least recently used when a new model is needed. + +## Why it exists + +`mlx_lm.server` holds exactly one model. Asking it for another unloads the +weights and loads the new ones — measured at **7.2 s between two small models**, +and far worse for large ones. PAIR's router works around that by preferring a +node that already holds the model, but on a single machine, or when every node +is busy with something else, someone still has to pay the swap. + +This moves that decision from "whoever asks last wins" to a bounded cache: the +models you actually alternate between stay resident, and only the (N+1)th one +costs a load. + +## Why a separate binary rather than logic in engine-manager + +`nvpair-engine-manager`'s contract is that adding an engine is a JSON manifest, +not code. A pool that spawns and evicts child processes is engine-specific by +nature, so putting it there would be the first exception to that rule. + +Instead PAIR starts *this* as the engine: one process, one port, the same +`/health` and `/v1/models` and `/v1/chat/completions` surface `mlx_lm.server` +offers. Nothing upstream — the manifest schema, `mlx-proxy`, the broker, the +desktop app — knows the difference. + +## How many models + +`--max-models`, defaulting to `$MLX_MAX_MODELS`, defaulting to 2. The MLX +manifest sets it in `runtime.env`, so a per-user override is a two-line file: + +```json +{ "engine": "mlx", "runtime": { "env": { "MLX_MAX_MODELS": "3" } } } +``` + +dropped in the per-user `engines/` directory, where it deep-merges onto the +bundled manifest. + +**The cap is a count, not a memory budget.** Two 27B models will exhaust a +machine that three 4B models would not. Size it for the models you actually +run; there is no accounting here that can save you from setting it to 4 on a +16 GB Mac. + +## Surface + +| Route | Behaviour | +|---|---| +| `GET /health` | `{"status":"ok","models":[{"id":…}]}` — every resident model, most recently used first. Answers immediately, with an empty list, before any child exists. | +| `GET /v1/models` | The downloadable catalogue, scanned from the Hugging Face cache directly so it works with no child running. | +| `POST /v1/chat/completions`, `/v1/completions` | Routed to the child holding `model`, spawning or evicting as needed. | +| anything else | 404 | + +## What it costs at `--max-models 1` + +The shipped default is **1**, and at 1 this component is *slower* than the thing +it wraps. Measured on an M5 Max, alternating two small models, 8 switches +(`bench/switch_ab.py`): + +| arm | median switch | min | max | +|---|---|---|---| +| `mlx-pool --max-models 1` | **1.62 s** | 1.24 s | 1.80 s | +| plain `mlx_lm.server` (in-process swap) | **0.61 s** | 0.42 s | 0.79 s | + +**+1.01 s per switch.** That is a Python interpreter start, imports and server +bootstrap that an in-process unload/reload does not pay. + +Two things to read alongside it. The overhead is **fixed**, not proportional: +these are 1B and 0.5B models chosen precisely because they make the pool look +worst — the load itself is under a second, so startup dominates. Against the +3-bit 27B, whose load is ~10 s, the same overhead is under 10%. + +And at N=1 the pool buys one real thing: **deterministic reclamation**. Evicting +kills the process, so the weights are returned to the OS rather than left to +Python and MLX to release. On a box already at the edge of its memory that is +the difference between a swap and an OOM. + +But the honest summary is that N=1 is a *safety* setting, not a performance one. +The component earns its keep at **N ≥ 2**, where a request for an already-hot +model costs nothing at all instead of a full reload — which is the case the +router's residency preference is trying to create in the first place. diff --git a/services/mlx-pool/bench/switch_ab.py b/services/mlx-pool/bench/switch_ab.py new file mode 100644 index 00000000..1d603d6a --- /dev/null +++ b/services/mlx-pool/bench/switch_ab.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +# SPDX-License-Identifier: Apache-2.0 +"""Is mlx-pool at --max-models 1 worse than plain mlx_lm.server? + +At N=1 the pool cannot keep a second model warm, so every cross-model request +pays a process teardown, a Python start, imports, server bootstrap and health +polling -- where mlx_lm.server pays only its own unload/reload inside a live +interpreter. That is a real objection and it deserves a number, not an opinion. + +Both arms alternate the same two models over the same sequence, back to back. +""" +import json, statistics, subprocess, sys, time, socket, urllib.request + +A = "mlx-community/Llama-3.2-1B-Instruct-4bit" +B = "mlx-community/Qwen2.5-0.5B-Instruct-4bit" +ROUNDS = 4 + + +def free_port(): + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def ask(port, model, timeout=900): + body = json.dumps({"model": model, "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 1, "temperature": 0}).encode() + req = urllib.request.Request(f"http://127.0.0.1:{port}/v1/chat/completions", data=body, + headers={"Content-Type": "application/json"}) + t0 = time.time() + with urllib.request.urlopen(req, timeout=timeout) as r: + json.load(r) + return time.time() - t0 + + +def wait_up(port, limit=120): + for _ in range(limit * 4): + try: + urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=2).read() + return True + except Exception: + time.sleep(0.25) + return False + + +def run(argv, port): + proc = subprocess.Popen(argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + if not wait_up(port): + return None + ask(port, A) # warm: both arms start holding A, so only switches are timed + out = [] + for _ in range(ROUNDS): + out.append(ask(port, B)) + out.append(ask(port, A)) + return out + finally: + proc.terminate() + try: + proc.wait(60) + except Exception: + proc.kill() + + +pool_bin, server_bin = sys.argv[1], sys.argv[2] +p1, p2 = free_port(), free_port() + +pool = run([pool_bin, "--port", str(p1), "--child-port-base", "8300", + "--max-models", "1", "--server-bin", server_bin], p1) +plain = run([server_bin, "--host", "127.0.0.1", "--port", str(p2), "--log-level", "ERROR"], p2) + +print(f"\n{2 * ROUNDS} model switches, same two models, alternating\n") +print(f"{'arm':<40}{'median':>9}{'min':>9}{'max':>9}") +for label, s in (("mlx-pool --max-models 1", pool), ("plain mlx_lm.server (in-process swap)", plain)): + if not s: + print(f"{label:<40} never came up") + continue + print(f"{label:<40}{statistics.median(s):>8.2f}s{min(s):>8.2f}s{max(s):>8.2f}s") +if pool and plain: + d = statistics.median(pool) - statistics.median(plain) + print(f"\npool costs {d:+.2f}s per switch vs the in-process swap") diff --git a/services/mlx-pool/go.mod b/services/mlx-pool/go.mod new file mode 100644 index 00000000..449b9db6 --- /dev/null +++ b/services/mlx-pool/go.mod @@ -0,0 +1,7 @@ +module mlx-pool + +go 1.25.0 + +require nvpair-shared v0.0.0-00010101000000-000000000000 + +replace nvpair-shared => ../shared diff --git a/services/mlx-pool/main.go b/services/mlx-pool/main.go new file mode 100644 index 00000000..7939f764 --- /dev/null +++ b/services/mlx-pool/main.go @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "nvpair-shared/parentwatch" +) + +// Version is stamped at build time via -ldflags "-X main.Version=...". +var Version = "dev" + +// multiFlag collects a repeatable string flag. +type multiFlag []string + +func (m *multiFlag) String() string { return strings.Join(*m, ",") } +func (m *multiFlag) Set(v string) error { + *m = append(*m, v) + return nil +} + +// envInt reads an integer from the environment, so the count of resident models +// is configurable through the manifest's runtime.env — a map, which the per-user +// manifest override deep-merges cleanly, unlike an argv array which it would +// have to replace wholesale. +func envInt(key string, def int) int { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return n + } + slog.Warn("ignoring unusable value", "env", key, "value", v, "using", def) + } + return def +} + +// defaultModelsDir is where a hand-built model most often lands. Scanned by +// default because a model you quantized yourself is invisible otherwise, and an +// absent directory costs one failed readdir. +func defaultModelsDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, "models") +} + +// envDirs reads a colon-separated list, expanding a leading ~. +func envDirs(key, fallback string) []string { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + if fallback == "" { + return nil + } + return []string{fallback} + } + var out []string + for _, d := range strings.Split(raw, ":") { + d = strings.TrimSpace(d) + if d == "" { + continue + } + if strings.HasPrefix(d, "~/") { + if home, err := os.UserHomeDir(); err == nil { + d = filepath.Join(home, d[2:]) + } + } + out = append(out, d) + } + return out +} + +func defaultHFCache() string { + if v := strings.TrimSpace(os.Getenv("HF_HOME")); v != "" { + return filepath.Join(v, "hub") + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".cache", "huggingface", "hub") +} + +func main() { + host := flag.String("host", "127.0.0.1", "listen address") + port := flag.Int("port", 8081, "listen port: the single port PAIR treats as the MLX engine") + maxModels := flag.Int("max-models", envInt("MLX_MAX_MODELS", 2), + "how many models may be resident at once; the least recently used is evicted for a new one ($MLX_MAX_MODELS)") + serverBin := flag.String("server-bin", "", "path to the mlx_lm.server entry point (required)") + childBase := flag.Int("child-port-base", 8200, "first port to place a model server on") + readyWait := flag.Duration("ready-timeout", 20*time.Minute, "how long a model may take to become ready") + hfCache := flag.String("hf-cache", defaultHFCache(), "Hugging Face hub cache to list models from") + var modelDirs multiFlag + flag.Var(&modelDirs, "models-dir", + "a directory of models kept outside the Hugging Face cache, advertised by absolute path (repeatable; $MLX_MODELS_DIRS, colon separated)") + var extras multiFlag + flag.Var(&extras, "extra-model", + "a model outside the Hugging Face cache to advertise, e.g. a local directory (repeatable)") + registeredFile := flag.String("registered-models-file", "", + "file of model paths added through the UI, one per line, re-read per catalogue request") + serverFlags := flag.String("server-flags", "", "extra flags passed to every mlx_lm.server child, space separated") + showVersion := flag.Bool("version", false, "print version and exit") + flag.Parse() + + if *showVersion { + fmt.Println(Version) + return + } + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))) + + if strings.TrimSpace(*serverBin) == "" { + slog.Error("--server-bin is required (the mlx_lm.server entry point inside the engine's virtualenv)") + os.Exit(2) + } + if _, err := os.Stat(*serverBin); err != nil { + slog.Error("mlx_lm.server not found", "path", *serverBin, "err", err) + os.Exit(2) + } + + pool := NewPool(*serverBin, strings.Fields(*serverFlags), *maxModels, *childBase, *readyWait) + if len(modelDirs) == 0 { + modelDirs = envDirs("MLX_MODELS_DIRS", defaultModelsDir()) + } + srv := (&Server{pool: pool, hfCache: *hfCache, extra: extras, modelDirs: modelDirs, + registeredFile: *registeredFile}).Listen(fmt.Sprintf("%s:%d", *host, *port)) + + slog.Info("mlx-pool listening", "addr", srv.Addr, "max_models", *maxModels, + "server_bin", *serverBin, "hf_cache", *hfCache, "extra_models", len(extras), "model_dirs", modelDirs, "version", Version) + + // Children hold gigabytes of weights, so shutdown stops them explicitly + // rather than relying on the parent's exit to reap them. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + // An orphaned pool is the costliest process here to leave behind: it holds a + // model resident, so the weights stay in memory and :8081 stays bound until + // someone finds it by hand. Losing the parent shuts it down exactly the way + // SIGTERM does. + defer parentwatch.Start("mlx-pool", stop)() + go func() { + <-ctx.Done() + slog.Info("shutting down; stopping every model") + pool.StopAll() + shutCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + _ = srv.Shutdown(shutCtx) + }() + + if err := srv.ListenAndServe(); err != nil && ctx.Err() == nil { + slog.Error("server exited", "err", err) + pool.StopAll() + os.Exit(1) + } + pool.StopAll() +} diff --git a/services/mlx-pool/pool.go b/services/mlx-pool/pool.go new file mode 100644 index 00000000..a067b5d0 --- /dev/null +++ b/services/mlx-pool/pool.go @@ -0,0 +1,409 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "os/exec" + "sort" + "sync" + "time" +) + +// child is one mlx_lm.server process, pinned to a single model for its lifetime. +// +// One model per process rather than reusing a process and switching models: a +// switch inside mlx_lm.server is exactly the unload/reload this component exists +// to avoid, and a process that only ever holds one model can be evicted by +// killing it, which releases the weights deterministically. mlx-lm has no +// "unload" API to call instead. +type child struct { + model string + port int + cmd *exec.Cmd + used time.Time // LRU position; touched on every request routed here + inuse int // in-flight requests; a child is never evicted while > 0 + // exit is closed-by-send when cmd.Wait returns. One waiter goroutine per + // child, started at spawn: calling cmd.Wait twice is an error, and both the + // readiness check and the stop path need to know when the process is gone. + exit chan error +} + +// exitCh returns a channel that yields once the process has been reaped. +// Buffered and single-shot, so a second read (ready-check then stop) still +// returns rather than blocking forever. +func (c *child) exitCh() <-chan error { + if c.exit == nil { + ch := make(chan error, 1) + close(ch) + return ch + } + return c.exit +} + +// Pool keeps up to max children alive, evicting the least recently used. +// +// Two locks on purpose, because one lock is a liveness bug: +// +// - mu guards the maps and is held only for map operations. /health reads +// through it, so it answers in microseconds even while a 27B is loading. +// Holding a single lock across the load instead would block the health +// probe for minutes, and PAIR would conclude the engine had died and +// restart it -- killing the very load it was waiting for. +// - loadMu serialises the slow path. Two large models loading at once on a +// memory-bound machine is the thing most likely to take the box down, and +// serialising them also makes the cap trivially correct: only one goroutine +// is ever evicting-then-spawning. +// +// inflight deduplicates concurrent requests for the same cold model, so ten +// simultaneous first-requests produce one process, not ten. +type Pool struct { + mu sync.Mutex + children map[string]*child + inflight map[string]*loadOp + freed *sync.Cond // signalled when a child's last request finishes + max int + + loadMu sync.Mutex + + serverBin string + serverFlags []string + portBase int + readyWait time.Duration +} + +// loadOp is one in-progress load that later arrivals wait on rather than repeat. +type loadOp struct { + done chan struct{} + port int + err error +} + +func NewPool(serverBin string, serverFlags []string, max, portBase int, readyWait time.Duration) *Pool { + if max < 1 { + max = 1 + } + p := &Pool{ + children: map[string]*child{}, inflight: map[string]*loadOp{}, + max: max, serverBin: serverBin, serverFlags: serverFlags, + portBase: portBase, readyWait: readyWait, + } + p.freed = sync.NewCond(&p.mu) + return p +} + +// Resident lists the models currently held, most recently used first. This is +// what /health reports and therefore what PAIR's routing keys on. +func (p *Pool) Resident() []string { + p.mu.Lock() + defer p.mu.Unlock() + out := make([]*child, 0, len(p.children)) + for _, c := range p.children { + out = append(out, c) + } + sort.Slice(out, func(i, j int) bool { return out[i].used.After(out[j].used) }) + models := make([]string, len(out)) + for i, c := range out { + models[i] = c.model + } + return models +} + +// Acquire returns the port serving model and a release func the caller MUST +// call when its request finishes. The child is pinned against eviction until +// then, so a streaming response cannot be terminated by a concurrent request +// for a different model. +func (p *Pool) Acquire(ctx context.Context, model string) (int, func(), error) { + // Fast path: already resident. Takes mu only. + p.mu.Lock() + if c, ok := p.children[model]; ok { + c.used = time.Now() + c.inuse++ + p.mu.Unlock() + return c.port, p.releaser(model), nil + } + // Someone else is already loading exactly this model: wait for them. + if op, ok := p.inflight[model]; ok { + p.mu.Unlock() + select { + case <-op.done: + case <-ctx.Done(): + return 0, nil, ctx.Err() + } + if op.err != nil { + return 0, nil, op.err + } + return p.Acquire(ctx, model) + } + op := &loadOp{done: make(chan struct{})} + p.inflight[model] = op + p.mu.Unlock() + + // Deliberately NOT the request's context. A 27B can take minutes; a client + // that gives up must not abort the load, or a model slow enough to time out + // could never finish loading -- every attempt would kill the last one's + // progress. The work is shared: later arrivals join through inflight above, + // and readyWait still bounds it. + loadCtx, cancelLoad := context.WithTimeout(context.Background(), p.readyWait) + port, err := p.load(loadCtx, model) + cancelLoad() + + p.mu.Lock() + op.port, op.err = port, err + delete(p.inflight, model) + close(op.done) + p.mu.Unlock() + + if err != nil { + return 0, nil, err + } + return p.Acquire(ctx, model) +} + +func (p *Pool) releaser(model string) func() { + var once sync.Once + return func() { + once.Do(func() { + p.mu.Lock() + if c, ok := p.children[model]; ok { + if c.inuse > 0 { + c.inuse-- + } + // Recency is "last touched", not "last admitted": a generation + // that ran for minutes must not come out of it looking older + // than a model that has been idle throughout. + c.used = time.Now() + } + p.freed.Broadcast() + p.mu.Unlock() + }) + } +} + +// load evicts if necessary and spawns. Serialised by loadMu: the caller has +// already claimed model in inflight, so only distinct models reach here at once. +func (p *Pool) load(ctx context.Context, model string) (int, error) { + p.loadMu.Lock() + defer p.loadMu.Unlock() + + for { + p.mu.Lock() + if len(p.children) < p.max { + p.mu.Unlock() + break + } + victim := p.evictableLocked() + if victim == nil { + // Every resident model is mid-request. Waiting is right: killing a + // child to serve a new model would abort someone's in-flight + // generation, and at max-models=1 that is the common case rather + // than a corner one. + slog.Info("pool full and every model is busy; waiting for a request to finish", + "want", model, "max_models", p.max) + if err := p.waitFreeLocked(ctx); err != nil { + p.mu.Unlock() + return 0, err + } + p.mu.Unlock() + continue + } + slog.Info("evicting least recently used model", "model", victim.model, "port", victim.port, + "idle", time.Since(victim.used).Round(time.Second), "want", model, "max_models", p.max) + p.mu.Unlock() + // Outside mu: stopping waits for the process to exit, which must not + // block /health. It is still inside loadMu, so no other load races it. + p.stop(victim) + } + + port, err := p.freePort() + if err != nil { + return 0, err + } + c, err := p.spawn(ctx, model, port) + if err != nil { + return 0, err + } + p.mu.Lock() + p.children[model] = c + p.mu.Unlock() + return port, nil +} + +// waitFreeLocked blocks until a request finishes. Caller holds mu; it is +// released while waiting. ctx cancellation is surfaced by a watchdog broadcast. +func (p *Pool) waitFreeLocked(ctx context.Context) error { + stop := context.AfterFunc(ctx, func() { + p.mu.Lock() + p.freed.Broadcast() + p.mu.Unlock() + }) + defer stop() + p.freed.Wait() + return ctx.Err() +} + +func (p *Pool) evictableLocked() *child { + var oldest *child + for _, c := range p.children { + if c.inuse > 0 { + continue + } + if oldest == nil || c.used.Before(oldest.used) { + oldest = c + } + } + return oldest +} + +// freePort picks a port for a new child. The bind test is advisory -- another +// process can take it between the check and the child's own bind -- so a child +// that fails to start is retried on the next candidate by the caller's error +// path rather than treated as fatal. +func (p *Pool) freePort() (int, error) { + p.mu.Lock() + taken := make(map[int]bool, len(p.children)) + for _, c := range p.children { + taken[c.port] = true + } + p.mu.Unlock() + for port := p.portBase; port < p.portBase+64; port++ { + if taken[port] { + continue + } + l, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + continue + } + l.Close() + return port, nil + } + return 0, fmt.Errorf("no free port in [%d,%d)", p.portBase, p.portBase+64) +} + +func (p *Pool) spawn(ctx context.Context, model string, port int) (*child, error) { + args := append([]string{ + "--model", model, "--host", "127.0.0.1", "--port", fmt.Sprint(port), + }, p.serverFlags...) + + cmd := exec.Command(p.serverBin, args...) + cmd.Env = os.Environ() + cmd.Stdout = os.Stderr // the child's logs are ours; our stdout stays clean + cmd.Stderr = os.Stderr + configureSysProcAttr(cmd) + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("start mlx_lm.server for %q: %w", model, err) + } + slog.Info("loading model", "model", model, "port", port, "pid", cmd.Process.Pid) + + c := &child{model: model, port: port, cmd: cmd, used: time.Now()} + // Surface an early exit (a bad model id, a missing file) instead of waiting + // out the whole ready timeout on a process that is already gone. + exited := make(chan error, 1) + go func() { exited <- cmd.Wait() }() + c.exit = exited + + if err := waitReady(ctx, port, p.readyWait, exited); err != nil { + p.stop(c) + return nil, fmt.Errorf("model %q did not become ready: %w", model, err) + } + slog.Info("model ready", "model", model, "port", port) + return c, nil +} + +// stop terminates a child and waits for it, so its memory is released before a +// replacement is spawned. Without the wait, an eviction made to free room could +// overlap the load it made room for -- the one moment both models are resident, +// on a machine that by definition could not hold both. +func (p *Pool) stop(c *child) { + p.mu.Lock() + delete(p.children, c.model) + p.mu.Unlock() + + if c.cmd == nil || c.cmd.Process == nil { + return + } + terminate(c.cmd) + select { + case <-c.exitCh(): + case <-time.After(30 * time.Second): + slog.Warn("model did not exit in time; killing", "model", c.model, "pid", c.cmd.Process.Pid) + _ = c.cmd.Process.Kill() + <-c.exitCh() + } + slog.Info("stopped model", "model", c.model, "port", c.port) +} + +// Unload releases one model's weights on demand, without waiting for another +// load to evict it. +// +// mlx-lm itself has no unload API -- which is why the engine long advertised no +// Eject at all -- but a pool child holds exactly one model for its lifetime, so +// ending the process IS the unload, and it frees the memory deterministically. +// +// Refuses while requests are in flight rather than killing mid-stream: the same +// rule eviction follows, and a user asking to free memory does not expect it to +// truncate somebody's answer. Unloading a model that is not resident is not an +// error -- the caller wanted it gone, and it is. +func (p *Pool) Unload(model string) error { + p.mu.Lock() + c, ok := p.children[model] + if !ok { + p.mu.Unlock() + return nil + } + if c.inuse > 0 { + inuse := c.inuse + p.mu.Unlock() + return fmt.Errorf("model %q is serving %d request(s); it will unload when they finish", model, inuse) + } + p.mu.Unlock() + + // stop() takes mu itself to unlink the child, then waits for the process to + // exit outside the lock -- so /health keeps answering while a 27B tears down. + p.stop(c) + return nil +} + +// StopAll tears the pool down so no orphan is left holding GPU memory. +func (p *Pool) StopAll() { + p.mu.Lock() + all := make([]*child, 0, len(p.children)) + for _, c := range p.children { + all = append(all, c) + } + p.mu.Unlock() + for _, c := range all { + p.stop(c) + } +} + +func waitReady(ctx context.Context, port int, within time.Duration, exited <-chan error) error { + deadline := time.Now().Add(within) + url := fmt.Sprintf("http://127.0.0.1:%d/health", port) + client := &http.Client{Timeout: 3 * time.Second} + for { + select { + case err := <-exited: + return fmt.Errorf("process exited before becoming ready: %v", err) + case <-ctx.Done(): + return ctx.Err() + default: + } + if resp, err := client.Get(url); err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + if time.Now().After(deadline) { + return fmt.Errorf("no /health within %s", within) + } + time.Sleep(250 * time.Millisecond) + } +} diff --git a/services/mlx-pool/pool_test.go b/services/mlx-pool/pool_test.go new file mode 100644 index 00000000..f211ad70 --- /dev/null +++ b/services/mlx-pool/pool_test.go @@ -0,0 +1,237 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +// A pool that never spawns anything real: enough to exercise the bookkeeping, +// which is where the concurrency bugs live. +func testPool(t *testing.T, max int) *Pool { + t.Helper() + return NewPool("/nonexistent", nil, max, 19000, time.Second) +} + +func (p *Pool) addFake(model string, port int, used time.Time) { + p.mu.Lock() + p.children[model] = &child{model: model, port: port, used: used} + p.mu.Unlock() +} + +func TestResidentIsMostRecentlyUsedFirst(t *testing.T) { + p := testPool(t, 3) + now := time.Now() + p.addFake("old", 1, now.Add(-time.Hour)) + p.addFake("new", 2, now) + p.addFake("mid", 3, now.Add(-time.Minute)) + got := p.Resident() + want := []string{"new", "mid", "old"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("Resident() = %v, want %v", got, want) + } + } +} + +// The eviction victim must be the least recently USED, not the first loaded -- +// a FIFO would evict a model that is being alternated with. +func TestEvictsLeastRecentlyUsedNotOldestLoaded(t *testing.T) { + p := testPool(t, 2) + now := time.Now() + p.addFake("a", 1, now.Add(-time.Hour)) // loaded first, but touched recently below + p.addFake("b", 2, now.Add(-time.Minute)) + p.mu.Lock() + p.children["a"].used = now // "a" was just used + victim := p.evictableLocked() + p.mu.Unlock() + if victim == nil || victim.model != "b" { + t.Fatalf("victim = %v, want b (the least recently used)", victim) + } +} + +// A child serving a request must never be chosen for eviction: at max-models=1 +// a second request for another model would otherwise kill a live generation. +func TestBusyChildIsNeverEvicted(t *testing.T) { + p := testPool(t, 1) + p.addFake("busy", 1, time.Now().Add(-time.Hour)) + p.mu.Lock() + p.children["busy"].inuse = 1 + victim := p.evictableLocked() + p.mu.Unlock() + if victim != nil { + t.Fatalf("victim = %q, want none: the only child is serving a request", victim.model) + } +} + +// Acquire on a resident model must not touch the slow path at all, so /health +// and other models stay responsive while something big is loading. +func TestAcquireResidentIsImmediateAndRefcounts(t *testing.T) { + p := testPool(t, 2) + p.addFake("hot", 4321, time.Now()) + + done := make(chan struct{}) + go func() { + defer close(done) + port, release, err := p.Acquire(context.Background(), "hot") + if err != nil || port != 4321 { + t.Errorf("Acquire(hot) = %d, %v", port, err) + return + } + p.mu.Lock() + inuse := p.children["hot"].inuse + p.mu.Unlock() + if inuse != 1 { + t.Errorf("inuse = %d, want 1 while the request is in flight", inuse) + } + release() + release() // must be idempotent; a double release would underflow + p.mu.Lock() + inuse = p.children["hot"].inuse + p.mu.Unlock() + if inuse != 0 { + t.Errorf("inuse = %d after release, want 0", inuse) + } + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Acquire on a resident model blocked; it must not take the load path") + } +} + +// The catalogue must list what mlx_lm.server would list, and nothing else: an +// embedding or BERT repo also carries a config and a tokenizer config, so the +// weight index is what separates a servable model from one that cannot load. +func TestScanHFCacheRequiresAWeightIndex(t *testing.T) { + root := t.TempDir() + mk := func(repo string, files ...string) { + dir := filepath.Join(root, repo, "snapshots", "abc") + os.MkdirAll(dir, 0o755) + for _, f := range files { + os.WriteFile(filepath.Join(dir, f), []byte("{}"), 0o644) + } + } + mk("models--mlx-community--Good", "config.json", "model.safetensors.index.json", "tokenizer_config.json") + mk("models--org--BertLike", "config.json", "tokenizer_config.json") // no index + mk("models--org--Empty") + + got := scanHFCache(root) + if len(got) != 1 || got[0] != "mlx-community/Good" { + t.Fatalf("scanHFCache = %v, want [mlx-community/Good]", got) + } +} + +// A model you quantized yourself never enters the Hugging Face cache, so a +// cache-only catalogue cannot show it — which is exactly what made a local 27B +// build invisible in the UI while it was the model actually being served. +func TestScanModelsDirFindsLocalBuilds(t *testing.T) { + root := t.TempDir() + mk := func(name string, files ...string) { + d := filepath.Join(root, name) + os.MkdirAll(d, 0o755) + for _, f := range files { + os.WriteFile(filepath.Join(d, f), []byte("{}"), 0o644) + } + } + // A local quantization: single shard, so no weight index. Requiring one + // here (as the cache scan does) would hide it. + mk("Qwen3.8-27B-3bit", "config.json", "tokenizer_config.json", "model.safetensors") + mk("TokenizerJsonOnly", "config.json", "tokenizer.json") + mk("NotAModel", "README.md") + mk("ConfigButNoTokenizer", "config.json") + os.WriteFile(filepath.Join(root, "loose-file.bin"), []byte("x"), 0o644) + + got := scanModelsDir(root) + want := map[string]bool{ + filepath.Join(root, "Qwen3.8-27B-3bit"): true, + filepath.Join(root, "TokenizerJsonOnly"): true, + } + if len(got) != len(want) { + t.Fatalf("scanModelsDir = %v, want %d entries", got, len(want)) + } + for _, g := range got { + if !want[g] { + t.Errorf("unexpected entry %q", g) + } + } + // Advertised by absolute path, because that is what a request must name. + for _, g := range got { + if !filepath.IsAbs(g) { + t.Errorf("%q is not an absolute path", g) + } + } +} + +// A path the user registered by hand must be offered exactly when it is still +// servable: MLX has no catalogue, so this list is the only way a model outside +// the cache and outside a scanned directory can be named at all. +func TestReadRegisteredSkipsEntriesThatAreNoLongerModels(t *testing.T) { + root := t.TempDir() + mk := func(name string, files ...string) string { + d := filepath.Join(root, name) + os.MkdirAll(d, 0o755) + for _, f := range files { + os.WriteFile(filepath.Join(d, f), []byte("{}"), 0o644) + } + return d + } + good := mk("Good", "config.json", "tokenizer_config.json") + noTok := mk("NoTokenizer", "config.json") + gone := filepath.Join(root, "Deleted") + + reg := filepath.Join(root, "registered-models.txt") + os.WriteFile(reg, []byte( + "# a comment\n\n"+good+"\n"+noTok+"\n"+gone+"\n "+good+" \n"), 0o644) + + got := readRegistered(reg) + // good appears twice in the file (once padded with whitespace); the + // catalogue dedupes, so both are returned here and collapse in handleModels. + for _, g := range got { + if g != good { + t.Errorf("offered %q, which is not a servable model directory", g) + } + } + if len(got) == 0 { + t.Fatalf("readRegistered dropped a valid entry") + } + // A missing file is not an error: the engine must still list everything else. + if r := readRegistered(filepath.Join(root, "nope.txt")); r != nil { + t.Errorf("missing registry file returned %v, want nil", r) + } +} + +// Unload is the Eject the UI offers. mlx-lm has no unload API, so the pool's +// answer is to end the child holding the model -- which must actually free it, +// and must not cut off a request that is mid-flight. +func TestUnloadReleasesAModelButNotOneInUse(t *testing.T) { + p := NewPool("/nonexistent", nil, 2, 8300, time.Second) + busy := &child{model: "busy", port: 8301, used: time.Now(), inuse: 1} + idle := &child{model: "idle", port: 8302, used: time.Now()} + p.children["busy"] = busy + p.children["idle"] = idle + + if err := p.Unload("busy"); err == nil { + t.Error("unloaded a model with a request in flight; that truncates the response") + } + if _, still := p.children["busy"]; !still { + t.Error("an in-use model was dropped from the pool anyway") + } + + if err := p.Unload("idle"); err != nil { + t.Errorf("Unload(idle) = %v, want nil", err) + } + if _, still := p.children["idle"]; still { + t.Error("an unloaded model is still resident; its weights were not freed") + } + + // Asking for memory back that is already back is what the caller wanted. + if err := p.Unload("never-loaded"); err != nil { + t.Errorf("Unload of a model that is not resident = %v, want nil", err) + } +} diff --git a/services/mlx-pool/proc_unix.go b/services/mlx-pool/proc_unix.go new file mode 100644 index 00000000..ac6ecb11 --- /dev/null +++ b/services/mlx-pool/proc_unix.go @@ -0,0 +1,30 @@ +//go:build !windows + +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os/exec" + "syscall" +) + +// configureSysProcAttr puts each child in its own process group so a signal +// reaches the whole tree. mlx_lm.server is a console entry point that may hold +// helper processes; signalling only the leader can leave weights resident. +func configureSysProcAttr(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} + +// terminate asks the child's whole group to exit. SIGTERM, not SIGKILL: mlx-lm +// releases Metal buffers on a clean exit, and an escalation path exists in the +// caller for a child that ignores it. +func terminate(cmd *exec.Cmd) { + if cmd.Process == nil { + return + } + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM); err != nil { + _ = cmd.Process.Signal(syscall.SIGTERM) + } +} diff --git a/services/mlx-pool/proc_windows.go b/services/mlx-pool/proc_windows.go new file mode 100644 index 00000000..fcb4a6e0 --- /dev/null +++ b/services/mlx-pool/proc_windows.go @@ -0,0 +1,24 @@ +//go:build windows + +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os/exec" + "syscall" +) + +// configureSysProcAttr hides the console window a child would otherwise flash. +// MLX does not run on Windows, so this exists only to keep the package building +// on every platform the rest of the tree builds on. +func configureSysProcAttr(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} +} + +func terminate(cmd *exec.Cmd) { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } +} diff --git a/services/mlx-pool/server.go b/services/mlx-pool/server.go new file mode 100644 index 00000000..d46ef28f --- /dev/null +++ b/services/mlx-pool/server.go @@ -0,0 +1,307 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +// maxBodyBytes bounds a request we buffer to read its "model" field. +// +// Buffering happens BEFORE admission, so every request waiting on a cold load +// holds its body for the whole wait -- at 64 MiB a handful of queued requests +// was a real amplification point. 8 MiB is far above any realistic chat payload +// (roughly two million tokens of text) while keeping that pool bounded. +const maxBodyBytes = 8 << 20 + +type modelEntry struct { + ID string `json:"id"` + Object string `json:"object"` +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +// handleHealth reports every resident model, most recently used first. +// +// It answers before any child exists, with an empty list. That matters more than +// it looks: PAIR's readiness probe hits this, so the engine must read as "up" +// while holding nothing — otherwise starting the engine would require loading a +// model first, and the whole pool would be un-startable from cold. +func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { + models := s.pool.Resident() + entries := make([]modelEntry, len(models)) + for i, m := range models { + entries[i] = modelEntry{ID: m, Object: "model"} + } + // `model` is the most recently used one, kept for parity with + // mlx_lm.server's own /health so a client written against that still works. + var mru any + if len(models) > 0 { + mru = models[0] + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ok", "model": mru, "models": entries, "max_models": s.pool.max, + }) +} + +// handleUnload releases a resident model's memory on request. +// +// mlx-lm has no unload of its own; a pool child is one model for its lifetime, +// so ending it is the unload. Body is {"model": ""}. Unloading something not +// resident succeeds: the caller wanted the memory back and it is already back. +func (s *Server) handleUnload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + Model string `json:"model"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req); err != nil || req.Model == "" { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "model is required"}) + return + } + if err := s.pool.Unload(req.Model); err != nil { + // In use, not invalid: the caller can retry once the stream finishes. + writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"unloaded": req.Model}) +} + +// handleModels lists the downloadable catalogue. +// +// Scanned from the Hugging Face cache here rather than forwarded to a child, +// because with no child running there is nobody to forward to — and "what can I +// load" is exactly the question asked when nothing is loaded. +func (s *Server) handleModels(w http.ResponseWriter, _ *http.Request) { + entries := make([]modelEntry, 0) + seen := map[string]bool{} + candidates := scanHFCache(s.hfCache) + for _, dir := range s.modelDirs { + candidates = append(candidates, scanModelsDir(dir)...) + } + candidates = append(candidates, readRegistered(s.registeredFile)...) + for _, id := range append(candidates, s.extra...) { + if id == "" || seen[id] { + continue + } + seen[id] = true + entries = append(entries, modelEntry{ID: id, Object: "model"}) + } + writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": entries}) +} + +// scanModelsDir lists model directories held OUTSIDE the Hugging Face cache. +// +// A model you built yourself -- a local quantization, say -- has no repo id and +// never enters the cache, so a catalogue that only scans the cache cannot show +// it. mlx_lm.server has the same blind spot and papers over it by advertising +// its single --model path; a pool has no single model, so it scans instead. +// +// Entries are advertised by absolute path, which is exactly what a request must +// name to load them. +func scanModelsDir(dir string) []string { + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var out []string + for _, e := range entries { + if !e.IsDir() { + continue + } + p := filepath.Join(dir, e.Name()) + if !isModelDir(p) { + continue + } + out = append(out, p) + } + return out +} + +// isModelDir reports whether a directory holds something mlx_lm can serve. +// +// No weight-index requirement, unlike the cache scan: a single-shard local +// build legitimately has only model.safetensors. Shared with the registered-path +// list so a path added through the UI is judged by exactly the same test as one +// found by scanning -- a path that passes here is one mlx_lm.server can load. +func isModelDir(p string) bool { + if st, err := os.Stat(p); err != nil || !st.IsDir() { + return false + } + if !hasAll(p, "config.json") { + return false + } + return hasAll(p, "tokenizer_config.json") || hasAll(p, "tokenizer.json") +} + +// readRegistered returns the model paths a user added by hand, one per line. +// +// Read on every catalogue request rather than at startup so a path registered +// through the UI shows up without restarting the engine. Entries that no longer +// pass isModelDir are skipped rather than reported: a model directory the user +// has since deleted or moved should quietly stop being offered, not break the +// catalogue for every other model. +func readRegistered(path string) []string { + if path == "" { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + var out []string + for _, line := range strings.Split(string(data), "\n") { + p := strings.TrimSpace(line) + if p == "" || strings.HasPrefix(p, "#") || !isModelDir(p) { + continue + } + out = append(out, p) + } + return out +} + +// scanHFCache returns the repo ids of cached models that look like a servable +// MLX model, mirroring the test mlx_lm.server applies: a repo is a candidate +// when its snapshot carries a config, a weight index and a tokenizer config. +func scanHFCache(root string) []string { + hub, err := os.ReadDir(root) + if err != nil { + return nil + } + var out []string + for _, e := range hub { + name := e.Name() + if !e.IsDir() || !strings.HasPrefix(name, "models--") { + continue + } + snaps, err := os.ReadDir(filepath.Join(root, name, "snapshots")) + if err != nil { + continue + } + for _, snap := range snaps { + dir := filepath.Join(root, name, "snapshots", snap.Name()) + // The same three files mlx_lm.server tests for. The weight INDEX + // specifically is what separates a servable LLM from the embedding + // and BERT models that share a cache and also carry a config and a + // tokenizer config -- without it the catalogue offers models that + // cannot be loaded. + if !hasAll(dir, "config.json", "model.safetensors.index.json", "tokenizer_config.json") { + continue + } + // repo id: models--org--name -> org/name + out = append(out, strings.ReplaceAll(strings.TrimPrefix(name, "models--"), "--", "/")) + break + } + } + return out +} + +func hasAll(dir string, files ...string) bool { + for _, f := range files { + if _, err := os.Stat(filepath.Join(dir, f)); err != nil { + return false + } + } + return true +} + +// handleInference routes a completion to the child holding its model. +func (s *Server) handleInference(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes)) + if err != nil { + http.Error(w, "cannot read request body", http.StatusBadRequest) + return + } + var probe struct { + Model string `json:"model"` + } + if err := json.Unmarshal(body, &probe); err != nil || strings.TrimSpace(probe.Model) == "" { + http.Error(w, `request must name a "model"`, http.StatusBadRequest) + return + } + + // The release pins the child against eviction for the whole exchange, + // including a streamed response: without it a concurrent request for another + // model could kill the process mid-generation, and at max-models=1 that is + // the ordinary case rather than a corner one. + port, release, err := s.pool.Acquire(r.Context(), probe.Model) + if err != nil { + slog.Warn("could not serve model", "model", probe.Model, "err", err) + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()}) + return + } + defer release() + + target, _ := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", port)) + proxy := httputil.NewSingleHostReverseProxy(target) + // Streaming responses must not be buffered, or a token stream arrives as one + // blob when the generation ends. + proxy.FlushInterval = -1 + proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) { + slog.Warn("upstream model failed", "model", probe.Model, "port", port, "err", err) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + } + r.Body = io.NopCloser(bytes.NewReader(body)) + r.ContentLength = int64(len(body)) + proxy.ServeHTTP(w, r) +} + +// Server is the single listener PAIR sees as "the MLX engine". +type Server struct { + pool *Pool + hfCache string + // extra are models that live outside the Hugging Face cache -- a directory + // of weights, typically. mlx_lm.server advertises its own --model path the + // same way; a pool has no single --model, so they are declared instead. + // Loading one never needed this: any id in a request is passed straight + // through. This is only so the catalogue can show them. + extra []string + // modelDirs are directories of models kept outside the cache, scanned on + // each request so a model built while the engine is running appears without + // a restart. + modelDirs []string + // registeredFile lists model paths added by hand through the UI, one per + // line. There is no MLX catalogue to browse -- the hub is empty for this + // engine -- so a model that is neither in the cache nor under a scanned + // directory can only be named. Re-read per request, like modelDirs. + registeredFile string +} + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/health", s.handleHealth) + mux.HandleFunc("/v1/models", s.handleModels) + mux.HandleFunc("/unload", s.handleUnload) + mux.HandleFunc("/v1/chat/completions", s.handleInference) + mux.HandleFunc("/v1/completions", s.handleInference) + return mux +} + +func (s *Server) Listen(addr string) *http.Server { + return &http.Server{ + Addr: addr, + Handler: s.Handler(), + // No write timeout: a large model's first token can be minutes away, and + // a streamed completion is open for as long as it generates. + ReadHeaderTimeout: 15 * time.Second, + } +} From 605fe29e8d0c9276af2fa10a87d3aafb2d82024a Mon Sep 17 00:00:00 2001 From: Denis Akimov Date: Mon, 14 Sep 2026 11:47:39 -0700 Subject: [PATCH 03/12] feat(mlx-proxy): route to a node that already holds the model Because a node holds one model at a time, sending a request to a node that must reload first is the expensive mistake -- a measured 7.2s. Routing to an owner that already has the model resident, and falling back to on-disk owners only when nobody does, moves resident hits from 31.8% to 100.0% against the load-only arm. go test -run TestRoutingPolicyAB runs both arms back to back. Signed-off-by: Denis Akimov --- services/mlx-proxy/README.md | 499 +++++ services/mlx-proxy/activity_test.go | 80 + services/mlx-proxy/bench/reload_cost.py | 124 ++ services/mlx-proxy/choose_reachable_test.go | 271 +++ services/mlx-proxy/cluster_trust_test.go | 81 + services/mlx-proxy/codec.go | 18 + services/mlx-proxy/discovery.go | 172 ++ services/mlx-proxy/e2e_test.go | 217 ++ services/mlx-proxy/failover_test.go | 600 ++++++ services/mlx-proxy/go.mod | 19 + services/mlx-proxy/go.sum | 35 + services/mlx-proxy/ingress.go | 165 ++ services/mlx-proxy/ingress_test.go | 111 + services/mlx-proxy/ipc.go | 12 + services/mlx-proxy/main.go | 102 + services/mlx-proxy/portstore.go | 81 + services/mlx-proxy/portstore_test.go | 145 ++ services/mlx-proxy/priority_test.go | 177 ++ services/mlx-proxy/proxy.go | 2104 +++++++++++++++++++ services/mlx-proxy/proxy_residency_test.go | 50 + services/mlx-proxy/proxy_test.go | 63 + services/mlx-proxy/reservation_test.go | 235 +++ services/mlx-proxy/routing_ab_test.go | 108 + services/mlx-proxy/server_timeout_test.go | 43 + services/mlx-proxy/subscribed_test.go | 98 + services/mlx-proxy/transport.go | 25 + services/mlx-proxy/transport_pool_test.go | 76 + services/mlx-proxy/zombie_test.go | 494 +++++ 28 files changed, 6205 insertions(+) create mode 100644 services/mlx-proxy/README.md create mode 100644 services/mlx-proxy/activity_test.go create mode 100755 services/mlx-proxy/bench/reload_cost.py create mode 100644 services/mlx-proxy/choose_reachable_test.go create mode 100644 services/mlx-proxy/cluster_trust_test.go create mode 100644 services/mlx-proxy/codec.go create mode 100644 services/mlx-proxy/discovery.go create mode 100644 services/mlx-proxy/e2e_test.go create mode 100644 services/mlx-proxy/failover_test.go create mode 100644 services/mlx-proxy/go.mod create mode 100644 services/mlx-proxy/go.sum create mode 100644 services/mlx-proxy/ingress.go create mode 100644 services/mlx-proxy/ingress_test.go create mode 100644 services/mlx-proxy/ipc.go create mode 100644 services/mlx-proxy/main.go create mode 100644 services/mlx-proxy/portstore.go create mode 100644 services/mlx-proxy/portstore_test.go create mode 100644 services/mlx-proxy/priority_test.go create mode 100644 services/mlx-proxy/proxy.go create mode 100644 services/mlx-proxy/proxy_residency_test.go create mode 100644 services/mlx-proxy/proxy_test.go create mode 100644 services/mlx-proxy/reservation_test.go create mode 100644 services/mlx-proxy/routing_ab_test.go create mode 100644 services/mlx-proxy/server_timeout_test.go create mode 100644 services/mlx-proxy/subscribed_test.go create mode 100644 services/mlx-proxy/transport.go create mode 100644 services/mlx-proxy/transport_pool_test.go create mode 100644 services/mlx-proxy/zombie_test.go diff --git a/services/mlx-proxy/README.md b/services/mlx-proxy/README.md new file mode 100644 index 00000000..6cfbbd16 --- /dev/null +++ b/services/mlx-proxy/README.md @@ -0,0 +1,499 @@ + + +# MLX Proxy + +A discovery-aware HTTP reverse proxy for MLX nodes on the local network. It runs no mDNS browse of its own: its routing targets come from the broker's discovery relay (it sends `discovery:subscribe {services:[mx]}` and replaces its routing overlay from each pushed `discovery:nodes` snapshot) plus user-added manual nodes. It forwards HTTP requests to the selected node, aggregates the model-list route across candidate nodes, and exposes a bidirectional JSON-RPC 2.0 control channel over stdio (or an IPC socket). + +> **Clone of `lmstudio-proxy`.** This proxy is a deliberate clone of [`lmstudio-proxy`](../lmstudio-proxy/README.md), which is itself a clone of [`ollama-proxy`](../ollama-proxy/README.md), so all three share identical failover, CORS, and node-selection behavior — the CORS policy is literally the same code, `nvpair-shared/cors`, and is documented [there](../ollama-proxy/README.md#http-reverse-proxy). The engine-specific differences are the usual ones: it subscribes to the discovery relay for `mx` nodes, forwards the OpenAI-compatible inference routes (`/v1/chat/completions`, `/v1/completions`, `/v1/embeddings`), tags workloads `mlx`, and persists its port to its own file. It has no `--alias-address`, so its self-forward guard covers only its own listener. +> +> **One behaviour is not inherited: residency-preferring routing.** See below. It is the only place this binary and its siblings disagree, and it exists because `mlx_lm.server` holds one model at a time. + +## Build + +```bash +go build -o mlx-proxy . +``` + +## Usage + +``` +mlx-proxy [flags] +``` + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--port` | `1234` | HTTP listen port for request forwarding | +| `--ignore-persisted-port` | `false` | Use `--port` even when a prior runtime port was saved | +| `--ipc` | *(empty — use stdio)* | Path to a Unix domain socket or Windows named pipe for IPC | +| `--cluster-dir` | *(empty)* | Cluster trust directory (`node.crt`/`node.key` plus trusted pins). Enables the LAN mTLS inference ingress while this node is a cluster member; empty means no ingress and no peer candidates. | +| `--log-level` | *(`$NVPAIR_LOG_LEVEL`, else `info`)* | Initial log level: `debug`, `info`, `warn`, or `error`. Changeable at runtime with `log/set-level`. | +| `--version` | | Print version and exit | + +### HTTP Reverse Proxy + +The proxy listens on `--port` (default 1234) and forwards incoming HTTP requests to the currently active MLX node — except the model-list route `GET /v1/models`, which is queried across every candidate node concurrently and merged into one de-duplicated inventory. Point your OpenAI-compatible client at `http://localhost:1234` and the proxy handles routing. + +**Cluster ingress.** The listener carries two personalities, demultiplexed by each connection's first byte. Plaintext HTTP is accepted only from loopback; a LAN caller is refused. When `--cluster-dir` shows this node is a cluster member, the same listener also terminates cluster mTLS: a peer whose client certificate matches one of this node's pins is forwarded straight to the local engine reported by `node/set-local-backend`, and is never re-routed onward to another node. Membership and pins are re-derived per request, so joining or leaving a cluster needs no restart. + +**Persisted port.** A port chosen at runtime via the `set-port` request (see below) is saved as `mlx-proxy-port.json` in the per-user data dir (`%LocalAppData%\Nvidia Corporation\Personal AI Router` on Windows, `~/.config/Nvidia Corporation/Personal AI Router` on Linux) and **restored on startup**, taking precedence over `--port`/the default. One value is exempt: a stored `1235` is discarded and `--port` is used instead, so that port cannot be restored even when it was chosen deliberately via `set-port`. Any other stored port is honoured. The broker uses `--ignore-persisted-port` while reserving the managed `1234` facade. + +Node selection: +- **Eligibility**: Before routing model-bearing inference, the proxy keeps only nodes whose current MLX inventory advertises the exact requested model ID. An empty or non-matching inventory is excluded until a later discovery update; if no advertised owner is routable, the proxy returns a local `502`. +- **Auto**: When no eligible node is explicitly selected, the proxy follows `node/set-priority` (see below), then discovered nodes in stable ID order. +- **Priority (scheduler-driven)**: The Job Scheduler ranks the cluster least-loaded-first by pending workload plus smoothed GPU pressure and, via `nvpair-ui-broker`, pushes the ordered node list with those per-node counts to this proxy with `node/set-priority`. Auto routing sends the request to the listed node carrying the least estimated load. See [`nvpair-job-scheduler`](../nvpair-job-scheduler/README.md). +- **Manual**: Use the `node/select` JSON-RPC method to pin traffic to a specific node. A manual pin **overrides the priority list only when that node is eligible** for the requested model. +- **Failover**: If the selected node disappears from the discovery set, the proxy falls back to auto-select and emits a `node/selection-changed` notification. A transport error or retryable status, including a model `404` from an advertised owner with stale inventory, steps to the next eligible owner. + +### IPC Transport + +By default the proxy communicates over **stdin/stdout** using newline-delimited JSON-RPC 2.0 (one message per line). All diagnostic logging goes to **stderr**. + +For environments where stdout may conflict with the host process (e.g. Electron), pass `--ipc` to redirect the JSON-RPC channel to a named socket or pipe. The parent process should create and listen on the endpoint before spawning the proxy. + +```bash +# Default: stdin/stdout +mlx-proxy + +# Unix domain socket +mlx-proxy --ipc /tmp/mlx-proxy.sock + +# Windows named pipe +mlx-proxy --ipc \\.\pipe\mlx-proxy +``` + +## JSON-RPC 2.0 Protocol + +All messages conform to the [JSON-RPC 2.0 specification](https://www.jsonrpc.org/specification). Messages are newline-delimited (one JSON object per `\n`). + +### Node Object + +Nodes are represented throughout the protocol with this shape: + +```json +{ + "id": "22222222-2222-2222-2222-222222222222", + "host": "my-workstation", + "port": 1234, + "addresses": ["192.168.1.50"], + "txt": ["uuid=22222222-2222-2222-2222-222222222222", "lm=1234"], + "models": ["qwen2.5-7b-instruct"], + "ip": "192.168.1.50" +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Stable per-host UUID from the discovery record (the ID you supply, for a manual node) | +| `host` | string | Hostname, for display — routing never keys on it | +| `port` | int | MLX port from the discovery record's `lm` service entry | +| `addresses` | string[] | Addresses to dial. A node fed by the discovery relay always carries exactly one canonical address; several only ever appear on a manual node | +| `txt` | string[] | The discovery record's TXT pairs, carried verbatim | +| `models` | string[] | The node's MLX model inventory from the discovery snapshot. Model-bearing inference is eligible only when this list advertises the exact requested model ID. An omitted or empty list excludes the node from that request until inventory updates; it remains available for non-inference routes and model-list aggregation | +| `ip` | string | The single canonical LAN address to dial or display, resolved from the node's `ip=` TXT if present and otherwise the best-scored advertised IPv4. Stamped onto outbound `node/*` notifications so consumers agree with the address the proxy routes to | + +--- + +### Notifications (proxy → client) + +Notifications have no `id` field and do not expect a response. + +#### `ready` + +Sent after startup, before discovery begins, and again after every successful `set-port` rebind — `port` carries the port now bound. + +```json +{"jsonrpc":"2.0","method":"ready","params":{"version":"0.1.0","port":1234}} +``` + +| Param | Type | Description | +|-------|------|-------------| +| `version` | string | Proxy version | +| `port` | int | HTTP listen port | + +#### `error` + +Sent when a fatal startup condition stops the proxy from serving — currently only a failed bind — immediately before the process exits non-zero. + +```json +{"jsonrpc":"2.0","method":"error","params":{"code":"bind-failed","message":"failed to bind port 1234: ...","port":1234}} +``` + +#### `node/discovered` + +A new MLX node appeared on the network. + +```json +{"jsonrpc":"2.0","method":"node/discovered","params":{"id":"22222222-2222-2222-2222-222222222222","host":"my-workstation","port":1234,"addresses":["192.168.1.50"]}} +``` + +#### `node/updated` + +A previously discovered node changed its host, port, or addresses. + +```json +{"jsonrpc":"2.0","method":"node/updated","params":{"id":"22222222-2222-2222-2222-222222222222","host":"my-workstation","port":1234,"addresses":["192.168.1.51"]}} +``` + +#### `node/removed` + +A node is no longer present in the discovery set (it left the relay's `lm` nodes, or a manual node was removed). + +```json +{"jsonrpc":"2.0","method":"node/removed","params":{"id":"22222222-2222-2222-2222-222222222222","host":"my-workstation","port":1234,"addresses":["192.168.1.50"]}} +``` + +#### `node/selection-changed` + +The active node selection changed (either explicitly via `node/select` or because the selected node was removed). + +```json +{"jsonrpc":"2.0","method":"node/selection-changed","params":{"id":"22222222-2222-2222-2222-222222222222"}} +``` + +An empty `id` means the proxy has reverted to auto-select mode. + +#### `proxy/request-started` + +A request has been committed to a target and its response body is about to stream. It pairs by `id` with the matching `proxy/request`, so a consumer can keep an in-flight count per node. Model-list aggregation has no single target, so it reports `"target":"cluster"` with no `node_id`; a request rejected before forwarding was never in flight and gets no started event. + +```json +{"jsonrpc":"2.0","method":"proxy/request-started","params":{"id":"17","node_id":"22222222-2222-2222-2222-222222222222","method":"POST","path":"/v1/chat/completions","target":"192.168.1.50:1234"}} +``` + +#### `proxy/request` + +A proxied request finished, or was rejected before forwarding. `duration_ms` covers the whole request; `ttfb_ms` is the time to the upstream's status line and is omitted where no response header arrived (rejection and transport-error paths). `error` carries normalized error text. This is operational metadata only — request and response bodies are never reported. + +```json +{"jsonrpc":"2.0","method":"proxy/request","params":{"id":"17","node_id":"22222222-2222-2222-2222-222222222222","method":"POST","path":"/v1/chat/completions","target":"192.168.1.50:1234","status":200,"duration_ms":6120,"ttfb_ms":95}} +``` + +#### `workload:started` / `workload:completed` / `workload:errored` + +One lifecycle transition per forwarded inference request, carrying a single `workloadInfo`. `engine` is always `mlx`; `originatedFrom` is left empty for the broker to stamp, and `scheduledOn` names the node that actually served (re-pointed if failover moved the request). The broker relays these to `nvpair-workload-manager`. The proxy never emits `workload:submitted` — it forwards immediately rather than queueing. + +```json +{"jsonrpc":"2.0","method":"workload:started","params":{"workloadInfo":{"id":"17","model":"mlx-community/Qwen3-8B-GGUF","engine":"mlx","runId":"3ce8a1740b62df95","state":"running","originatedFrom":"","scheduledOn":"22222222-2222-2222-2222-222222222222","createdAt":1716998400000,"startedAt":1716998400000,"completedAt":null,"error":null,"requesterId":null}}} +``` + +#### `node/activity` + +Raised while a node's engine is streaming a response back through the proxy: every successful write of upstream body bytes reports the node that produced them. The broker relays it to `nvpair-node-scanner`, which treats it as proof of life and cancels that node's eviction — a node saturated by inference cannot answer a liveness probe, but it is demonstrably alive precisely because it is streaming. Coalesced to one report per node per 2s (`nvpair-shared/nodeactivity`), since a generation writes hundreds of chunks and the scanner treats one report as good for a minute. `msSince` is the age of the observation; the broker adds its own relay delay before passing it on. + +Only bytes that came from the upstream count. The proxy's own error bodies travel through the same writer and are never reported, because they say nothing about the node. + +```json +{"jsonrpc":"2.0","method":"node/activity","params":{"hostUuid":"22222222-2222-2222-2222-222222222222","msSince":0}} +``` + +#### `errors:report` / `errors:clear` + +Entries for the `nvpair-errors` pipeline, keyed by a stable `id` so a report and its clear cannot drift. A node dropping out of the discovery set raises `mlx-proxy:upstream-unreachable:`; its reappearance clears the same id. `nodeId` and `timestamp` are left unset for the broker to stamp. + +```json +{"jsonrpc":"2.0","method":"errors:report","params":{"id":"mlx-proxy:upstream-unreachable:22222222-2222-2222-2222-222222222222","message":"Upstream node \"my-workstation\" is no longer reachable (dropped from discovery)","severity":"warning","action":"none"}} +``` + +--- + +### Requests (client → proxy) + +Requests carry an `id` and receive a response. + +#### `nodes/list` + +Returns all currently discovered nodes. + +**Request:** +```json +{"jsonrpc":"2.0","id":1,"method":"nodes/list"} +``` + +**Response:** +```json +{"jsonrpc":"2.0","id":1,"result":{"nodes":[{"id":"22222222-2222-2222-2222-222222222222","host":"my-workstation","port":1234,"addresses":["192.168.1.50"]}]}} +``` + +#### `node/select` + +Pin the proxy to route HTTP traffic to a specific node. Pass an empty `id` to return to auto-select. + +**Request:** +```json +{"jsonrpc":"2.0","id":2,"method":"node/select","params":{"id":"22222222-2222-2222-2222-222222222222"}} +``` + +**Response:** +```json +{"jsonrpc":"2.0","id":2,"result":{"id":"22222222-2222-2222-2222-222222222222"}} +``` + +**Error** (node not found): +```json +{"jsonrpc":"2.0","id":2,"error":{"code":-32602,"message":"node \"xyz\" not found"}} +``` + +#### `node/selected` + +Query the currently selected node. + +**Request:** +```json +{"jsonrpc":"2.0","id":3,"method":"node/selected"} +``` + +**Response:** +```json +{"jsonrpc":"2.0","id":3,"result":{"id":"22222222-2222-2222-2222-222222222222"}} +``` + +An empty `id` means auto-select is active. + +#### `node/set-priority` + +Set the **auto-routing priority order** — an ordered list of node IDs, highest +priority first, optionally with each node's pending-work count and GPU pressure +in `ranks`. Delivered by `nvpair-ui-broker` on behalf of `nvpair-job-scheduler`, +which ranks the cluster least-loaded-first by total pending workload across +engines plus smoothed GPU pressure (see +[`nvpair-job-scheduler`](../nvpair-job-scheduler/README.md)). The snapshot is +stored verbatim and applied at request time. A `nodes`-only payload is valid and +supplies zero pending and GPU-pressure baselines. + +**Request:** +```json +{"jsonrpc":"2.0","id":8,"method":"node/set-priority","params":{"nodes":["MY-PC","LAB-DESK-B","GPU-RIG"],"ranks":[{"id":"MY-PC","pending":0,"gpuPressure":0,"rank":0},{"id":"LAB-DESK-B","pending":1,"gpuPressure":1,"rank":1},{"id":"GPU-RIG","pending":3,"gpuPressure":3,"rank":2}]}} +``` + +**Response:** +```json +{"jsonrpc":"2.0","id":8,"result":{"count":3}} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `count` | int | Number of node IDs stored (the length of the accepted list) | + +Semantics: +- **Capability gate.** A model-bearing inference request first intersects the + discovery snapshot with nodes advertising that exact MLX model ID. + Selection, priority, reservations, and failover operate only on that + request-local owner set. If it is empty, the proxy returns `502` without + contacting an engine. +- **Auto ordering.** Within the eligible owner set, the proxy picks the listed + node carrying the least estimated load — + `pending + gpuPressure` from the last snapshot plus the proxy's own + reservations for requests it has already dispatched but whose workload feedback + has not come back yet — breaking ties by position in `nodes`. It increments the + chosen node's reservation before forwarding, so a concurrent burst spreads + instead of repeatedly choosing from the same stale snapshot. That node moves to + the front of this request's failover list and the rest keeps its order, so a + transport error or retryable status (the existing failover trigger) steps to + the next candidate. +- **Snapshot reset.** Each new snapshot replaces the pending and GPU-pressure + baselines and clears the reservations. GPU pressure is clamped to the + scheduler's 0–3 range. +- **Eligible manual pin wins.** An active `node/select` pin takes precedence when + it is in the request's owner set. An ineligible pin is ignored for that request, + so automatic reservations still apply among eligible owners. Clearing the pin + (`node/select` with an empty `id`) activates the most-recently-set list. Setting + a priority list does **not** emit `node/selection-changed` (the manual selection + is unchanged). +- **Unknown IDs are ignored.** IDs not currently in discovery are kept in the + stored list (a node may appear later) but contribute nothing until discovered. +- **Eligible unlisted nodes are a lowest-priority fallback.** An advertised owner + absent from the list stays routable, but only after every listed owner — + ordered among themselves by the default stable ID sort. This ensures an + eligible manually-added node the scheduler never saw is never stranded. +- **Empty list reverts to default.** `{"nodes":[]}` clears the scheduler's + influence and returns the proxy to its default auto ordering (eligible + discovered nodes by stable ID). + +The list persists only in memory for the proxy's lifetime; it is not saved across +restarts. On restart the proxy comes back with an empty list, and the broker +re-pushes the last order once the proxy re-announces `ready`. + +#### `set-port` + +Change the HTTP listen port at runtime and persist the choice. The proxy +binds the new port first (so a bind failure leaves the current listener +serving), starts serving on it, then closes the old listener — in-flight +connections on the old port drain naturally. The new port is saved to +`mlx-proxy-port.json` and a fresh `ready` notification announces it. + +**Request:** +```json +{"jsonrpc":"2.0","id":7,"method":"set-port","params":{"port":1300}} +``` + +**Response:** +```json +{"jsonrpc":"2.0","id":7,"result":{"version":"0.9.0","port":1300}} +``` + +**Error** (port in use / out of range): +```json +{"jsonrpc":"2.0","id":7,"error":{"code":-32000,"message":"failed to bind port 1300: ..."}} +``` + +When supervised by `nvpair-ui-broker`, callers reach this as `proxy:set-port`, +and the broker first steers the port clear of any running engine's port +(engines take precedence) before handing it down — see the broker README. + +#### `node/add-manual` + +Add a node manually (for networks where mDNS is blocked). If the node ID already exists as a manual node, it is updated. + +**Request:** +```json +{"jsonrpc":"2.0","id":5,"method":"node/add-manual","params":{"id":"remote-server","host":"remote-server","port":1234,"addresses":["10.0.1.50"]}} +``` + +**Response:** +```json +{"jsonrpc":"2.0","id":5,"result":{"added":true}} +``` + +The proxy emits a `node/discovered` notification (or `node/updated` if the node was already registered). Manual nodes are a separate overlay that discovery snapshots never touch — they persist until explicitly removed. + +#### `node/remove-manual` + +Remove a previously added manual node. + +**Request:** +```json +{"jsonrpc":"2.0","id":6,"method":"node/remove-manual","params":{"id":"remote-server"}} +``` + +**Response:** +```json +{"jsonrpc":"2.0","id":6,"result":{"removed":true}} +``` + +The proxy emits a `node/removed` notification and clears the active selection if it pointed to this node. + +#### `node/set-local-backend` + +Tell the proxy which loopback engine this node's own traffic terminates on. The broker sends it once the local MLX address and health are known. It is the target the cluster mTLS ingress forwards to, and the substitute used when discovery advertises this node's own proxy endpoint as a candidate. A zero `port` or `"healthy":false` effectively clears it, and the ingress then answers `503`. + +**Request:** +```json +{"jsonrpc":"2.0","id":9,"method":"node/set-local-backend","params":{"engine":"mlx","host":"127.0.0.1","port":1235,"healthy":true}} +``` + +**Response:** +```json +{"jsonrpc":"2.0","id":9,"result":{"ok":true}} +``` + +#### `log/set-level` + +Change the active log level at runtime (`debug`, `info`, `warn`, `error`). Accepted as a request or a notification; as a request it responds with the resolved level and rejects an unknown one with `-32602`. + +**Request:** +```json +{"jsonrpc":"2.0","id":10,"method":"log/set-level","params":{"level":"debug"}} +``` + +**Response:** +```json +{"jsonrpc":"2.0","id":10,"result":{"level":"debug"}} +``` + +#### `shutdown` + +Gracefully shuts down the proxy. + +**Request:** +```json +{"jsonrpc":"2.0","id":4,"method":"shutdown"} +``` + +**Response:** +```json +{"jsonrpc":"2.0","id":4,"result":null} +``` + +--- + +### Error Codes + +Standard JSON-RPC 2.0 error codes apply: + +| Code | Meaning | +|------|---------| +| `-32601` | Method not found | +| `-32602` | Invalid params | +| `-32000` | The request was well-formed but could not be carried out — returned by `set-port` when the new port cannot be bound | + +--- + +## Shutdown + +The proxy shuts down gracefully on any of: + +1. **stdin EOF** — parent closes stdin (stdio mode only) +2. **`shutdown` JSON-RPC request** — programmatic shutdown +3. **SIGINT / SIGTERM** — standard OS signals + +## Discovery + +The proxy does not browse mDNS. On startup it subscribes to the broker's discovery relay for `lm` (MLX) nodes (`discovery:subscribe {services:[lm]}`). Targets then arrive as `discovery:nodes` notifications carrying the relay's full filtered node set, and each snapshot replaces the routing overlay wholesale — a departed node is simply absent from the next one — while the diff against the previous overlay is what produces the `node/discovered`, `node/updated`, and `node/removed` notifications. User-added manual nodes are merged on top. Nodes are keyed by the discovery record's stable per-host UUID, so routing survives a machine being renamed. The single `_nvpair-node` browse that feeds the relay lives in the `nvpair-node-scanner` daemon (see its README) — this proxy is a pure consumer of the resulting routing set. + +## Residency-preferring routing + +The proxies this one was cloned from choose among every node that has the +requested model **on disk**, ranked by the scheduler's least-loaded order. That +is right for Ollama and LM Studio, which keep several models resident and load +on demand. It is wrong for MLX. + +`mlx_lm.server` holds exactly one model. A request for a different one unloads +the current weights and loads the new ones before a single token is produced. +So mlx-proxy ranks owners in two tiers: + +1. **Resident owners** — the node's `loadedByEngine["mlx"]` names the requested + model. Residency comes from the peer's engine-manager, which reads it from + `GET /health`; mlx-lm sets its model key only once the weights are in, so a + node part-way through a load reports nothing and is correctly not preferred. +2. **Disk owners** — the node has the model in its Hugging Face cache. Used + only when no owner is resident. + +The fallback is the point. Routing to resident owners *alone* reads as the +stricter, thrash-free policy, and it deadlocks a cold cluster: with nothing +loaded anywhere, every node is ineligible and the first request fails on a +system that is entirely healthy. Falling back to disk owners means the first +request pays one load and every request after it lands on the node that already +paid — the cluster converges on its own, with no lease, no warm-up job, and no +coordinator to designate a loader. + +### Measuring it, not asserting it + +The rule is A/B-tested rather than argued. `NVPAIR_MLX_ROUTING=any` turns it off +and restores the inherited load-only ranking, which is the control arm. + +```bash +go test -run TestRoutingPolicyAB -v . # the routing decision +python3 bench/reload_cost.py --server \ + --model-a --model-b # what a miss physically costs +``` + +Both arms run in one invocation over the same nodes and the same request +sequence — a number saved from an earlier run is not a control. Measured on an +M5 Max, 600 requests over 3 nodes each holding one of 3 models: + +| arm | resident hits | rate | +|---|---|---| +| A residency-preferred (default) | 600 / 600 | 100.0% | +| B load-only (control) | 191 / 600 | 31.8% | + +The control's 31.8% is the ~1/3 that random selection over three models +predicts, which is the sanity check that the harness is measuring what it +claims. `reload_cost.py` puts a swap between two *small* models (1B and 0.5B, +4-bit) at a **7.2 s median**, so the policy is worth roughly `0.682 x 7.2 s ≈ +4.9 s` per request at that model size — and the reload term grows with the +weights, so a 30B swap is far worse. diff --git a/services/mlx-proxy/activity_test.go b/services/mlx-proxy/activity_test.go new file mode 100644 index 00000000..6c8374c3 --- /dev/null +++ b/services/mlx-proxy/activity_test.go @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// The two inference proxies are held to deliberate parity, so the liveness +// report a streaming response raises is asserted on both. See ollama-proxy's +// activity_test.go for the reasoning behind the signal itself. + +// TestStreamedBytesReportNodeActivity mirrors the ollama-proxy test of the same +// name. +func TestStreamedBytesReportNodeActivity(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"choices":[]}`)) + })) + defer upstream.Close() + + rec := &prRec{} + disc := NewDiscovery() + disc.AddManual(nodeForModel(t, "serving-node", upstream.URL, "qwen")) + p := NewProxy(NewCodec(rec), disc, 1235) + + p.handleHTTP(httptest.NewRecorder(), + httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"qwen"}`))) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && !rec.has(`"method":"node/activity"`) { + time.Sleep(5 * time.Millisecond) + } + if !rec.has(`"method":"node/activity"`) { + t.Fatal("no node/activity was reported after the upstream streamed a response") + } + if !rec.has(`"hostUuid":"serving-node"`) { + t.Fatal("node/activity did not name the node that served the request") + } +} + +// A node that never wrote a response byte has proved nothing and must not be +// vouched for. +func TestNoActivityReportedWithoutUpstreamBytes(t *testing.T) { + rec := &prRec{} + disc := NewDiscovery() + // A port nothing is listening on: the dial fails, so no upstream byte can + // ever reach the client. It still has to advertise the requested model, or + // candidate pruning rejects the request before anything is dialled and the + // test passes without exercising the dial failure at all. + disc.AddManual(Node{ + ID: "dead-node", + Addresses: []string{"127.0.0.1"}, + Port: closedPortFor(t), + Models: []string{"qwen"}, + }) + p := NewProxy(NewCodec(rec), disc, 1235) + + p.handleHTTP(httptest.NewRecorder(), + httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"qwen"}`))) + + if rec.has(`"method":"node/activity"`) { + t.Fatal("activity was reported for a node that never answered") + } +} + +// closedPortFor returns a port nothing is listening on, by binding and releasing +// it, so the dial is a prompt refusal rather than a timeout. +func closedPortFor(t *testing.T) int { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + port := nodeFor(t, "probe", srv.URL).Port + srv.Close() + return port +} diff --git a/services/mlx-proxy/bench/reload_cost.py b/services/mlx-proxy/bench/reload_cost.py new file mode 100755 index 00000000..43b9c4fa --- /dev/null +++ b/services/mlx-proxy/bench/reload_cost.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +# SPDX-License-Identifier: Apache-2.0 +"""Measure what a model switch costs a single mlx_lm.server, in seconds. + +This is the physical constant behind mlx-proxy's residency-preferring routing. +TestRoutingPolicyAB (in the parent directory) measures how often each routing +arm lands a request on a node that already holds the model; this measures what +NOT landing on one costs. They multiply: + + latency saved per request = (miss-rate delta) x (reload seconds) + +Method: alternate two models against one server. A request for the resident +model is a hit; a request for the other forces a full unload and reload. Both +are one-token completions, so generation time is negligible and the difference +is the swap. Hits and misses are interleaved rather than batched so that thermal +state, page cache, and any background load fall on both equally. + +Usage: + python3 reload_cost.py --server \\ + --model-a mlx-community/Llama-3.2-1B-Instruct-4bit \\ + --model-b mlx-community/Qwen2.5-0.5B-Instruct-4bit +""" + +import argparse +import json +import socket +import statistics +import subprocess +import sys +import time +import urllib.error +import urllib.request + + +def free_port(): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def post(port, model, timeout=600): + body = json.dumps({ + "model": model, + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 1, + "temperature": 0, + "stream": False, + }).encode() + req = urllib.request.Request( + f"http://127.0.0.1:{port}/v1/chat/completions", + data=body, headers={"Content-Type": "application/json"}, + ) + started = time.time() + with urllib.request.urlopen(req, timeout=timeout) as resp: + json.load(resp) + return time.time() - started + + +def resident(port): + with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=10) as resp: + return json.load(resp).get("model") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--server", required=True, help="path to the mlx_lm.server entry point") + ap.add_argument("--model-a", required=True) + ap.add_argument("--model-b", required=True) + ap.add_argument("--rounds", type=int, default=5) + args = ap.parse_args() + + port = free_port() + proc = subprocess.Popen( + [args.server, "--host", "127.0.0.1", "--port", str(port), "--log-level", "ERROR"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + try: + for _ in range(120): + try: + resident(port) + break + except Exception: + time.sleep(0.5) + else: + raise SystemExit("mlx_lm.server never became healthy") + + # Warm up: the first load of each model also pays for reading weights + # off cold disk, which is not what a steady-state swap costs. + post(port, args.model_a) + post(port, args.model_b) + post(port, args.model_a) + + hits, misses = [], [] + for _ in range(args.rounds): + # A is resident here: this is the hit. + hits.append(post(port, args.model_a)) + # B is not: this is the miss, and it leaves B resident. + misses.append(post(port, args.model_b)) + # Back to A: another miss, and it restores the invariant for the + # next round. Counted, because it is the same kind of event. + misses.append(post(port, args.model_a)) + + print(f"\nresident model after run: {resident(port)}") + print(f"{'':<24}{'n':>4}{'median s':>12}{'min s':>10}{'max s':>10}") + for label, samples in (("hit (already resident)", hits), ("miss (forces reload)", misses)): + print(f"{label:<24}{len(samples):>4}{statistics.median(samples):>12.3f}" + f"{min(samples):>10.3f}{max(samples):>10.3f}") + cost = statistics.median(misses) - statistics.median(hits) + print(f"\nreload cost (median miss - median hit): {cost:.3f} s") + print("Multiply by the miss-rate delta from TestRoutingPolicyAB for the") + print("per-request latency the residency-preferring policy saves.") + print("\nThese two models are small on purpose -- the number scales with") + print("weight size, so a 30B swap costs far more than this figure.") + finally: + proc.terminate() + try: + proc.wait(20) + except Exception: + proc.kill() + + +if __name__ == "__main__": + main() diff --git a/services/mlx-proxy/choose_reachable_test.go b/services/mlx-proxy/choose_reachable_test.go new file mode 100644 index 00000000..7d3db76b --- /dev/null +++ b/services/mlx-proxy/choose_reachable_test.go @@ -0,0 +1,271 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "net" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "nvpair-shared/clustertrust" + "nvpair-shared/clustertrusttest" + "nvpair-shared/reach" +) + +// waitForTarget polls targetURL until it settles on want. +// +// Routing never waits on a handshake — reach.Prefer answers with the node's own +// ranking and confirms behind the request — so the address a multi-homed peer +// settles on is what the requests after the first see. +func waitForTarget(t *testing.T, p *Proxy, n Node, want string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + u := p.targetURL(n) + if u != nil && u.Host == want { + return + } + if time.Now().After(deadline) { + got := "" + if u != nil { + got = u.Host + } + t.Fatalf("targetURL settled on %s, want %s", got, want) + } + time.Sleep(time.Millisecond) + } +} + +// countingChooser installs a target chooser that records how many connection +// attempts routing makes and whether they succeed, so a test can assert on +// confirmation behaviour without opening sockets. +func countingChooser(p *Proxy, accept bool) *atomic.Int32 { + var dials atomic.Int32 + p.targets = reach.NewChooser(reach.WithDial( + func(_, _ string, _ time.Duration) (net.Conn, error) { + dials.Add(1) + if !accept { + return nil, net.ErrClosed + } + c1, c2 := net.Pipe() + _ = c2.Close() + return c1, nil + })) + return &dials +} + +func TestChooseReachableFailsOverForPinnedPeer(t *testing.T) { + const peerUUID = "principal-peer" + const reachable = "192.0.2.11" + clusterDir := filepath.Join(t.TempDir(), "cluster") + clustertrusttest.Join(t, clusterDir, "cluster-xyz", "principal-self", peerUUID) + + p := testProxy(NewDiscovery(), 1235) + p.mesh = clustertrust.Open(clusterDir) + var dials atomic.Int32 + p.targets = reach.NewChooser(reach.WithDial( + func(_, address string, _ time.Duration) (net.Conn, error) { + dials.Add(1) + host, _, _ := net.SplitHostPort(address) + if host != reachable { + return nil, net.ErrClosed + } + c1, c2 := net.Pipe() + _ = c2.Close() + return c1, nil + })) + + n := Node{ + ID: "peer-a", + Port: 1234, + Addresses: []string{"192.0.2.10", "192.0.2.11"}, + ClusterUUID: peerUUID, + } + // The first request is not made to wait for the confirmation, so it uses the + // node's own top-ranked address; the ones behind it use the one that answers. + if u := p.targetURL(n); u == nil || u.Host != net.JoinHostPort("192.0.2.10", "1234") { + t.Fatalf("first selection = %v, want the published ranking without waiting", u) + } + waitForTarget(t, p, n, net.JoinHostPort(reachable, "1234")) + if dials.Load() != 2 { + t.Fatalf("pinned peer triggered %d TCP probes, want both candidates tried", dials.Load()) + } +} + +func TestChooseReachableProbesPlainMultiHomed(t *testing.T) { + p := testProxy(NewDiscovery(), 1235) + dials := countingChooser(p, true) + n := Node{ + ID: "manual-a", + Port: 1234, + Addresses: []string{"192.0.2.10", "192.0.2.11"}, + } + if u := p.targetURL(n); u == nil { + t.Fatal("targetURL returned nil") + } + deadline := time.Now().Add(2 * time.Second) + for dials.Load() == 0 { + if time.Now().After(deadline) { + t.Fatal("plain multi-homed target did not confirm reachability") + } + time.Sleep(time.Millisecond) + } +} + +// TestTargetURLFailsOverToAReachableAddress is the reported defect at the routing +// layer: a peer whose canonical address is a direct-connect link this host cannot +// reach must still be routed to, at the address that answers. +func TestTargetURLFailsOverToAReachableAddress(t *testing.T) { + const reachable = "10.172.55.129" + p := testProxy(NewDiscovery(), 1235) + p.targets = reach.NewChooser(reach.WithDial( + func(_, address string, _ time.Duration) (net.Conn, error) { + host, _, _ := net.SplitHostPort(address) + if host != reachable { + return nil, net.ErrClosed + } + c1, c2 := net.Pipe() + _ = c2.Close() + return c1, nil + })) + + n := Node{ + ID: "spark", + Port: 1234, + // The node's own ranking leads with a link only its cabled neighbour can + // reach; this host is not that neighbour. + Addresses: []string{"192.168.240.1", reachable}, + TXT: []string{"ip=192.168.240.1", "ips=192.168.240.1," + reachable}, + } + waitForTarget(t, p, n, net.JoinHostPort(reachable, "1234")) +} + +// TestNodeCandidatesKeepsPublishedOrder: the node ranked its addresses from +// evidence no observer has, so routing must try them in that order rather than +// re-sorting by address class. +func TestNodeCandidatesKeepsPublishedOrder(t *testing.T) { + n := Node{ + ID: "spark", + Port: 1234, + Addresses: []string{"192.168.240.1", "10.172.55.129"}, + TXT: []string{"ip=10.172.55.129", "ips=10.172.55.129,192.168.240.1"}, + } + got := nodeCandidates(n) + want := []string{ + net.JoinHostPort("10.172.55.129", "1234"), + net.JoinHostPort("192.168.240.1", "1234"), + } + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("nodeCandidates = %v, want %v", got, want) + } +} + +// fakeNetwork is a chooser dialer whose accepting address can be moved, so a test +// can describe an address that stops answering and another that starts. Probes run +// on background goroutines, so both fields are read concurrently with the test. +type fakeNetwork struct { + mu sync.Mutex + accepting string + dials atomic.Int32 +} + +func (f *fakeNetwork) accept(address string) { + f.mu.Lock() + defer f.mu.Unlock() + f.accepting = address +} + +func (f *fakeNetwork) install(p *Proxy) { + p.targets = reach.NewChooser(reach.WithDial( + func(_, address string, _ time.Duration) (net.Conn, error) { + f.dials.Add(1) + host, _, _ := net.SplitHostPort(address) + f.mu.Lock() + accepting := f.accepting + f.mu.Unlock() + if host != accepting { + return nil, net.ErrClosed + } + c1, c2 := net.Pipe() + _ = c2.Close() + return c1, nil + })) +} + +// confirmedDeadPeer builds a proxy whose only routing target is a multi-homed peer +// whose confirmed address no longer answers: the address is a loopback endpoint +// whose server is already closed, so a forwarded request fails at the transport +// the way an unplugged link does, while the chooser still believes in it. +func confirmedDeadPeer(t *testing.T, replacement string) (*Proxy, Node, *fakeNetwork) { + t.Helper() + dead := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + n := nodeForModel(t, "peer-a", dead.URL, "llama") + dead.Close() + confirmed := n.Addresses[0] + n.Addresses = append(n.Addresses, replacement) + + disc := NewDiscovery() + disc.AddManual(n) + p := testProxy(disc, 1235) + fake := &fakeNetwork{accepting: confirmed} + fake.install(p) + + waitForTarget(t, p, n, net.JoinHostPort(confirmed, strconv.Itoa(n.Port))) + return p, n, fake +} + +// assertReprobed moves the accepting address and requires the selections after the +// failure to confirm again and land on the replacement. A cached winner that +// outlived the failure keeps answering with the old address and dials nothing. +func assertReprobed(t *testing.T, p *Proxy, n Node, fake *fakeNetwork, replacement string) { + t.Helper() + probesBefore := fake.dials.Load() + fake.accept(replacement) + + waitForTarget(t, p, n, net.JoinHostPort(replacement, strconv.Itoa(n.Port))) + if fake.dials.Load() == probesBefore { + t.Fatal("selection probed nothing: the failed address is still cached") + } +} + +// TestUpstreamTransportFailureReprobesTheNextSelection: a dial failure against a +// multi-homed peer must retire the confirmed address. Without that, every later +// request keeps being sent to the address that just failed, and the peer's other +// published addresses are never tried — which is the whole reason routing confirms +// reachability in the first place. +func TestUpstreamTransportFailureReprobesTheNextSelection(t *testing.T) { + const replacement = "192.0.2.11" + p, n, fake := confirmedDeadPeer(t, replacement) + + rec := httptest.NewRecorder() + p.handleHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"llama"}`))) + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want 502 from the only, unreachable candidate", rec.Code) + } + + assertReprobed(t, p, n, fake, replacement) +} + +// TestModelListTransportFailureReprobesTheNextSelection: the aggregated model list +// reaches every candidate directly, so it learns about a dead address before any +// inference request does, and must retire it on the same evidence. +func TestModelListTransportFailureReprobesTheNextSelection(t *testing.T) { + const replacement = "192.0.2.11" + p, n, fake := confirmedDeadPeer(t, replacement) + + rec := httptest.NewRecorder() + p.handleHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/models", nil)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503 when the only inventory source is unreachable", rec.Code) + } + + assertReprobed(t, p, n, fake, replacement) +} diff --git a/services/mlx-proxy/cluster_trust_test.go b/services/mlx-proxy/cluster_trust_test.go new file mode 100644 index 00000000..233a409a --- /dev/null +++ b/services/mlx-proxy/cluster_trust_test.go @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "path/filepath" + "testing" + + "nvpair-shared/clustertrust" + "nvpair-shared/clustertrusttest" +) + +// TestResolveCandidatesFollowsLivePinSet reproduces the join ordering that made +// a freshly-joined node route to nobody. The peer is discovered BEFORE this node +// has any cluster identity, and its discovery record never changes again — which +// is the normal steady state, because the relay only re-sends a node when its +// mDNS record actually moves. Routing must still pick the peer up the moment the +// pin lands, and drop it again the moment the pin is removed, because it reads +// the live pin set rather than a trust flag cached at discovery time. +func TestResolveCandidatesFollowsLivePinSet(t *testing.T) { + const peerUUID = "principal-peer" + clusterDir := filepath.Join(t.TempDir(), "cluster") + + disc := NewDiscovery() + disc.SetSubscribed([]Node{{ + ID: "peer-a", Host: "peer-a", Port: 1234, + Addresses: []string{"192.0.2.10"}, + IP: "192.0.2.10", + ClusterUUID: peerUUID, + }}) + p := testProxy(disc, 1235) + p.mesh = clustertrust.Open(clusterDir) + + // Pre-join: no identity, no pins, so the peer is not a routable target. + if got := p.resolveCandidates(""); len(got) != 0 { + t.Fatalf("pre-join candidates = %+v, want none", got) + } + + // The cluster-manager lands the join on disk while the proxy is running. No + // new discovery snapshot arrives — the peer's mDNS record has not changed. + clustertrusttest.Join(t, clusterDir, "cluster-xyz", "principal-self", peerUUID) + + cands := p.resolveCandidates("") + if len(cands) != 1 { + t.Fatalf("post-join candidates = %+v, want the peer", cands) + } + if cands[0].id != "peer-a" || cands[0].peerUUID != peerUUID || cands[0].url.Scheme != "https" { + t.Fatalf("post-join candidate = %+v, want peer-a over https pinned to %s", cands[0], peerUUID) + } + + // Removing the peer from the cluster retires it as a target just as promptly, + // again with no discovery event involved. + clustertrusttest.RemovePeerPin(t, clusterDir, peerUUID) + if got := p.resolveCandidates(""); len(got) != 0 { + t.Fatalf("post-removal candidates = %+v, want none", got) + } +} + +// TestResolveCandidatesRejectsUnpinnedClusteredPeer keeps the isolation property +// honest now that trust is read locally: a peer that advertises a cluster +// principal we hold no pin for is not routable, even though this node is itself +// a healthy cluster member. Two clusters on one LAN must not route to each other. +func TestResolveCandidatesRejectsUnpinnedClusteredPeer(t *testing.T) { + clusterDir := filepath.Join(t.TempDir(), "cluster") + clustertrusttest.Join(t, clusterDir, "cluster-ours", "principal-self", "principal-ourpeer") + + disc := NewDiscovery() + disc.SetSubscribed([]Node{{ + ID: "stranger", Host: "stranger", Port: 1234, + Addresses: []string{"192.0.2.30"}, + IP: "192.0.2.30", + ClusterUUID: "principal-stranger", + }}) + p := testProxy(disc, 1235) + p.mesh = clustertrust.Open(clusterDir) + + if got := p.resolveCandidates(""); len(got) != 0 { + t.Fatalf("candidates = %+v, want none for a peer in another cluster", got) + } +} diff --git a/services/mlx-proxy/codec.go b/services/mlx-proxy/codec.go new file mode 100644 index 00000000..ac9566de --- /dev/null +++ b/services/mlx-proxy/codec.go @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +// The newline-delimited JSON-RPC 2.0 codec is single-sourced in +// nvpair-shared/jsonrpc. These local aliases keep this package's call sites and +// tests unchanged after removing the copy-pasted per-service codec. + +import "nvpair-shared/jsonrpc" + +type ( + Message = jsonrpc.Message + RPCError = jsonrpc.RPCError + Codec = jsonrpc.Codec +) + +var NewCodec = jsonrpc.NewCodec diff --git a/services/mlx-proxy/discovery.go b/services/mlx-proxy/discovery.go new file mode 100644 index 00000000..9846d8db --- /dev/null +++ b/services/mlx-proxy/discovery.go @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +// Discovery is the proxy's routing-target set. The proxy runs no mDNS of its +// own: routing targets are pushed down from the broker's discovery relay +// (discovery:nodes snapshots for the lm service) into the subscribed overlay, +// merged with user-added manual nodes. The proxy is itself advertised — as an lm +// service — by the node-scanner daemon's single _nvpair-node record, keyed off +// the engine port the broker's poller registers. +// +// The routable Node projection (IP / withPrimaryIP) and the manual-node +// overlay live here; request-path reachability (TCP-probe + failover) lives in +// proxy.go. + +import ( + "slices" + "sync" + + "nvpair-shared/discovery" + "nvpair-shared/netpick" +) + +// uuidFromTXT extracts a node's stable uuid= from its TXT records. Kept as a +// thin re-export of the shared helper (it was triplicated across the two proxies +// and the scanner before consolidation). +var uuidFromTXT = discovery.UUIDFromTXT + +// Node is the proxy's routable view of a node. It adds a canonical dialable IP +// field over the discovered node shape. +type Node struct { + ID string `json:"id"` + Host string `json:"host"` + Port int `json:"port"` + Addresses []string `json:"addresses"` + TXT []string `json:"txt"` + // Models is the latest model inventory carried by the broker's discovery + // snapshot. Model-bearing inference is eligible only when this list + // advertises the requested model; an empty list stays in discovery but is + // not an inference candidate until a later inventory update. + Models []string `json:"models,omitempty"` + // Loaded is the subset of Models the node currently holds in memory. + // mlx_lm.server keeps exactly one model resident and reloads from scratch + // when asked for another, so residency -- not mere availability on disk -- + // is what decides which owner should take a request. Empty means "nothing + // resident" or "the peer did not report residency"; both are handled the + // same way, as "not preferred", never as "not eligible". + Loaded []string `json:"loaded,omitempty"` + // IP is the single canonical LAN address a consumer should dial/display for + // this node, resolved via the shared netpick ranker: the node's + // own ip= TXT if present, else the best-scored advertised IPv4. It is + // stamped onto outbound node/* notifications only (see withPrimaryIP) so a + // downstream consumer agrees on the same address the proxy routes to. + IP string `json:"ip,omitempty"` + // ClusterUUID is the relay peer's cluster principal (its mTLS cert UUID), + // carried from the discovery DirectoryNode. Non-empty only for a clustered + // peer; it is the key used to pin the peer's server cert when dialing its + // promoted proxy over cluster mTLS. Whether we actually hold that pin is + // resolved against the live mesh at routing time (resolveCandidates), never + // cached here: a cached answer goes stale the moment a peer is paired or + // removed. Internal routing metadata, not part of the proxy's outward node + // contract. + ClusterUUID string `json:"-"` +} + +// withPrimaryIP returns a copy of the node with IP resolved by the shared ranker +// (netpick.Primary over its TXT + Addresses). +func (n Node) withPrimaryIP() Node { + n.IP = netpick.Primary(n.TXT, n.Addresses) + return n +} + +// Discovery holds the proxy's routing targets: the relay-fed subscribed overlay +// and the user-added manual overlay. +type Discovery struct { + mu sync.RWMutex + manualNodes map[string]Node + // subscribedNodes are routing targets pushed down by the broker's discovery + // relay (discovery:nodes snapshots for the lm service), keyed by node ID (the + // directory instance name). + subscribedNodes map[string]Node +} + +func NewDiscovery() *Discovery { + return &Discovery{ + manualNodes: make(map[string]Node), + subscribedNodes: make(map[string]Node), + } +} + +// Nodes returns the merged subscribed + manual node set (manual entries that +// aren't also present via the relay are appended). +func (d *Discovery) Nodes() []Node { + d.mu.RLock() + defer d.mu.RUnlock() + out := make([]Node, 0, len(d.manualNodes)+len(d.subscribedNodes)) + seen := make(map[string]struct{}, len(d.subscribedNodes)) + for id, n := range d.subscribedNodes { + out = append(out, n) + seen[id] = struct{}{} + } + for id, n := range d.manualNodes { + if _, exists := seen[id]; !exists { + out = append(out, n) + } + } + return out +} + +// SetSubscribed replaces the relay-fed routing overlay with the given set and +// reports what changed versus the previous set (keyed by node ID): nodes newly +// present, nodes whose routable details changed, and nodes that dropped out. The +// broker pushes the full filtered snapshot on every change, so the overlay is +// replaced wholesale; the returned diff lets the caller emit node/discovered| +// updated|removed so a consumer (the UI) learns which peers currently run this +// engine. Manual nodes are a separate overlay and are untouched. +func (d *Discovery) SetSubscribed(nodes []Node) (discovered, updated, removed []Node) { + d.mu.Lock() + defer d.mu.Unlock() + next := make(map[string]Node, len(nodes)) + for _, n := range nodes { + next[n.ID] = n + switch prev, ok := d.subscribedNodes[n.ID]; { + case !ok: + discovered = append(discovered, n) + case !nodeEqual(prev, n): + updated = append(updated, n) + } + } + for id, prev := range d.subscribedNodes { + if _, ok := next[id]; !ok { + removed = append(removed, prev) + } + } + d.subscribedNodes = next + return discovered, updated, removed +} + +// nodeEqual reports whether two routable Nodes carry the same routing/display +// identity — the fields a consumer dials or renders. A change in any of them +// warrants a node/updated. +func nodeEqual(a, b Node) bool { + return a.ID == b.ID && a.Host == b.Host && a.Port == b.Port && a.IP == b.IP && + slices.Equal(a.Addresses, b.Addresses) && slices.Equal(a.TXT, b.TXT) && + slices.Equal(a.Models, b.Models) +} + +func (d *Discovery) AddManual(node Node) (added bool) { + d.mu.Lock() + defer d.mu.Unlock() + _, exists := d.manualNodes[node.ID] + d.manualNodes[node.ID] = node + return !exists +} + +func (d *Discovery) RemoveManual(id string) (removed bool) { + d.mu.Lock() + defer d.mu.Unlock() + _, exists := d.manualNodes[id] + if exists { + delete(d.manualNodes, id) + } + return exists +} + +func (d *Discovery) IsManual(id string) bool { + d.mu.RLock() + defer d.mu.RUnlock() + _, exists := d.manualNodes[id] + return exists +} diff --git a/services/mlx-proxy/e2e_test.go b/services/mlx-proxy/e2e_test.go new file mode 100644 index 00000000..cab2a3de --- /dev/null +++ b/services/mlx-proxy/e2e_test.go @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" +) + +// proxyBin is the real mlx-proxy binary, built once in TestMain so the +// e2e test exercises the shipped artifact (not just in-process handlers). +var proxyBin string + +func TestMain(m *testing.M) { + tmp, err := os.MkdirTemp("", "nvpair-lmproxy-e2e-*") + if err != nil { + panic(err) + } + suffix := "" + if runtime.GOOS == "windows" { + suffix = ".exe" + } + proxyBin = filepath.Join(tmp, "mlx-proxy"+suffix) + if out, err := exec.Command("go", "build", "-o", proxyBin, ".").CombinedOutput(); err != nil { + panic("build mlx-proxy: " + err.Error() + "\n" + string(out)) + } + code := m.Run() + _ = os.RemoveAll(tmp) + os.Exit(code) +} + +type e2eFrame struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` +} + +func e2eReadFrames(r io.Reader, out chan<- e2eFrame) { + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + var f e2eFrame + if err := json.Unmarshal(sc.Bytes(), &f); err != nil { + continue + } + out <- f + } +} + +func e2eSend(t *testing.T, w io.Writer, id int, method string, params any) { + t.Helper() + msg := map[string]any{"jsonrpc": "2.0", "id": id, "method": method} + if params != nil { + msg["params"] = params + } + data, _ := json.Marshal(msg) + if _, err := w.Write(append(data, '\n')); err != nil { + t.Fatalf("send %s: %v", method, err) + } +} + +func e2eWaitResult(t *testing.T, frames <-chan e2eFrame, id string, timeout time.Duration) { + t.Helper() + deadline := time.After(timeout) + for { + select { + case f := <-frames: + if string(f.ID) != id { + continue + } + if len(f.Error) > 0 && string(f.Error) != "null" { + t.Fatalf("rpc id %s returned error: %s", id, f.Error) + } + return + case <-deadline: + t.Fatalf("timed out waiting for response id %s", id) + } + } +} + +func e2eWaitReadyPort(t *testing.T, frames <-chan e2eFrame, timeout time.Duration) int { + t.Helper() + deadline := time.After(timeout) + for { + select { + case f := <-frames: + if f.Method != "ready" { + continue + } + var p struct { + Port int `json:"port"` + } + if err := json.Unmarshal(f.Params, &p); err != nil { + t.Fatalf("parse ready params: %v", err) + } + return p.Port + case <-deadline: + t.Fatalf("timed out waiting for ready notification") + return 0 + } + } +} + +func e2eFreePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +} + +func e2eSplitHostPort(t *testing.T, serverURL string) (string, int) { + t.Helper() + host, portStr, err := net.SplitHostPort(strings.TrimPrefix(serverURL, "http://")) + if err != nil { + t.Fatalf("split %q: %v", serverURL, err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + t.Fatalf("port %q: %v", portStr, err) + } + return host, port +} + +// TestE2EFailoverOverRealBinary spawns the real mlx-proxy binary and +// drives it the way the broker/UI does: register a busy (503) and a healthy +// (200) upstream as manual nodes over JSON-RPC stdio, then send a genuine +// OpenAI inference POST to the proxy's real HTTP port. It asserts the request +// fails over from the busy node to the healthy one, the original body is +// replayed, and CORS headers are present — the whole shipped path (binary + +// stdio control plane + HTTP forwarding + failover) end-to-end, no mocks. +func TestE2EFailoverOverRealBinary(t *testing.T) { + var gotBody string + busy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer busy.Close() + good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.WriteHeader(http.StatusOK) + io.WriteString(w, `{"ok":true}`) + })) + defer good.Close() + + port := e2eFreePort(t) + cmd := exec.Command(proxyBin, "--port", strconv.Itoa(port)) + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer func() { + _ = stdin.Close() + _ = cmd.Process.Kill() + _ = cmd.Wait() + }() + + frames := make(chan e2eFrame, 256) + go e2eReadFrames(stdout, frames) + + if got := e2eWaitReadyPort(t, frames, 10*time.Second); got != port { + t.Fatalf("ready port = %d, want %d", got, port) + } + + busyHost, busyPort := e2eSplitHostPort(t, busy.URL) + goodHost, goodPort := e2eSplitHostPort(t, good.URL) + e2eSend(t, stdin, 1, "node/add-manual", map[string]any{"id": "busy", "host": busyHost, "port": busyPort, "addresses": []string{busyHost}, "models": []string{"m"}}) + e2eWaitResult(t, frames, "1", 5*time.Second) + e2eSend(t, stdin, 2, "node/add-manual", map[string]any{"id": "good", "host": goodHost, "port": goodPort, "addresses": []string{goodHost}, "models": []string{"m"}}) + e2eWaitResult(t, frames, "2", 5*time.Second) + // Select the busy node so the failover path is deterministic. + e2eSend(t, stdin, 3, "node/select", map[string]any{"id": "busy"}) + e2eWaitResult(t, frames, "3", 5*time.Second) + + resp, err := http.Post(fmt.Sprintf("http://127.0.0.1:%d/v1/chat/completions", port), "application/json", strings.NewReader(`{"model":"m"}`)) + if err != nil { + t.Fatalf("inference POST: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (should fail over from the 503 node)", resp.StatusCode) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("Access-Control-Allow-Origin = %q, want *", got) + } + if gotBody != `{"model":"m"}` { + t.Errorf("healthy upstream got body %q, want the original request body", gotBody) + } + + e2eSend(t, stdin, 9, "shutdown", nil) + e2eWaitResult(t, frames, "9", 5*time.Second) +} diff --git a/services/mlx-proxy/failover_test.go b/services/mlx-proxy/failover_test.go new file mode 100644 index 00000000..f6e3456c --- /dev/null +++ b/services/mlx-proxy/failover_test.go @@ -0,0 +1,600 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + "time" +) + +// rwNop is a no-op io.ReadWriter so a Codec can be constructed in tests +// without a real transport: reads hit EOF immediately and writes are +// discarded. handleHTTP only ever writes (notifications), so this is enough. +type rwNop struct{} + +func (rwNop) Read([]byte) (int, error) { return 0, io.EOF } +func (rwNop) Write(p []byte) (int, error) { return len(p), nil } + +func testProxy(disc *Discovery, port int) *Proxy { + return NewProxy(NewCodec(rwNop{}), disc, port) +} + +// nodeFor turns an httptest server URL into a discovery Node pointing at it. +func nodeFor(t *testing.T, id, serverURL string) Node { + t.Helper() + u, err := url.Parse(serverURL) + if err != nil { + t.Fatalf("parse %q: %v", serverURL, err) + } + host, portStr, err := net.SplitHostPort(u.Host) + if err != nil { + t.Fatalf("split %q: %v", u.Host, err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + t.Fatalf("port %q: %v", portStr, err) + } + return Node{ID: id, Addresses: []string{host}, Port: port} +} + +func nodeForModel(t *testing.T, id, serverURL, model string) Node { + t.Helper() + node := nodeFor(t, id, serverURL) + node.Models = []string{model} + return node +} + +// TestHandlePlain_OptionsPreflight: a CORS preflight is answered locally with +// 204 + permissive headers and never forwarded. +func TestHandlePlain_OptionsPreflight(t *testing.T) { + p := testProxy(NewDiscovery(), 11434) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodOptions, "/v1/chat/completions", nil) + req.RemoteAddr = "127.0.0.1:40000" + req.Header.Set("Access-Control-Request-Headers", "X-Custom-Token") + p.handlePlain(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204", rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("Access-Control-Allow-Origin = %q, want *", got) + } + if rec.Header().Get("Access-Control-Allow-Methods") == "" { + t.Errorf("missing Access-Control-Allow-Methods") + } + if got := rec.Header().Get("Access-Control-Expose-Headers"); got != "*" { + t.Errorf("Access-Control-Expose-Headers = %q, want *", got) + } + // The browser's requested headers are echoed so an arbitrary header clears preflight. + if got := rec.Header().Get("Access-Control-Allow-Headers"); got != "X-Custom-Token" { + t.Errorf("Access-Control-Allow-Headers = %q, want echoed X-Custom-Token", got) + } +} + +// TestHandlePlain_EngineCredentialedPreflightPreserved: when an engine opts an +// exact origin into credentialed CORS, its preflight policy reaches the browser +// instead of being replaced by the proxy's uncredentialed wildcard fallback. +func TestHandlePlain_EngineCredentialedPreflightPreserved(t *testing.T) { + preflightSeen := make(chan struct{}, 1) + engine := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodOptions { + t.Errorf("engine method = %s, want OPTIONS", r.Method) + } + preflightSeen <- struct{}{} + w.Header().Set("Access-Control-Allow-Origin", "https://app.example") + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Methods", "POST") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.WriteHeader(http.StatusNoContent) + })) + defer engine.Close() + + disc := NewDiscovery() + disc.AddManual(nodeFor(t, "engine", engine.URL)) + p := testProxy(disc, 11434) + req := httptest.NewRequest(http.MethodOptions, "/v1/chat/completions", nil) + req.RemoteAddr = "127.0.0.1:40000" + req.Header.Set("Origin", "https://app.example") + req.Header.Set("Access-Control-Request-Method", http.MethodPost) + req.Header.Set("Access-Control-Request-Headers", "Content-Type") + rec := httptest.NewRecorder() + + p.handlePlain(rec, req) + + select { + case <-preflightSeen: + default: + t.Fatal("engine did not receive the credentialed preflight") + } + if rec.Code != http.StatusNoContent { + t.Errorf("status = %d, want %d", rec.Code, http.StatusNoContent) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example" { + t.Errorf("Access-Control-Allow-Origin = %q, want the engine's exact origin", got) + } + if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" { + t.Errorf("Access-Control-Allow-Credentials = %q, want the engine's true", got) + } +} + +// TestHandleHTTP_EngineCORSPolicyPreserved: an engine that declares its own +// origin policy keeps it. Replacing it with the proxy's wildcard would widen +// what the user configured, and would break a credentialed response outright. +func TestHandleHTTP_EngineCORSPolicyPreserved(t *testing.T) { + engine := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "https://app.example") + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.WriteHeader(http.StatusOK) + io.WriteString(w, `{"done":true}`) + })) + defer engine.Close() + + disc := NewDiscovery() + disc.AddManual(nodeForModel(t, "engine", engine.URL, "llama")) + p := testProxy(disc, 11434) + + rec := httptest.NewRecorder() + p.handleHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"llama"}`))) + + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example" { + t.Errorf("Access-Control-Allow-Origin = %q, want the engine's own origin", got) + } + if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" { + t.Errorf("Access-Control-Allow-Credentials = %q, want the engine's true", got) + } +} + +// TestHandleHTTP_EngineCredentialsWithoutOriginDropped: an engine (or an +// intermediary in front of it) that sends Allow-Credentials but no origin has +// declared no policy to keep, so the proxy supplies its own. The wildcard it +// writes is invalid next to Allow-Credentials: true, and a browser rejects that +// pair, so the inherited header must not survive the forward. +func TestHandleHTTP_EngineCredentialsWithoutOriginDropped(t *testing.T) { + engine := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.WriteHeader(http.StatusOK) + io.WriteString(w, `{"done":true}`) + })) + defer engine.Close() + + disc := NewDiscovery() + disc.AddManual(nodeForModel(t, "engine", engine.URL, "llama")) + p := testProxy(disc, 11434) + + rec := httptest.NewRecorder() + p.handleHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"llama"}`))) + + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("Access-Control-Allow-Origin = %q, want the proxy's wildcard", got) + } + if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "" { + t.Errorf("Access-Control-Allow-Credentials = %q, want cleared alongside the wildcard origin", got) + } +} + +// TestHandleHTTP_HappyPathSingleNode: the common case — one healthy node +// answers directly, body forwarded, CORS present on the success response. +func TestHandleHTTP_HappyPathSingleNode(t *testing.T) { + var gotBody string + good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.WriteHeader(http.StatusOK) + io.WriteString(w, `{"done":true}`) + })) + defer good.Close() + + disc := NewDiscovery() + disc.AddManual(nodeForModel(t, "good", good.URL, "llama")) + p := testProxy(disc, 11434) + + rec := httptest.NewRecorder() + p.handleHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"llama"}`))) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if gotBody != `{"model":"llama"}` { + t.Errorf("node got body %q, want the original request body", gotBody) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("Access-Control-Allow-Origin = %q, want * on success", got) + } +} + +// TestHandleHTTP_NoRetryOn400: a client error (400) is returned as-is and not +// failed over — retrying elsewhere would return the same error and mask it. +func TestHandleHTTP_NoRetryOn400(t *testing.T) { + hits := 0 + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, `{"error":"bad request"}`) + })) + defer bad.Close() + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer other.Close() + + disc := NewDiscovery() + disc.AddManual(nodeForModel(t, "bad", bad.URL, "llama")) + disc.AddManual(nodeForModel(t, "other", other.URL, "llama")) + p := testProxy(disc, 11434) + p.SetSelected("bad") + + rec := httptest.NewRecorder() + p.handleHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"llama"}`))) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (client errors must not fail over)", rec.Code) + } + if hits != 1 { + t.Errorf("bad node hit %d times, want exactly 1 (no retry on 400)", hits) + } +} + +// TestHandleHTTP_RejectionHasCORS: even the no-node rejection carries CORS so a +// browser sees the real 502 instead of an opaque CORS error. +func TestHandleHTTP_RejectionHasCORS(t *testing.T) { + p := testProxy(NewDiscovery(), 11434) + rec := httptest.NewRecorder() + p.handleHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"x"}`))) + + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want 502", rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("Access-Control-Allow-Origin = %q, want * on rejection", got) + } +} + +// TestHandleHTTP_FailoverOn503: a busy first node (503) is skipped and the +// request is filled by the next node, with the original body replayed. +func TestHandleHTTP_FailoverOn503(t *testing.T) { + busy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + io.WriteString(w, `{"error":"loading model"}`) + })) + defer busy.Close() + + var gotBody string + good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.WriteHeader(http.StatusOK) + io.WriteString(w, `{"done":true}`) + })) + defer good.Close() + + disc := NewDiscovery() + disc.AddManual(nodeForModel(t, "busy", busy.URL, "llama")) + disc.AddManual(nodeForModel(t, "good", good.URL, "llama")) + p := testProxy(disc, 11434) + p.SetSelected("busy") // deterministic: busy is tried first + + rec := httptest.NewRecorder() + p.handleHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"llama"}`))) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (should have failed over past the 503)", rec.Code) + } + if gotBody != `{"model":"llama"}` { + t.Errorf("failover node got body %q, want the original request body", gotBody) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("Access-Control-Allow-Origin = %q, want * on proxied success", got) + } +} + +// TestHandleHTTP_AllNodesDownReturnsError: when every candidate fails at the +// transport, the client gets one clean 502 (not a hang), still with CORS. +func TestHandleHTTP_AllNodesDownReturnsError(t *testing.T) { + // Two servers we immediately close so dials fail. + a := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + b := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + na := nodeForModel(t, "a", a.URL, "llama") + nb := nodeForModel(t, "b", b.URL, "llama") + a.Close() + b.Close() + + disc := NewDiscovery() + disc.AddManual(na) + disc.AddManual(nb) + p := testProxy(disc, 11434) + + rec := httptest.NewRecorder() + p.handleHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"llama"}`))) + + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want 502 when all nodes are down", rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("Access-Control-Allow-Origin = %q, want * on exhausted error", got) + } +} + +// TestHandleHTTP_404FailoverInferenceOnly: a 404 (model-not-found) on an +// inference call fails over to the next advertised owner, but a 404 on a +// non-inference path is returned as-is. +func TestHandleHTTP_404FailoverInferenceOnly(t *testing.T) { + missing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + io.WriteString(w, `{"error":"model not found"}`) + })) + defer missing.Close() + has := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + io.WriteString(w, `{"done":true}`) + })) + defer has.Close() + + newProxy := func() *Proxy { + disc := NewDiscovery() + disc.AddManual(nodeForModel(t, "missing", missing.URL, "llama")) + disc.AddManual(nodeForModel(t, "has", has.URL, "llama")) + p := testProxy(disc, 11434) + p.SetSelected("missing") + return p + } + + // Inference POST: 404 on first → fail over → 200. + rec := httptest.NewRecorder() + newProxy().handleHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"llama"}`))) + if rec.Code != http.StatusOK { + t.Fatalf("inference 404: status = %d, want 200 (should fail over)", rec.Code) + } + + // An ordinary non-inference GET still returns the first node's 404. + rec = httptest.NewRecorder() + newProxy().handleHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/unknown", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("non-inference 404: status = %d, want 404 (must NOT fail over)", rec.Code) + } +} + +func TestHandleHTTP_AggregatesModelList(t *testing.T) { + entered := make(chan struct{}, 2) + release := make(chan struct{}) + server := func(body string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v1/models" { + t.Errorf("upstream request = %s %s, want GET /v1/models", r.Method, r.URL.Path) + } + if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" { + t.Errorf("client credentials leaked to fan-out target") + } + entered <- struct{}{} + <-release + _, _ = io.WriteString(w, body) + })) + } + a := server(`{"object":"list","data":[{"id":"a","owned_by":"a-only"},{"id":"shared","owned_by":"first"}]}`) + defer a.Close() + b := server(`{"object":"list","data":[{"id":"shared","owned_by":"second"},{"id":"c","owned_by":"c-only"}]}`) + defer b.Close() + malformed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"object":"list","data":null}`) + })) + defer malformed.Close() + down := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + downNode := nodeFor(t, "down", down.URL) + down.Close() + + disc := NewDiscovery() + disc.AddManual(nodeFor(t, "a", a.URL)) + disc.AddManual(nodeFor(t, "b", b.URL)) + disc.AddManual(downNode) + disc.AddManual(nodeFor(t, "malformed", malformed.URL)) + p := testProxy(disc, 1234) + p.SetSelected("a") + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + req.Header.Set("Authorization", "Bearer client-secret") + req.Header.Set("Cookie", "session=client-secret") + done := make(chan struct{}) + go func() { + p.handleHTTP(rec, req) + close(done) + }() + + for range 2 { + select { + case <-entered: + case <-time.After(5 * time.Second): + close(release) + t.Fatal("model-list requests were not issued concurrently") + } + } + close(release) + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("aggregate request did not finish") + } + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var got struct { + Object string `json:"object"` + Data []struct { + ID string `json:"id"` + OwnedBy string `json:"owned_by"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Object != "list" { + t.Errorf("object = %q, want list", got.Object) + } + if len(got.Data) != 3 || got.Data[0].ID != "a" || got.Data[1].ID != "shared" || got.Data[2].ID != "c" { + t.Fatalf("models = %+v, want a, shared, c", got.Data) + } + if got.Data[1].OwnedBy != "first" { + t.Errorf("duplicate metadata = %q, want deterministic first candidate", got.Data[1].OwnedBy) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("Access-Control-Allow-Origin = %q, want *", got) + } +} + +func TestHandleHTTP_ModelListEmptyAndUnavailable(t *testing.T) { + empty := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"object":"list","data":[]}`) + })) + emptyNode := nodeFor(t, "empty", empty.URL) + empty.Close() + + disc := NewDiscovery() + disc.AddManual(emptyNode) + rec := httptest.NewRecorder() + testProxy(disc, 1234).handleHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/models", nil)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("unavailable status = %d, want 503", rec.Code) + } + + empty = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"object":"list","data":[]}`) + })) + defer empty.Close() + disc = NewDiscovery() + disc.AddManual(nodeFor(t, "empty", empty.URL)) + rec = httptest.NewRecorder() + testProxy(disc, 1234).handleHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/models", nil)) + if rec.Code != http.StatusOK || rec.Body.String() != `{"object":"list","data":[]}` { + t.Fatalf("empty response = %d %s, want 200 native empty list", rec.Code, rec.Body.String()) + } +} + +// TestHandleHTTP_StrictModelRouting proves capability is a gate before +// selection and priority. Unknown and known-missing nodes are excluded from +// inference but remain available for non-inference routes. +func TestHandleHTTP_StrictModelRouting(t *testing.T) { + missHits, unknownHits, matchHits := 0, 0, 0 + miss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + missHits++ + w.WriteHeader(http.StatusOK) + })) + defer miss.Close() + unknown := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + unknownHits++ + w.WriteHeader(http.StatusOK) + })) + defer unknown.Close() + match := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + matchHits++ + body, _ := io.ReadAll(r.Body) + if string(body) != `{"model":"llama"}` { + t.Errorf("matching node got body %q", body) + } + w.WriteHeader(http.StatusOK) + })) + defer match.Close() + + disc := NewDiscovery() + missNode := nodeFor(t, "selected-miss", miss.URL) + missNode.Models = []string{"mistral"} + unknownNode := nodeFor(t, "a-unknown", unknown.URL) + matchNode := nodeFor(t, "z-match", match.URL) + matchNode.Models = []string{"llama"} + disc.AddManual(missNode) + disc.AddManual(unknownNode) + disc.AddManual(matchNode) + p := testProxy(disc, 11434) + p.SetSelected("selected-miss") + p.SetPriority([]string{"a-unknown", "selected-miss", "z-match"}) + candidates := p.resolveCandidates("llama") + if len(candidates) != 1 || candidates[0].id != "z-match" { + t.Fatalf("model candidates = %v, want only z-match", candidates) + } + + rec := httptest.NewRecorder() + p.handleHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"llama"}`))) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if missHits != 0 || unknownHits != 0 || matchHits != 1 { + t.Fatalf("hits miss=%d unknown=%d match=%d, want 0/0/1", missHits, unknownHits, matchHits) + } + + // Capability filtering applies only to model-bearing inference. + rec = httptest.NewRecorder() + p.handleHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/unknown", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("non-inference status = %d, want 200", rec.Code) + } + if missHits != 1 || unknownHits != 0 || matchHits != 1 { + t.Fatalf("non-inference hits miss=%d unknown=%d match=%d, want 1/0/1", missHits, unknownHits, matchHits) + } +} + +func TestHandleHTTP_NoAdvertisedModelRejectsLocally(t *testing.T) { + hits := 0 + upstream := func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits++ + w.WriteHeader(http.StatusOK) + })) + } + missing := upstream() + defer missing.Close() + unknown := upstream() + defer unknown.Close() + + disc := NewDiscovery() + disc.AddManual(nodeForModel(t, "missing", missing.URL, "mistral")) + disc.AddManual(nodeFor(t, "unknown", unknown.URL)) + events := &prRec{} + p := NewProxy(NewCodec(events), disc, 1234) + p.SetSelected("missing") + + rec := httptest.NewRecorder() + p.handleHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"llama"}`))) + + if rec.Code != http.StatusBadGateway || + !strings.Contains(rec.Body.String(), "no available node advertises the requested model") { + t.Fatalf("response = %d %s, want actionable local 502", rec.Code, rec.Body.String()) + } + if hits != 0 { + t.Fatalf("ineligible upstreams received %d requests, want 0", hits) + } + if !events.has("no node advertises requested model") { + t.Fatalf("missing rejected request event: %s", events.b) + } +} + +// TestResolveCandidates_SelfGuard: a node resolving to the proxy's own +// listen address is dropped so we never self-forward. +func TestResolveCandidates_SelfGuard(t *testing.T) { + disc := NewDiscovery() + disc.AddManual(Node{ID: "self", Addresses: []string{"127.0.0.1"}, Port: 11434}) + disc.AddManual(Node{ID: "real", Addresses: []string{"192.0.2.10"}, Port: 11434}) + p := testProxy(disc, 11434) + + cands := p.resolveCandidates("") + var haveReal bool + for _, c := range cands { + if c.id == "self" { + t.Errorf("self-target node must be excluded, got candidate %+v", c) + } + if c.id == "real" { + haveReal = true + } + } + if !haveReal { + t.Errorf("expected the real node to survive the self-guard, candidates = %+v", cands) + } +} diff --git a/services/mlx-proxy/go.mod b/services/mlx-proxy/go.mod new file mode 100644 index 00000000..76e85cde --- /dev/null +++ b/services/mlx-proxy/go.mod @@ -0,0 +1,19 @@ +module mlx-proxy + +go 1.25.0 + +require nvpair-shared v0.0.0-00010101000000-000000000000 + +replace nvpair-shared => ../shared + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff v2.2.1+incompatible // indirect + github.com/grandcat/zeroconf v1.0.0 // indirect + github.com/miekg/dns v1.1.55 // indirect + golang.org/x/mod v0.17.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect +) diff --git a/services/mlx-proxy/go.sum b/services/mlx-proxy/go.sum new file mode 100644 index 00000000..fe333ffe --- /dev/null +++ b/services/mlx-proxy/go.sum @@ -0,0 +1,35 @@ +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE= +github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs= +github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= +github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo= +github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/services/mlx-proxy/ingress.go b/services/mlx-proxy/ingress.go new file mode 100644 index 00000000..eefb62ca --- /dev/null +++ b/services/mlx-proxy/ingress.go @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "log/slog" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strconv" + + "nvpair-shared/cors" +) + +const engineIdentityProbeHeader = "X-NVPAIR-Engine-Identity-Probe" + +// localBackend is the explicit loopback engine the cluster mTLS ingress +// forwards to. It is supplied by the broker over node/set-local-backend and is +// deliberately NOT sourced from the discovery overlay: a request that arrived +// over the LAN mTLS ingress can only ever be dumped on this node's own local +// engine, never re-routed to a peer, so the ingress path is strictly terminal +// and cannot recurse or amplify. +type localBackend struct { + Engine string `json:"engine"` + Host string `json:"host"` + Port int `json:"port"` + Healthy bool `json:"healthy"` +} + +// setLocalBackend records (or, with a zero port / unhealthy flag, effectively +// clears) the local engine the ingress serves. +func (p *Proxy) setLocalBackend(b localBackend) { + p.backendMu.Lock() + p.backend = b + p.backendMu.Unlock() +} + +// localBackendTarget returns the loopback URL of the current local engine, and +// false when none is set/healthy (the ingress then answers 503 rather than +// forwarding). The host defaults to 127.0.0.1 and is always loopback. +func (p *Proxy) localBackendTarget() (*url.URL, bool) { + p.backendMu.RLock() + b := p.backend + p.backendMu.RUnlock() + if b.Port <= 0 || !b.Healthy { + return nil, false + } + host := b.Host + if host == "" { + host = "127.0.0.1" + } + return &url.URL{Scheme: "http", Host: net.JoinHostPort(host, strconv.Itoa(b.Port))}, true +} + +// handlePlain is the plaintext personality: it accepts requests only from +// loopback and hands them to the full local router (handleHTTP). A non-loopback +// caller — any LAN peer — is refused; peers must use the mTLS ingress. This is +// what closes the former open-relay exposure (the listener still binds all +// interfaces for the TLS personality, but plaintext is loopback-only). +func (p *Proxy) handlePlain(w http.ResponseWriter, r *http.Request) { + if !isLoopbackRemote(r.RemoteAddr) { + // Answer a non-loopback preflight ahead of the gate. It grants no access + // on its own; the request that follows still receives the real 403. A + // loopback preflight continues into handleHTTP so an available engine's + // exact origin and credentials policy can be preserved. + if cors.WritePreflight(w, r) { + return + } + slog.Warn("rejected non-loopback plaintext request; cluster peers must use mTLS", + "remote", r.RemoteAddr, "method", r.Method, "path", r.URL.Path) + writeIngressError(w, http.StatusForbidden, "loopback-only", + "plaintext requests are accepted only from loopback; cluster peers must use the mTLS ingress") + return + } + // Engine-manager marks identity/action requests so the federated model-list + // facade can never satisfy MLX's own /v1/models readiness probe. + if r.Header.Get(engineIdentityProbeHeader) == "1" { + writeIngressError(w, http.StatusConflict, "proxy-facade", "the compatibility facade is not an MLX engine") + return + } + p.handleHTTP(w, r) +} + +// handleClusterIngress is the LAN mTLS personality: it authenticates the caller +// against this node's cluster pins and, once the peer is a trusted cluster +// member, forwards the request straight to the local loopback engine — exactly +// like the local plaintext path, with no route filtering. The mTLS pin is the +// sole authorization boundary (a trusted peer is treated like a local client), +// so the two personalities stay behaviorally identical toward the engine. It +// never calls resolveCandidates, so a peer request cannot be re-routed onward. +func (p *Proxy) handleClusterIngress(w http.ResponseWriter, r *http.Request) { + // Re-derive membership and pins per request so a cluster left, or a peer + // paired or removed, after startup is reflected immediately without a proxy + // restart — a removed peer must stop being accepted right away, which is the + // whole point of the gate. + p.mesh.Refresh() + peer, ok := p.mesh.VerifyClientPin(r) + if !ok { + writeIngressError(w, http.StatusForbidden, "cluster-auth", + "client certificate is not a pinned member of this node's cluster") + return + } + target, ok := p.localBackendTarget() + if !ok { + writeIngressError(w, http.StatusServiceUnavailable, "no-local-backend", + "no local inference backend is available on this node") + return + } + slog.Debug("cluster ingress forwarding to local backend", + "peer", peer, "method", r.Method, "path", r.URL.Path, "target", target.Host) + p.reverseProxyToLocal(w, r, target) +} + +// reverseProxyToLocal streams the request to the local engine, preserving +// cancellation (the request context is the proxy's root context, so a client +// disconnect or shutdown tears down the upstream call and stops generation). +func (p *Proxy) reverseProxyToLocal(w http.ResponseWriter, r *http.Request, target *url.URL) { + p.newLocalReverseProxy(target).ServeHTTP(w, r) +} + +func (p *Proxy) newLocalReverseProxy(target *url.URL) *httputil.ReverseProxy { + return &httputil.ReverseProxy{ + Director: func(req *http.Request) { + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + req.Host = target.Host + }, + Transport: p.plainHTTPTransport(), + ErrorHandler: func(ew http.ResponseWriter, _ *http.Request, err error) { + slog.Warn("cluster ingress upstream error", "target", target.Host, "err", err) + writeIngressError(ew, http.StatusBadGateway, "backend-error", "local inference backend error") + }, + } +} + +// isLoopbackRemote reports whether an http.Request RemoteAddr (host:port) is a +// loopback address (127.0.0.0/8 or ::1). An unparseable/empty RemoteAddr is not +// loopback, so it fails closed. +func isLoopbackRemote(remoteAddr string) bool { + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + host = remoteAddr + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// writeIngressError writes a small structured JSON error. It never echoes the +// request body or any generated output. CORS headers are included because these +// are the proxy's own rejections: without them a browser client cannot read the +// status or reason, and every one of them looks like a generic CORS failure. +func writeIngressError(w http.ResponseWriter, status int, code, msg string) { + cors.Apply(w.Header()) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(status) + body, err := json.Marshal(map[string]string{"error": msg, "code": code}) + if err != nil { + body = []byte(`{"error":"ingress error"}`) + } + _, _ = w.Write(body) +} diff --git a/services/mlx-proxy/ingress_test.go b/services/mlx-proxy/ingress_test.go new file mode 100644 index 00000000..3e66dcae --- /dev/null +++ b/services/mlx-proxy/ingress_test.go @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +// TestResolveCandidatesUnclusteredDropsRelayPeers is the core isolation +// assertion: an unclustered node (nil mesh) must not route inference to +// relay-discovered peers, only to explicit user-added manual nodes. +func TestResolveCandidatesUnclusteredDropsRelayPeers(t *testing.T) { + disc := NewDiscovery() + disc.SetSubscribed([]Node{{ + ID: "peer-a", Host: "peer-a", Port: 1234, + Addresses: []string{"192.0.2.10"}, + IP: "192.0.2.10", + ClusterUUID: "cluster-uuid-a", + }}) + disc.AddManual(Node{ + ID: "manual-x", Host: "manual-x", Port: 1234, + Addresses: []string{"192.0.2.20"}, IP: "192.0.2.20", + }) + p := testProxy(disc, 1235) // mesh nil => unclustered + + cands := p.resolveCandidates("") + if len(cands) != 1 { + t.Fatalf("unclustered candidate set = %+v, want exactly the manual node", cands) + } + if cands[0].id != "manual-x" || cands[0].peerUUID != "" || cands[0].url.Scheme != "http" { + t.Fatalf("unclustered candidate = %+v, want plaintext manual-x with no peerUUID", cands[0]) + } +} + +func TestHandlePlainRejectsNonLoopback(t *testing.T) { + p := testProxy(NewDiscovery(), 1235) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + req.RemoteAddr = "192.0.2.50:40000" + rec := httptest.NewRecorder() + + p.handlePlain(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("non-loopback plaintext status = %d, want %d", rec.Code, http.StatusForbidden) + } + // The refusal carries CORS so a browser client reads this 403 and its reason + // instead of an opaque "CORS error" that hides why the call failed. + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("Access-Control-Allow-Origin = %q, want * on the refusal", got) + } +} + +// TestHandlePlainAnswersPreflightBeforeLoopbackGate: the preflight is answered +// even for a caller the gate will refuse. It authorizes nothing — the request +// that follows is still rejected — but without it the browser never sends that +// request and reports the refusal as a generic CORS failure. +func TestHandlePlainAnswersPreflightBeforeLoopbackGate(t *testing.T) { + p := testProxy(NewDiscovery(), 1235) + req := httptest.NewRequest(http.MethodOptions, "/v1/chat/completions", nil) + req.RemoteAddr = "192.0.2.50:40000" + rec := httptest.NewRecorder() + + p.handlePlain(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("preflight status = %d, want %d", rec.Code, http.StatusNoContent) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("Access-Control-Allow-Origin = %q, want *", got) + } +} + +func TestHandlePlainRejectsEngineIdentityProbe(t *testing.T) { + p := testProxy(NewDiscovery(), 1235) + req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + req.RemoteAddr = "127.0.0.1:40000" + req.Header.Set("X-NVPAIR-Engine-Identity-Probe", "1") + rec := httptest.NewRecorder() + + p.handlePlain(rec, req) + if rec.Code != http.StatusConflict { + t.Fatalf("identity probe status = %d, want %d", rec.Code, http.StatusConflict) + } +} + +func TestHandleClusterIngressUnclusteredForbids(t *testing.T) { + p := testProxy(NewDiscovery(), 1235) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + rec := httptest.NewRecorder() + + p.handleClusterIngress(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("unclustered ingress status = %d, want %d", rec.Code, http.StatusForbidden) + } +} + +func TestLocalReverseProxyUsesSharedPlainTransport(t *testing.T) { + p := testProxy(NewDiscovery(), 1235) + shared := p.plainHTTPTransport() + target := &url.URL{Scheme: "http", Host: "127.0.0.1:1"} + rp := p.newLocalReverseProxy(target) + tr, ok := rp.Transport.(*http.Transport) + if !ok { + t.Fatalf("Transport type = %T, want *http.Transport", rp.Transport) + } + if tr != shared { + t.Fatal("ingress reverse proxy did not use the shared plain Transport") + } +} diff --git a/services/mlx-proxy/ipc.go b/services/mlx-proxy/ipc.go new file mode 100644 index 00000000..954af4ed --- /dev/null +++ b/services/mlx-proxy/ipc.go @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +// The IPC transport (Unix socket / Windows named pipe) is single-sourced in +// nvpair-shared/ipc; the platform split lives there. dialIPC aliases it so call +// sites are unchanged. + +import "nvpair-shared/ipc" + +var dialIPC = ipc.Dial diff --git a/services/mlx-proxy/main.go b/services/mlx-proxy/main.go new file mode 100644 index 00000000..86dd357c --- /dev/null +++ b/services/mlx-proxy/main.go @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "flag" + "fmt" + "io" + "log" + "log/slog" + "os" + "os/signal" + "syscall" + + "nvpair-shared/applog" + "nvpair-shared/clustertrust" + "nvpair-shared/parentwatch" +) + +func main() { + port := flag.Int("port", defaultProxyPort, "HTTP listen port") + ignorePersistedPort := flag.Bool("ignore-persisted-port", false, "use --port even when a persisted port exists") + ipcPath := flag.String("ipc", "", "IPC endpoint: Unix domain socket path or Windows named pipe (default: stdin/stdout)") + clusterDir := flag.String("cluster-dir", "", "cluster trust directory (node.crt/key + trusted pins); enables the LAN mTLS inference ingress when this node is clustered") + showVersion := flag.Bool("version", false, "print version and exit") + resolveLevel := applog.RegisterFlag(nil, slog.LevelInfo) + flag.Parse() + + if *showVersion { + fmt.Println(Version) + os.Exit(0) + } + + applog.Init("mlx-proxy", resolveLevel()) + + var transport io.ReadWriteCloser + if *ipcPath != "" { + conn, err := dialIPC(*ipcPath) + if err != nil { + log.Fatalf("failed to connect to IPC endpoint %q: %v", *ipcPath, err) + } + transport = conn + log.Printf("using IPC transport: %s", *ipcPath) + } else { + transport = newStdioTransport() + log.Print("using stdio transport") + } + defer transport.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Stdin EOF is this process's usual "my parent is gone" signal, but it is + // only delivered once EVERY holder of the pipe closes it -- and an Electron + // helper that outlives the app inherits that descriptor. Watching the parent + // directly is what actually guarantees no orphan is left holding a port. + defer parentwatch.Start("mlx-proxy", cancel)() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + select { + case sig := <-sigCh: + log.Printf("received %s, shutting down", sig) + cancel() + case <-ctx.Done(): + } + }() + + // Restore a previously chosen port (set via set-port) over the + // --port/default, so the proxy comes back up where the user last put it. + persisted, hasPersisted := loadPersistedPort() + effectivePort := chooseStartupPort(*port, *ignorePersistedPort, persisted, hasPersisted) + if hasPersisted && !*ignorePersistedPort && effectivePort == persisted { + log.Printf("restored persisted proxy port %d", persisted) + } + + codec := NewCodec(transport) + disc := NewDiscovery() + proxy := NewProxy(codec, disc, effectivePort) + // Open a live view of this node's cluster mTLS trust fabric. While unclustered + // the proxy serves only the loopback plaintext personality; once this node is + // a member the same listener also serves the pin-gated LAN mTLS ingress, and + // peers become routable candidates. The proxy needs no restart to notice + // either transition — it re-derives membership per request and on a watch. + // + // Membership is gated on an active admission or a pin, never on keypair + // presence: a left/removed node keeps its keypair by design, and would + // otherwise keep logging cluster_ingress with no cluster peers to serve. + proxy.mesh = clustertrust.Open(*clusterDir) + go proxy.mesh.Watch(ctx, func(clustered bool) { + slog.Info("cluster inference ingress switched personality", "cluster_ingress", clustered) + proxy.dropUnpinnedPeerTransports() + }) + + if err := proxy.Run(ctx); err != nil && ctx.Err() == nil { + log.Fatalf("proxy error: %v", err) + } + log.Print("shutdown complete") +} diff --git a/services/mlx-proxy/portstore.go b/services/mlx-proxy/portstore.go new file mode 100644 index 00000000..9f5b20c4 --- /dev/null +++ b/services/mlx-proxy/portstore.go @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + + "nvpair-shared/appdir" +) + +const proxyPortFile = "mlx-proxy-port.json" + +const ( + defaultProxyPort = 8080 + legacyDefaultProxyPort = 8081 +) + +type persistedPort struct { + Port int `json:"port"` +} + +func chooseStartupPort(flagPort int, ignorePersisted bool, persisted int, hasPersisted bool) int { + if ignorePersisted || !hasPersisted || persisted == legacyDefaultProxyPort { + return flagPort + } + return persisted +} + +func proxyPortPath() (string, error) { + return appdir.Path(proxyPortFile) +} + +// loadPersistedPort returns the previously chosen proxy port, if a valid one +// was saved. Any error (no file, bad JSON, out-of-range) reports "none" so +// startup falls back to the --port flag / default. +func loadPersistedPort() (int, bool) { + path, err := proxyPortPath() + if err != nil { + return 0, false + } + data, err := os.ReadFile(path) + if err != nil { + return 0, false + } + var pp persistedPort + if err := json.Unmarshal(data, &pp); err != nil { + return 0, false + } + if pp.Port < 1 || pp.Port > 65535 { + return 0, false + } + return pp.Port, true +} + +// savePersistedPort atomically writes the chosen port (tmp + rename) so a +// crash mid-write can't leave a truncated file behind. +func savePersistedPort(port int) error { + path, err := proxyPortPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.Marshal(persistedPort{Port: port}) + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return err + } + return nil +} diff --git a/services/mlx-proxy/portstore_test.go b/services/mlx-proxy/portstore_test.go new file mode 100644 index 00000000..57d51b0b --- /dev/null +++ b/services/mlx-proxy/portstore_test.go @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "context" + "fmt" + "net" + "os" + "strings" + "testing" + "time" +) + +// redirectConfigDir points os.UserConfigDir() at a temp dir for the test, so +// proxy-port.json reads/writes don't touch the real per-user config. Sets all +// three env vars os.UserConfigDir() consults across platforms: XDG_CONFIG_HOME +// on Linux, $HOME/Library on macOS, and APPDATA on Windows. Missing APPDATA +// meant the Windows-first-class path read/wrote the real %AppData% file — +// clobbering the user's saved port and making the test fail on repeat runs. +func redirectConfigDir(t *testing.T) { + t.Helper() + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("HOME", dir) + t.Setenv("APPDATA", dir) + t.Setenv("LOCALAPPDATA", dir) +} + +func freeTCPPort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve free port: %v", err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +} + +func TestDefaultProxyPort(t *testing.T) { + if defaultProxyPort != 8080 { + t.Fatalf("default proxy port = %d, want 8080", defaultProxyPort) + } +} + +func TestChooseStartupPort(t *testing.T) { + for _, tc := range []struct { + name string + flagPort, persisted int + ignorePersisted, hasPersisted bool + want int + }{ + {"new default", 8080, 0, false, false, 8080}, + {"engine port never restored", 8080, 8081, false, true, 8080}, + {"custom survives opt-out", 8080, 12400, false, true, 12400}, + {"managed flag wins", 8080, 12400, true, true, 8080}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := chooseStartupPort(tc.flagPort, tc.ignorePersisted, tc.persisted, tc.hasPersisted); got != tc.want { + t.Fatalf("chooseStartupPort() = %d, want %d", got, tc.want) + } + }) + } +} + +func TestPersistedPortRoundTrip(t *testing.T) { + redirectConfigDir(t) + + if _, ok := loadPersistedPort(); ok { + t.Fatal("expected no persisted port before any save") + } + if err := savePersistedPort(11500); err != nil { + t.Fatalf("savePersistedPort: %v", err) + } + if p, ok := loadPersistedPort(); !ok || p != 11500 { + t.Errorf("round-trip: got %d ok=%v, want 11500", p, ok) + } + + // An out-of-range stored value is treated as "none" so startup falls + // back to the flag/default rather than trying to bind port 0. + path, err := proxyPortPath() + if err != nil { + t.Fatalf("proxyPortPath: %v", err) + } + if err := os.WriteFile(path, []byte(`{"port":0}`), 0o644); err != nil { + t.Fatal(err) + } + if _, ok := loadPersistedPort(); ok { + t.Error("port 0 should be treated as none") + } +} + +// TestSetPortRebinds drives a live rebind: the proxy starts serving on one +// port, set-port moves it to another, and afterward the new port accepts +// connections, the old one doesn't, the choice is persisted, and a fresh +// ready notification carries the new port. +func TestSetPortRebinds(t *testing.T) { + redirectConfigDir(t) + + buf := &bytes.Buffer{} + codec := NewCodec(buf) + disc := NewDiscovery() + + portA := freeTCPPort(t) + proxy := NewProxy(codec, disc, portA) + + lnA, err := net.Listen("tcp", fmt.Sprintf(":%d", portA)) + if err != nil { + t.Fatalf("listen on port A: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + proxy.serveHTTP(ctx, lnA) + defer proxy.shutdown(context.Background()) + + portB := freeTCPPort(t) + if err := proxy.setPort(portB); err != nil { + t.Fatalf("setPort: %v", err) + } + + // New port is now serving. + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", portB), 2*time.Second) + if err != nil { + t.Fatalf("new port %d not listening after rebind: %v", portB, err) + } + conn.Close() + + // Old port stopped accepting. + if c, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", portA), 500*time.Millisecond); err == nil { + c.Close() + t.Errorf("old port %d should be closed after rebind", portA) + } + + // Persisted for next startup. + if p, ok := loadPersistedPort(); !ok || p != portB { + t.Errorf("persisted port: got %d ok=%v, want %d", p, ok, portB) + } + + // A fresh ready notification announced the new port. + if !strings.Contains(buf.String(), fmt.Sprintf("\"port\":%d", portB)) { + t.Errorf("expected ready notification carrying port %d, got %q", portB, buf.String()) + } +} diff --git a/services/mlx-proxy/priority_test.go b/services/mlx-proxy/priority_test.go new file mode 100644 index 00000000..63356679 --- /dev/null +++ b/services/mlx-proxy/priority_test.go @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "io" + "strconv" + "sync" + "testing" +) + +// prRec is a thread-safe io.ReadWriter that records codec writes so a test can +// assert the response emitted for a request. Reads hit EOF immediately. +type prRec struct { + mu sync.Mutex + b []byte +} + +func (r *prRec) Read([]byte) (int, error) { return 0, io.EOF } + +func (r *prRec) Write(p []byte) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.b = append(r.b, p...) + return len(p), nil +} + +func (r *prRec) has(s string) bool { + r.mu.Lock() + defer r.mu.Unlock() + return string(r.b) != "" && contains(string(r.b), s) +} + +func contains(hay, needle string) bool { + for i := 0; i+len(needle) <= len(hay); i++ { + if hay[i:i+len(needle)] == needle { + return true + } + } + return false +} + +// prNode builds a discovery Node with a single non-local address so +// resolveCandidates resolves it deterministically (single candidate → no TCP +// probe) and it never trips the loopback rewrite or self-forward guard. The +// octet is derived from the id's first byte so each id gets a distinct valid IP. +func prNode(id string) Node { + return Node{ID: id, Addresses: []string{"192.0.2." + strconv.Itoa(int(id[0]))}, Port: 1234} +} + +// prProxy returns a proxy whose discovery holds the given node ids. +func prProxy(t *testing.T, ids ...string) *Proxy { + t.Helper() + disc := NewDiscovery() + for _, id := range ids { + disc.AddManual(prNode(id)) + } + return testProxy(disc, 1235) +} + +func candidateIDs(p *Proxy) []string { + return candidateIDsForModel(p, "") +} + +func candidateIDsForModel(p *Proxy, model string) []string { + cands := p.resolveCandidates(model) + out := make([]string, 0, len(cands)) + for _, c := range cands { + out = append(out, c.id) + } + return out +} + +func assertOrder(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("candidate order = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("candidate order = %v, want %v", got, want) + } + } +} + +// TestResolveCandidates_PriorityOrder: the priority list dictates auto order. +func TestResolveCandidates_PriorityOrder(t *testing.T) { + p := prProxy(t, "a", "b", "c") + p.SetPriority([]string{"c", "a", "b"}) + assertOrder(t, candidateIDs(p), []string{"c", "a", "b"}) +} + +// TestResolveCandidates_UnlistedFallback: nodes absent from the priority list +// come last, in stable ID order. +func TestResolveCandidates_UnlistedFallback(t *testing.T) { + p := prProxy(t, "a", "b", "c") + p.SetPriority([]string{"b"}) + assertOrder(t, candidateIDs(p), []string{"b", "a", "c"}) +} + +// TestResolveCandidates_UnknownIgnored: an id not in discovery is skipped. +func TestResolveCandidates_UnknownIgnored(t *testing.T) { + p := prProxy(t, "a", "b", "c") + p.SetPriority([]string{"zzz", "c"}) + assertOrder(t, candidateIDs(p), []string{"c", "a", "b"}) +} + +// TestResolveCandidates_ManualPinOverridesPriority: an explicit node/select pin +// wins over the priority list; the rest follow priority order. +func TestResolveCandidates_ManualPinOverridesPriority(t *testing.T) { + p := prProxy(t, "a", "b", "c") + p.SetPriority([]string{"a", "b", "c"}) + p.SetSelected("b") + assertOrder(t, candidateIDs(p), []string{"b", "a", "c"}) +} + +func TestResolveCandidates_FiltersBeforeSelectionAndPriority(t *testing.T) { + disc := NewDiscovery() + a, c, d := prNode("a"), prNode("c"), prNode("d") + a.Models, c.Models, d.Models = []string{"llama"}, []string{"llama"}, []string{"mistral"} + for _, n := range []Node{a, prNode("b"), c, d} { + disc.AddManual(n) + } + p := testProxy(disc, 1235) + p.SetSelected("d") + p.SetPriority([]string{"d", "b", "c", "a"}) + assertOrder(t, candidateIDsForModel(p, "llama"), []string{"c", "a"}) + + p.SetSelected("a") + assertOrder(t, candidateIDsForModel(p, "llama"), []string{"a", "c"}) +} + +// TestResolveCandidates_EmptyReverts: an empty priority list reverts to the +// default stable ID order. +func TestResolveCandidates_EmptyReverts(t *testing.T) { + p := prProxy(t, "c", "a", "b") + p.SetPriority([]string{"c", "a"}) + p.SetPriority(nil) // clear + assertOrder(t, candidateIDs(p), []string{"a", "b", "c"}) +} + +// TestSetPriority_CountAndCopy: SetPriority returns the stored length and +// PriorityList hands back an independent copy. +func TestSetPriority_CountAndCopy(t *testing.T) { + p := prProxy(t, "a") + if n := p.SetPriority([]string{"a", "b", "c"}); n != 3 { + t.Fatalf("SetPriority count = %d, want 3", n) + } + got := p.PriorityList() + got[0] = "mutated" + if again := p.PriorityList(); again[0] != "a" { + t.Fatalf("PriorityList returned an aliased slice: %v", again) + } +} + +// TestHandleSetPriority_Response: the node/set-priority request returns {count}. +func TestHandleSetPriority_Response(t *testing.T) { + rec := &prRec{} + p := NewProxy(NewCodec(rec), NewDiscovery(), 1235) + + id := json.RawMessage(`7`) + p.handleMessage(&Message{ + JSONRPC: "2.0", + ID: &id, + Method: "node/set-priority", + Params: json.RawMessage(`{"nodes":["x","y"]}`), + }) + + if !rec.has(`"count":2`) { + t.Fatalf("expected response with count=2, got: %s", rec.b) + } + if got := p.PriorityList(); len(got) != 2 || got[0] != "x" || got[1] != "y" { + t.Fatalf("stored priority = %v, want [x y]", got) + } +} diff --git a/services/mlx-proxy/proxy.go b/services/mlx-proxy/proxy.go new file mode 100644 index 00000000..01840e9c --- /dev/null +++ b/services/mlx-proxy/proxy.go @@ -0,0 +1,2104 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/tls" + "encoding/hex" + "encoding/json" + stderrors "errors" + "fmt" + "io" + "log" + "log/slog" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "sort" + "strconv" + "sync" + "sync/atomic" + "time" + + "nvpair-shared/applog" + "nvpair-shared/clustertrust" + "nvpair-shared/cors" + "nvpair-shared/errors" + "nvpair-shared/netmon" + "nvpair-shared/netpick" + "nvpair-shared/nodeactivity" + "nvpair-shared/noderec" + "nvpair-shared/reach" + "nvpair-shared/schedulerwire" + "nvpair-shared/splitlisten" +) + +// Version is stamped at build time via -ldflags "-X main.Version=...". +// See versions.json at the repo root for the source of truth. +var Version = "dev" + +type ReadyParams struct { + Version string `json:"version"` + Port int `json:"port"` +} + +// ErrorParams is sent as a JSON-RPC "error" notification when the +// proxy encounters a fatal startup-time condition it wants to surface +// to the orchestrator before exiting. Code is a short machine-readable +// tag ("bind-failed" today); Message is a human-friendly string suitable +// for an error bar. +type ErrorParams struct { + Code string `json:"code"` + Message string `json:"message"` + Port int `json:"port,omitempty"` +} + +type NodesResult struct { + Nodes []Node `json:"nodes"` +} + +type SelectParams struct { + ID string `json:"id"` +} + +type SelectedResult struct { + ID string `json:"id"` +} + +// RequestStartedEvent is emitted as a `proxy/request-started` +// notification the moment we've resolved a target node and are about +// to forward the request to it. Pairs with the existing +// `proxy/request` completion event by ID so the orchestrator can +// track in-flight requests per target — increment on start, decrement +// on the matching completion. Rejection-path requests (no active +// node) never get a started event because they were never in flight; +// they go straight to a completion event with an unmatched ID. +// +// NodeID is the chosen node's identifier in the discovery list. It's +// the authoritative way to attribute activity to a node card in the +// UI: Target (host:port) would be ambiguous whenever the proxy +// rewrote a local-interface address to 127.0.0.1 (see nodeURL), so +// multiple nodes could plausibly match the same Target string. +// Cluster model-list fan-out has no single node, so it reports an empty +// NodeID and the explicit Target "cluster". +type RequestStartedEvent struct { + ID string `json:"id"` + NodeID string `json:"node_id,omitempty"` + Method string `json:"method"` + Path string `json:"path"` + Target string `json:"target"` +} + +// RequestEvent is emitted as a `proxy/request` notification when a +// proxied request finishes (or is rejected before forwarding). The +// ID is unique within one proxy process lifetime — paired with the +// matching RequestStartedEvent so consumers can pop it from an +// in-flight map. ID is always populated even on the rejection path +// (where no Started event was emitted) so consumers don't need a +// separate code path for ID-less completions. +// +// NodeID is empty on the rejection path (no target was resolved) and +// on cluster model-list fan-out, and is the chosen node's identifier otherwise. See RequestStartedEvent +// for why attribution by NodeID is needed instead of by Target. +// +// TTFB is the time-to-first-byte: milliseconds from the moment we +// started forwarding to the node until its HTTP response status line +// came back, captured via ReverseProxy.ModifyResponse. Omitted +// (serialized as absent rather than zero) when not applicable: +// rejection path (no forward happened) and upstream-error path +// (ModifyResponse is never called on connection/dial failures). This +// is the "is the node snappy?" signal — distinct from Duration, +// which for streaming MLX responses is dominated by token +// generation time and so doesn't really reflect latency at all. +type RequestEvent struct { + ID string `json:"id"` + NodeID string `json:"node_id,omitempty"` + Method string `json:"method"` + Path string `json:"path"` + Target string `json:"target"` + Status int `json:"status"` + Duration int64 `json:"duration_ms"` + TTFB int64 `json:"ttfb_ms,omitempty"` + Error string `json:"error,omitempty"` +} + +// Workload lifecycle method names (workload-manager spec 7). The proxy is +// a workload *producer*: it emits one of these per forwarded inference +// request so the broker can stamp the origin (originatedFrom) and forward it to the +// workload-manager, which broadcasts it cluster-wide. We don't emit +// workload:submitted (the proxy never queues — it forwards immediately) or +// workloads:remove (retirement is a broker concern). +const ( + workloadStartedMethod = "workload:started" + workloadCompletedMethod = "workload:completed" + workloadErroredMethod = "workload:errored" + + // workloadEngine is the opaque engine identifier carried in every + // workload this proxy produces. This proxy only ever fronts MLX. + workloadEngine = "mlx" +) + +// inferenceEndpoints is the set of request paths that count as cluster +// workloads. Health checks, model listings (/v1/models), and other control +// traffic are deliberately excluded so we don't flood the cluster with +// non-inference noise. MLX serves the OpenAI-compatible API, so these +// are the OpenAI inference routes. +var inferenceEndpoints = map[string]bool{ + "/v1/chat/completions": true, + "/v1/completions": true, + "/v1/embeddings": true, +} + +// isInferenceRequest reports whether a request should be tracked as a +// workload — a POST to one of the known inference endpoints. +func isInferenceRequest(method, path string) bool { + return method == http.MethodPost && inferenceEndpoints[path] +} + +// Workload mirrors the workload-manager spec 6 object. The proxy populates +// the fields it can observe: originatedFrom is intentionally left empty for +// the broker to stamp with the authoritative local node id (exactly like +// errors:report), while scheduledOn is set to the node this proxy actually +// routed the request to (the served candidate's node id — the same +// authoritative attribution handle), so a consumer can attribute the workload +// to where it ran rather than to where it came from. requesterId is omitted. +// Pointer fields serialize as JSON null when unset, matching the spec's +// nullable columns. +type Workload struct { + ID string `json:"id"` + Model string `json:"model"` + Engine string `json:"engine"` + RunID string `json:"runId"` + State string `json:"state"` + OriginatedFrom string `json:"originatedFrom"` + ScheduledOn string `json:"scheduledOn,omitempty"` + CreatedAt int64 `json:"createdAt"` + StartedAt *int64 `json:"startedAt"` + CompletedAt *int64 `json:"completedAt"` + Error *string `json:"error"` + RequesterID *string `json:"requesterId"` +} + +// workloadParams is the params envelope for a workload:* notification +// (spec 7.1): a single workloadInfo carrying the full Workload. +type workloadParams struct { + WorkloadInfo Workload `json:"workloadInfo"` +} + +// bufferBodyAndModel reads the request body once and returns the raw bytes +// (so each failover attempt can replay it — see the loop in handleHTTP) along +// with the JSON "model" field for workload tracking. Inference bodies are +// small (prompt + model), so full buffering is cheap. Returns (nil, "") when +// the body is absent and an empty model when none is parseable. The caller +// restores r.Body from the returned bytes before each forward attempt. +func bufferBodyAndModel(r *http.Request) ([]byte, string) { + if r.Body == nil { + return nil, "" + } + body, err := io.ReadAll(r.Body) + _ = r.Body.Close() + if err != nil { + return body, "" + } + var probe struct { + Model string `json:"model"` + } + if err := json.Unmarshal(body, &probe); err != nil { + return body, "" + } + return body, probe.Model +} + +type statusCapture struct { + http.ResponseWriter + status int + + // idle bounds how long a single write of streamed bytes to the client may + // block before it's abandoned. Zero disables the deadline. See + // idleClientWriteTimeout for the rationale (killed-client / half-open + // socket zombie jobs). + idle time.Duration + rc *http.ResponseController + // wroteErr retains the first error returned when writing the response body + // to the client (e.g. a dead client's send buffer filling and the write + // deadline tripping), so handleHTTP can mark the workload failed rather + // than misreporting the truncated stream as completed. + wroteErr error + + // upstreamAlive is called after each successful body write, but only once the + // upstream has committed — handleHTTP sets it in ModifyResponse, so it stays + // nil while the only thing this writer could carry is the proxy's own error + // body. Past that point every byte written came from the node serving the + // request, which is proof that node is working: the liveness evidence + // discovery cannot obtain for itself while the node is too busy to answer a + // probe. Called on the reverse proxy's copy goroutine, so it must be cheap. + upstreamAlive func() +} + +// Unwrap exposes the underlying ResponseWriter so http.ResponseController can +// reach the connection for SetWriteDeadline (and Flush) through this wrapper. +func (sc *statusCapture) Unwrap() http.ResponseWriter { return sc.ResponseWriter } + +func (sc *statusCapture) WriteHeader(code int) { + sc.status = code + sc.ResponseWriter.WriteHeader(code) +} + +// Write bounds each streamed write to the client with a deadline so a write +// blocked on a dead/half-open client fails promptly instead of hanging the +// reverse-proxy copy indefinitely. The deadline is cleared after every +// successful write, so a legitimately slow generation with long gaps between +// tokens is never penalized — only a write actively stuck on a gone client +// trips it. The first write error is retained (wroteErr) for the caller. +func (sc *statusCapture) Write(b []byte) (int, error) { + if sc.idle > 0 { + if sc.rc == nil { + sc.rc = http.NewResponseController(sc.ResponseWriter) + } + _ = sc.rc.SetWriteDeadline(time.Now().Add(sc.idle)) + } + n, err := sc.ResponseWriter.Write(b) + if err != nil { + if sc.wroteErr == nil { + sc.wroteErr = err + } + } else if sc.rc != nil { + _ = sc.rc.SetWriteDeadline(time.Time{}) + } + // Reported on every chunk rather than once per response so a long generation + // keeps vouching for its node for as long as it streams. The reporter + // coalesces, so the cost of calling this per chunk is a mutex and a clock + // read. + if err == nil && sc.upstreamAlive != nil { + sc.upstreamAlive() + } + return n, err +} + +// FlushError makes the streamed flush deadline-aware. For a streaming +// (chunked) upstream, ReverseProxy flushes after every write via +// http.NewResponseController(w).Flush — and because a small chunk buffers on +// Write without touching the socket, the actual network write for it happens +// here in Flush, not in Write. Without this method that flush reaches the +// underlying connection through Unwrap with no deadline and blocks unbounded on +// a stalled client (the same zombie the Write deadline guards against). So arm +// the same idle deadline around the flush, clear it on success, and retain a +// real flush error so the workload is classified failed. Implementing +// FlushError (which also satisfies the Flusher path via the ResponseController) +// means the flush routes through here instead of unwrapping past us. +func (sc *statusCapture) FlushError() error { + if sc.rc == nil { + sc.rc = http.NewResponseController(sc.ResponseWriter) + } + if sc.idle > 0 { + _ = sc.rc.SetWriteDeadline(time.Now().Add(sc.idle)) + } + err := sc.rc.Flush() + if err != nil { + // A ResponseWriter that genuinely can't flush is not a client failure; + // only retain real I/O errors (e.g. the deadline tripping on a dead + // client) so we don't misreport an unsupported-flush as a failed write. + if !stderrors.Is(err, http.ErrNotSupported) && sc.wroteErr == nil { + sc.wroteErr = err + } + return err + } + if sc.idle > 0 { + _ = sc.rc.SetWriteDeadline(time.Time{}) + } + return nil +} + +type Proxy struct { + codec *Codec + discovery *Discovery + cancel context.CancelFunc + + // httpMu guards port, the servers, the split listener, and ln across a + // live set-port rebind. The HTTP handlers never read port (they route by + // upstream node), so the only contention is set-port vs set-port + // (serialized) and the initial serveHTTP store vs a later rebind. + httpMu sync.Mutex + port int + plainSrv *http.Server + tlsSrv *http.Server + split *splitlisten.Splitter + ln net.Listener + + // mesh is this node's cluster mTLS trust fabric, loaded from --cluster-dir. + // nil = unclustered: the LAN TLS ingress accepts nothing and the node does + // only loopback-plaintext local routing. Read-only after startup. + mesh *clustertrust.Mesh + + // backendMu guards backend, the explicit loopback engine the cluster mTLS + // ingress forwards to. The broker sets/clears it via node/set-local-backend; + // it is never sourced from discovery, so an ingress request can only ever + // reach this node's own local engine and can never be re-routed to a peer. + backendMu sync.RWMutex + backend localBackend + + selectedMu sync.RWMutex + selectedID string + + // activity coalesces the liveness reports raised when a peer's engine streams + // response bytes back through us (see reportActivity). + activity *nodeactivity.Reporter + + // priorityMu guards the scheduler's authoritative baseline and the + // optimistic reservations made since that snapshot arrived. resolveCandidates + // reads priority to form the failover list; reserveCandidate atomically adds + // local dispatches before forwarding so a concurrent burst cannot repeatedly + // choose from the same stale scheduler state. + priorityMu sync.RWMutex + priority []string + priorityPending map[string]int + priorityGPUPressure map[string]int + priorityReservations map[string]int + + // targets remembers, per node, which of its published addresses accepted a + // connection, so a repeated forward costs no confirmation. An entry is + // re-confirmed when the node's candidate list changes and forgotten on an + // upstream error, so the next request fails over to another address. + targets *reach.Chooser + + // transportMu guards the long-lived HTTP transports reused across forwards + // and model-list fetches. Allocating a new http.Transport per request + // defeats connection pooling and leaks idle sockets until GC. + transportMu sync.Mutex + plainTransport *http.Transport + peerTransports map[string]*http.Transport + + // nextRequestID is a monotonic counter for tagging RequestStarted / + // RequestEvent pairs. Atomic add returns the new value, so request + // IDs start at 1 and never collide within a single proxy lifetime. + // IDs deliberately reset across restarts — they're only meaningful + // while the orchestrator's in-flight map is also alive, and a + // fresh proxy session always starts that map empty on the + // orchestrator side via the proxy:stopped → proxy:ready event + // pair. + nextRequestID atomic.Uint64 + + // runID is a per-process nonce minted at startup and stamped on every + // workload this proxy emits. It makes a workload's identity + // (originatedFrom, engine, runId, id) globally unique even though + // nextRequestID resets to 1 on restart and the Ollama proxy also counts + // from 1 — without it, two concurrent cross-engine jobs, or a reused id + // after a restart, would collide in the broker's store. + runID string +} + +func NewProxy(codec *Codec, discovery *Discovery, port int) *Proxy { + return &Proxy{ + codec: codec, + discovery: discovery, + port: port, + targets: reach.NewChooser(), + runID: newRunID(), + activity: nodeactivity.NewReporter(activityReportInterval), + } +} + +// activityReportInterval is how often a single node's streaming may raise a +// liveness report. A generation writes hundreds of chunks and the scanner treats +// a report as good for a minute, so anything finer is pure noise on the broker +// pipe. +const activityReportInterval = 2 * time.Second + +// reportActivity tells the broker a node's engine just returned response bytes, +// so discovery can keep that node even while it is too busy to answer a liveness +// probe. This is the only liveness signal that strengthens under load, which is +// exactly when the probe-based ones fail. +// +// Reports are not filtered to remote nodes here: this proxy knows targets by URL +// and port, not by whether a uuid is its own. The scanner holds that identity and +// drops its own (see noteActivity). +func (p *Proxy) reportActivity(nodeID string) { + if !p.activity.Due(nodeID) { + return + } + if err := p.codec.Notify(noderec.NotifyNodeActivity, noderec.NodeActivityParams{HostUUID: nodeID}); err != nil { + slog.Debug("failed to report node activity", "node_id", nodeID, "err", err) + } +} + +// newRunID returns a short random per-process nonce (hex). A crypto/rand read +// failure falls back to a timestamp — uniqueness matters more than +// unpredictability here. +func newRunID() string { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return strconv.FormatInt(time.Now().UnixNano(), 16) + } + return hex.EncodeToString(b[:]) +} + +func (p *Proxy) Run(ctx context.Context) error { + ctx, cancel := context.WithCancel(ctx) + p.cancel = cancel + defer cancel() + + // Keep the "is this address local?" set fresh as interfaces come and go, + // so the loopback rewrite in nodeURL stays correct after VPN/dock changes + // or a sleep/wake IP reassignment. + startLocalAddrWatch(ctx) + + // Bind synchronously before announcing "ready": if the port is + // already in use, we want the UI to see a real error reason + // instead of being stuck on "Proxy running" while ListenAndServe + // silently fails in a goroutine. + ln, err := p.listen() + if err != nil { + // Best-effort: notify the orchestrator with a structured + // reason. The process will exit non-zero regardless (main.go + // log.Fatalf's on a non-nil Run error), so a failed Notify + // here is not worth surfacing separately. + _ = p.codec.Notify("error", ErrorParams{ + Code: "bind-failed", + Message: fmt.Sprintf("failed to bind port %d: %v", p.port, err), + Port: p.port, + }) + return fmt.Errorf("failed to bind port %d: %w", p.port, err) + } + + if err := p.codec.Notify("ready", ReadyParams{ + Version: Version, + Port: p.port, + }); err != nil { + // Don't leave a dangling listener holding the port if we + // couldn't even tell the orchestrator about it. + _ = ln.Close() + return fmt.Errorf("failed to send ready notification: %w", err) + } + + // Routing targets come from the broker's discovery relay. Subscribe + // for lm nodes; they arrive as discovery:nodes snapshots (handled in + // handleMessage), each replacing the subscribed overlay. Non-fatal: if the + // parent isn't a relay-aware broker the proxy still routes to manual nodes. + slog.Debug("subscribing to discovery relay for routing targets", "service", string(noderec.ServiceMLX)) + if err := p.codec.Notify(noderec.MethodSubscribe, noderec.SubscribeParams{Services: []noderec.ServiceKey{noderec.ServiceMLX}}); err != nil { + slog.Warn("failed to subscribe to discovery relay", "err", err) + } + + p.serveHTTP(ctx, ln) + + err = p.readLoop(ctx) + + // The app is going away (stdin closed or ctx cancelled). Stop any + // inference requests still in flight rather than letting them run to + // completion: cancelling the proxy's root context propagates to every + // in-flight request context — and thus the upstream reverse-proxy + // connection — so the target MLX sees the client disconnect and stops + // generating instead of burning the GPU on a result nobody will read. + cancel() + + // srv.Shutdown then waits for the handlers to unwind (now fast, since + // their upstream calls were just cancelled). As each returns it emits its + // own terminal workload:errored, so peers don't keep showing the workload + // as a "running" ghost. A hard kill (SIGKILL) bypasses all of this. + shutCtx, shutCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer shutCancel() + p.shutdown(shutCtx) + + return err +} + +// Timeouts for upstream connections. Logged at startup so they're always +// present in any captured log for post-mortem analysis. +const ( + proxyDialTimeout = 10 * time.Second + proxyKeepAlive = 30 * time.Second + proxyResponseTimeout = 120 * time.Second + proxyMaxIdleConns = 50 + proxyIdleConnTimeout = 90 * time.Second + // Inbound http.Server limits — keep IdleTimeout aligned with client + // IdleConnTimeout so idle keep-alives are reaped on both sides. + proxyReadHeaderTimeout = 10 * time.Second + proxyServerIdleTimeout = 90 * time.Second + maxModelListBytes = 16 << 20 +) + +// idleClientWriteTimeout bounds how long a single write of streamed response +// bytes to the client may block. A killed client can leave a half-open socket +// whose kernel send buffer fills and never drains; without this deadline the +// reverse-proxy copy blocks indefinitely (TCP retransmit backoff runs into +// minutes, and r.Context() never fires when no FIN/RST arrives), so the request +// handler never returns and its terminal workload event is never emitted — the +// "zombie job" left showing as running until PAIR restarts. statusCapture.Write +// resets the deadline after every successful write, so this only trips a write +// that is actively stuck on a gone client, never a slow-but-live generation. +// +// It is a var (not a const) only so a test can shorten it to exercise the +// deadline against a real socket; production never reassigns it. +var idleClientWriteTimeout = 30 * time.Second + +var modelListClient = &http.Client{ + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: proxyDialTimeout, + KeepAlive: proxyKeepAlive, + }).DialContext, + ResponseHeaderTimeout: proxyDialTimeout, + MaxIdleConns: proxyMaxIdleConns, + IdleConnTimeout: proxyIdleConnTimeout, + }, + Timeout: proxyDialTimeout, +} + +// listen binds the proxy's TCP listener synchronously so bind failures +// (EADDRINUSE and friends) can be reported through a structured error +// notification before the process exits. The caller is responsible for +// closing the returned listener if it doesn't hand it to serveHTTP. +func (p *Proxy) listen() (net.Listener, error) { + return net.Listen("tcp", fmt.Sprintf(":%d", p.port)) +} + +// serveHTTP takes the already-bound base listener and drives the two proxy +// personalities over it: a plaintext HTTP server (loopback-only, full local +// router) and a LAN mTLS ingress (pin-gated, forwards to the local engine), +// split by the connection's first byte via nvpair-shared/splitlisten. The two +// http.Servers are recorded so set-port can rebind both onto a fresh split +// without tearing the servers down. +func (p *Proxy) serveHTTP(ctx context.Context, ln net.Listener) { + base := func(_ net.Listener) context.Context { return ctx } + plainSrv := &http.Server{ + Handler: http.HandlerFunc(p.handlePlain), + BaseContext: base, + ReadHeaderTimeout: proxyReadHeaderTimeout, + IdleTimeout: proxyServerIdleTimeout, + } + tlsSrv := &http.Server{ + Handler: http.HandlerFunc(p.handleClusterIngress), + BaseContext: base, + ReadHeaderTimeout: proxyReadHeaderTimeout, + IdleTimeout: proxyServerIdleTimeout, + } + + p.httpMu.Lock() + p.plainSrv = plainSrv + p.tlsSrv = tlsSrv + p.ln = ln + p.startSplitLocked(ln) + p.httpMu.Unlock() + + slog.Info("proxy timeouts configured", + "dial_timeout", proxyDialTimeout, + "keep_alive", proxyKeepAlive, + "response_header_timeout", proxyResponseTimeout, + "max_idle_conns", proxyMaxIdleConns, + "idle_conn_timeout", proxyIdleConnTimeout, + ) + slog.Info("HTTP proxy listening", "port", p.port, "addr", ln.Addr().String(), + "cluster_ingress", p.mesh.Clustered()) +} + +// startSplitLocked wraps base in a first-byte splitter and starts both servers +// on its sub-listeners. Caller holds httpMu. Reuses the persistent plainSrv / +// tlsSrv so set-port can call it repeatedly on fresh listeners. +func (p *Proxy) startSplitLocked(base net.Listener) { + split := splitlisten.New(base) + p.split = split + go func() { + if err := p.plainSrv.Serve(split.Plain()); err != nil && err != http.ErrServerClosed { + slog.Error("plaintext HTTP server exited", "err", err) + } + }() + go p.serveTLS(split.TLS()) +} + +// serveTLS terminates cluster mTLS on the split's TLS sub-listener. The server +// certificate is resolved per handshake from the live mesh, so this one +// sub-listener covers both states: while this node is unclustered there is no +// leaf to present and the handshake is refused (it exposes no LAN inference +// surface), and the moment the node becomes a member the same sub-listener +// serves the pin-gated ingress — no rebind, and no process restart to pick up a +// freshly-minted identity. +func (p *Proxy) serveTLS(l net.Listener) { + if err := p.tlsSrv.Serve(tls.NewListener(l, p.mesh.ServerTLSConfig())); err != nil && err != http.ErrServerClosed { + slog.Error("cluster mTLS ingress exited", "err", err) + } +} + +// shutdown gracefully stops both personalities and closes the split listener. +func (p *Proxy) shutdown(ctx context.Context) { + p.httpMu.Lock() + plainSrv, tlsSrv, split := p.plainSrv, p.tlsSrv, p.split + p.httpMu.Unlock() + if plainSrv != nil { + _ = plainSrv.Shutdown(ctx) + } + if tlsSrv != nil { + _ = tlsSrv.Shutdown(ctx) + } + if split != nil { + _ = split.Close() + } + p.closeIdleTransports() +} + +// setPort live-rebinds the HTTP listener onto newPort and persists the choice +// so it survives a restart. It binds the new listener first (so a bind +// failure leaves the current one serving), starts the same server on it, then +// closes the old listener — in-flight connections on the old port drain +// naturally. A fresh `ready` notification announces the new port so the +// orchestrator/UI learn where the proxy is now listening. +func (p *Proxy) setPort(newPort int) error { + p.httpMu.Lock() + defer p.httpMu.Unlock() + + if newPort == p.port { + return nil + } + newLn, err := net.Listen("tcp", fmt.Sprintf(":%d", newPort)) + if err != nil { + return fmt.Errorf("failed to bind port %d: %w", newPort, err) + } + oldSplit := p.split + p.ln = newLn + p.port = newPort + + // Re-serve both personalities on a fresh split over the new listener, then + // close the old split (and its base listener) so in-flight connections on + // the old port drain naturally. The plaintext and mTLS personalities always + // move together as one unit. + slog.Info("HTTP proxy listening", "port", newPort, "addr", newLn.Addr().String(), + "cluster_ingress", p.mesh.Clustered()) + p.startSplitLocked(newLn) + if oldSplit != nil { + _ = oldSplit.Close() + } + + if err := savePersistedPort(newPort); err != nil { + slog.Warn("failed to persist proxy port", "port", newPort, "err", err) + } + if err := p.codec.Notify("ready", ReadyParams{Version: Version, Port: newPort}); err != nil { + slog.Warn("failed to emit ready after rebind", "err", err) + } + return nil +} + +// emitWorkload sends a workload:* lifecycle notification to the +// orchestrator. The broker stamps the origin (originatedFrom) and forwards it to the +// workload-manager; a failed write is logged but never blocks the request. +func (p *Proxy) emitWorkload(method string, w Workload) { + if err := p.codec.Notify(method, workloadParams{WorkloadInfo: w}); err != nil { + slog.Warn("failed to emit workload notification", "method", method, "err", err) + } +} + +// candidate is one forwarding target: the node's discovery ID (the +// authoritative attribution handle, stable across nodeURL's 127.0.0.1 +// rewrite) and its resolved URL. peerUUID is set for a remote cluster peer: +// the request is dialed over cluster mTLS to the peer's promoted proxy (https), +// pinned to that peer's exact server cert. Empty peerUUID means a plain-HTTP +// dial — the local backend (self) or an explicit manual node. +type candidate struct { + id string + url *url.URL + peerUUID string +} + +// candidateTransport returns the reverse-proxy / model-list transport for a +// candidate. Plain/self/manual candidates share one long-lived Transport. +// Cluster peers share one long-lived mTLS Transport per peerUUID. Callers must +// not CloseIdleConnections on the returned value except via closeIdleTransports. +func (p *Proxy) candidateTransport(c candidate) *http.Transport { + if c.peerUUID == "" { + return p.plainHTTPTransport() + } + return p.peerHTTPTransport(c.peerUUID) +} + +func newProxyTransport(tlsCfg *tls.Config) *http.Transport { + tr := &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: proxyDialTimeout, + KeepAlive: proxyKeepAlive, + }).DialContext, + ResponseHeaderTimeout: proxyResponseTimeout, + MaxIdleConns: proxyMaxIdleConns, + MaxIdleConnsPerHost: proxyMaxIdleConns, + IdleConnTimeout: proxyIdleConnTimeout, + } + if tlsCfg != nil { + tr.TLSClientConfig = tlsCfg + } + return tr +} + +func (p *Proxy) plainHTTPTransport() *http.Transport { + p.transportMu.Lock() + defer p.transportMu.Unlock() + if p.plainTransport == nil { + p.plainTransport = newProxyTransport(nil) + } + return p.plainTransport +} + +func (p *Proxy) peerHTTPTransport(peerUUID string) *http.Transport { + p.transportMu.Lock() + defer p.transportMu.Unlock() + if tr, ok := p.peerTransports[peerUUID]; ok { + if p.mesh != nil && p.mesh.HasPin(peerUUID) { + return tr + } + tr.CloseIdleConnections() + delete(p.peerTransports, peerUUID) + } + if p.mesh == nil { + return newProxyTransport(nil) + } + cfg, ok := p.mesh.ClientTLSConfig(peerUUID) + if !ok { + return newProxyTransport(nil) + } + tr := newProxyTransport(cfg) + if p.peerTransports == nil { + p.peerTransports = make(map[string]*http.Transport) + } + p.peerTransports[peerUUID] = tr + return tr +} + +// dropUnpinnedPeerTransports closes idle conns for peer Transports whose pins +// are gone. Safe to call from the mesh Watch callback. +func (p *Proxy) dropUnpinnedPeerTransports() { + p.transportMu.Lock() + defer p.transportMu.Unlock() + for uuid, tr := range p.peerTransports { + if p.mesh != nil && p.mesh.HasPin(uuid) { + continue + } + tr.CloseIdleConnections() + delete(p.peerTransports, uuid) + } +} + +func (p *Proxy) closeIdleTransports() { + p.transportMu.Lock() + defer p.transportMu.Unlock() + if p.plainTransport != nil { + p.plainTransport.CloseIdleConnections() + } + for uuid, tr := range p.peerTransports { + tr.CloseIdleConnections() + delete(p.peerTransports, uuid) + } +} + +// retrySignal is returned from ModifyResponse to abort a retryable upstream +// response before its body streams to the client, so handleHTTP can fail over +// to the next candidate. It's a distinct type rather than errors.New(...) +// because this package aliases nvpair-shared/errors as `errors` (which has no New). +type retrySignal struct{} + +func (retrySignal) Error() string { return "mlx-proxy: retry next candidate" } + +type modelListItem struct { + key string + raw json.RawMessage +} + +type modelListResult struct { + items []modelListItem + ok bool + err error +} + +// serveModelList queries every MLX candidate concurrently and returns +// the native /v1/models envelope with duplicate IDs removed. Results are +// merged in candidate order, not completion order, so duplicate metadata is +// deterministic while an unavailable peer cannot hide healthy inventories. +func (p *Proxy) serveModelList(w http.ResponseWriter, r *http.Request, candidates []candidate) (int, error) { + writeJSON := func(status int, body []byte) { + cors.Apply(w.Header()) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(status) + _, _ = w.Write(body) + } + results := make([]modelListResult, len(candidates)) + var wg sync.WaitGroup + for i, cand := range candidates { + target := *cand.url + target.Path = r.URL.Path + target.RawPath = r.URL.RawPath + target.RawQuery = r.URL.RawQuery + upstream, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil) + if err != nil { + results[i].err = err + continue + } + upstream.Header.Set("Accept", "application/json") + + // A cluster-peer candidate is queried over mTLS to its promoted proxy; + // self/manual candidates use the shared plain client. + client := modelListClient + if cand.peerUUID != "" { + client = &http.Client{Timeout: modelListClient.Timeout, Transport: p.candidateTransport(cand)} + } + + wg.Add(1) + go func(i int, cand candidate, req *http.Request, client *http.Client) { + defer wg.Done() + resp, err := client.Do(req) + if err != nil { + p.targets.Forget(cand.id) + results[i].err = err + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + results[i].err = fmt.Errorf("upstream returned %s", resp.Status) + return + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxModelListBytes+1)) + if err != nil { + results[i].err = err + return + } + if len(body) > maxModelListBytes { + results[i].err = fmt.Errorf("model list exceeds %d bytes", maxModelListBytes) + return + } + var envelope struct { + Data *[]json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + results[i].err = err + return + } + if envelope.Data == nil { + results[i].err = fmt.Errorf("upstream response has no data array") + return + } + models := *envelope.Data + items := make([]modelListItem, 0, len(models)) + for _, raw := range models { + var identity struct { + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &identity); err != nil { + results[i].err = fmt.Errorf("invalid model record: %w", err) + return + } + if identity.ID == "" { + results[i].err = fmt.Errorf("model record has no id") + return + } + items = append(items, modelListItem{key: identity.ID, raw: raw}) + } + results[i] = modelListResult{items: items, ok: true} + }(i, cand, upstream, client) + } + wg.Wait() + + success := false + models := make([]json.RawMessage, 0) + seen := make(map[string]bool) + for i, result := range results { + if !result.ok { + slog.Debug("model list candidate unavailable", + "node_id", candidates[i].id, "target", candidates[i].url.Host, "err", result.err) + continue + } + success = true + for _, item := range result.items { + if !seen[item.key] { + seen[item.key] = true + models = append(models, item.raw) + } + } + } + if !success { + err := fmt.Errorf("no valid model list from %d candidate(s)", len(candidates)) + writeJSON(http.StatusServiceUnavailable, []byte(`{"error":"model inventory unavailable"}`)) + return http.StatusServiceUnavailable, err + } + body, err := json.Marshal(struct { + Object string `json:"object"` + Data []json.RawMessage `json:"data"` + }{Object: "list", Data: models}) + if err != nil { + writeJSON(http.StatusInternalServerError, []byte(`{"error":"failed to encode model inventory"}`)) + return http.StatusInternalServerError, err + } + writeJSON(http.StatusOK, body) + return http.StatusOK, nil +} + +func (p *Proxy) handleHTTP(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Allocate the request ID up front so both code paths (rejection + // and forward) can stamp the same value into their notification. + // The rejection path never emits a Started event, so its ID won't + // appear in any orchestrator in-flight map — that's fine; the + // completion event still bumps the failed counter regardless of + // whether a matching Started was seen. + reqID := strconv.FormatUint(p.nextRequestID.Add(1), 10) + + // Parse the request's model before choosing a node. Model eligibility only + // applies to inference routes; control endpoints retain their existing + // routing behavior even when their JSON happens to contain a model field. + bodyBytes, model := bufferBodyAndModel(r) + isInf := isInferenceRequest(r.Method, r.URL.Path) + routingModel := "" + if isInf { + routingModel = model + } + candidates := p.resolveCandidates(routingModel) + if isInf && model != "" { + candidates = p.reserveCandidate(candidates) + } + if r.Method == http.MethodGet && r.URL.Path == "/v1/models" { + if len(candidates) > 0 { + p.codec.Notify("proxy/request-started", RequestStartedEvent{ + ID: reqID, Method: r.Method, Path: r.URL.Path, Target: "cluster", + }) + } + status, err := p.serveModelList(w, r, candidates) + errText := "" + if err != nil { + errText = err.Error() + } + p.codec.Notify("proxy/request", RequestEvent{ + ID: reqID, Method: r.Method, Path: r.URL.Path, Target: "cluster", + Status: status, Duration: time.Since(start).Milliseconds(), Error: errText, + }) + return + } + if len(candidates) == 0 { + // With no engine to consult, retain the local permissive preflight used + // for engines that do not publish a CORS policy. + if cors.WritePreflight(w, r) { + return + } + cors.Apply(w.Header()) + rejectionBody := `{"error":"no active node selected or available"}` + rejectionError := "no active node" + if isInf && model != "" { + rejectionBody = `{"error":"no available node advertises the requested model"}` + rejectionError = "no node advertises requested model" + } + slog.Warn("proxy request rejected", + "id", reqID, "method", r.Method, "path", r.URL.Path, + "remote", r.RemoteAddr, "reason", rejectionError) + http.Error(w, rejectionBody, http.StatusBadGateway) + p.codec.Notify("proxy/request", RequestEvent{ + ID: reqID, + Method: r.Method, + Path: r.URL.Path, + Status: http.StatusBadGateway, + Duration: time.Since(start).Milliseconds(), + Error: rejectionError, + }) + return + } + // shouldRetry reports whether an upstream status warrants failing over to + // the next candidate: busy/unavailable/gateway statuses, plus a 404 on an + // inference call (an advertised owner's inventory may have become stale). + // Genuine client errors (400/401/422…) are not retried — they'd fail + // identically on every node. + shouldRetry := func(code int) bool { + switch code { + case http.StatusRequestTimeout, + http.StatusTooManyRequests, + http.StatusBadGateway, + http.StatusServiceUnavailable, + http.StatusGatewayTimeout: + return true + case http.StatusNotFound: + return isInf + } + return code >= 500 + } + + var ( + servedNodeID string + servedTarget string + ttfbMs int64 + proxyErr string + finalStatus int + started bool + wl *Workload + ) + + // Emit workload:started up front, the moment we begin forwarding, naming + // the first candidate we'll try. A burst of concurrent inference requests + // must surface as job cards immediately; the upstream engine serializes + // concurrent requests on a single GPU slot, so gating "started" on the + // upstream response headers (the commit point) left every queued-but- + // forwarded job invisible until the node dequeued it — only one card at a + // time (a prior regression). If failover later commits a different + // node, the commit block re-points scheduledOn; the terminal + // completed/errored transition is emitted once at the end regardless. + if isInf && model != "" { + createdMs := start.UnixMilli() + wl = &Workload{ + ID: reqID, + Model: model, + Engine: workloadEngine, + RunID: p.runID, + State: "running", + ScheduledOn: candidates[0].id, + CreatedAt: createdMs, + StartedAt: &createdMs, + } + p.emitWorkload(workloadStartedMethod, *wl) + } + + // The terminal workload transition (completed/errored) can be reached from + // two places: the normal path after the stream copy unwinds below, and the + // disconnect watcher that fires while the copy is still blocked. terminalOnce + // guarantees exactly one is emitted; wlMu guards the shared wl fields the + // watcher (a separate goroutine) and ModifyResponse's failover re-point both + // touch; terminated suppresses a late started re-point once we've finalized. + var ( + terminalOnce sync.Once + wlMu sync.Mutex + terminated bool + ) + emitTerminal := func(state, errMsg string) { + if wl == nil { + return + } + terminalOnce.Do(func() { + now := time.Now().UnixMilli() + wlMu.Lock() + terminated = true + wl.CompletedAt = &now + wl.State = state + if errMsg != "" { + wl.Error = &errMsg + } + snapshot := *wl + wlMu.Unlock() + method := workloadCompletedMethod + if state != "completed" { + method = workloadErroredMethod + } + p.emitWorkload(method, snapshot) + }) + } + + // Watch for the client going away while the request is in flight. The + // terminal event is otherwise emitted only after the stream copy returns; + // a client that disconnects mid-stream can leave the copy blocked, so we + // emit the terminal here the moment r.Context() is cancelled instead of + // waiting for the unwind. Cancelling r.Context() (client close, or our own + // shutdown) also propagates to the ReverseProxy's upstream request, so the + // engine stops generating. terminalOnce keeps this from double-emitting + // with the normal path. The half-open case (no FIN, r.Context() never + // fires) is caught instead by statusCapture's write deadline below. + if wl != nil { + reqCtx := r.Context() + finished := make(chan struct{}) + defer close(finished) + go func() { + select { + case <-reqCtx.Done(): + emitTerminal("failed", "client disconnected before completion") + case <-finished: + } + }() + } + + // committedSC is the statusCapture of the candidate we committed to + // streaming; its wroteErr tells us after the fact whether the client write + // failed (dead/half-open client) so we can mark the workload failed. + var committedSC *statusCapture + + // Failover loop: try candidates in order until one returns a + // usable response or the list is exhausted. We can only retry before the + // first byte reaches the client; once a response starts streaming we're + // committed. proxy/request-started fires at that commit point so it names + // the node that actually serves the request, not one we failed over from; + // workload:started was already emitted above (and is re-pointed there on a + // failover). The self-forward guard lives in resolveCandidates. + for i := range candidates { + cand := candidates[i] + last := i == len(candidates)-1 + if bodyBytes != nil { + r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + } + retry := false + sc := &statusCapture{ResponseWriter: w, status: http.StatusOK, idle: idleClientWriteTimeout} + + proxy := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + req.URL.Scheme = cand.url.Scheme + req.URL.Host = cand.url.Host + req.Host = cand.url.Host + }, + // A remote cluster peer is dialed over mTLS (per-peer pinned config); + // self/manual candidates use the plain transport. See candidateTransport. + Transport: p.candidateTransport(cand), + // ModifyResponse fires when the upstream's status line + headers + // have arrived but before the body streams. That's both the retry + // decision point and, on commit, the time-to-first-byte boundary. + ModifyResponse: func(resp *http.Response) error { + if !last && shouldRetry(resp.StatusCode) { + // Abort before streaming: ReverseProxy closes resp.Body and + // calls ErrorHandler with our sentinel, then we try next. + retry = true + return retrySignal{} + } + // Prefer an engine-declared preflight policy so an exact origin plus + // Allow-Credentials can pass a credentialed browser fetch. Engines + // that publish no policy retain the proxy's permissive 204 fallback. + cors.CompletePreflightFallback(resp) + // Committing to this candidate — body stream is about to begin. + ttfbMs = time.Since(start).Milliseconds() + servedNodeID = cand.id + servedTarget = cand.url.Host + proxyErr = "" // clear any error recorded from a failed-over candidate + // Arm the liveness report only now. statusCapture also carries + // the proxy's OWN error bodies — ReverseProxy's ErrorHandler + // writes a failed dial's message through it — and those bytes + // prove nothing about the node. Reaching here means the upstream + // returned a status line, so everything written from this point + // came from the node. Same goroutine as the body copy, so no + // synchronization is needed. + sc.upstreamAlive = func() { p.reportActivity(cand.id) } + // The engine may enforce its own origin policy. Honor it: + // overwriting a declared Access-Control-Allow-Origin would + // silently widen the user's policy, and a wildcard is invalid + // alongside Allow-Credentials, so it would break a credentialed + // response outright. An engine that omits the header has + // expressed nothing to preserve, so the proxy supplies its own. + if resp.Header.Get("Access-Control-Allow-Origin") == "" { + cors.Apply(resp.Header) + } + if !started { + started = true + p.codec.Notify("proxy/request-started", RequestStartedEvent{ + ID: reqID, + NodeID: cand.id, + Method: r.Method, + Path: r.URL.Path, + Target: cand.url.Host, + }) + // workload:started was already emitted up front naming the + // first candidate. If failover landed us on a different + // node, re-point scheduledOn so the card — and the terminal + // completed/errored event, which carries the same wl — name + // the node that actually served. Guarded by wlMu against the + // disconnect watcher, and skipped once terminated so a late + // re-point can't resurrect a workload we've already failed. + if wl != nil { + wlMu.Lock() + if !terminated && wl.ScheduledOn != cand.id { + wl.ScheduledOn = cand.id + snapshot := *wl + wlMu.Unlock() + p.emitWorkload(workloadStartedMethod, snapshot) + } else { + wlMu.Unlock() + } + } + } + return nil + }, + ErrorHandler: func(ew http.ResponseWriter, _ *http.Request, err error) { + if _, ok := err.(retrySignal); ok { + return // retryable status — the loop advances to the next candidate + } + // Transport/dial error (not a status-based retry): forget this + // node's confirmed address so the next request re-confirms and + // can fail over to another of its published addresses + // (multi-homed peer). The in-request failover below moves on to + // the next node. + p.targets.Forget(cand.id) + if !last { + // Transport/dial error with candidates left: fail over. + retry = true + proxyErr = err.Error() + slog.Warn("proxy upstream error, failing over", + "id", reqID, "node_id", cand.id, "target", cand.url.Host, + "path", r.URL.Path, "err", err) + return + } + // Last candidate failed at the transport: terminal, surface it. + servedNodeID = cand.id + servedTarget = cand.url.Host + if cors.WritePreflight(ew, r) { + proxyErr = "" + return + } + proxyErr = err.Error() + slog.Warn("proxy upstream error, candidates exhausted", + "id", reqID, "node_id", cand.id, "target", cand.url.Host, + "method", r.Method, "path", r.URL.Path, + "duration_ms", time.Since(start).Milliseconds(), "err", err) + body, mErr := json.Marshal(map[string]string{ + "error": "upstream error: " + err.Error(), + }) + if mErr != nil { + body = []byte(`{"error":"upstream error"}`) + } + cors.Apply(ew.Header()) + ew.Header().Set("Content-Type", "application/json") + ew.Header().Set("X-Content-Type-Options", "nosniff") + ew.WriteHeader(http.StatusBadGateway) + ew.Write(body) + }, + } + + proxy.ServeHTTP(sc, r) + if !retry { + finalStatus = sc.status + committedSC = sc + break + } + } + + slog.Debug("proxy request complete", + "id", reqID, + "node_id", servedNodeID, + "method", r.Method, + "path", r.URL.Path, + "target", servedTarget, + "status", finalStatus, + "duration_ms", time.Since(start).Milliseconds(), + "ttfb_ms", ttfbMs, + "err", proxyErr, + ) + + p.codec.Notify("proxy/request", RequestEvent{ + ID: reqID, + NodeID: servedNodeID, + Method: r.Method, + Path: r.URL.Path, + Target: servedTarget, + Status: finalStatus, + Duration: time.Since(start).Milliseconds(), + TTFB: ttfbMs, + Error: proxyErr, + }) + + // Terminal workload transition pairs with the workload:started emitted at + // the commit point above. Cancellation, an upstream/transport error, a + // failed client write (dead/half-open client), or any non-2xx status is a + // failure; a clean 2xx is a completion. The Workload carries the same id so + // the broker (and peers) can collapse the start/finish pair. Routed through + // emitTerminal so the disconnect watcher and this path emit exactly once. + if wl != nil { + switch { + case r.Context().Err() != nil: + // The request was cancelled before it finished — either the + // client disconnected or, on shutdown, we cancelled it to stop + // the in-flight inference. A mid-stream cancel never reaches + // ErrorHandler (the 200 headers are already sent), so without this + // branch it would be misreported as completed. (The watcher above + // usually beats us to it; emitTerminal makes that a no-op.) + emitTerminal("failed", "request cancelled before completion") + case committedSC != nil && committedSC.wroteErr != nil: + // The response committed but a write to (or flush toward) the + // client failed — typically the idle deadline tripping on a + // dead/half-open client. The stream is truncated, so this is a + // failure, not the completion the 200 status would otherwise + // suggest. + emitTerminal("failed", "client connection lost: "+committedSC.wroteErr.Error()) + case proxyErr != "" || finalStatus >= http.StatusBadRequest: + msg := proxyErr + if msg == "" { + msg = fmt.Sprintf("upstream returned HTTP %d", finalStatus) + } + emitTerminal("failed", msg) + default: + emitTerminal("completed", "") + } + } +} + +// resolveCandidates returns the ordered list of nodes to try for the current +// request. A model-bearing request first filters a request-local node copy to +// advertised owners. A user-selected eligible node then leads, followed by +// scheduler priority and stable ID fallback. The failover loop walks the +// resulting owner list until a node returns a usable response. +// +// A node that resolves to this proxy's own listen address is dropped +// (self-forward guard): a local MLX advertisement could otherwise +// resolve back to this proxy's own port and loop. +// +// Returns an empty slice when no forwarding target is available; the caller +// treats that as the rejection path. +func (p *Proxy) resolveCandidates(model string) []candidate { + p.selectedMu.RLock() + id := p.selectedID + p.selectedMu.RUnlock() + + priority := p.PriorityList() + + p.httpMu.Lock() + selfPort := p.port + p.httpMu.Unlock() + + // Re-derive membership and pins before resolving so a cluster joined or left, + // and a peer paired or removed, since the last request is reflected without a + // restart: a removed peer stops being a routable candidate immediately, and a + // freshly-paired one becomes one. + p.mesh.Refresh() + + nodes := p.discovery.Nodes() + known := len(nodes) + if model != "" { + owners := make([]Node, 0, len(nodes)) + resident := make([]Node, 0, len(nodes)) + for _, node := range nodes { + if !nodeAdvertisesModel(node, model) { + continue + } + owners = append(owners, node) + if nodeHoldsModel(node, model) { + resident = append(resident, node) + } + } + // mlx_lm.server holds ONE model at a time: serving a different one + // costs a full unload and reload of multi-gigabyte weights. So an + // owner that already has this model resident takes the request, and an + // owner that merely has it on disk is a candidate only when nobody is + // resident. + // + // The fallback is the point. Routing to resident owners *alone* reads + // as the stricter, thrash-free policy, but it deadlocks a cold + // cluster: with nothing loaded anywhere, every node is ineligible and + // the first request 502s on a system that is entirely healthy. Falling + // back to disk owners means the first request pays one load and every + // request after it lands on the node that already paid -- the cluster + // converges on its own, with no lease, no warm-up job, and no + // coordinator to designate a loader. + if preferResidentOwners() && len(resident) > 0 { + owners = resident + } + nodes = owners + } + // Sort by ID so candidate order is stable across calls — Discovery.Nodes() + // iterates a map, whose order is randomized per call, which otherwise bounced + // back-to-back requests between nodes. The ID sort is also the + // fallback order for nodes the scheduler's priority list doesn't mention. + sort.Slice(nodes, func(i, j int) bool { return nodes[i].ID < nodes[j].ID }) + byID := make(map[string]Node, len(nodes)) + for _, n := range nodes { + byID[n.ID] = n + } + + out := make([]candidate, 0, len(nodes)) + // Dedup by resolved backend host: the same physical node can appear under two + // IDs (e.g. a manually-added entry and its relay-discovered record), and + // routing to the same engine twice is wasteful. + seenHost := make(map[string]bool, len(nodes)) + // placed tracks node IDs already considered so the priority-ordered and + // fallback passes don't reconsider one (scheduler ordering). + placed := make(map[string]bool, len(nodes)) + add := func(n Node) { + placed[n.ID] = true + // targetURL picks a reachable address for a multi-homed node (cached, + // TCP-probed), falling back to the first candidate; nil only when the + // node advertises no usable address. + u := p.targetURL(n) + if u == nil { + return + } + peerUUID := "" + switch { + case isSelfTarget(u, selfPort): + // Our own advertised endpoint (mx now points at this proxy). Serve + // it from the explicit local backend — the loopback engine — rather + // than dialing our own mTLS ingress, which would recurse. Ranking + // still used this node's real (discovered) model list above. + lb, ok := p.localBackendTarget() + if !ok { + slog.Debug("resolveCandidates: no local backend for self", "node_id", n.ID) + return + } + u = lb + case p.mesh.HasPin(n.ClusterUUID): + // A pinned cluster peer: reach it only over mTLS to its promoted + // proxy (the lm port now advertises the proxy, not the engine). + // The pin is read from the live mesh refreshed above, not from the + // relayed n.Trusted: that flag is the scanner's answer from whenever + // it last saw this peer's mDNS record, so a peer discovered before + // this node's pins were written stays false until its record next + // changes. It is also strictly weaker than what we hold here — the + // dial itself is gated on ClientTLSConfig finding the same pin — so + // the relayed value can only ever disagree by being stale. + u.Scheme = "https" + peerUUID = n.ClusterUUID + case p.discovery.IsManual(n.ID): + // An explicit user-added manual node: dialed plain to the address + // the user supplied (a deliberate, separately-labeled bypass). + default: + // A relay peer we don't hold a pin for (untrusted, or this node is + // unclustered). Its engine is loopback-only and its proxy refuses + // plaintext from the LAN, so it is not a routable target. + slog.Debug("resolveCandidates: dropping unpinned relay peer", + "node_id", n.ID, "cluster_uuid", n.ClusterUUID) + return + } + // Defensive: the local backend must never resolve back to this proxy. + if isSelfTarget(u, selfPort) { + slog.Debug("resolveCandidates: skipping self-target node", + "node_id", n.ID, "target", u.Host, "self_port", selfPort) + return + } + if seenHost[u.Host] { + return + } + seenHost[u.Host] = true + out = append(out, candidate{ + id: n.ID, + url: u, + peerUUID: peerUUID, + }) + } + + // Capability has already been enforced. An eligible explicit selection wins, + // then the scheduler's least-loaded order, then unlisted owners by stable ID. + if id != "" { + if n, ok := byID[id]; ok && !placed[id] { + add(n) + } + } + for _, pid := range priority { + if n, ok := byID[pid]; ok && !placed[pid] { + add(n) + } + } + for _, n := range nodes { + if !placed[n.ID] { + add(n) + } + } + + slog.Debug("resolveCandidates resolved", + "selected", id, "priority", len(priority), "candidates", len(out), + "eligible", len(nodes), "known", known) + return out +} + +// reserveCandidate atomically moves the least estimated loaded scheduler-listed +// candidate to the front of this request's failover list. The scheduler's +// pending count and GPU pressure form the authoritative baseline; reservations +// are local dispatches made since that snapshot arrived and have not necessarily +// completed the proxy→broker→scheduler→proxy feedback loop yet. +// +// Model eligibility was enforced before this function receives the list. An +// explicit node/select pin bypasses reservations, and unlisted/manual owners +// retain their existing fallback position. +func (p *Proxy) reserveCandidate(candidates []candidate) []candidate { + if len(candidates) == 0 { + return candidates + } + if selectedID := p.SelectedID(); selectedID != "" { + for _, cand := range candidates { + if cand.id == selectedID { + return candidates + } + } + } + + candidateIndex := make(map[string]int, len(candidates)) + for i, cand := range candidates { + candidateIndex[cand.id] = i + } + + p.priorityMu.Lock() + defer p.priorityMu.Unlock() + if len(p.priority) == 0 { + return candidates + } + if p.priorityReservations == nil { + p.priorityReservations = make(map[string]int) + } + + bestIndex := -1 + bestOrder := len(p.priority) + var bestLoad uint64 + for order, id := range p.priority { + index, ok := candidateIndex[id] + if !ok { + continue + } + load := uint64(p.priorityPending[id]) + + uint64(p.priorityGPUPressure[id]) + + uint64(p.priorityReservations[id]) + if bestIndex < 0 || load < bestLoad || (load == bestLoad && order < bestOrder) { + bestIndex = index + bestOrder = order + bestLoad = load + } + } + if bestIndex < 0 { + return candidates + } + + chosen := candidates[bestIndex] + p.priorityReservations[chosen.id]++ + if bestIndex > 0 { + copy(candidates[1:bestIndex+1], candidates[:bestIndex]) + candidates[0] = chosen + } + return candidates +} + +// routingPolicyEnv names the A/B switch for the one routing rule that differs +// from the proxy this one was cloned from. It exists so the residency +// preference can be measured against the inherited behaviour rather than +// asserted: set NVPAIR_MLX_ROUTING=any and mlx-proxy ranks owners exactly as +// ollama-proxy and lmstudio-proxy do, which is the control arm. Any other value +// (including unset) is the residency-preferring default. +// +// It is read per call rather than cached at startup so a benchmark can flip +// arms without a respawn, and because the read is a map lookup against a +// process-local copy of the environment -- far cheaper than the routing +// decision it guards. +const routingPolicyEnv = "NVPAIR_MLX_ROUTING" + +func preferResidentOwners() bool { + return os.Getenv(routingPolicyEnv) != "any" +} + +// nodeHoldsModel reports whether the node has this model resident in memory +// right now, as opposed to merely downloaded. See resolveCandidates for why the +// two are ranked rather than filtered. +func nodeHoldsModel(n Node, model string) bool { + if model == "" { + return false + } + for _, loaded := range n.Loaded { + if loaded == model { + return true + } + } + return false +} + +func nodeAdvertisesModel(n Node, model string) bool { + if model == "" { + return false + } + for _, available := range n.Models { + if available == model { + return true + } + } + return false +} + +// isSelfTarget reports whether u points back at this proxy's own listener. +// nodeURL has already rewritten local-interface addresses to 127.0.0.1, so a +// loopback host on our own port is us. +func isSelfTarget(u *url.URL, selfPort int) bool { + host, portStr, err := net.SplitHostPort(u.Host) + if err != nil { + return false + } + port, err := strconv.Atoi(portStr) + if err != nil || port != selfPort { + return false + } + switch host { + case "127.0.0.1", "::1", "localhost": + return true + } + return false +} + +// nodeURL returns the single best forward URL for a node (the first candidate +// in deterministic, loopback-first order). It does no reachability probing — +// p.targetURL is the request-path entry point. Kept as a free function so the +// URL-construction unit test can exercise it without a Proxy. +func nodeURL(n Node) *url.URL { + candidates := nodeCandidates(n) + if len(candidates) == 0 { + return nil + } + return &url.URL{Scheme: "http", Host: candidates[0]} +} + +// targetURL picks the forward URL for a node, preferring an address we can +// actually reach. With a single candidate it's just that candidate; with +// several (a multi-homed peer) the shared reach.Chooser returns the confirmed +// last-good address, or the node's own top-ranked one while it confirms in the +// background. The confirmation is transport-neutral: a TCP accept proves the +// address is reachable, while the real pinned mTLS request still authenticates +// which peer answered there. +// +// reach.Prefer, not a blocking confirmation: this runs once per discovered node per +// request, including nodes this request will not be routed to, so a handshake here +// would charge every request for every node's connectivity. An address that is +// wrong is caught by the ErrorHandler below, which forgets it and fails over. +func (p *Proxy) targetURL(n Node) *url.URL { + candidates := nodeCandidates(n) + if len(candidates) == 0 { + return nil + } + host := p.targets.Prefer(n.ID, candidates) + return &url.URL{Scheme: "http", Host: host} +} + +// nodeCandidates returns the ordered, de-duplicated host:port targets for a node. +// +// Order comes from the node itself: netpick.Candidates keeps the node's published +// ranking, which it derived from evidence no observer has, and appends anything +// else it advertised. Re-sorting here by address class is what previously put a +// two-host direct-connect link ahead of a peer's real LAN address. +// +// Any local-interface address is rewritten to loopback (the engine binds loopback +// only) and floated to the front because it's unambiguously reachable. +func nodeCandidates(n Node) []string { + port := strconv.Itoa(n.Port) + sorted := netpick.Candidates(n.TXT, n.Addresses) + if len(sorted) == 0 { + // A non-IP entry (a .local hostname) that netpick cannot parse. + hosts := n.Addresses + if len(hosts) == 0 { + if n.Host == "" { + return nil + } + hosts = []string{n.Host} + } + sorted = append([]string(nil), hosts...) + } + + seen := make(map[string]bool, len(sorted)) + var loopback, rest []string + for _, h := range sorted { + // If the address belongs to a local interface, use loopback instead; + // connecting via the machine's own external IP would be refused. + if isLocalAddress(h) { + h = "127.0.0.1" + } + // net.JoinHostPort bracket-wraps IPv6 literals (fe80::1 -> [fe80::1]). + hp := net.JoinHostPort(h, port) + if seen[hp] { + continue + } + seen[hp] = true + if ip := net.ParseIP(h); ip != nil && ip.IsLoopback() { + loopback = append(loopback, hp) + } else { + rest = append(rest, hp) + } + } + return append(loopback, rest...) +} + +var ( + localAddrsMu sync.RWMutex + // localAddrs is the set of IPs currently bound to this host's interfaces. + // It's used to decide whether a discovered node is actually us, so we can + // dial loopback instead of our own external IP (the engine binds loopback + // only). The initial value is a one-shot enumeration; startLocalAddrWatch + // then keeps it in sync with live interface changes, so a late VPN/dock + // interface or a sleep/wake IP reassignment can't strand us dialing a + // stale address. + localAddrs = netmon.Enumerate().LocalIPs +) + +func setLocalAddrs(s map[string]bool) { + localAddrsMu.Lock() + localAddrs = s + localAddrsMu.Unlock() +} + +func isLocalAddress(addr string) bool { + localAddrsMu.RLock() + defer localAddrsMu.RUnlock() + return localAddrs[addr] +} + +// startLocalAddrWatch keeps localAddrs in sync with the host's live interface +// set for the lifetime of ctx. If the network monitor can't start, the set +// stays at its initial enumeration rather than failing the proxy. +func startLocalAddrWatch(ctx context.Context) { + mon, err := netmon.Watch(ctx) + if err != nil { + slog.Warn("proxy: network monitor unavailable; local address set is static", "err", err) + return + } + setLocalAddrs(mon.LocalIPs()) + ch := mon.Subscribe() + go func() { + for range ch { + setLocalAddrs(mon.LocalIPs()) + slog.Debug("proxy: refreshed local address set after network change") + } + }() +} + +func (p *Proxy) SelectedID() string { + p.selectedMu.RLock() + defer p.selectedMu.RUnlock() + return p.selectedID +} + +func (p *Proxy) SetSelected(id string) { + p.selectedMu.Lock() + p.selectedID = id + p.selectedMu.Unlock() +} + +// clearSelectionIfNotPresent resets the user-selected node (and notifies the +// client) when it's neither in the relay-fed set nor a manual node, so a stale +// selection can't pin routing to a departed target. +func (p *Proxy) clearSelectionIfNotPresent(present map[string]bool) { + p.selectedMu.Lock() + sel := p.selectedID + p.selectedMu.Unlock() + if sel == "" || present[sel] || p.discovery.IsManual(sel) { + return + } + p.selectedMu.Lock() + cleared := p.selectedID == sel + if cleared { + p.selectedID = "" + } + p.selectedMu.Unlock() + if cleared { + p.codec.Notify("node/selection-changed", SelectedResult{ID: ""}) + } +} + +// PriorityList returns a copy of the current scheduler-supplied priority order. +func (p *Proxy) PriorityList() []string { + p.priorityMu.RLock() + defer p.priorityMu.RUnlock() + return append([]string(nil), p.priority...) +} + +// SetPriority stores the auto-routing priority order (highest first) and returns +// the number of ids stored. The list is kept verbatim — unknown ids are retained +// (a node may appear in discovery later) and only consulted at request time. An +// empty list clears the scheduler's influence. +func (p *Proxy) SetPriority(nodes []string) int { + return p.SetPrioritySnapshot(schedulerwire.Priority{Nodes: nodes}) +} + +// SetPrioritySnapshot replaces the scheduler baseline and clears optimistic +// reservations made against the previous snapshot. Nodes-only callers remain +// valid: a missing rank supplies zero pending and GPU-pressure baselines. +func (p *Proxy) SetPrioritySnapshot(priority schedulerwire.Priority) int { + cleaned := append([]string(nil), priority.Nodes...) + pending := make(map[string]int, len(priority.Ranks)) + gpuPressure := make(map[string]int, len(priority.Ranks)) + for _, rank := range priority.Ranks { + if rank.ID == "" { + continue + } + if rank.Pending < 0 { + rank.Pending = 0 + } + if rank.GPUPressure < 0 { + rank.GPUPressure = 0 + } else if rank.GPUPressure > schedulerwire.MaxGPUPressure { + rank.GPUPressure = schedulerwire.MaxGPUPressure + } + pending[rank.ID] = rank.Pending + gpuPressure[rank.ID] = rank.GPUPressure + } + + p.priorityMu.Lock() + p.priority = cleaned + p.priorityPending = pending + p.priorityGPUPressure = gpuPressure + p.priorityReservations = make(map[string]int) + p.priorityMu.Unlock() + return len(cleaned) +} + +// replaceSubscribed replaces the proxy's relay-fed routing overlay from a +// discovery:nodes snapshot: it projects every node advertising lm with a dialable +// IP into the overlay (dropping the rest) and clears a user selection pinned to a +// node that's no longer routable. The broker sends the full filtered set on every +// change, so this is a wholesale replace, not a per-node apply — a departed node +// is simply absent from the next snapshot. +func (p *Proxy) replaceSubscribed(params json.RawMessage) { + var res noderec.GetNodesResult + if err := json.Unmarshal(params, &res); err != nil { + slog.Warn("invalid discovery:nodes snapshot", "err", err) + return + } + nodes := make([]Node, 0, len(res.Nodes)) + present := make(map[string]bool, len(res.Nodes)) + for _, dn := range res.Nodes { + n, ok := subscribedToNode(dn) + if !ok { + continue + } + nodes = append(nodes, n) + present[n.ID] = true + } + discovered, updated, removed := p.discovery.SetSubscribed(nodes) + // Surface the relay-fed set to the client as node/* events — the signal a + // consumer (the UI) uses to show which peers run this engine — mirroring how + // manual nodes are announced. Without this the routing overlay updates + // silently and peers appear engine-less. A node dropping out is also the + // proxy's "this upstream is gone" signal, surfaced through the errors + // pipeline (the broker forwards these to nvpair-errors); a re-appearance clears + // it. NodeID/Timestamp are left unset so the broker stamps the authoritative + // values. + for _, n := range discovered { + p.codec.Notify("node/discovered", n.withPrimaryIP()) + if err := p.codec.Notify("errors:clear", errors.ClearParams{ID: upstreamUnreachableID(n.ID)}); err != nil { + slog.Debug("failed to send errors:clear", "node", n.ID, "err", err) + } + } + for _, n := range updated { + p.codec.Notify("node/updated", n.withPrimaryIP()) + } + for _, n := range removed { + p.codec.Notify("node/removed", n.withPrimaryIP()) + if err := p.codec.Notify("errors:report", errors.ServiceError{ + ID: upstreamUnreachableID(n.ID), + Message: fmt.Sprintf("Upstream node %q is no longer reachable (dropped from discovery)", n.Host), + Severity: "warning", + Action: "none", + }); err != nil { + slog.Debug("failed to send errors:report", "node", n.ID, "err", err) + } + } + p.clearSelectionIfNotPresent(present) +} + +// upstreamUnreachableID is the canonical ServiceError id for an upstream the +// proxy no longer sees in discovery. Kept as a single function so the report and +// clear can't drift (nvpair-errors matches by literal id). +func upstreamUnreachableID(nodeID string) string { + return "mlx-proxy:upstream-unreachable:" + nodeID +} + +// subscribedToNode projects a relay DirectoryNode onto the proxy's routable Node +// for the lm service, returning false when the node doesn't advertise lm or has +// no dialable address. The engine port comes from the lm service key (the real +// MLX port the broker's engine poller registered, not the proxy's listen +// port). +func subscribedToNode(n noderec.DirectoryNode) (Node, bool) { + svc, ok := n.Services[noderec.ServiceMLX] + if !ok || n.IP == "" { + return Node{}, false + } + // Key routing by the stable per-host UUID, not the hostname: candidate ids, + // scheduledOn, node selection, and the scheduler's priority list are all this + // value, so routing survives a PC rename and never conflates two same-named + // machines. Host stays the hostname (display / dial name). A relay + // DirectoryNode always carries a hostUuid (the scanner guarantees it at the + // browse boundary), so there is no name fallback here. + return Node{ + ID: n.HostUUID, + Host: n.Name, + Port: svc.Port, + // The node's whole ranked address list, not just its canonical one: a + // multi-homed peer's best address from its own vantage point may be a + // direct-connect link this host cannot reach, and routing needs somewhere + // to fail over to when that happens. + Addresses: n.CandidateIPs(), + TXT: n.AddressTXT(), + IP: n.IP, + ClusterUUID: n.ClusterUUID, + // Filter on this node's MLX models only, not the cross-engine union, + // so a model a dual-engine node serves solely via Ollama isn't accepted as + // an MLX owner here (falls back to the union for a peer that sends + // no attribution — see DirectoryNode.EngineModels). + Models: append([]string(nil), n.EngineModels("mlx")...), + // Residency, reported by the peer's engine-manager from mlx-lm's + // /health. A load in progress reports no model at all (mlx-lm sets its + // model key only once the weights are in), so a node mid-load is + // correctly not preferred rather than advertising a model it cannot + // yet serve. + Loaded: append([]string(nil), n.EngineLoaded("mlx")...), + }, true +} + +func (p *Proxy) readLoop(ctx context.Context) error { + for { + msg, err := p.codec.Read() + if err != nil { + if err == io.EOF || ctx.Err() != nil { + return nil + } + log.Printf("JSON-RPC read error: %v", err) + continue + } + p.handleMessage(msg) + } +} + +func (p *Proxy) handleMessage(msg *Message) { + if msg.Method == applog.SetLevelMethod { + resolved, err := applog.HandleSetLevelParams(msg.Params) + if msg.IsRequest() { + if err != nil { + p.codec.RespondError(msg.ID, -32602, err.Error()) + return + } + p.codec.Respond(msg.ID, map[string]string{"level": resolved}) + } + if err != nil { + slog.Warn("log/set-level rejected", "err", err) + } else { + slog.Info("log level changed", "level", resolved) + } + return + } + + switch msg.Method { + case noderec.NotifyNodes: + p.replaceSubscribed(msg.Params) + return + } + + if !msg.IsRequest() { + if msg.IsNotification() { + log.Printf("ignoring incoming notification: %s", msg.Method) + } + return + } + + switch msg.Method { + case "nodes/list": + nodes := p.discovery.Nodes() + if err := p.codec.Respond(msg.ID, NodesResult{Nodes: nodes}); err != nil { + log.Printf("failed to respond to nodes/list: %v", err) + } + + case "node/select": + var params SelectParams + if err := json.Unmarshal(msg.Params, ¶ms); err != nil { + p.codec.RespondError(msg.ID, -32602, "invalid params: expected {\"id\": \"...\"}") + return + } + if params.ID != "" { + found := false + for _, n := range p.discovery.Nodes() { + if n.ID == params.ID { + found = true + break + } + } + if !found { + p.codec.RespondError(msg.ID, -32602, fmt.Sprintf("node %q not found", params.ID)) + return + } + } + p.SetSelected(params.ID) + log.Printf("node selection changed to %q", params.ID) + if err := p.codec.Respond(msg.ID, SelectedResult{ID: params.ID}); err != nil { + log.Printf("failed to respond to node/select: %v", err) + } + p.codec.Notify("node/selection-changed", SelectedResult{ID: params.ID}) + + case "node/selected": + if err := p.codec.Respond(msg.ID, SelectedResult{ID: p.SelectedID()}); err != nil { + log.Printf("failed to respond to node/selected: %v", err) + } + + case "node/set-priority": + var params schedulerwire.Priority + if err := json.Unmarshal(msg.Params, ¶ms); err != nil { + p.codec.RespondError(msg.ID, -32602, "invalid params: expected {\"nodes\": [\"id\", ...], \"ranks\": [...]}") + return + } + count := p.SetPrioritySnapshot(params) + log.Printf("priority snapshot set (%d nodes, %d ranks): %v", count, len(params.Ranks), params.Nodes) + if err := p.codec.Respond(msg.ID, map[string]int{"count": count}); err != nil { + log.Printf("failed to respond to node/set-priority: %v", err) + } + + case "set-port": + var params struct { + Port int `json:"port"` + } + if err := json.Unmarshal(msg.Params, ¶ms); err != nil { + p.codec.RespondError(msg.ID, -32602, "invalid params: expected {\"port\": }") + return + } + if params.Port < 1 || params.Port > 65535 { + p.codec.RespondError(msg.ID, -32602, "port must be between 1 and 65535") + return + } + if err := p.setPort(params.Port); err != nil { + p.codec.RespondError(msg.ID, -32000, err.Error()) + return + } + if err := p.codec.Respond(msg.ID, ReadyParams{Version: Version, Port: params.Port}); err != nil { + log.Printf("failed to respond to set-port: %v", err) + } + + case "node/add-manual": + var node Node + if err := json.Unmarshal(msg.Params, &node); err != nil { + p.codec.RespondError(msg.ID, -32602, "invalid params: expected {\"id\",\"host\",\"port\",\"addresses\"}") + return + } + if node.ID == "" || node.Port == 0 || len(node.Addresses) == 0 { + p.codec.RespondError(msg.ID, -32602, "id, port, and at least one address are required") + return + } + added := p.discovery.AddManual(node) + if err := p.codec.Respond(msg.ID, map[string]bool{"added": added}); err != nil { + log.Printf("failed to respond to node/add-manual: %v", err) + } + if added { + log.Printf("manual node added: %s (%s:%d)", node.ID, node.Addresses[0], node.Port) + p.codec.Notify("node/discovered", node.withPrimaryIP()) + } else { + log.Printf("manual node updated: %s (%s:%d)", node.ID, node.Addresses[0], node.Port) + p.codec.Notify("node/updated", node.withPrimaryIP()) + } + + case "node/remove-manual": + var params SelectParams + if err := json.Unmarshal(msg.Params, ¶ms); err != nil { + p.codec.RespondError(msg.ID, -32602, "invalid params: expected {\"id\": \"...\"}") + return + } + removed := p.discovery.RemoveManual(params.ID) + if err := p.codec.Respond(msg.ID, map[string]bool{"removed": removed}); err != nil { + log.Printf("failed to respond to node/remove-manual: %v", err) + } + if removed { + log.Printf("manual node removed: %s", params.ID) + p.selectedMu.Lock() + if p.selectedID == params.ID { + p.selectedID = "" + p.selectedMu.Unlock() + p.codec.Notify("node/selection-changed", SelectedResult{ID: ""}) + } else { + p.selectedMu.Unlock() + } + p.codec.Notify("node/removed", Node{ID: params.ID}) + } + + case "node/set-local-backend": + var b localBackend + if err := json.Unmarshal(msg.Params, &b); err != nil { + p.codec.RespondError(msg.ID, -32602, "invalid params: expected {\"engine\",\"host\",\"port\",\"healthy\"}") + return + } + p.setLocalBackend(b) + slog.Info("local backend updated", "engine", b.Engine, "host", b.Host, "port", b.Port, "healthy", b.Healthy) + if err := p.codec.Respond(msg.ID, map[string]bool{"ok": true}); err != nil { + log.Printf("failed to respond to node/set-local-backend: %v", err) + } + + case "shutdown": + if err := p.codec.Respond(msg.ID, nil); err != nil { + log.Printf("failed to respond to shutdown: %v", err) + } + log.Println("shutdown requested via JSON-RPC") + p.cancel() + + default: + if err := p.codec.RespondError(msg.ID, -32601, fmt.Sprintf("method not found: %s", msg.Method)); err != nil { + log.Printf("failed to send error response: %v", err) + } + } +} diff --git a/services/mlx-proxy/proxy_residency_test.go b/services/mlx-proxy/proxy_residency_test.go new file mode 100644 index 00000000..3190c3b9 --- /dev/null +++ b/services/mlx-proxy/proxy_residency_test.go @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import "testing" + +// mlx-proxy's one behavioural departure from the proxy it was cloned from: +// mlx_lm.server holds a single model, so an owner that already has the +// requested model resident is preferred over one that merely has it on disk -- +// but a disk owner still routes when nobody is resident, which is what stops a +// cold cluster from refusing its own first request. +func TestPreferResidentOwners(t *testing.T) { + const model = "mlx-community/Qwen3-VL-8B-Instruct-4bit" + other := "mlx-community/Qwen3-VL-2B-Instruct-bf16" + + disk := Node{ID: "disk", Models: []string{model, other}, Loaded: []string{other}} + resident := Node{ID: "resident", Models: []string{model, other}, Loaded: []string{model}} + stranger := Node{ID: "stranger", Models: []string{other}, Loaded: []string{other}} + + for _, tc := range []struct { + name string + node Node + advertises bool + holds bool + }{ + {"resident owner advertises and holds", resident, true, true}, + {"disk owner advertises but does not hold", disk, true, false}, + {"non-owner does neither", stranger, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := nodeAdvertisesModel(tc.node, model); got != tc.advertises { + t.Errorf("nodeAdvertisesModel = %v, want %v", got, tc.advertises) + } + if got := nodeHoldsModel(tc.node, model); got != tc.holds { + t.Errorf("nodeHoldsModel = %v, want %v", got, tc.holds) + } + }) + } + + // A node that reports no residency at all (an older peer, or one whose + // engine-manager could not be reached) must stay eligible, not vanish. + unknown := Node{ID: "unknown", Models: []string{model}} + if !nodeAdvertisesModel(unknown, model) { + t.Error("a node with unknown residency must remain an eligible owner") + } + if nodeHoldsModel(unknown, model) { + t.Error("unknown residency must not be reported as resident") + } +} diff --git a/services/mlx-proxy/proxy_test.go b/services/mlx-proxy/proxy_test.go new file mode 100644 index 00000000..b5ce36ad --- /dev/null +++ b/services/mlx-proxy/proxy_test.go @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import "testing" + +// TestNodeURL covers the URL-construction part of nodeURL — specifically +// that IPv6 literals are bracket-wrapped and IPv4/hostnames remain +// byte-identical to the pre-JoinHostPort implementation. The +// local-address shortcut is not exercised here because it depends on +// the host's network interfaces (see init() in proxy.go) and would be +// flaky across environments. +func TestNodeURL(t *testing.T) { + // Pick addresses that are unlikely to appear on any local interface. + tests := []struct { + name string + node Node + wantHost string + wantURL string + }{ + { + name: "ipv4", + node: Node{Addresses: []string{"192.0.2.10"}, Port: 11434}, + wantHost: "192.0.2.10:11434", + wantURL: "http://192.0.2.10:11434", + }, + { + name: "ipv6", + node: Node{Addresses: []string{"2001:db8::1"}, Port: 11434}, + wantHost: "[2001:db8::1]:11434", + wantURL: "http://[2001:db8::1]:11434", + }, + { + name: "hostname", + node: Node{Addresses: []string{"gpu-host.lan"}, Port: 11434}, + wantHost: "gpu-host.lan:11434", + wantURL: "http://gpu-host.lan:11434", + }, + { + // Empty Addresses slice — nodeURL should fall back to Host. + name: "fallback to Host", + node: Node{Host: "gpu-host.lan", Port: 11434}, + wantHost: "gpu-host.lan:11434", + wantURL: "http://gpu-host.lan:11434", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + u := nodeURL(tc.node) + if u == nil { + t.Fatalf("nodeURL returned nil") + } + if u.Host != tc.wantHost { + t.Errorf("Host = %q, want %q", u.Host, tc.wantHost) + } + if got := u.String(); got != tc.wantURL { + t.Errorf("String() = %q, want %q", got, tc.wantURL) + } + }) + } +} diff --git a/services/mlx-proxy/reservation_test.go b/services/mlx-proxy/reservation_test.go new file mode 100644 index 00000000..425bb5dd --- /dev/null +++ b/services/mlx-proxy/reservation_test.go @@ -0,0 +1,235 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "sync" + "testing" + + "nvpair-shared/schedulerwire" +) + +func reservationCandidates(ids ...string) []candidate { + out := make([]candidate, 0, len(ids)) + for _, id := range ids { + out = append(out, candidate{id: id}) + } + return out +} + +func reservedID(p *Proxy, candidates []candidate) string { + candidates = append([]candidate(nil), candidates...) + return p.reserveCandidate(candidates)[0].id +} + +func TestReserveCandidate_ConcurrentEqualLoadHasAtMostOneSkew(t *testing.T) { + p := prProxy(t) + ids := []string{"a", "b", "c", "d"} + ranks := make([]schedulerwire.NodeRank, 0, len(ids)) + for i, id := range ids { + ranks = append(ranks, schedulerwire.NodeRank{ID: id, Rank: i}) + } + p.SetPrioritySnapshot(schedulerwire.Priority{Nodes: ids, Ranks: ranks}) + candidates := reservationCandidates(ids...) + + const requests = 100 + chosen := make(chan string, requests) + var wg sync.WaitGroup + for range requests { + wg.Add(1) + go func() { + defer wg.Done() + chosen <- reservedID(p, candidates) + }() + } + wg.Wait() + close(chosen) + + counts := make(map[string]int, len(ids)) + for id := range chosen { + counts[id]++ + } + min, max := requests, 0 + for _, id := range ids { + if counts[id] < min { + min = counts[id] + } + if counts[id] > max { + max = counts[id] + } + } + if max-min > 1 { + t.Fatalf("100 equal-load reservations are imbalanced: %v", counts) + } +} + +func TestReserveCandidate_ConvergesUnequalPendingDepths(t *testing.T) { + p := prProxy(t) + p.SetPrioritySnapshot(schedulerwire.Priority{ + Nodes: []string{"a", "b", "c"}, + Ranks: []schedulerwire.NodeRank{ + {ID: "a", Pending: 0, Rank: 0}, + {ID: "b", Pending: 2, Rank: 1}, + {ID: "c", Pending: 4, Rank: 2}, + }, + }) + candidates := reservationCandidates("a", "b", "c") + assigned := map[string]int{} + for range 6 { + assigned[reservedID(p, candidates)]++ + } + + total := map[string]int{ + "a": assigned["a"], + "b": 2 + assigned["b"], + "c": 4 + assigned["c"], + } + if total["a"] != 4 || total["b"] != 4 || total["c"] != 4 { + t.Fatalf("unequal depths did not converge: assigned=%v total=%v", assigned, total) + } +} + +func TestReserveCandidate_CombinesPendingPressureAndReservations(t *testing.T) { + p := prProxy(t) + p.SetPrioritySnapshot(schedulerwire.Priority{ + Nodes: []string{"a", "b", "c"}, + Ranks: []schedulerwire.NodeRank{ + {ID: "a", Pending: 0, GPUPressure: 3}, + {ID: "b", Pending: 1, GPUPressure: 0}, + {ID: "c", Pending: 0, GPUPressure: 2}, + }, + }) + candidates := reservationCandidates("a", "b", "c") + got := []string{ + reservedID(p, candidates), + reservedID(p, candidates), + reservedID(p, candidates), + } + want := []string{"b", "b", "c"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("GPU-aware reservations = %v, want %v", got, want) + } + } +} + +func TestSetPrioritySnapshotClampsGPUPressure(t *testing.T) { + p := prProxy(t) + p.SetPrioritySnapshot(schedulerwire.Priority{ + Nodes: []string{"low", "high"}, + Ranks: []schedulerwire.NodeRank{ + {ID: "low", GPUPressure: -1}, + {ID: "high", GPUPressure: schedulerwire.MaxGPUPressure + 1}, + }, + }) + p.priorityMu.RLock() + low := p.priorityGPUPressure["low"] + high := p.priorityGPUPressure["high"] + p.priorityMu.RUnlock() + if low != 0 || high != schedulerwire.MaxGPUPressure { + t.Fatalf("clamped GPU pressure = low:%d high:%d", low, high) + } +} + +func TestReserveCandidate_LegacyNodesOnlyUsesZeroBaseline(t *testing.T) { + p := prProxy(t) + p.SetPriority([]string{"a", "b", "c"}) + candidates := reservationCandidates("a", "b", "c") + counts := map[string]int{} + for range 5 { + counts[reservedID(p, candidates)]++ + } + want := map[string]int{"a": 2, "b": 2, "c": 1} + for id, n := range want { + if counts[id] != n { + t.Fatalf("legacy nodes-only assignments = %v, want %v", counts, want) + } + } +} + +func TestReserveCandidate_UsesEligibleCandidates(t *testing.T) { + p := prProxy(t) + p.SetPrioritySnapshot(schedulerwire.Priority{ + Nodes: []string{"missing", "owner-a", "owner-b", "unknown"}, + Ranks: []schedulerwire.NodeRank{ + {ID: "missing", Pending: 0}, + {ID: "owner-a", Pending: 4}, + {ID: "owner-b", Pending: 5}, + {ID: "unknown", Pending: 0}, + }, + }) + candidates := reservationCandidates("owner-a", "owner-b") + + for range 8 { + got := reservedID(p, candidates) + if got != "owner-a" && got != "owner-b" { + t.Fatalf("reservation escaped eligible candidates to %q", got) + } + } +} + +func TestReserveCandidate_ManualPinBypassesReservations(t *testing.T) { + p := prProxy(t) + p.SetPrioritySnapshot(schedulerwire.Priority{ + Nodes: []string{"a", "b"}, + Ranks: []schedulerwire.NodeRank{{ID: "a"}, {ID: "b"}}, + }) + p.SetSelected("b") + candidates := reservationCandidates("b", "a") // resolveCandidates puts the pin first + if got := reservedID(p, candidates); got != "b" { + t.Fatalf("manual pin resolved to %q, want b", got) + } + p.priorityMu.RLock() + defer p.priorityMu.RUnlock() + if len(p.priorityReservations) != 0 { + t.Fatalf("manual pin created optimistic reservations: %v", p.priorityReservations) + } +} + +func TestReserveCandidate_IneligibleManualPinDoesNotBypassReservations(t *testing.T) { + p := prProxy(t) + p.SetPrioritySnapshot(schedulerwire.Priority{ + Nodes: []string{"owner-b", "owner-a"}, + Ranks: []schedulerwire.NodeRank{{ID: "owner-b"}, {ID: "owner-a"}}, + }) + p.SetSelected("missing") + if got := reservedID(p, reservationCandidates("owner-a", "owner-b")); got != "owner-b" { + t.Fatalf("reservation with ineligible pin = %q, want owner-b", got) + } +} + +func TestReserveCandidate_PreservesFailoverAndSnapshotReset(t *testing.T) { + p := prProxy(t) + p.SetPrioritySnapshot(schedulerwire.Priority{ + Nodes: []string{"b", "a", "c"}, + Ranks: []schedulerwire.NodeRank{ + {ID: "b", Pending: 0}, + {ID: "a", Pending: 5}, + {ID: "c", Pending: 6}, + }, + }) + got := p.reserveCandidate(reservationCandidates("a", "b", "c")) + want := []string{"b", "a", "c"} + for i, id := range want { + if got[i].id != id { + t.Fatalf("reserved failover order = %v, want %v", candidateIDsFrom(got), want) + } + } + + p.SetPrioritySnapshot(schedulerwire.Priority{ + Nodes: []string{"a", "b", "c"}, + Ranks: []schedulerwire.NodeRank{{ID: "a"}, {ID: "b"}, {ID: "c"}}, + }) + if next := reservedID(p, reservationCandidates("a", "b", "c")); next != "a" { + t.Fatalf("new snapshot did not reset reservations: next = %q, want a", next) + } +} + +func candidateIDsFrom(candidates []candidate) []string { + out := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + out = append(out, candidate.id) + } + return out +} diff --git a/services/mlx-proxy/routing_ab_test.go b/services/mlx-proxy/routing_ab_test.go new file mode 100644 index 00000000..33798e30 --- /dev/null +++ b/services/mlx-proxy/routing_ab_test.go @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "math/rand" + "testing" +) + +// A/B for the one routing rule mlx-proxy does not inherit from the proxy it was +// cloned from: an owner that already holds the requested model resident is +// preferred over one that merely has it on disk. +// +// arm A NVPAIR_MLX_ROUTING unset residency preferred (shipped default) +// arm B NVPAIR_MLX_ROUTING=any rank by load only -- what ollama-proxy and +// lmstudio-proxy do today (the control) +// +// Both arms run in one invocation over the same nodes and the same request +// sequence, because a number saved from an earlier run is not a control. +// +// What is measured is the routing DECISION, not inference: resolveCandidates is +// the whole policy, and driving it directly keeps multi-second generation +// variance out of a question that is really about counting. The physical cost of +// the reload this avoids is measured separately (bench/reload_cost.py); the two +// multiply: +// +// latency saved per request = (miss-rate delta) x (measured reload seconds) +// +// Run it with: go test -run TestRoutingPolicyAB -v . + +var abModels = []string{ + "mlx-community/Llama-3.2-1B-Instruct-4bit", + "mlx-community/Qwen3-4B-4bit", + "mlx-community/Mistral-7B-Instruct-v0.3-4bit", +} + +// abNode is a node holding every model on disk and exactly one resident, which +// is what mlx_lm.server can actually be in: one model in memory at a time. +func abNode(id string, resident string) Node { + n := prNode(id) + n.Models = append([]string(nil), abModels...) + n.Loaded = []string{resident} + return n +} + +func runArm(t *testing.T, sequence []string, residentOf map[string]string) int { + t.Helper() + disc := NewDiscovery() + for id, resident := range residentOf { + disc.AddManual(abNode(id, resident)) + } + p := testProxy(disc, 1235) + + hits := 0 + for _, model := range sequence { + cands := p.resolveCandidates(model) + if len(cands) == 0 { + t.Fatalf("no candidate for %q: a node holding the model on disk must always be routable", model) + } + // Only the first candidate matters: the rest are the failover chain, + // and a healthy node never falls through to them. + if residentOf[cands[0].id] == model { + hits++ + } + } + return hits +} + +func TestRoutingPolicyAB(t *testing.T) { + const requests = 600 + residentOf := map[string]string{} + for i, m := range abModels { + residentOf[fmt.Sprintf("node-%d", i)] = m + } + + rng := rand.New(rand.NewSource(7)) + sequence := make([]string, requests) + for i := range sequence { + sequence[i] = abModels[rng.Intn(len(abModels))] + } + + t.Setenv(routingPolicyEnv, "") + armA := runArm(t, sequence, residentOf) + + t.Setenv(routingPolicyEnv, "any") + armB := runArm(t, sequence, residentOf) + + rate := func(h int) float64 { return float64(h) / float64(requests) * 100 } + t.Logf("%d requests, %d nodes, %d models", requests, len(residentOf), len(abModels)) + t.Logf(" A residency-preferred (default): %3d/%d resident hits (%.1f%%)", armA, requests, rate(armA)) + t.Logf(" B load-only (control) : %3d/%d resident hits (%.1f%%)", armB, requests, rate(armB)) + t.Logf(" delta : %+.1f pp", rate(armA)-rate(armB)) + t.Logf(" multiply the delta by the measured reload seconds (bench/reload_cost.py)") + t.Logf(" for the per-request latency the policy saves") + + // The arms have to actually differ, or the flag is not wired and the whole + // comparison is measuring one policy twice. + if armA <= armB { + t.Errorf("residency preference did not beat the control (%d vs %d); is the arm switch wired?", armA, armB) + } + // Every request has a resident owner available here, so arm A should find + // one every time. Anything less means ranking is overriding residency. + if armA != requests { + t.Errorf("arm A resident hits = %d, want %d: a resident owner existed for every request", armA, requests) + } +} diff --git a/services/mlx-proxy/server_timeout_test.go b/services/mlx-proxy/server_timeout_test.go new file mode 100644 index 00000000..4f792e29 --- /dev/null +++ b/services/mlx-proxy/server_timeout_test.go @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "net" + "testing" + + "nvpair-shared/clustertrust" +) + +func TestHTTPServersConfigureIdleTimeouts(t *testing.T) { + p := testProxy(NewDiscovery(), 1235) + p.mesh = clustertrust.Open(t.TempDir()) + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + p.serveHTTP(context.Background(), ln) + defer p.shutdown(context.Background()) + + if p.plainSrv == nil || p.tlsSrv == nil { + t.Fatal("servers not recorded") + } + for name, srv := range map[string]struct { + readHeader, idle interface{} + }{ + "plain": {p.plainSrv.ReadHeaderTimeout, p.plainSrv.IdleTimeout}, + "tls": {p.tlsSrv.ReadHeaderTimeout, p.tlsSrv.IdleTimeout}, + } { + if srv.readHeader != proxyReadHeaderTimeout { + t.Errorf("%s ReadHeaderTimeout = %v, want %v", name, srv.readHeader, proxyReadHeaderTimeout) + } + if srv.idle != proxyServerIdleTimeout { + t.Errorf("%s IdleTimeout = %v, want %v", name, srv.idle, proxyServerIdleTimeout) + } + } + if proxyServerIdleTimeout != proxyIdleConnTimeout { + t.Fatalf("server IdleTimeout %v != client IdleConnTimeout %v", proxyServerIdleTimeout, proxyIdleConnTimeout) + } +} diff --git a/services/mlx-proxy/subscribed_test.go b/services/mlx-proxy/subscribed_test.go new file mode 100644 index 00000000..37a154b3 --- /dev/null +++ b/services/mlx-proxy/subscribed_test.go @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "testing" + + "nvpair-shared/noderec" +) + +// TestSubscribedToNode covers the DirectoryNode -> routable Node projection for +// the lm service, including per-engine model attribution: the proxy ranks on the +// node's MLX models only, never the cross-engine union. +func TestSubscribedToNode(t *testing.T) { + withLM := noderec.DirectoryNode{ + HostUUID: "uuid-a", + Name: "host-a", + IP: "10.0.0.5", + Models: []string{"gguf"}, + Services: map[noderec.ServiceKey]noderec.ServiceStatus{ + noderec.ServiceMLX: {Port: 1234}, + }, + } + got, ok := subscribedToNode(withLM) + if !ok { + t.Fatal("node with lm + IP should project") + } + // No attribution -> fall back to the flat union (single-engine peer). + if got.ID != "uuid-a" || got.Port != 1234 || got.IP != "10.0.0.5" || + len(got.Models) != 1 || got.Models[0] != "gguf" { + t.Fatalf("unexpected projection: %+v", got) + } + + noIP := withLM + noIP.IP = "" + if _, ok := subscribedToNode(noIP); ok { + t.Fatal("node without IP should not project") + } + + // A node advertising only a non-lm service must not be an lm routing target. + olOnly := noderec.DirectoryNode{ + Name: "host-b", + IP: "10.0.0.6", + Services: map[noderec.ServiceKey]noderec.ServiceStatus{noderec.ServiceOllama: {Port: 11434}}, + } + if _, ok := subscribedToNode(olOnly); ok { + t.Fatal("node without lm should not project") + } + + // Per-engine attribution: a dual-engine node projects ONLY its MLX + // models, never the union — so an Ollama-only model isn't ranked as an + // MLX owner. + dual := noderec.DirectoryNode{ + HostUUID: "uuid-d", + Name: "host-d", + IP: "10.0.0.7", + Models: []string{"ollama-model", "mlx-model"}, + ModelsByEngine: map[string][]string{ + "ollama": {"ollama-model"}, + "mlx": {"mlx-model"}, + }, + Services: map[noderec.ServiceKey]noderec.ServiceStatus{ + noderec.ServiceMLX: {Port: 1234}, + }, + } + got, ok = subscribedToNode(dual) + if !ok { + t.Fatal("dual-engine node with lm should project") + } + if len(got.Models) != 1 || got.Models[0] != "mlx-model" { + t.Fatalf("dual-engine projection Models = %v, want [mlx-model] only", got.Models) + } +} + +// TestSubscribedToNodeKeysByHostUUID: the routable Node keys on the stable +// hostUuid, not the hostname, so routing/scheduledOn/selection survive a PC +// rename and never conflate same-named machines. Host stays the hostname for +// display. +func TestSubscribedToNodeKeysByHostUUID(t *testing.T) { + const uuid = "22222222-2222-2222-2222-222222222222" + n := noderec.DirectoryNode{ + HostUUID: uuid, + Name: "host-a", + IP: "10.0.0.5", + Services: map[noderec.ServiceKey]noderec.ServiceStatus{noderec.ServiceMLX: {Port: 1234}}, + } + got, ok := subscribedToNode(n) + if !ok { + t.Fatal("node with lm + IP should project") + } + if got.ID != uuid { + t.Fatalf("ID = %q, want hostUuid %q", got.ID, uuid) + } + if got.Host != "host-a" { + t.Fatalf("Host = %q, want hostname for display", got.Host) + } +} diff --git a/services/mlx-proxy/transport.go b/services/mlx-proxy/transport.go new file mode 100644 index 00000000..10a47ed6 --- /dev/null +++ b/services/mlx-proxy/transport.go @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "io" + "os" +) + +type stdioTransport struct { + io.Reader + io.Writer +} + +func newStdioTransport() io.ReadWriteCloser { + return &stdioTransport{ + Reader: os.Stdin, + Writer: os.Stdout, + } +} + +func (s *stdioTransport) Close() error { + return nil +} diff --git a/services/mlx-proxy/transport_pool_test.go b/services/mlx-proxy/transport_pool_test.go new file mode 100644 index 00000000..75fefa38 --- /dev/null +++ b/services/mlx-proxy/transport_pool_test.go @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "path/filepath" + "testing" + + "nvpair-shared/clustertrust" + "nvpair-shared/clustertrusttest" +) + +func TestCandidateTransportReusesPlainTransport(t *testing.T) { + p := testProxy(NewDiscovery(), 1235) + a := p.candidateTransport(candidate{}) + b := p.candidateTransport(candidate{id: "manual"}) + if a != b { + t.Fatalf("plain candidates returned distinct Transports") + } + if a == nil { + t.Fatal("plain Transport is nil") + } +} + +func TestCandidateTransportReusesPeerTransport(t *testing.T) { + const peerUUID = "principal-peer" + clusterDir := filepath.Join(t.TempDir(), "cluster") + clustertrusttest.Join(t, clusterDir, "cluster-xyz", "principal-self", peerUUID) + + p := testProxy(NewDiscovery(), 1235) + p.mesh = clustertrust.Open(clusterDir) + + a := p.candidateTransport(candidate{peerUUID: peerUUID}) + b := p.candidateTransport(candidate{peerUUID: peerUUID}) + if a != b { + t.Fatalf("same peerUUID returned distinct Transports") + } + if a.TLSClientConfig == nil { + t.Fatal("peer Transport missing TLSClientConfig") + } + + other := p.candidateTransport(candidate{peerUUID: "principal-other"}) + if other == a { + t.Fatal("unpinned peer reused pinned peer Transport") + } +} + +func TestDropUnpinnedPeerTransportsRemovesEntry(t *testing.T) { + const peerUUID = "principal-peer" + clusterDir := filepath.Join(t.TempDir(), "cluster") + clustertrusttest.Join(t, clusterDir, "cluster-xyz", "principal-self", peerUUID) + + p := testProxy(NewDiscovery(), 1235) + p.mesh = clustertrust.Open(clusterDir) + + tr := p.candidateTransport(candidate{peerUUID: peerUUID}) + p.transportMu.Lock() + if _, ok := p.peerTransports[peerUUID]; !ok { + p.transportMu.Unlock() + t.Fatal("peer Transport was not cached") + } + p.transportMu.Unlock() + + clustertrusttest.RemovePeerPin(t, clusterDir, peerUUID) + p.mesh.Refresh() + p.dropUnpinnedPeerTransports() + + p.transportMu.Lock() + _, still := p.peerTransports[peerUUID] + p.transportMu.Unlock() + if still { + t.Fatal("peer Transport remained after pin removal") + } + _ = tr +} diff --git a/services/mlx-proxy/zombie_test.go b/services/mlx-proxy/zombie_test.go new file mode 100644 index 00000000..62a0b038 --- /dev/null +++ b/services/mlx-proxy/zombie_test.go @@ -0,0 +1,494 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +// count reports how many times s appears across the recorded codec frames. +// Method names appear once per emitted notification, so this counts emissions. +func (r *prRec) count(s string) int { + r.mu.Lock() + defer r.mu.Unlock() + hay := string(r.b) + n := 0 + for i := 0; i+len(s) <= len(hay); i++ { + if hay[i:i+len(s)] == s { + n++ + } + } + return n +} + +// clientGoneWriter is an http.ResponseWriter whose body Write fails after the +// status line is sent, standing in for a client that vanished mid-stream: the +// idle write deadline trips (statusCapture.Write) and the underlying +// connection write returns an error. It records the status and supports Flush +// so the reverse proxy streams through it. This is the shape of the zombie-job +// bug — the response has committed (200), so without the wroteErr check the +// terminal would be misreported as completed. +type clientGoneWriter struct { + header http.Header + status int + err error + wrote bool +} + +func (c *clientGoneWriter) Header() http.Header { + if c.header == nil { + c.header = make(http.Header) + } + return c.header +} + +func (c *clientGoneWriter) WriteHeader(code int) { c.status = code } + +func (c *clientGoneWriter) Write(b []byte) (int, error) { + c.wrote = true + return 0, c.err +} + +func (c *clientGoneWriter) Flush() {} + +// TestHandleHTTP_ClientWriteError_MarksFailed is the zombie-job regression: a +// streaming inference response that has committed (200 headers sent) but whose +// body write to the client fails — the signature of a killed / half-open client +// whose write deadline tripped — must terminate the workload as FAILED, not be +// silently reported completed. +func TestHandleHTTP_ClientWriteError_MarksFailed(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + io.WriteString(w, `{"choices":[{"delta":{"content":"partial tokens"}}]}`) + })) + defer upstream.Close() + + rec := &prRec{} + disc := NewDiscovery() + disc.AddManual(nodeForModel(t, "node-a", upstream.URL, "llama")) + p := NewProxy(NewCodec(rec), disc, 1235) + + cw := &clientGoneWriter{err: errors.New("write tcp: connection reset by peer")} + + done := make(chan struct{}) + go func() { + defer close(done) + p.handleHTTP(cw, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"llama"}`))) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("handleHTTP did not return after the client write failed (zombie: handler blocked)") + } + + if !cw.wrote { + t.Fatal("reverse proxy never attempted a body write to the client; test did not exercise the streaming path") + } + if got := rec.count("workload:errored"); got != 1 { + t.Fatalf("workload:errored emitted %d times, want exactly 1", got) + } + if rec.has("workload:completed") { + t.Fatal("workload:completed emitted for a request whose client write failed (should be failed)") + } +} + +// TestHandleHTTP_ClientDisconnect_TerminalOnce covers the disconnect watcher: a +// request whose context is cancelled mid-flight must emit exactly one terminal +// (errored) — the watcher and post-handler path are guarded by terminalOnce — +// and handleHTTP must return promptly rather than hang. +func TestHandleHTTP_ClientDisconnect_TerminalOnce(t *testing.T) { + received := make(chan struct{}, 1) + release := make(chan struct{}) + var releaseOnce sync.Once + doRelease := func() { releaseOnce.Do(func() { close(release) }) } + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + io.WriteString(w, `{"choices":[{"delta":{"content":"first chunk"}}]}`+"\n") + f.Flush() + } + select { + case received <- struct{}{}: + default: + } + <-release + })) + defer upstream.Close() + defer doRelease() + + rec := &prRec{} + disc := NewDiscovery() + disc.AddManual(nodeForModel(t, "node-a", upstream.URL, "llama")) + p := NewProxy(NewCodec(rec), disc, 1235) + + ctx, cancel := context.WithCancel(context.Background()) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"llama"}`)).WithContext(ctx) + + done := make(chan struct{}) + go func() { + defer close(done) + p.handleHTTP(httptest.NewRecorder(), req) + }() + + select { + case <-received: + case <-time.After(5 * time.Second): + t.Fatal("upstream never started streaming") + } + cancel() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("handleHTTP did not return after client disconnect (zombie: handler blocked)") + } + + if got := rec.count("workload:errored"); got != 1 { + t.Fatalf("workload:errored emitted %d times, want exactly 1 (terminalOnce guard)", got) + } + if rec.has("workload:completed") { + t.Fatal("workload:completed emitted for a cancelled request") + } +} + +// deadlineRW records SetWriteDeadline calls and can force a Write error, so the +// statusCapture write-deadline mechanics can be tested without a real socket. +type deadlineRW struct { + *httptest.ResponseRecorder + deadlines []time.Time + writeErr error + flushErr error + flushed int +} + +func (d *deadlineRW) SetWriteDeadline(t time.Time) error { + d.deadlines = append(d.deadlines, t) + return nil +} + +func (d *deadlineRW) Write(b []byte) (int, error) { + if d.writeErr != nil { + return 0, d.writeErr + } + return d.ResponseRecorder.Write(b) +} + +// FlushError lets a test drive statusCapture.FlushError against a controllable +// flush outcome (recorded so the deadline arm/clear can be asserted). +func (d *deadlineRW) FlushError() error { + d.flushed++ + return d.flushErr +} + +// TestStatusCapture_WriteDeadline verifies statusCapture arms a write deadline +// around each streamed write and clears it after a successful one, and that the +// first write error is retained for the caller to classify the workload failed. +func TestStatusCapture_WriteDeadline(t *testing.T) { + t.Run("armed then cleared on success", func(t *testing.T) { + d := &deadlineRW{ResponseRecorder: httptest.NewRecorder()} + sc := &statusCapture{ResponseWriter: d, status: http.StatusOK, idle: 50 * time.Millisecond} + if _, err := sc.Write([]byte("tokens")); err != nil { + t.Fatalf("Write returned error: %v", err) + } + if len(d.deadlines) != 2 { + t.Fatalf("SetWriteDeadline called %d times, want 2 (arm + clear)", len(d.deadlines)) + } + if d.deadlines[0].IsZero() { + t.Fatal("first SetWriteDeadline should arm a future deadline, got zero") + } + if !d.deadlines[1].IsZero() { + t.Fatal("second SetWriteDeadline should clear the deadline (zero time)") + } + if sc.wroteErr != nil { + t.Fatalf("wroteErr set after a successful write: %v", sc.wroteErr) + } + }) + + t.Run("write error retained, deadline not cleared", func(t *testing.T) { + boom := errors.New("i/o timeout") + d := &deadlineRW{ResponseRecorder: httptest.NewRecorder(), writeErr: boom} + sc := &statusCapture{ResponseWriter: d, status: http.StatusOK, idle: 50 * time.Millisecond} + if _, err := sc.Write([]byte("tokens")); !errors.Is(err, boom) { + t.Fatalf("Write err = %v, want %v", err, boom) + } + if !errors.Is(sc.wroteErr, boom) { + t.Fatalf("wroteErr = %v, want %v", sc.wroteErr, boom) + } + if len(d.deadlines) != 1 { + t.Fatalf("SetWriteDeadline called %d times, want 1 (arm only; not cleared on error)", len(d.deadlines)) + } + }) + + t.Run("no deadline when idle is zero", func(t *testing.T) { + d := &deadlineRW{ResponseRecorder: httptest.NewRecorder()} + sc := &statusCapture{ResponseWriter: d, status: http.StatusOK} + if _, err := sc.Write([]byte("tokens")); err != nil { + t.Fatalf("Write returned error: %v", err) + } + if len(d.deadlines) != 0 { + t.Fatalf("SetWriteDeadline called %d times with idle=0, want 0", len(d.deadlines)) + } + }) +} + +// TestStatusCapture_FlushDeadline verifies the flush path is deadline-aware: +// statusCapture.FlushError arms the idle deadline around the underlying flush, +// clears it after a successful flush, and retains a real flush error (but not an +// unsupported-flush) so a stalled client's blocked flush is classified failed +// rather than hanging forever. +func TestStatusCapture_FlushDeadline(t *testing.T) { + t.Run("armed then cleared on success", func(t *testing.T) { + d := &deadlineRW{ResponseRecorder: httptest.NewRecorder()} + sc := &statusCapture{ResponseWriter: d, status: http.StatusOK, idle: 50 * time.Millisecond} + if err := sc.FlushError(); err != nil { + t.Fatalf("FlushError returned error: %v", err) + } + if d.flushed != 1 { + t.Fatalf("underlying flushed %d times, want 1", d.flushed) + } + if len(d.deadlines) != 2 { + t.Fatalf("SetWriteDeadline called %d times, want 2 (arm + clear)", len(d.deadlines)) + } + if d.deadlines[0].IsZero() { + t.Fatal("flush should arm a future deadline, got zero") + } + if !d.deadlines[1].IsZero() { + t.Fatal("flush should clear the deadline on success (zero time)") + } + if sc.wroteErr != nil { + t.Fatalf("wroteErr set after a successful flush: %v", sc.wroteErr) + } + }) + + t.Run("flush error retained, deadline not cleared", func(t *testing.T) { + boom := errors.New("i/o timeout") + d := &deadlineRW{ResponseRecorder: httptest.NewRecorder(), flushErr: boom} + sc := &statusCapture{ResponseWriter: d, status: http.StatusOK, idle: 50 * time.Millisecond} + if err := sc.FlushError(); !errors.Is(err, boom) { + t.Fatalf("FlushError = %v, want %v", err, boom) + } + if !errors.Is(sc.wroteErr, boom) { + t.Fatalf("wroteErr = %v, want %v", sc.wroteErr, boom) + } + if len(d.deadlines) != 1 { + t.Fatalf("SetWriteDeadline called %d times, want 1 (arm only; not cleared on error)", len(d.deadlines)) + } + }) + + t.Run("unsupported flush is not a client failure", func(t *testing.T) { + d := &deadlineRW{ResponseRecorder: httptest.NewRecorder(), flushErr: http.ErrNotSupported} + sc := &statusCapture{ResponseWriter: d, status: http.StatusOK, idle: 50 * time.Millisecond} + _ = sc.FlushError() + if sc.wroteErr != nil { + t.Fatalf("ErrNotSupported must not be retained as wroteErr, got %v", sc.wroteErr) + } + }) +} + +// TestHandleHTTP_RealSocketWriteDeadline is the end-to-end, OS-level proof of +// the zombie-job fix. It drives handleHTTP over a REAL TCP socket with a client +// that reads the response headers and then stops reading — the shape of a +// killed / half-open client whose receive window closes without a FIN/RST. The +// upstream streams without end, so the proxy's kernel send buffer to the client +// fills and its next write blocks. Before the fix that write blocks ~forever +// (no terminal event; the zombie job), so r.Context() never fires and the +// handler never returns. With the fix, statusCapture arms a real +// SetWriteDeadline that the Go runtime's netpoller enforces on every platform +// (IOCP on Windows, epoll/kqueue elsewhere) regardless of the peer's TCP state, +// so the stuck write fails and the workload terminates as failed. This is the +// piece the in-process tests stub out — here the deadline is genuinely enforced +// by the OS/runtime. +func TestHandleHTTP_RealSocketWriteDeadline(t *testing.T) { + // Shorten the idle write deadline so a stuck write trips quickly; restore + // the production default for any test that runs after this one. + orig := idleClientWriteTimeout + idleClientWriteTimeout = 300 * time.Millisecond + defer func() { idleClientWriteTimeout = orig }() + + // Upstream streams 64 KiB chunks endlessly. Once the proxy stops reading + // from it (because the proxy is itself blocked writing to the stalled + // client), the upstream's own writes block too — no busy loop — and it + // unwinds when the proxy tears the connection down (write error or context + // cancel). + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + chunk := bytes.Repeat([]byte("x"), 64*1024) + for { + if _, err := w.Write(chunk); err != nil { + return + } + if flusher != nil { + flusher.Flush() + } + select { + case <-r.Context().Done(): + return + default: + } + } + })) + defer upstream.Close() + + rec := &prRec{} + disc := NewDiscovery() + disc.AddManual(nodeForModel(t, "node-a", upstream.URL, "llama")) + p := NewProxy(NewCodec(rec), disc, 1235) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + srv := &http.Server{Handler: http.HandlerFunc(p.handleHTTP)} + go func() { _ = srv.Serve(ln) }() + defer srv.Close() + + conn, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("dial proxy: %v", err) + } + defer conn.Close() + + body := `{"model":"llama"}` + reqText := "POST /v1/chat/completions HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "Content-Type: application/json\r\n" + + fmt.Sprintf("Content-Length: %d\r\n", len(body)) + + "\r\n" + body + if _, err := conn.Write([]byte(reqText)); err != nil { + t.Fatalf("write request: %v", err) + } + + // Read the status line only — enough to confirm the response committed and + // started streaming — then STOP reading so the proxy's send buffer backs + // up. A read deadline guards against a hang if the proxy never responds. + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + statusLine, err := bufio.NewReader(conn).ReadString('\n') + if err != nil { + t.Fatalf("read status line: %v", err) + } + if !strings.Contains(statusLine, "200") { + t.Fatalf("unexpected status line: %q", statusLine) + } + + // The stuck write must trip the deadline and terminate the workload as + // failed within a few multiples of the deadline — never completed. + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) && !rec.has("workload:errored") { + time.Sleep(10 * time.Millisecond) + } + if !rec.has("workload:errored") { + t.Fatal("workload never terminated after the client stopped reading (zombie: write deadline did not trip / no terminal emitted)") + } + if rec.has("workload:completed") { + t.Fatal("workload:completed emitted for a client that stopped reading (should be failed)") + } +} + +// TestHandleHTTP_RealSocketFlushDeadline is the flush-path counterpart to the +// write-deadline test. A streaming response is flushed after every chunk, so a +// small chunk buffers on a successful Write (no network I/O) and the actual +// network write happens in a separate Flush. If only Write is deadline-aware, a +// stalled client makes that Flush block unbounded and the handler never returns +// — a zombie the 64 KiB Write-blocking test does not catch. This drives real, +// paced small flushed chunks over a real socket and asserts the flush deadline +// terminates the workload as failed. +func TestHandleHTTP_RealSocketFlushDeadline(t *testing.T) { + orig := idleClientWriteTimeout + idleClientWriteTimeout = 300 * time.Millisecond + defer func() { idleClientWriteTimeout = orig }() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + flusher, ok := w.(http.Flusher) + if !ok { + t.Errorf("upstream ResponseWriter is not a Flusher") + return + } + chunk := bytes.Repeat([]byte("x"), 1500) + for { + if _, err := w.Write(chunk); err != nil { + return + } + flusher.Flush() + // Pace so the reverse proxy's 32 KiB copy read returns one small + // chunk per iteration rather than coalescing many into a >2 KiB + // write (which would block inside Write, not Flush). + time.Sleep(2 * time.Millisecond) + select { + case <-r.Context().Done(): + return + default: + } + } + })) + defer upstream.Close() + + rec := &prRec{} + disc := NewDiscovery() + disc.AddManual(nodeForModel(t, "node-a", upstream.URL, "llama")) + p := NewProxy(NewCodec(rec), disc, 1235) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + srv := &http.Server{Handler: http.HandlerFunc(p.handleHTTP)} + go func() { _ = srv.Serve(ln) }() + defer srv.Close() + + conn, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("dial proxy: %v", err) + } + defer conn.Close() + + body := `{"model":"llama"}` + reqText := "POST /v1/chat/completions HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "Content-Type: application/json\r\n" + + fmt.Sprintf("Content-Length: %d\r\n", len(body)) + + "\r\n" + body + if _, err := conn.Write([]byte(reqText)); err != nil { + t.Fatalf("write request: %v", err) + } + + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + statusLine, err := bufio.NewReader(conn).ReadString('\n') + if err != nil { + t.Fatalf("read status line: %v", err) + } + if !strings.Contains(statusLine, "200") { + t.Fatalf("unexpected status line: %q", statusLine) + } + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) && !rec.has("workload:errored") { + time.Sleep(10 * time.Millisecond) + } + if !rec.has("workload:errored") { + t.Fatal("workload never terminated after the client stopped reading (flush path not deadline-aware)") + } + if rec.has("workload:completed") { + t.Fatal("workload:completed emitted for a stalled client (should be failed)") + } +} From f758a581bfd9861ca21e024e99ad0bb976c4e0c1 Mon Sep 17 00:00:00 2001 From: Denis Akimov Date: Mon, 14 Sep 2026 11:47:39 -0700 Subject: [PATCH 04/12] build(services): build, register and supervise the new binaries mlx-proxy and mlx-pool join the supervised set, so the broker starts and stops them like every other worker, the build and installer scripts produce them, and uninstall removes them. Signed-off-by: Denis Akimov --- desktop/native/PrivilegedHelper/main.swift | 1 + desktop/scripts/build-modular-binaries.ts | 4 + desktop/scripts/build/macos/uninstall.sh | 3 +- .../src/shared/constants/modular-binaries.ts | 22 +- scripts/wipe-app-data.sh | 1 + services/.gitignore | 2 + services/build.sh | 19 +- services/installer_build.sh | 2 + services/nvpair-ui-broker/broker.go | 131 +++++++- .../nvpair-ui-broker/broker_lifecycle_test.go | 3 +- services/nvpair-ui-broker/discovery.go | 21 ++ services/nvpair-ui-broker/main.go | 22 ++ services/nvpair-ui-broker/mlxproxy.go | 281 ++++++++++++++++++ services/nvpair-ui-broker/nodeinfo.go | 6 + services/readme.md | 12 +- services/shared/noderec/noderec.go | 52 +++- services/versions.json | 12 +- 17 files changed, 578 insertions(+), 16 deletions(-) create mode 100644 services/nvpair-ui-broker/mlxproxy.go diff --git a/desktop/native/PrivilegedHelper/main.swift b/desktop/native/PrivilegedHelper/main.swift index a0c4e2e9..1d996f78 100644 --- a/desktop/native/PrivilegedHelper/main.swift +++ b/desktop/native/PrivilegedHelper/main.swift @@ -38,6 +38,7 @@ enum Firewall { static let networkedBinaries = [ "ollama-proxy", "lmstudio-proxy", + "mlx-proxy", "nvpair-node-info", "nvpair-node-scanner", "nvpair-workload-manager", diff --git a/desktop/scripts/build-modular-binaries.ts b/desktop/scripts/build-modular-binaries.ts index bfdc3cb1..2e42e7ed 100644 --- a/desktop/scripts/build-modular-binaries.ts +++ b/desktop/scripts/build-modular-binaries.ts @@ -211,6 +211,10 @@ function listFingerprintFiles(repo: string): string[] { out.push(full) } else if (entry === 'go.mod' || entry === 'go.sum') { out.push(full) + } else if (entry.endsWith('.json') && path.basename(dir) === 'manifests') { + // Engine manifests are go:embed'ed, so editing one changes the + // built binary even though no .go file moved. + out.push(full) } } } diff --git a/desktop/scripts/build/macos/uninstall.sh b/desktop/scripts/build/macos/uninstall.sh index ce88d22b..00faeb08 100644 --- a/desktop/scripts/build/macos/uninstall.sh +++ b/desktop/scripts/build/macos/uninstall.sh @@ -58,6 +58,7 @@ for proc in \ "nvpair-tui" \ "ollama-proxy" \ "lmstudio-proxy" \ + "mlx-proxy" \ "nvpair-node-info" \ "nvpair-node-scanner" \ "nvpair-manual-nodes" \ @@ -74,7 +75,7 @@ sleep 1 FW=/usr/libexec/ApplicationFirewall/socketfilterfw if [ -x "$FW" ]; then - for bin in ollama-proxy lmstudio-proxy nvpair-node-info nvpair-node-scanner \ + for bin in ollama-proxy lmstudio-proxy mlx-proxy nvpair-node-info nvpair-node-scanner \ nvpair-workload-manager nvpair-errors nvpair-cluster-manager nvpair-engine-manager; do "$FW" --remove "$APP_PATH/Contents/Resources/cli-bin/$bin" >/dev/null 2>&1 || true done diff --git a/desktop/src/shared/constants/modular-binaries.ts b/desktop/src/shared/constants/modular-binaries.ts index 8f24c8d7..590ac483 100644 --- a/desktop/src/shared/constants/modular-binaries.ts +++ b/desktop/src/shared/constants/modular-binaries.ts @@ -6,6 +6,7 @@ import type { SupportedPlatform } from '@/shared/types/platform' export type ModularProcessName = | 'proxy' | 'lmstudio-proxy' + | 'mlx-proxy' | 'broker' | 'node-info' | 'scanner' @@ -75,6 +76,19 @@ export const MODULAR_RUNTIME_BINARIES: ModularRuntimeBinary[] = [ needsFirewallAccess: true, optional: true }, + { + // MLX reverse proxy — the third sibling of `ollama-proxy`, relayed under + // the `mlx-proxy:` namespace (`--mlx-proxy-path`). Optional for the + // usual reason plus one more: MLX runs only on Apple Silicon, so on any + // other host the binary is legitimately absent and the broker degrades + // to "no local MLX proxy". + processName: 'mlx-proxy', + baseName: 'mlx-proxy', + args: [], + launchOwner: 'broker', + needsFirewallAccess: true, + optional: true + }, { processName: 'scanner', baseName: 'nvpair-node-scanner', @@ -171,7 +185,13 @@ export const MODULAR_RUNTIME_BINARIES: ModularRuntimeBinary[] = [ * `nvpair-tui` is a headless terminal client that spawns its own `nvpair-ui-broker` — * see `services/nvpair-tui/README.md`. */ -export const MODULAR_BUNDLED_BINARIES: { baseName: string }[] = [{ baseName: 'nvpair-tui' }] +export const MODULAR_BUNDLED_BINARIES: { baseName: string }[] = [ + { baseName: 'nvpair-tui' }, + // Started by nvpair-engine-manager AS the MLX engine (the manifest's + // {pair_bin}/mlx-pool), not by Electron or the broker — so it ships in the + // bundle but appears in no supervisor's worker list. + { baseName: 'mlx-pool' } +] /** Every backend binary shipped in the installer (runtime workers + bundled tools). */ export function modularShippedBinaryBaseNames(): string[] { diff --git a/scripts/wipe-app-data.sh b/scripts/wipe-app-data.sh index 335415de..bd525bd4 100755 --- a/scripts/wipe-app-data.sh +++ b/scripts/wipe-app-data.sh @@ -148,6 +148,7 @@ PAIR_PROCS=( nvpair-ui-broker ollama-proxy lmstudio-proxy + mlx-proxy nvpair-node-info nvpair-node-scanner nvpair-manual-nodes diff --git a/services/.gitignore b/services/.gitignore index 4ecef157..2663fb2b 100644 --- a/services/.gitignore +++ b/services/.gitignore @@ -12,6 +12,8 @@ dist/ # happen to share a name with a component directory. ollama-proxy/ollama-proxy lmstudio-proxy/lmstudio-proxy +mlx-proxy/mlx-proxy +mlx-pool/mlx-pool nvpair-node-info/nvpair-node-info nvpair-node-scanner/nvpair-node-scanner nvpair-manual-nodes/nvpair-manual-nodes diff --git a/services/build.sh b/services/build.sh index feadff14..15407c7f 100755 --- a/services/build.sh +++ b/services/build.sh @@ -57,6 +57,8 @@ echo V_PRODUCT=$(jq -r '.product' "$VERSIONS_FILE") V_PROXY=$( jq -r --arg k 'ollama-proxy' '.components[$k]' "$VERSIONS_FILE") V_LMPROXY=$(jq -r --arg k 'lmstudio-proxy' '.components[$k]' "$VERSIONS_FILE") +V_MLXPROXY=$(jq -r --arg k 'mlx-proxy' '.components[$k]' "$VERSIONS_FILE") +V_MLXPOOL=$(jq -r --arg k 'mlx-pool' '.components[$k]' "$VERSIONS_FILE") V_NINFO=$( jq -r --arg k 'nvpair-node-info' '.components[$k]' "$VERSIONS_FILE") V_NSCAN=$( jq -r --arg k 'nvpair-node-scanner' '.components[$k]' "$VERSIONS_FILE") V_MNODES=$( jq -r --arg k 'nvpair-manual-nodes' '.components[$k]' "$VERSIONS_FILE") @@ -77,6 +79,8 @@ fi printf ' product = %s\n' "$V_PRODUCT" printf ' ollama-proxy = %s\n' "$V_PROXY" printf ' lmstudio-proxy = %s\n' "$V_LMPROXY" +printf ' mlx-proxy = %s\n' "$V_MLXPROXY" +printf ' mlx-pool = %s\n' "$V_MLXPOOL" printf ' nvpair-node-info = %s\n' "$V_NINFO" printf ' nvpair-node-scanner = %s\n' "$V_NSCAN" printf ' nvpair-manual-nodes = %s\n' "$V_MNODES" @@ -97,12 +101,21 @@ echo build_subbinary() { local idx="$1" name="$2" version="$3" - echo "[$idx/13] Building $name (v$version)..." + echo "[$idx/15] Building $name (v$version)..." (cd "$ROOT/$name" && go build -ldflags "-X main.Version=$version" -o "$name" .) echo " OK" } build_subbinary 1 ollama-proxy "$V_PROXY" build_subbinary 2 lmstudio-proxy "$V_LMPROXY" +# mlx-proxy is built on every platform even though MLX itself runs only on +# Apple Silicon: it is pure Go, and a host with no MLX engine manifest simply +# never advertises the mx service, so the binary sits inert rather than needing +# a platform branch here. +build_subbinary 14 mlx-proxy "$V_MLXPROXY" +# mlx-pool is not a PAIR service: nvpair-engine-manager starts it AS the MLX +# engine (see the manifest's {pair_bin}/mlx-pool), so it speaks OpenAI HTTP +# rather than JSON-RPC and the broker never supervises it. +build_subbinary 15 mlx-pool "$V_MLXPOOL" build_subbinary 3 nvpair-node-info "$V_NINFO" build_subbinary 4 nvpair-node-scanner "$V_NSCAN" build_subbinary 5 nvpair-manual-nodes "$V_MNODES" @@ -132,6 +145,8 @@ rm -rf "$BIN_OUT" mkdir -p "$BIN_OUT" cp "$ROOT/ollama-proxy/ollama-proxy" "$BIN_OUT/ollama-proxy" cp "$ROOT/lmstudio-proxy/lmstudio-proxy" "$BIN_OUT/lmstudio-proxy" +cp "$ROOT/mlx-proxy/mlx-proxy" "$BIN_OUT/mlx-proxy" +cp "$ROOT/mlx-pool/mlx-pool" "$BIN_OUT/mlx-pool" cp "$ROOT/nvpair-node-info/nvpair-node-info" "$BIN_OUT/nvpair-node-info" cp "$ROOT/nvpair-node-scanner/nvpair-node-scanner" "$BIN_OUT/nvpair-node-scanner" cp "$ROOT/nvpair-manual-nodes/nvpair-manual-nodes" "$BIN_OUT/nvpair-manual-nodes" @@ -151,6 +166,8 @@ echo "========================================" echo printf ' Proxy: %s\n' "$BIN_OUT/ollama-proxy" printf ' LM Studio Proxy: %s\n' "$BIN_OUT/lmstudio-proxy" +printf ' MLX Proxy: %s\n' "$BIN_OUT/mlx-proxy" +printf ' MLX Pool: %s\n' "$BIN_OUT/mlx-pool" printf ' Node Info: %s\n' "$BIN_OUT/nvpair-node-info" printf ' Node Scanner: %s\n' "$BIN_OUT/nvpair-node-scanner" printf ' Manual Nodes: %s\n' "$BIN_OUT/nvpair-manual-nodes" diff --git a/services/installer_build.sh b/services/installer_build.sh index cba2c440..66ccd6c3 100755 --- a/services/installer_build.sh +++ b/services/installer_build.sh @@ -139,6 +139,8 @@ fi cp "$BIN_SRC/ollama-proxy" "$STAGE/bin/" cp "$BIN_SRC/lmstudio-proxy" "$STAGE/bin/" +cp "$BIN_SRC/mlx-proxy" "$STAGE/bin/" +cp "$BIN_SRC/mlx-pool" "$STAGE/bin/" cp "$BIN_SRC/nvpair-node-info" "$STAGE/bin/" cp "$BIN_SRC/nvpair-node-scanner" "$STAGE/bin/" cp "$BIN_SRC/nvpair-manual-nodes" "$STAGE/bin/" diff --git a/services/nvpair-ui-broker/broker.go b/services/nvpair-ui-broker/broker.go index 0d189578..a263c17a 100644 --- a/services/nvpair-ui-broker/broker.go +++ b/services/nvpair-ui-broker/broker.go @@ -153,6 +153,7 @@ type Broker struct { nodeInfoPath string proxyPath string lmstudioProxyPath string + mlxProxyPath string workloadMgrPath string errorsPath string engineMgrPath string @@ -185,6 +186,9 @@ type Broker struct { lmstudioBackendPort atomic.Int32 lmstudioProxyStartupPort atomic.Int32 lmstudioProxyGeneration atomic.Uint64 + mlxProxyGeneration atomic.Uint64 + mlxProxyStartupPort atomic.Int32 + mlxProxyRebinding atomic.Bool lmstudioProxyPublishedGeneration atomic.Uint64 lmstudioPortReady chan struct{} lmstudioPortReadyOnce sync.Once @@ -213,6 +217,7 @@ type Broker struct { nodeInfo *nodeInfoProcess proxy *proxyProcess lmstudioProxy *proxyProcess + mlxProxy *proxyProcess workloadMgr *workloadManagerProcess errorsProc *errorsProcess engineMgr *rpcWorker @@ -228,6 +233,7 @@ type Broker struct { nodeInfoSup *supervisor proxySup *supervisor lmstudioProxySup *supervisor + mlxProxySup *supervisor workloadMgrSup *supervisor errorsSup *supervisor engineMgrSup *supervisor @@ -252,6 +258,7 @@ type Broker struct { proxyMu sync.Mutex proxySubscribed bool lmstudioProxySubscribed bool + mlxProxySubscribed bool // workloadsMu guards workloadsSubscribed. The workloads:* stream is // opt-in too: emitWorkloadEvent (called on the proxy reader goroutine @@ -330,6 +337,7 @@ type workerPaths struct { nodeInfo string proxy string lmstudioProxy string + mlxProxy string workloadMgr string errors string engineMgr string @@ -369,6 +377,7 @@ func NewBroker(codec *Codec, paths workerPaths) *Broker { nodeInfoPath: paths.nodeInfo, proxyPath: paths.proxy, lmstudioProxyPath: paths.lmstudioProxy, + mlxProxyPath: paths.mlxProxy, workloadMgrPath: paths.workloadMgr, errorsPath: paths.errors, engineMgrPath: paths.engineMgr, @@ -509,11 +518,13 @@ func (b *Broker) runEngineAvailabilityAfterPortGates( ctx context.Context, runOllama func(context.Context), runLMStudio func(context.Context), + runMLX func(context.Context), ) bool { if !b.restoreEnabledEnginesAfterPortGate(ctx) { return false } go runOllama(ctx) + go runMLX(ctx) runLMStudio(ctx) return true } @@ -626,6 +637,8 @@ func (b *Broker) spawnNodeInfo() (supervisedHandle, error) { // /v1/node-info but holds no cluster dir to read it from, so this push is the // only source. It runs on every spawn, which also covers a supervised restart. b.pushClusterIdentityToNodeInfo() + // Same for the peer set: a respawned node-info starts fail-open until told. + b.pushTrustedReadersToNodeInfo() // Register node-info's service so the daemon advertises ni= on _nvpair-node. // node-info binds the fixed :14318 (force_ports is inert), so the broker // knows its port. Idempotent across restarts. @@ -1128,6 +1141,48 @@ func (b *Broker) clusterPrincipal() string { // the next spawn pushes again. A failed write is warned about rather than traced, // because until the next push lands peers cannot learn this node's membership // over HTTP — which is the whole point of reporting it. +// pushTrustedReadersToNodeInfo hands node-info every address the discovery +// store currently sees a peer on. node-info answers its plaintext inventory to +// those and to loopback, and refuses the rest -- so a GPU/CPU inventory, live +// utilisation and a stable host UUID stop being readable by any printer, phone +// or guest laptop that can reach port 14318. +// +// The set is derived from discovery rather than from cluster pins on purpose: +// the desktop app polls a PEER's node-info directly and holds no cluster +// identity of its own, so pin-based gating would blank the UI's node cards -- +// the exact regression spawnNodeInfo warns about. Every machine running PAIR is +// already announcing itself on mDNS, so keying on "is a discovered node" costs +// nothing that discovery has not already published. +// +// Pushed on every discovery change, so a peer that leaves loses access on the +// next snapshot rather than at some TTL. +func (b *Broker) pushTrustedReadersToNodeInfo() { + np := b.getNodeInfo() + if np == nil { + return + } + seen := map[string]struct{}{} + addresses := []string{} + for _, n := range b.store.Snapshot() { + // Every address the peer published, not just its canonical one: a + // multi-homed peer may reach us from any of them, and a poll arriving + // on an unlisted interface would be refused. + for _, ip := range append([]string{n.IPAddress}, n.IPAddresses...) { + if ip == "" { + continue + } + if _, dup := seen[ip]; dup { + continue + } + seen[ip] = struct{}{} + addresses = append(addresses, ip) + } + } + if err := np.SetTrustedReaders(addresses); err != nil { + slog.Warn("failed to push trusted readers to node-info", "err", err) + } +} + func (b *Broker) pushClusterIdentityToNodeInfo() { np := b.getNodeInfo() if np == nil { @@ -1464,6 +1519,8 @@ func (b *Broker) proxyForEngine(engine string) *proxyProcess { return b.getProxy() case "lmstudio": return b.getLMStudioProxy() + case "mlx": + return b.getMLXProxy() default: return nil } @@ -1775,11 +1832,27 @@ func (b *Broker) Serve(ctx context.Context) error { b.finishLMStudioProxyTerminal() } + // mlx-proxy is the third sibling. It needs neither an ownership gate nor a + // terminal-outcome hook: it reserves no compatibility port, so a failure to + // start costs only MLX routing and blocks nothing else (see mlxproxy.go). + if b.mlxProxyPath != "" { + b.mlxProxySup = newSupervisor("mlx-proxy", defaultRestartPolicy(), b.spawnMLXProxy) + b.configureMLXProxySupervisorCallbacks(b.mlxProxySup) + if err := b.mlxProxySup.Start(); err != nil { + slog.Warn("mlx-proxy failed to start; continuing without local MLX proxy", "path", b.mlxProxyPath, "err", err) + b.mlxProxySup = nil + } else { + defer b.mlxProxySup.Stop() + } + } else { + slog.Info("mlx-proxy path not resolved; running without local MLX proxy") + } + // Restore engines and begin both advertising loops only after both proxy // startup attempts have established either readiness or a terminal outcome. // This prevents a restored engine from taking a persisted proxy port before // the broker can resolve ownership. - go b.runEngineAvailabilityAfterPortGates(ctx, b.runAutoAdvertise, b.runAutoAdvertiseLMStudio) + go b.runEngineAvailabilityAfterPortGates(ctx, b.runAutoAdvertise, b.runAutoAdvertiseLMStudio, b.runAutoAdvertiseMLX) // nvpair-workload-manager is another auxiliary worker: it relays local // workload lifecycle events to peer nodes and surfaces peer events @@ -1860,6 +1933,10 @@ func (b *Broker) shutdownInferenceStack() { b.lmstudioProxySup.Stop() b.setLMStudioProxy(nil) } + if b.mlxProxySup != nil { + b.mlxProxySup.Stop() + b.setMLXProxy(nil) + } if b.proxySup != nil { b.proxySup.Stop() b.setProxy(nil) @@ -1902,6 +1979,10 @@ func (b *Broker) emitNodesChanged() { // external peer opted into discovery:nodes-changed. b.fanDiscoveryToScheduler() + // So is node-info's reader set: who may read this host's inventory cannot + // depend on whether a UI happens to be subscribed to the node stream. + b.pushTrustedReadersToNodeInfo() + b.subMu.Lock() subscribed := b.subscribed b.subMu.Unlock() @@ -2236,6 +2317,11 @@ func (b *Broker) forwardLogLevel(level string) { slog.Warn("failed to forward log/set-level to proxy", "err", err) } } + if p := b.getMLXProxy(); p != nil { + if err := p.SetLogLevel(level); err != nil { + slog.Warn("failed to forward log/set-level to mlx-proxy", "err", err) + } + } if p := b.getLMStudioProxy(); p != nil { if err := p.SetLogLevel(level); err != nil { slog.Warn("failed to forward log/set-level to lmstudio-proxy", "err", err) @@ -2819,6 +2905,45 @@ func (b *Broker) handleMessage(msg *Message) { // is bumped) before handing the proxy the port to bind. b.handleProxySetPort(msg) + case "mlx-proxy:get-status": + // Answered locally from the mlx-proxy handle's captured state, + // mirroring proxy:get-status. Zero value when none is supervised. + var mlxResult ProxyStatusResult + if p := b.getMLXProxy(); p != nil { + ready, port := p.Status() + mlxResult.Ready = ready + mlxResult.Port = port + } + if err := b.codec.Respond(msg.ID, mlxResult); err != nil { + log.Printf("failed to respond to mlx-proxy:get-status: %v", err) + } + + case "mlx-proxy:subscribe": + b.proxyMu.Lock() + mlxWasSubscribed := b.mlxProxySubscribed + b.mlxProxySubscribed = true + b.proxyMu.Unlock() + if err := b.codec.Respond(msg.ID, SubscriptionResult{Subscribed: true}); err != nil { + log.Printf("failed to respond to mlx-proxy:subscribe: %v", err) + } + if !mlxWasSubscribed { + if p := b.getMLXProxy(); p != nil { + if rp := p.ReadyParams(); rp != nil { + if err := b.codec.Notify("mlx-proxy:ready", rp); err != nil { + slog.Warn("emit baseline mlx-proxy:ready failed", "err", err) + } + } + } + } + + case "mlx-proxy:unsubscribe": + b.proxyMu.Lock() + b.mlxProxySubscribed = false + b.proxyMu.Unlock() + if err := b.codec.Respond(msg.ID, SubscriptionResult{Subscribed: false}); err != nil { + log.Printf("failed to respond to mlx-proxy:unsubscribe: %v", err) + } + case "lmstudio-proxy:set-port": b.handleLMStudioProxySetPort(msg) @@ -2943,6 +3068,10 @@ func (b *Broker) handleMessage(msg *Message) { // lmstudio-proxy:* is checked before proxy:* — though the prefixes // don't actually overlap (lmstudio-proxy: vs proxy:), keeping it // first makes the LM Studio namespace explicit. + if strings.HasPrefix(msg.Method, "mlx-proxy:") { + b.relayToMLXProxy(msg) + return + } if strings.HasPrefix(msg.Method, "lmstudio-proxy:") { b.relayToLMStudioProxy(msg) return diff --git a/services/nvpair-ui-broker/broker_lifecycle_test.go b/services/nvpair-ui-broker/broker_lifecycle_test.go index 51cc0915..ea943048 100644 --- a/services/nvpair-ui-broker/broker_lifecycle_test.go +++ b/services/nvpair-ui-broker/broker_lifecycle_test.go @@ -53,7 +53,7 @@ func TestEngineAvailabilityWaitsForBothProxyOutcomes(t *testing.T) { restore <- msg.Method } }() - advertised := make(chan string, 2) + advertised := make(chan string, 3) ctx, cancel := context.WithCancel(context.Background()) defer cancel() done := make(chan bool, 1) @@ -62,6 +62,7 @@ func TestEngineAvailabilityWaitsForBothProxyOutcomes(t *testing.T) { ctx, func(context.Context) { advertised <- "ollama" }, func(context.Context) { advertised <- "lmstudio" }, + func(context.Context) { advertised <- "mlx" }, ) }() diff --git a/services/nvpair-ui-broker/discovery.go b/services/nvpair-ui-broker/discovery.go index 8f216533..5978fff3 100644 --- a/services/nvpair-ui-broker/discovery.go +++ b/services/nvpair-ui-broker/discovery.go @@ -651,6 +651,27 @@ func writeSetLevelFrame(mu *sync.Mutex, w io.Writer, level string) error { // nodeinfo:set-cluster-identity notification and writes it to a child's stdin // under mu. An empty principal is a real value ("this node is in no cluster"), // so it is sent like any other. +func writeTrustedReadersFrame(mu *sync.Mutex, w io.Writer, addresses []string) error { + frame := struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params noderec.TrustedReadersParams `json:"params"` + }{ + JSONRPC: "2.0", + Method: noderec.MethodSetTrustedReaders, + Params: noderec.TrustedReadersParams{Addresses: addresses}, + } + data, err := json.Marshal(frame) + if err != nil { + return err + } + data = append(data, '\n') + mu.Lock() + defer mu.Unlock() + _, err = w.Write(data) + return err +} + func writeClusterIdentityFrame(mu *sync.Mutex, w io.Writer, clusterUUID string) error { frame := struct { JSONRPC string `json:"jsonrpc"` diff --git a/services/nvpair-ui-broker/main.go b/services/nvpair-ui-broker/main.go index 3c45cf0d..ae8c5786 100644 --- a/services/nvpair-ui-broker/main.go +++ b/services/nvpair-ui-broker/main.go @@ -27,6 +27,7 @@ func main() { nodeInfoPath := flag.String("node-info-path", "", "path to nvpair-node-info binary (default: ./nvpair-node-info in the current working directory)") proxyPath := flag.String("proxy-path", "", "path to ollama-proxy binary (default: ./ollama-proxy in the current working directory)") lmstudioProxyPath := flag.String("lmstudio-proxy-path", "", "path to lmstudio-proxy binary (default: ./lmstudio-proxy in the current working directory)") + mlxProxyPath := flag.String("mlx-proxy-path", "", "path to mlx-proxy binary (default: ./mlx-proxy in the current working directory)") workloadMgrPath := flag.String("workload-manager-path", "", "path to nvpair-workload-manager binary (default: ./nvpair-workload-manager in the current working directory)") errorsPath := flag.String("errors-path", "", "path to nvpair-errors binary (default: ./nvpair-errors in the current working directory)") engineMgrPath := flag.String("engine-manager-path", "", "path to nvpair-engine-manager binary (default: ./nvpair-engine-manager in the current working directory)") @@ -137,6 +138,18 @@ func main() { resolvedLMStudioProxy = "" } + // mlx-proxy is auxiliary too, resolved with the same rules. On a machine + // that is not Apple Silicon the binary is simply absent, which is the same + // degrade-quietly path as any other unresolved sibling. + resolvedMLXProxy, err := resolveMLXProxyPath(*mlxProxyPath) + if err != nil { + if *mlxProxyPath != "" { + fatalf("mlx-proxy binary: %v", err) + } + slog.Warn("mlx-proxy binary not found; broker will run without local MLX proxy", "err", err) + resolvedMLXProxy = "" + } + // nvpair-workload-manager is auxiliary too, resolved with the same rules: // an explicit --workload-manager-path that doesn't exist is a loud // operator mistake (fatal), but an absent default sibling just means @@ -260,6 +273,7 @@ func main() { nodeInfo: resolvedNodeInfo, proxy: resolvedProxy, lmstudioProxy: resolvedLMStudioProxy, + mlxProxy: resolvedMLXProxy, workloadMgr: resolvedWorkloadMgr, errors: resolvedErrors, engineMgr: resolvedEngineMgr, @@ -327,6 +341,14 @@ func resolveLMStudioProxyPath(override string) (string, error) { return resolveSiblingBinary(override, "lmstudio-proxy", "--lmstudio-proxy-path") } +// resolveMLXProxyPath mirrors resolveProxyPath for the mlx-proxy binary. Its +// result is optional at the call site for the usual reason plus one more: MLX +// runs only on Apple Silicon, so on any other host the binary is legitimately +// absent and the broker degrades to "no local MLX proxy". +func resolveMLXProxyPath(override string) (string, error) { + return resolveSiblingBinary(override, "mlx-proxy", "--mlx-proxy-path") +} + // resolveWorkloadManagerPath mirrors resolveProxyPath for the // nvpair-workload-manager binary the broker supervises. Like node-info and the // proxy its result is optional at the call site: a not-found default sibling diff --git a/services/nvpair-ui-broker/mlxproxy.go b/services/nvpair-ui-broker/mlxproxy.go new file mode 100644 index 00000000..73bfda90 --- /dev/null +++ b/services/nvpair-ui-broker/mlxproxy.go @@ -0,0 +1,281 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "log/slog" + "net/http" + "strings" + "time" + + "nvpair-shared/applog" + "nvpair-shared/noderec" +) + +// mlxproxy.go is the broker's MLX wiring, the third sibling of proxy.go +// (ollama-proxy) and lmstudioproxy.go (lmstudio-proxy). mlx-proxy speaks the +// same JSON-RPC control plane as both, so it reuses proxyProcess; only the +// namespace differs — relayed under mlx-proxy: rather than proxy: or +// lmstudio-proxy:. +// +// What is deliberately absent is the managed-facade machinery in +// lmstudioport.go. That exists because LM Studio's server and its proxy both +// want :1234, so the broker has to move a running engine off the compatibility +// port before the proxy can take it. MLX has no such collision: PAIR starts +// mlx_lm.server with an explicit --port from the manifest (8081), leaving the +// proxy the conventional :8080 that clients already point at. No port to +// reclaim means no ownership gate, no fallback ladder, and no rebind dance. + +// defaultMLXPort is mlx_lm.server's stock port. It is only the fallback used +// when engine-manager cannot say where the engine actually is; the manifest's +// runtime.port (8081) is the real answer, and the proxy owns 8080. +const defaultMLXPort = 8081 + +// mlxProxyFallbackStart is where the search for a replacement listener begins +// when mlx-proxy's preferred port is taken. mlx-proxy defaults to :8080 because +// that is mlx_lm.server's documented port, so a client already pointed at a +// local MLX server is routed with no reconfiguration -- but :8080 is also the +// most contended port on a developer's machine (Docker Desktop and any number +// of dev servers claim it), so the fallback is not an edge case, it is the +// common case. Starting at 8090 keeps the replacement recognisably in the MLX +// range rather than scattering it into ephemeral territory. +const mlxProxyFallbackStart = 8090 + +func (b *Broker) setMLXProxy(p *proxyProcess) { + b.workersMu.Lock() + b.mlxProxy = p + b.workersMu.Unlock() +} + +func (b *Broker) getMLXProxy() *proxyProcess { + b.workersMu.Lock() + defer b.workersMu.Unlock() + return b.mlxProxy +} + +func (b *Broker) configureMLXProxySupervisorCallbacks(sup *supervisor) { + report, recovered := b.supervisedWorkerCallbacks("mlx-proxy", func() { b.setMLXProxy(nil) }) + // A process that exits after reporting bind-failed did not crash: it told us + // why it was leaving, and the next spawn already has a free port. Reporting + // that as "subprocess mlx-proxy exited unexpectedly" puts a red error in the + // UI on every launch, because :8080 being taken is the normal case for this + // engine rather than a fault. The error does clear itself after the + // supervisor's healthy-reset window, but a minute of looking broken on first + // launch is exactly the wrong first impression. + // + // Only the one exit we were told about is swallowed -- the flag is consumed + // here, so a genuine crash on the next spawn reports normally. + sup.onCrash = func(attempt int) { + if b.mlxProxyRebinding.Swap(false) { + b.setMLXProxy(nil) + slog.Info("mlx-proxy exited to rebind after a port conflict; not reporting a crash", "attempt", attempt) + return + } + report(attempt) + } + sup.onRecovered = recovered + sup.onExhausted = func(attempt int) { + slog.Warn("mlx-proxy is terminally unavailable", "attempt", attempt) + } +} + +// spawnMLXProxy is the mlx-proxy supervisor's spawn closure. Unlike its two +// siblings it passes no startup port: with no facade to reserve there is +// nothing to override, so the proxy uses its own persisted-or-default port. +func (b *Broker) spawnMLXProxy() (supervisedHandle, error) { + generation := b.mlxProxyGeneration.Add(1) + pp, err := startProxy( + "mlx-proxy", + b.mlxProxyPath, + applog.LevelString(), + b.relayDir, + func(method string, params json.RawMessage) { + b.forwardMLXProxyNotificationForGeneration(generation, method, params) + }, + b.mlxProxyArgs()..., + ) + if err != nil { + return nil, err + } + b.setMLXProxy(pp) + slog.Info("mlx-proxy started", "path", b.mlxProxyPath, "pid", pp.cmd.Process.Pid) + return pp, nil +} + +// mlxProxyArgs passes an explicit port only after a bind failure has forced one. +// On the first spawn the proxy chooses for itself (its persisted port, else +// :8080), which is what makes the default the compatibility value rather than +// something the broker imposes. +func (b *Broker) mlxProxyArgs() []string { + var args []string + if port := int(b.mlxProxyStartupPort.Load()); port != 0 { + args = []string{"--port", fmt.Sprintf("%d", port), "--ignore-persisted-port"} + } + return append(args, b.clusterDirArgs()...) +} + +// setMLXProxyFallback picks the next free port for the following spawn after a +// bind failure. The failed port is excluded explicitly: a process that owns +// :8080 is unlikely to release it between one spawn and the next, and without +// the exclusion the search would hand back the same port and crash-loop. +func (b *Broker) setMLXProxyFallback(excludedPorts ...int) int { + fallback := nextAvailablePortExcluding(mlxProxyFallbackStart, excludedPorts, tcpPortAvailable) + b.mlxProxyStartupPort.Store(int32(fallback)) + return fallback +} + +// forwardMLXProxyNotification mirrors forwardLMStudioProxyNotification: +// errors:report / errors:clear go into the nvpair-errors pipeline; workload +// lifecycle events are stamped and forwarded to the workload-manager for +// cluster broadcast (mlx-proxy tags its workloads "mlx"); everything else is +// re-emitted to mlx-proxy:subscribe'd clients as mlx-proxy:. +func (b *Broker) forwardMLXProxyNotification(method string, params json.RawMessage) { + b.forwardMLXProxyNotificationForGeneration(b.mlxProxyGeneration.Load(), method, params) +} + +func (b *Broker) forwardMLXProxyNotificationForGeneration(generation uint64, method string, params json.RawMessage) { + if b.mlxProxyGeneration.Load() != generation { + return + } + if b.dispatchErrorsNotif("mlx-proxy", method, params) { + return + } + // A bind failure is reported by the exiting process, so the fix has to land + // on the NEXT spawn rather than on this one. Without it the supervisor + // restarts the proxy onto the same taken port forever. + if method == "error" { + var ep struct { + Code string `json:"code"` + Port int `json:"port"` + } + if json.Unmarshal(params, &ep) == nil && ep.Code == "bind-failed" { + fallback := b.setMLXProxyFallback(ep.Port) + // Consumed by the supervisor's onCrash: this exit is expected. + b.mlxProxyRebinding.Store(true) + slog.Warn("MLX proxy bind failed; retrying on fallback", "port", ep.Port, "fallback", fallback) + } + } + if proxyWorkloadMethods[method] { + b.routeProxyWorkload(method, params) + return + } + if method == noderec.NotifyNodeActivity { + b.routeNodeActivity(params) + return + } + b.proxyMu.Lock() + subscribed := b.mlxProxySubscribed + b.proxyMu.Unlock() + if !subscribed { + return + } + if err := b.codec.Notify("mlx-proxy:"+method, params); err != nil { + slog.Warn("forward mlx-proxy notification failed", "method", method, "err", err) + } +} + +// relayToMLXProxy forwards an mlx-proxy: request to mlx-proxy as +// (prefix stripped) and maps its response straight back, mirroring +// relayToLMStudioProxy. mlx-proxy:shutdown is refused — the broker owns the +// proxy's lifecycle. +func (b *Broker) relayToMLXProxy(msg *Message) { + method := strings.TrimPrefix(msg.Method, "mlx-proxy:") + if method == "shutdown" { + if err := b.codec.RespondError(msg.ID, -32601, "mlx-proxy:shutdown is not allowed; the broker owns the proxy lifecycle"); err != nil { + log.Printf("failed to respond to mlx-proxy:shutdown: %v", err) + } + return + } + + p := b.getMLXProxy() + if p == nil { + if err := b.codec.RespondError(msg.ID, -32000, "mlx-proxy not available"); err != nil { + log.Printf("failed to respond to %s: %v", msg.Method, err) + } + return + } + + result, rpcErr, err := p.Call(context.Background(), method, msg.Params) + switch { + case err != nil: + if err := b.codec.RespondError(msg.ID, -32000, fmt.Sprintf("mlx-proxy call failed: %v", err)); err != nil { + log.Printf("failed to respond to %s: %v", msg.Method, err) + } + case rpcErr != nil: + if err := b.codec.RespondError(msg.ID, rpcErr.Code, rpcErr.Message); err != nil { + log.Printf("failed to relay mlx-proxy error for %s: %v", msg.Method, err) + } + default: + if err := b.codec.Respond(msg.ID, result); err != nil { + log.Printf("failed to relay mlx-proxy result for %s: %v", msg.Method, err) + } + } +} + +// runAutoAdvertiseMLX is the MLX sibling of runAutoAdvertise / …LMStudio: it +// polls the local mlx_lm.server and reconciles this node's mx registration +// against it, so an MLX host appears on the cluster the same way the other two +// do. +func (b *Broker) runAutoAdvertiseMLX(ctx context.Context) { + client := &http.Client{Timeout: 2 * time.Second} + ticker := time.NewTicker(autoAdvertiseInterval) + defer ticker.Stop() + + b.reconcileAdvertiseMLX(client) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + b.reconcileAdvertiseMLX(client) + } + } +} + +// reconcileAdvertiseMLX advertises the proxy port (never the engine) as this +// node's mx service and hands the engine's loopback port to mlx-proxy via +// node/set-local-backend. Simpler than the LM Studio reconciler by exactly the +// facade cache it does not need: there is no managed port to reclaim, so a +// stock-port fallback here can never poison a backend cache, and the only guard +// left is the one that matters — never hand the proxy its own listener. +func (b *Broker) reconcileAdvertiseMLX(client *http.Client) { + enginePort, probe := b.localEnginePort("mlx", defaultMLXPort) + proxyPort := b.mlxProxyListenPort() + up := probe && proxyPort != 0 && enginePort != proxyPort && checkMLXHealth(client, enginePort) + if up { + b.registerService(noderec.RegisterParams{Service: noderec.ServiceMLX, Port: proxyPort}) + b.setProxyLocalBackend(b.getMLXProxy(), "mlx", enginePort, true) + return + } + b.unregisterService(noderec.ServiceMLX) + b.setProxyLocalBackend(b.getMLXProxy(), "mlx", enginePort, false) +} + +func (b *Broker) mlxProxyListenPort() int { + if p := b.getMLXProxy(); p != nil { + if ready, port := p.Status(); ready { + return port + } + } + return 0 +} + +// checkMLXHealth reports whether a local mlx_lm.server is answering. /health is +// used rather than /v1/models because it is the endpoint that also reports +// which model is resident, and because /v1/models walks the whole Hugging Face +// cache on every call — too expensive for a liveness poll on a machine holding +// tens of gigabytes of weights. +func checkMLXHealth(client *http.Client, port int) bool { + resp, err := client.Get(fmt.Sprintf("http://localhost:%d/health", port)) + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode == http.StatusOK +} diff --git a/services/nvpair-ui-broker/nodeinfo.go b/services/nvpair-ui-broker/nodeinfo.go index af890547..f8986589 100644 --- a/services/nvpair-ui-broker/nodeinfo.go +++ b/services/nvpair-ui-broker/nodeinfo.go @@ -58,6 +58,12 @@ func (n *nodeInfoProcess) SetClusterIdentity(clusterUUID string) error { return writeClusterIdentityFrame(&n.stdinMu, n.stdin, clusterUUID) } +// SetTrustedReaders tells node-info which addresses currently host a PAIR peer, +// so its plaintext inventory stops answering every device on the LAN. +func (n *nodeInfoProcess) SetTrustedReaders(addresses []string) error { + return writeTrustedReadersFrame(&n.stdinMu, n.stdin, addresses) +} + // Done implements supervisedHandle: the returned channel closes once the // node-info process has exited (cmd.Wait returned). func (n *nodeInfoProcess) Done() <-chan struct{} { return n.done } diff --git a/services/readme.md b/services/readme.md index ee3523d5..21689337 100644 --- a/services/readme.md +++ b/services/readme.md @@ -35,13 +35,14 @@ see the [root README](../README.md#what-is-supported). ## Architecture -This tree builds thirteen Go binaries. `nvpair-ui-broker` is the parent service and supervises the eleven workers, all spawned at startup — only the scanner is required, and a missing binary for any other leaves the broker running without that capability. `nvpair-tui` is the thirteenth: a terminal client that launches and supervises its own broker rather than being supervised. Processes communicate via newline-delimited JSON-RPC 2.0 over stdio or, optionally, a Unix socket / Windows named pipe. +This tree builds fifteen Go binaries. `nvpair-ui-broker` is the parent service and supervises the eleven workers, all spawned at startup — only the scanner is required, and a missing binary for any other leaves the broker running without that capability. `nvpair-tui` is the thirteenth: a terminal client that launches and supervises its own broker rather than being supervised. Processes communicate via newline-delimited JSON-RPC 2.0 over stdio or, optionally, a Unix socket / Windows named pipe. | Binary | Role | | --- | --- | | `nvpair-ui-broker` | Parent service and JSON-RPC API surface used by the bundled UI and other clients. Supervises workers, relays consolidated discovery, and coordinates routing and scheduling. | | `ollama-proxy` | Ollama-compatible HTTP reverse proxy. Routes only to advertised model owners, with owner failover and scheduler priorities. | | `lmstudio-proxy` | LM Studio counterpart to `ollama-proxy`, forwarding OpenAI-compatible inference routes with equivalent owner-only routing and failover behavior. | +| `mlx-proxy` | MLX counterpart to `lmstudio-proxy` (Apple Silicon). Identical except for one rule: because `mlx_lm.server` holds a single model, it prefers an owner that already has the requested model resident and falls back to on-disk owners. See [`docs/mlx.mdx`](../docs/mlx.mdx). | | `nvpair-node-info` | Local HTTP service on `:14318` exposing GPU, CPU, and memory inventory at `/v1/node-info`. | | `nvpair-node-scanner` | Consolidated discovery daemon. Advertises and browses `_nvpair-node._tcp`, maintains the node directory, and enriches peers with hardware and model information over HTTP. | | `nvpair-manual-nodes` | Manages user-added nodes that don't appear via mDNS; probes them every 10 s. | @@ -70,6 +71,7 @@ so bursts spread without waiting for workload feedback. nvpair-ui-broker/ Parent service / JSON-RPC API surface ollama-proxy/ Ollama-compatible routing proxy lmstudio-proxy/ OpenAI-compatible routing proxy for LM Studio +mlx-proxy/ OpenAI-compatible routing proxy for MLX (Apple Silicon) nvpair-node-info/ Local GPU-inventory HTTP service nvpair-node-scanner/ Consolidated _nvpair-node._tcp discovery daemon nvpair-manual-nodes/ Manual-node manager @@ -84,8 +86,8 @@ shared/ Shared Go module (nvpair-shared/…) eap-noob/ EAP-NOOB implementation used by cluster pairing tests/ Cross-process integration tests (separate go.mod) versions.json Single source of truth for every component version -build.bat Builds all thirteen binaries (Windows) -build.sh Builds all thirteen binaries (Linux) +build.bat Builds all thirteen binaries (Windows; no mlx-proxy) +build.sh Builds all fourteen binaries (Linux and macOS) VERSIONING.md SemVer rules and version-bump workflow ``` @@ -115,7 +117,7 @@ On Linux and macOS: ./build.sh ``` -Both scripts read `versions.json`, build all thirteen Go binaries with `-X main.Version=…` ldflags, and stage them together in `services/build/bin/`. +Both scripts read `versions.json`, build the Go binaries with `-X main.Version=…` ldflags, and stage them together in `services/build/bin/`. Do **not** build individual components by hand without also copying their binaries into `build/bin/`: the broker will silently keep using the older binary there. @@ -190,7 +192,7 @@ cd shared go test ./... ``` -**Every one of the thirteen binaries has tests**, as do `shared/` and +**Every one of the fourteen binaries has tests**, as do `shared/` and `eap-noob/`. Depth varies with how much behaviour a component carries: `nvpair-engine-manager` and `nvpair-cluster-manager` have the largest suites, while a component with one test file may still hold twenty test functions in it. diff --git a/services/shared/noderec/noderec.go b/services/shared/noderec/noderec.go index 112e7fe3..733ce3db 100644 --- a/services/shared/noderec/noderec.go +++ b/services/shared/noderec/noderec.go @@ -87,6 +87,11 @@ const ( ServiceNodeInfo ServiceKey = "ni" ServiceOllama ServiceKey = "ol" ServiceLMStudio ServiceKey = "lm" + // ServiceMLX is mlx-proxy's listener. MLX is its own service rather than a + // flavour of lm because a node's eligibility for a request is per-engine: + // mlx_lm.server holds one model at a time, so an MLX node advertises a + // different inventory than an LM Studio node on the same machine. + ServiceMLX ServiceKey = "mx" ServiceErrors ServiceKey = "er" ServiceWorkload ServiceKey = "wl" ServiceCluster ServiceKey = "cl" @@ -104,7 +109,7 @@ const ( // serviceKeyOrder is the deterministic emit order for service ports in TXT. var serviceKeyOrder = []ServiceKey{ - ServiceNodeInfo, ServiceOllama, ServiceLMStudio, + ServiceNodeInfo, ServiceOllama, ServiceLMStudio, ServiceMLX, ServiceErrors, ServiceWorkload, ServiceCluster, ServiceEngineManager, ServiceEngineControl, } @@ -326,6 +331,23 @@ const ( // record keeps its last observed value indefinitely. MethodSetClusterIdentity = "nodeinfo:set-cluster-identity" + // MethodSetTrustedReaders tells nvpair-node-info which peer addresses are + // currently known PAIR nodes, so its plaintext inventory answers those and + // loopback instead of every device on the LAN. + // + // It exists because node-info is the one inter-node surface deliberately + // kept plain (see MethodSetClusterIdentity above), which also made a GPU/CPU + // inventory, live utilisation, and a stable host UUID readable by any + // printer, phone or guest laptop on the network. The broker knows who the + // real peers are and node-info does not, so the broker tells it. + // + // This is an exposure reduction, NOT an authentication boundary: source + // addresses are forgeable on a LAN, and anything advertising + // _nvpair-node._tcp joins the set by design. What it removes is the passive + // case -- reading the inventory without announcing yourself as a node, which + // every peer's UI would show. + MethodSetTrustedReaders = "nodeinfo:set-trusted-readers" + // NotifyObservedAddresses is nvpair-node-info -> broker: the local addresses // peers have actually reached this node on, learned from its own accepted // connections. @@ -577,6 +599,34 @@ func (n DirectoryNode) EngineModels(engine string) []string { return n.Models } +// EngineLoaded returns the models one engine currently holds in memory on this +// node, by engine-manager engine name. It is the residency counterpart of +// EngineModels, for a consumer that must distinguish "this node can serve the +// model right now" from "this node has the file on disk". +// +// It deliberately does NOT fall back to EngineModels for a node that reports no +// residency: absent residency means unknown, and treating unknown as "loaded" +// would let a caller that prefers resident owners silently prefer an arbitrary +// one. A caller that wants a fallback picks it explicitly. +func (n DirectoryNode) EngineLoaded(engine string) []string { + if n.LoadedByEngine == nil { + return nil + } + return n.LoadedByEngine[engine] +} + +// TrustedReadersParams is the payload of MethodSetTrustedReaders: every address +// this node currently sees a PAIR peer on. Replaces the previous set wholesale, +// so a departed peer loses access on the next push. +// +// An empty list is meaningful and is NOT "trust nobody": it means the broker +// knows of no peers, which is the normal state of a single machine. The receiver +// keeps its own "have I ever been told" flag to separate that from a broker that +// never pushes at all. +type TrustedReadersParams struct { + Addresses []string `json:"addresses"` +} + // SubscribeParams filters a subscription to nodes advertising any of the listed // services; an empty list subscribes to all nodes. type SubscribeParams struct { diff --git a/services/versions.json b/services/versions.json index 29d8c230..55a42522 100644 --- a/services/versions.json +++ b/services/versions.json @@ -1,18 +1,20 @@ { "$comment": "Single source of truth for all version numbers. See VERSIONING.md for bump rules.", - "product": "0.91.7", - "installer": "0.91.7", + "product": "0.92.0-mlx", + "installer": "0.92.0-mlx", "components": { "ollama-proxy": "0.26.2", "lmstudio-proxy": "0.16.2", - "nvpair-node-info": "0.13.3", + "mlx-proxy": "0.1.0", + "mlx-pool": "0.2.0", + "nvpair-node-info": "0.14.0", "nvpair-node-scanner": "0.20.3", "nvpair-manual-nodes": "0.11.1", "nvpair-workload-manager": "0.13.3", "nvpair-errors": "0.7.4", "nvpair-node-settings": "1.0.4", - "nvpair-ui-broker": "0.40.2", - "nvpair-engine-manager": "0.17.4", + "nvpair-ui-broker": "0.42.0", + "nvpair-engine-manager": "0.21.0", "nvpair-cluster-manager": "1.1.4", "nvpair-job-scheduler": "0.4.1", "nvpair-tui": "0.7.2" From 982913863a49cd8960f0b80cfc9fdf3f152b525a Mon Sep 17 00:00:00 2001 From: Denis Akimov Date: Mon, 14 Sep 2026 11:47:39 -0700 Subject: [PATCH 05/12] feat(engine-manager): add MLX as a managed engine The engine manager is manifest-driven, so MLX arrives as a manifest rather than as code: fetch uv, build a virtualenv, install mlx-lm from PyPI at a pinned version. The install is retry-safe (uv venv --clear) and writes nothing into the app bundle, and a user manifest can point the engine at a different mlx-lm without editing the bundled one. Signed-off-by: Denis Akimov --- services/nvpair-engine-manager/actions.go | 77 +++++- .../nvpair-engine-manager/controlserver.go | 28 +- services/nvpair-engine-manager/executor.go | 9 +- .../nvpair-engine-manager/executor_test.go | 8 +- services/nvpair-engine-manager/install.go | 11 +- services/nvpair-engine-manager/lifecycle.go | 20 +- services/nvpair-engine-manager/manager.go | 1 + .../nvpair-engine-manager/manifests/mlx.json | 164 +++++++++++ .../manifests/ollama.json | 260 +++++++++++++++--- .../mlx_manifest_test.go | 169 ++++++++++++ services/nvpair-engine-manager/pairbin.go | 32 +++ services/nvpair-engine-manager/registry.go | 59 +++- 12 files changed, 773 insertions(+), 65 deletions(-) create mode 100644 services/nvpair-engine-manager/manifests/mlx.json create mode 100644 services/nvpair-engine-manager/mlx_manifest_test.go create mode 100644 services/nvpair-engine-manager/pairbin.go diff --git a/services/nvpair-engine-manager/actions.go b/services/nvpair-engine-manager/actions.go index 372ee104..cfde7c7b 100644 --- a/services/nvpair-engine-manager/actions.go +++ b/services/nvpair-engine-manager/actions.go @@ -113,9 +113,9 @@ func (e *Executor) dispatchAction(ctx context.Context, st *engineState, engine, } url := fmt.Sprintf("http://127.0.0.1:%d%s", port, path) - var body io.Reader - if len(params) > 0 && string(params) != "null" { - body = bytes.NewReader(params) + body, err := actionBody(act, params) + if err != nil { + return nil, fmt.Errorf("action %q: %w", action, err) } req, err := http.NewRequestWithContext(ctx, strings.ToUpper(act.HTTP.Method), url, body) if err != nil { @@ -126,8 +126,16 @@ func (e *Executor) dispatchAction(ctx context.Context, st *engineState, engine, } req.Header.Set(engineIdentityProbeHeader, "1") client := e.client - if engine == "ollama" && action == "run_model" && e.ollamaLoadClient != nil { - client = e.ollamaLoadClient + // A model load is slow on any engine: weights have to come off disk before + // the first response header can be written. The engine's default 30s header + // timeout is right for a control call and wrong for this one, so an action + // declares long_running and gets the patient client. The Ollama pair below + // predates the flag and is kept as-is so its manifest needs no change. + if act.LongRunning && e.slowActionClient != nil { + client = e.slowActionClient + } + if engine == "ollama" && action == "run_model" && e.slowActionClient != nil { + client = e.slowActionClient } resp, err := client.Do(req) if err != nil { @@ -148,6 +156,47 @@ func (e *Executor) dispatchAction(ctx context.Context, st *engineState, engine, return wrapped, nil } +// actionBody builds an HTTP action's request body. By default the caller's +// params are the body verbatim. A manifest that declares http.body instead +// supplies a fixed JSON document whose {placeholder} tokens are filled from +// those params, JSON-escaped, so an engine whose only way to perform an +// operation is a request the caller cannot compose (mlx-lm has no load +// endpoint, so load_model is a one-token chat completion) keeps that shape in +// the manifest rather than in every caller. +func actionBody(act Action, params json.RawMessage) (io.Reader, error) { + if len(act.HTTP.Body) == 0 { + if len(params) > 0 && string(params) != "null" { + return bytes.NewReader(params), nil + } + return nil, nil + } + vars := map[string]string{} + if len(params) > 0 { + var pm map[string]any + if err := json.Unmarshal(params, &pm); err == nil { + for k, v := range pm { + enc, err := json.Marshal(fmt.Sprint(v)) + if err != nil { + continue + } + // Strip the quotes json.Marshal adds: the manifest template + // carries them, so what is substituted is the escaped inner + // text. Without this a model name holding a quote or + // backslash would produce a malformed body. + vars[k] = string(enc[1 : len(enc)-1]) + } + } + } + resolved, err := resolvePlaceholders(string(act.HTTP.Body), vars) + if err != nil { + return nil, err + } + if !json.Valid([]byte(resolved)) { + return nil, fmt.Errorf("http.body did not resolve to valid JSON") + } + return strings.NewReader(resolved), nil +} + // runRemovePathAction resolves templated path/root placeholders and deletes // the target when it stays under the declared root. func (e *Executor) runRemovePathAction(ctx context.Context, st *engineState, act Action, params json.RawMessage) (json.RawMessage, error) { @@ -156,7 +205,7 @@ func (e *Executor) runRemovePathAction(ctx context.Context, st *engineState, act } vars := map[string]string{ "install_dir": st.installDir, - "models_dir": lmstudioModelsDir(), + "models_dir": engineModelsDir(st.manifest.Engine), } if len(params) > 0 { var pm map[string]any @@ -233,8 +282,22 @@ func (e *Executor) runCmdAction(ctx context.Context, st *engineState, act Action } vars["port"] = strconv.Itoa(port) vars["install_dir"] = st.installDir + vars["pair_bin"] = pairBinDir() if cli := st.plat.Runtime.CLI; cli != "" { - vars["cli"] = expandPath(cli) + // The manifest's cli may itself be templated ("{install_dir}/venv/bin/hf" + // for an engine PAIR installs into its own directory, rather than a fixed + // path like LM Studio's ~/.lmstudio/bin/lms). resolveArgs substitutes in a + // single pass, so a {cli} that expands to another placeholder would reach + // exec as a literal "{install_dir}/..." path. Resolve the runner-owned + // values inside it first. expandPath still runs after, for ~ and $VAR. + resolved, err := resolvePlaceholders(cli, map[string]string{ + "install_dir": st.installDir, + "port": strconv.Itoa(port), + }) + if err != nil { + return nil, fmt.Errorf("runtime.cli: %w", err) + } + vars["cli"] = expandPath(resolved) } // Most cmd actions run once with the params as given. An action that diff --git a/services/nvpair-engine-manager/controlserver.go b/services/nvpair-engine-manager/controlserver.go index b1708579..bf9a47d5 100644 --- a/services/nvpair-engine-manager/controlserver.go +++ b/services/nvpair-engine-manager/controlserver.go @@ -40,6 +40,23 @@ const controlEnginesPath = "/v1/engines" type controlServer struct { exec *Executor mesh *clustertrust.Mesh + // copyFrom asks THIS node to copy a model from a third node. Injected + // because resolving a peer and minting a pinned client belongs to the + // Manager, and the control surface should not grow a second copy of it. + copyFrom func(ctx context.Context, sourceNode, engine, model string) error + // hub is the Hugging Face cache this node serves models from. A field + // rather than a call to hubRoot() inside the handler, because a handler + // that reads the environment cannot be tested against a second node + // without the two of them fighting over one process-wide value. + hub string +} + +// hubDir returns the configured cache, falling back to the ambient one. +func (s *controlServer) hubDir() string { + if s.hub != "" { + return s.hub + } + return hubRoot() } // requirePin gates a handler on cluster-peer mTLS, sharing the one gate the @@ -61,6 +78,12 @@ func (s *controlServer) mux() *http.ServeMux { mux.HandleFunc(controlDeletePath, s.requirePin(s.handleDelete)) mux.HandleFunc(controlStartPath, s.requirePin(s.handleStart)) mux.HandleFunc(controlStopPath, s.requirePin(s.handleStop)) + // LAN model transfer: what this node holds, and the bytes themselves. + // Pin-gated like everything else here — a model list and its weights go + // only to a peer this node has actually paired with. + mux.HandleFunc(controlCacheManifestPath, s.requirePin(s.handleCacheManifest)) + mux.HandleFunc(controlCacheBlobPath, s.requirePin(s.handleCacheBlob)) + mux.HandleFunc(controlCopyFromPath, s.requirePin(s.handleCopyFrom)) return mux } @@ -79,7 +102,8 @@ func (s *controlServer) handleEngines(w http.ResponseWriter, r *http.Request) { // serveControl runs the ec mTLS control surface on 0.0.0.0:port until ctx is // cancelled. A bind failure is non-fatal: stdio engine management (and local // peers' remote calls being unavailable) must not take the process down. -func serveControl(ctx context.Context, port int, exec *Executor, mesh *clustertrust.Mesh) { +func serveControl(ctx context.Context, port int, exec *Executor, mesh *clustertrust.Mesh, + copyFrom func(ctx context.Context, sourceNode, engine, model string) error) { addr := net.JoinHostPort("0.0.0.0", strconv.Itoa(port)) ln, err := net.Listen("tcp", addr) if err != nil { @@ -88,7 +112,7 @@ func serveControl(ctx context.Context, port int, exec *Executor, mesh *clustertr } ln = tls.NewListener(ln, mesh.ServerTLSConfig()) srv := &http.Server{ - Handler: (&controlServer{exec: exec, mesh: mesh}).mux(), + Handler: (&controlServer{exec: exec, mesh: mesh, hub: hubRoot(), copyFrom: copyFrom}).mux(), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: clustertrust.PeerListenerIdleTimeout, } diff --git a/services/nvpair-engine-manager/executor.go b/services/nvpair-engine-manager/executor.go index 85ca5536..2782ec11 100644 --- a/services/nvpair-engine-manager/executor.go +++ b/services/nvpair-engine-manager/executor.go @@ -21,7 +21,7 @@ var winEnvRe = regexp.MustCompile(`%([^%]+)%`) const ( engineResponseHeaderTimeout = 30 * time.Second - ollamaLoadResponseHeaderTimeout = 10 * time.Minute + slowActionResponseHeaderTimeout = 10 * time.Minute ) // EngineStatus is the snapshot returned by engine:status and @@ -73,7 +73,7 @@ type Executor struct { reporter *Reporter emit func(method string, params any) client *http.Client - ollamaLoadClient *http.Client + slowActionClient *http.Client // progress fans install/pull progress to transient subscribers (the ec // streaming handlers) in addition to the local engine:install-progress // notification path. See progress.go. @@ -92,6 +92,9 @@ type Executor struct { // actionTimeout bounds a single engine:action call (HTTP or CLI) so a // hung engine can't park the goroutine or starve the caller forever. actionTimeout time.Duration + // hub overrides the Hugging Face cache this node reads and writes; empty + // means the ambient one. Set only by tests, which stand in for two nodes. + hub string // loadedPollInterval is the cadence of the loaded-model watcher // (loadedwatch.go), which polls each running engine's resident set and emits // engine:models-changed on change. 0 disables it. Overridable via @@ -115,7 +118,7 @@ func NewExecutor(reg *Registry, reporter *Reporter, emit func(string, any), base reporter: reporter, emit: emit, client: newEngineHTTPClient(engineResponseHeaderTimeout), - ollamaLoadClient: newEngineHTTPClient(ollamaLoadResponseHeaderTimeout), + slowActionClient: newEngineHTTPClient(slowActionResponseHeaderTimeout), progress: newProgressHub(), baseDir: baseDir, desired: newDesiredStateStore(baseDir), diff --git a/services/nvpair-engine-manager/executor_test.go b/services/nvpair-engine-manager/executor_test.go index 40ebe249..14b1ee48 100644 --- a/services/nvpair-engine-manager/executor_test.go +++ b/services/nvpair-engine-manager/executor_test.go @@ -73,8 +73,8 @@ func TestEngineHTTPClientsBoundResponseHeaders(t *testing.T) { if got := responseHeaderTimeout(t, ex.client); got != engineResponseHeaderTimeout { t.Fatalf("ordinary response-header timeout = %s, want %s", got, engineResponseHeaderTimeout) } - if got := responseHeaderTimeout(t, ex.ollamaLoadClient); got != ollamaLoadResponseHeaderTimeout { - t.Fatalf("Ollama load response-header timeout = %s, want %s", got, ollamaLoadResponseHeaderTimeout) + if got := responseHeaderTimeout(t, ex.slowActionClient); got != slowActionResponseHeaderTimeout { + t.Fatalf("Ollama load response-header timeout = %s, want %s", got, slowActionResponseHeaderTimeout) } } @@ -99,7 +99,7 @@ func TestOnlyOllamaRunModelUsesSlowResponseHeaderBudget(t *testing.T) { } ex := newTestExecutor(t, m) ex.client = newEngineHTTPClient(20 * time.Millisecond) - ex.ollamaLoadClient = newEngineHTTPClient(500 * time.Millisecond) + ex.slowActionClient = newEngineHTTPClient(500 * time.Millisecond) st, err := ex.state("ollama") if err != nil { t.Fatal(err) @@ -123,7 +123,7 @@ func TestOnlyOllamaRunModelUsesSlowResponseHeaderBudget(t *testing.T) { } otherEx := newTestExecutor(t, other) otherEx.client = newEngineHTTPClient(20 * time.Millisecond) - otherEx.ollamaLoadClient = newEngineHTTPClient(500 * time.Millisecond) + otherEx.slowActionClient = newEngineHTTPClient(500 * time.Millisecond) otherState, err := otherEx.state("other") if err != nil { t.Fatal(err) diff --git a/services/nvpair-engine-manager/install.go b/services/nvpair-engine-manager/install.go index 268bd0b9..a36b0833 100644 --- a/services/nvpair-engine-manager/install.go +++ b/services/nvpair-engine-manager/install.go @@ -296,12 +296,17 @@ func (e *Executor) download(ctx context.Context, engine string, f *Fetch) (strin return "", fmt.Errorf("checksum mismatch for %s: got %s, want %s", f.URL, sum, want) } } else { - // Unpinned download: bytes are not integrity-checked, only - // transport-secured (HTTPS, enforced above) — the same weaker - // guarantee as a `script` install. Logged loudly, and the computed + // Unpinned download: bytes carry no publisher checksum, only transport + // security (HTTPS, enforced above). Logged loudly, and the computed // digest is surfaced so a manifest author can pin it later. slog.Warn("UNPINNED download: manifest has no sha256, integrity not verified", "engine", engine, "url", f.URL, "computed_sha256", sum) + // Trust on first use, fail closed afterwards. See tofu.go for why this + // is the strongest control available for a versionless vendor URL. + if err := checkInstallerPin(engine, f.URL, sum); err != nil { + os.Remove(tmp.Name()) + return "", err + } } return tmp.Name(), nil } diff --git a/services/nvpair-engine-manager/lifecycle.go b/services/nvpair-engine-manager/lifecycle.go index c10244b0..ffd96021 100644 --- a/services/nvpair-engine-manager/lifecycle.go +++ b/services/nvpair-engine-manager/lifecycle.go @@ -169,6 +169,7 @@ func (e *Executor) doStart(ctx context.Context, st *engineState, engine string, "host": effectiveBind(rt.Bind, opts.Bind), "port": strconv.Itoa(port), "install_dir": st.installDir, + "pair_bin": pairBinDir(), } if rt.CLI != "" { vars["cli"] = expandPath(rt.CLI) @@ -227,6 +228,21 @@ func (e *Executor) bringUpProcess(ctx context.Context, st *engineState, engine s } vars["bin"] = binPath + // A launcher runs in the binary's place, with {bin} pointing at what was + // detected — so the manifest can hand the real engine binary to its + // supervisor without restating where the installer put it. + launchPath := binPath + if rt.Launcher != "" { + l, err := resolvePlaceholders(rt.Launcher, vars) + if err != nil { + return err + } + launchPath = expandPath(l) + if _, statErr := os.Stat(launchPath); statErr != nil { + return fmt.Errorf("launcher %q for engine %q is missing: %w", launchPath, engine, statErr) + } + } + args, err := resolveArgs(rt.Args, vars) if err != nil { return err @@ -240,7 +256,7 @@ func (e *Executor) bringUpProcess(ctx context.Context, st *engineState, engine s env[k] = rv } - proc, err := startManagedProc(binPath, args, env, func(stream, line string) { + proc, err := startManagedProc(launchPath, args, env, func(stream, line string) { st.logs.append(stream, line) }) if err != nil { @@ -456,7 +472,7 @@ func (e *Executor) runCommandStop(st *engineState, engine string, rt Runtime, po if sp == nil || len(sp.Cmd) == 0 { return fmt.Errorf("cannot stop engine %q: no stop command is configured", engine) } - vars := map[string]string{"port": strconv.Itoa(port), "install_dir": st.installDir} + vars := map[string]string{"port": strconv.Itoa(port), "install_dir": st.installDir, "pair_bin": pairBinDir()} if rt.CLI != "" { vars["cli"] = expandPath(rt.CLI) } diff --git a/services/nvpair-engine-manager/manager.go b/services/nvpair-engine-manager/manager.go index 80d572b4..e1e2a3dd 100644 --- a/services/nvpair-engine-manager/manager.go +++ b/services/nvpair-engine-manager/manager.go @@ -265,6 +265,7 @@ func (m *Manager) handleMessage(ctx context.Context, msg *Message) { go m.runAction(ctx, msg) case "engine:remote-get-installed", "engine:remote-install", "engine:remote-pull-model", + "engine:copy-model-from", "engine:remote-copy-model", "engine:remote-load-model", "engine:remote-unload-model", "engine:remote-delete-model", "engine:remote-start", "engine:remote-stop": go m.runRemote(ctx, msg) diff --git a/services/nvpair-engine-manager/manifests/mlx.json b/services/nvpair-engine-manager/manifests/mlx.json new file mode 100644 index 00000000..2d417eea --- /dev/null +++ b/services/nvpair-engine-manager/manifests/mlx.json @@ -0,0 +1,164 @@ +{ + "engine": "mlx", + "display_name": "MLX", + "manifest_version": 1, + "install": { + "mode": "user" + }, + "runtime": { + "args": [ + "--host", + "{host}", + "--port", + "{port}", + "--server-bin", + "{bin}", + "--child-port-base", + "8200", + "--registered-models-file", + "{install_dir}/registered-models.txt" + ], + "bind": "127.0.0.1", + "port": 8081, + "ready": { + "http": "http://127.0.0.1:{port}/health", + "status": 200, + "timeout_s": 60 + }, + "stop": { + "signal": "term", + "grace_s": 45 + }, + "health": { + "http": "http://127.0.0.1:{port}/health", + "status": 200, + "interval_s": 10 + }, + "env": { + "MLX_MAX_MODELS": "1", + "HF_HUB_DISABLE_TELEMETRY": "1", + "HF_HUB_DISABLE_IMPLICIT_TOKEN": "1", + "HF_HUB_DISABLE_UPDATE_CHECK": "1" + }, + "launcher": "{pair_bin}/mlx-pool" + }, + "platforms": { + "darwin/arm64": { + "detect": [ + "{install_dir}/venv/bin/mlx_lm.server" + ], + "install": { + "fetch": { + "url": "https://github.com/astral-sh/uv/releases/download/0.12.10/uv-aarch64-apple-darwin.tar.gz", + "sha256": "51c6170e8e3a01cef9f33b94f582b7b81ac65046f55d40afb35f9cff5a68c179" + }, + "run": [ + "sh", + "-c", + "set -e; tar -xzf \"{download}\" -C \"{install_dir}\" --strip-components=1; \"{install_dir}/uv\" venv --no-config --clear --python 3.13 \"{install_dir}/venv\"; \"{install_dir}/uv\" pip install --no-config --refresh-package mlx-lm --python \"{install_dir}/venv/bin/python\" mlx-lm==0.31.3; \"{install_dir}/venv/bin/python\" -c 'import mlx.core, mlx_lm'; \"{install_dir}/venv/bin/python\" -c \"import mlx_lm,pathlib,json;pathlib.Path('{install_dir}/installed.json').write_text(json.dumps({'mlx_lm_version':mlx_lm.__version__,'source':'pypi:mlx-lm==0.31.3'}))\"" + ], + "description": "mlx-lm is installed from PyPI at a pinned version rather than bundled: uv verifies the published hashes, nothing has to ship inside the app, and 0.31.3 is the current release. To run a fork instead -- upstream's tool-call and thinking-mode handling has known gaps -- override only this install block with a user manifest in /engines/mlx.json; it is deep-merged over this one, so everything else keeps inheriting. See docs/mlx.mdx." + }, + "uninstall": { + "run": [ + "rm", + "-rf", + "{install_dir}" + ] + }, + "runtime": { + "bin": "{pair_bin}/mlx-pool", + "cli": "{install_dir}/venv/bin/hf" + } + } + }, + "actions": { + "list_models": { + "description": "List MLX models downloaded to the Hugging Face cache. This is the catalogue the UI offers, NOT the routing inventory -- mlx-lm holds one model at a time, so eligibility comes from loaded_models.", + "http": { + "method": "GET", + "path": "/v1/models" + }, + "result": { + "array": "data", + "field": "id" + } + }, + "loaded_models": { + "description": "Models currently resident, most recently used first. mlx-pool keeps up to MLX_MAX_MODELS alive and evicts the least recently used, so this is a list rather than the single model a bare mlx_lm.server would report.", + "http": { + "method": "GET", + "path": "/health" + }, + "result": { + "array": "models", + "field": "id" + } + }, + "load_model": { + "description": "Make a model resident (params: {\"model\": \"\"}). mlx-lm has no load endpoint, so this is a one-token completion whose only purpose is the load it forces; the generated token is discarded.", + "http": { + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "{model}", + "messages": [ + { + "role": "user", + "content": "ok" + } + ], + "max_tokens": 1, + "temperature": 0, + "stream": false + } + }, + "long_running": true + }, + "pull_model": { + "description": "Add a model (params: {\"model\": \"\"}). A Hugging Face repo id is downloaded to the cache. An absolute path is instead REGISTERED: MLX has no catalogue to browse, and a model built locally (a quantization, say) has no repo id, so naming its directory is the only way to add it. The path is validated as a servable model directory before it is recorded, and mlx-pool re-reads the file on every /v1/models, so it appears without restarting the engine. Hugging Face telemetry is suppressed on argv because runtime.env does not reach a cmd action.", + "cmd": [ + "/usr/bin/env", + "HF_HUB_DISABLE_TELEMETRY=1", + "HF_HUB_DISABLE_IMPLICIT_TOKEN=1", + "HF_HUB_DISABLE_UPDATE_CHECK=1", + "sh", + "-c", + "set -e\nm=$1; cli=$2; reg=$3\ncase $m in \"~/\"*) m=$HOME/${m#\"~/\"} ;; esac\ncase $m in\n/*)\n [ -d \"$m\" ] || { echo \"no such directory: $m\" >&2; exit 1; }\n [ -f \"$m/config.json\" ] || { echo \"not an MLX model directory (no config.json): $m\" >&2; exit 1; }\n if [ ! -f \"$m/tokenizer_config.json\" ] && [ ! -f \"$m/tokenizer.json\" ]; then\n echo \"not an MLX model directory (no tokenizer): $m\" >&2; exit 1\n fi\n mkdir -p \"$(dirname \"$reg\")\"; touch \"$reg\"\n grep -qxF \"$m\" \"$reg\" || printf '%s\\n' \"$m\" >> \"$reg\"\n echo \"registered local model $m\"\n ;;\n*)\n exec \"$cli\" download \"$m\"\n ;;\nesac", + "mlx-pull-model", + "{model}", + "{cli}", + "{install_dir}/registered-models.txt" + ] + }, + "delete_model": { + "description": "Delete a cached model (params: {\"model\": \"\"}). The Hugging Face cache is shared with every other Hugging Face tool on this machine. A model addressed by absolute path is not in the cache and is routed to delete_model_path instead. No restart_after: the catalogue is rescanned on every /v1/models.", + "cmd": [ + "/usr/bin/env", + "HF_HUB_DISABLE_TELEMETRY=1", + "HF_HUB_DISABLE_IMPLICIT_TOKEN=1", + "HF_HUB_DISABLE_UPDATE_CHECK=1", + "{cli}", + "cache", + "rm", + "-y", + "{model}" + ] + }, + "delete_model_path": { + "description": "Delete a model that lives as a directory rather than a cache entry (params: {\"model\": \"\"}). Locally built models -- a quantization, say -- have no repo id, so this is the only way to remove one; without it a model found by the models-directory scan could never be removed from the catalogue at all. The delete is confined to the engine's model directory, so a path outside it is refused.", + "remove_path": { + "root": "{models_dir}", + "path": "{model}" + } + }, + "unload_model": { + "description": "Release a resident model's memory (params: {\"model\": \"\"}). mlx-lm has no unload of its own -- a model leaves memory only when the process ends -- but mlx-pool runs one model per child process, so ending that child IS the unload and frees the weights deterministically. Refused with 409 while the model is serving a request, so an unload cannot truncate an answer in flight.", + "http": { + "method": "POST", + "path": "/unload", + "body": "{\"model\": \"{model}\"}" + } + } + } +} diff --git a/services/nvpair-engine-manager/manifests/ollama.json b/services/nvpair-engine-manager/manifests/ollama.json index 6c925131..34482a7d 100644 --- a/services/nvpair-engine-manager/manifests/ollama.json +++ b/services/nvpair-engine-manager/manifests/ollama.json @@ -2,34 +2,97 @@ "engine": "ollama", "display_name": "Ollama", "manifest_version": 1, - "install": { "mode": "user" }, + "install": { + "mode": "user" + }, "runtime": { - "args": ["serve"], - "env": { "OLLAMA_HOST": "{host}:{port}" }, + "args": [ + "serve" + ], + "env": { + "OLLAMA_HOST": "{host}:{port}" + }, "bind": "127.0.0.1", "port": 11434, - "ready": { "http": "http://127.0.0.1:{port}/api/version", "status": 200, "timeout_s": 600 }, - "stop": { "signal": "term", "grace_s": 5 }, - "health": { "http": "http://127.0.0.1:{port}/api/version", "status": 200, "interval_s": 5 } + "ready": { + "http": "http://127.0.0.1:{port}/api/version", + "status": 200, + "timeout_s": 600 + }, + "stop": { + "signal": "term", + "grace_s": 5 + }, + "health": { + "http": "http://127.0.0.1:{port}/api/version", + "status": 200, + "interval_s": 5 + } }, "platforms": { "windows/amd64": { - "detect": ["{install_dir}\\ollama.exe", "%LOCALAPPDATA%\\Programs\\Ollama\\ollama.exe"], + "detect": [ + "{install_dir}\\ollama.exe", + "%LOCALAPPDATA%\\Programs\\Ollama\\ollama.exe" + ], "install": { - "fetch": { "url": "https://ollama.com/download/ollama-windows-amd64.zip" }, - "run": ["tar", "-xf", "{download}", "-C", "{install_dir}"] + "fetch": { + "url": "https://github.com/ollama/ollama/releases/download/v0.33.3/ollama-windows-amd64.zip", + "sha256": "52cb36a62e7e501f61514f60212dec7117b6c098811357585e02fffe32d2fcd7" + }, + "run": [ + "tar", + "-xf", + "{download}", + "-C", + "{install_dir}" + ] }, - "uninstall": { "run": ["cmd", "/c", "rmdir", "/s", "/q", "{install_dir}"] }, - "runtime": { "bin": "{install_dir}\\ollama.exe" } + "uninstall": { + "run": [ + "cmd", + "/c", + "rmdir", + "/s", + "/q", + "{install_dir}" + ] + }, + "runtime": { + "bin": "{install_dir}\\ollama.exe" + } }, "windows/arm64": { - "detect": ["{install_dir}\\ollama.exe", "%LOCALAPPDATA%\\Programs\\Ollama\\ollama.exe"], + "detect": [ + "{install_dir}\\ollama.exe", + "%LOCALAPPDATA%\\Programs\\Ollama\\ollama.exe" + ], "install": { - "fetch": { "url": "https://ollama.com/download/ollama-windows-arm64.zip" }, - "run": ["tar", "-xf", "{download}", "-C", "{install_dir}"] + "fetch": { + "url": "https://github.com/ollama/ollama/releases/download/v0.33.3/ollama-windows-arm64.zip", + "sha256": "98b9ddaab6baece0418c6d1231526eb1e4e66944985e0a8eeb7d6171bcd7b6d8" + }, + "run": [ + "tar", + "-xf", + "{download}", + "-C", + "{install_dir}" + ] }, - "uninstall": { "run": ["cmd", "/c", "rmdir", "/s", "/q", "{install_dir}"] }, - "runtime": { "bin": "{install_dir}\\ollama.exe" } + "uninstall": { + "run": [ + "cmd", + "/c", + "rmdir", + "/s", + "/q", + "{install_dir}" + ] + }, + "runtime": { + "bin": "{install_dir}\\ollama.exe" + } }, "darwin/arm64": { "detect": [ @@ -38,11 +101,28 @@ "{install_dir}/Ollama.app/Contents/Resources/ollama" ], "install": { - "fetch": { "url": "https://ollama.com/download/Ollama-darwin.zip" }, - "run": ["unzip", "-o", "{download}", "-d", "{install_dir}"] + "fetch": { + "url": "https://github.com/ollama/ollama/releases/download/v0.33.3/Ollama-darwin.zip", + "sha256": "335f1a11299f5f60dc2d5f2651cf12af9d3c303812c68e978be3e45ea7d6eaf4" + }, + "run": [ + "unzip", + "-o", + "{download}", + "-d", + "{install_dir}" + ] + }, + "uninstall": { + "run": [ + "rm", + "-rf", + "{install_dir}/Ollama.app" + ] }, - "uninstall": { "run": ["rm", "-rf", "{install_dir}/Ollama.app"] }, - "runtime": { "bin": "{install_dir}/Ollama.app/Contents/Resources/ollama" } + "runtime": { + "bin": "{install_dir}/Ollama.app/Contents/Resources/ollama" + } }, "darwin/amd64": { "detect": [ @@ -51,63 +131,159 @@ "{install_dir}/Ollama.app/Contents/Resources/ollama" ], "install": { - "fetch": { "url": "https://ollama.com/download/Ollama-darwin.zip" }, - "run": ["unzip", "-o", "{download}", "-d", "{install_dir}"] + "fetch": { + "url": "https://github.com/ollama/ollama/releases/download/v0.33.3/Ollama-darwin.zip", + "sha256": "335f1a11299f5f60dc2d5f2651cf12af9d3c303812c68e978be3e45ea7d6eaf4" + }, + "run": [ + "unzip", + "-o", + "{download}", + "-d", + "{install_dir}" + ] }, - "uninstall": { "run": ["rm", "-rf", "{install_dir}/Ollama.app"] }, - "runtime": { "bin": "{install_dir}/Ollama.app/Contents/Resources/ollama" } + "uninstall": { + "run": [ + "rm", + "-rf", + "{install_dir}/Ollama.app" + ] + }, + "runtime": { + "bin": "{install_dir}/Ollama.app/Contents/Resources/ollama" + } }, "linux/amd64": { - "detect": ["{install_dir}/bin/ollama"], + "detect": [ + "{install_dir}/bin/ollama" + ], "install": { - "fetch": { "url": "https://ollama.com/download/ollama-linux-amd64.tar.zst" }, - "run": ["tar", "--zstd", "-xf", "{download}", "-C", "{install_dir}"] + "fetch": { + "url": "https://github.com/ollama/ollama/releases/download/v0.33.3/ollama-linux-amd64.tar.zst", + "sha256": "c13cea8f3389db4145f8a6cb88d1747242a48639d7c13e3bda7c1ebdc6eebb2f" + }, + "run": [ + "tar", + "--zstd", + "-xf", + "{download}", + "-C", + "{install_dir}" + ] + }, + "uninstall": { + "run": [ + "rm", + "-rf", + "{install_dir}" + ] }, - "uninstall": { "run": ["rm", "-rf", "{install_dir}"] }, "runtime": { "bin": "{install_dir}/bin/ollama", - "env": { "LD_LIBRARY_PATH": "{install_dir}/lib/ollama" } + "env": { + "LD_LIBRARY_PATH": "{install_dir}/lib/ollama" + } } }, "linux/arm64": { - "detect": ["{install_dir}/bin/ollama"], + "detect": [ + "{install_dir}/bin/ollama" + ], "install": { - "fetch": { "url": "https://ollama.com/download/ollama-linux-arm64.tar.zst" }, - "run": ["tar", "--zstd", "-xf", "{download}", "-C", "{install_dir}"] + "fetch": { + "url": "https://github.com/ollama/ollama/releases/download/v0.33.3/ollama-linux-arm64.tar.zst", + "sha256": "4425a112af999ae6572c1ce211fbabeaca7bab23ed5860972acdfc0cc2358420" + }, + "run": [ + "tar", + "--zstd", + "-xf", + "{download}", + "-C", + "{install_dir}" + ] + }, + "uninstall": { + "run": [ + "rm", + "-rf", + "{install_dir}" + ] }, - "uninstall": { "run": ["rm", "-rf", "{install_dir}"] }, "runtime": { "bin": "{install_dir}/bin/ollama", - "env": { "LD_LIBRARY_PATH": "{install_dir}/lib/ollama" } + "env": { + "LD_LIBRARY_PATH": "{install_dir}/lib/ollama" + } } } }, "actions": { "list_models": { "description": "List models installed on this engine.", - "http": { "method": "GET", "path": "/api/tags" }, - "result": { "array": "models", "field": "name" } + "http": { + "method": "GET", + "path": "/api/tags" + }, + "result": { + "array": "models", + "field": "name" + } }, "loaded_models": { "description": "List models currently loaded in memory (every /api/ps entry is resident).", - "http": { "method": "GET", "path": "/api/ps" }, - "result": { "array": "models", "field": "name" } + "http": { + "method": "GET", + "path": "/api/ps" + }, + "result": { + "array": "models", + "field": "name" + } }, "pull_model": { "description": "Pull a model by name (params: {\"name\": \"\"}).", - "http": { "method": "POST", "path": "/api/pull", "body_schema": { "name": "string" } } + "http": { + "method": "POST", + "path": "/api/pull", + "body_schema": { + "name": "string" + } + } }, "run_model": { "description": "Run a one-shot generation (params: {\"model\": \"\", \"prompt\": \"\", \"stream\": false}).", - "http": { "method": "POST", "path": "/api/generate", "body_schema": { "model": "string", "prompt": "string", "stream": "bool" } } + "http": { + "method": "POST", + "path": "/api/generate", + "body_schema": { + "model": "string", + "prompt": "string", + "stream": "bool" + } + } }, "unload_model": { "description": "Unload a model from memory (params: {\"model\": \"\", \"keep_alive\": 0}).", - "http": { "method": "POST", "path": "/api/generate", "body_schema": { "model": "string", "keep_alive": "number" } } + "http": { + "method": "POST", + "path": "/api/generate", + "body_schema": { + "model": "string", + "keep_alive": "number" + } + } }, "delete_model": { "description": "Delete a model by name (params: {\"name\": \"\"}).", - "http": { "method": "DELETE", "path": "/api/delete", "body_schema": { "name": "string" } } + "http": { + "method": "DELETE", + "path": "/api/delete", + "body_schema": { + "name": "string" + } + } } } } diff --git a/services/nvpair-engine-manager/mlx_manifest_test.go b/services/nvpair-engine-manager/mlx_manifest_test.go new file mode 100644 index 00000000..82829b2e --- /dev/null +++ b/services/nvpair-engine-manager/mlx_manifest_test.go @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "io" + "reflect" + "strings" + "testing" +) + +// The two schema additions the MLX manifest needs, and the bundled manifest +// that uses them. mlx-lm reports its single resident model as a scalar and has +// no load endpoint, so without these the manifest cannot express residency or a +// load at all. + +func TestExtractScalarResult(t *testing.T) { + spec := &ActionResult{Scalar: "model"} + for _, tc := range []struct { + name string + body string + want []string + ok bool + }{ + {"loaded", `{"status":"ok","model":"mlx-community/Qwen3-VL-8B-Instruct-4bit"}`, []string{"mlx-community/Qwen3-VL-8B-Instruct-4bit"}, true}, + // null is mlx-lm's answer both before anything is loaded and while a + // load is in flight: it sets its model key only once the weights are + // in. Authoritative empty, so the node is not advertised as an owner. + {"nothing loaded", `{"status":"ok","model":null}`, []string{}, true}, + {"empty string", `{"status":"ok","model":""}`, []string{}, true}, + // Unknown, not empty: a response we cannot read must never be reported + // as an authoritative "serving nothing". + {"field absent", `{"status":"ok"}`, nil, false}, + {"wrong type", `{"status":"ok","model":["a"]}`, nil, false}, + {"not an object", `["a"]`, nil, false}, + } { + t.Run(tc.name, func(t *testing.T) { + got, ok := extractStringsResult(json.RawMessage(tc.body), spec) + if ok != tc.ok { + t.Fatalf("ok = %v, want %v", ok, tc.ok) + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("got %#v, want %#v", got, tc.want) + } + }) + } +} + +func TestActionBodyTemplate(t *testing.T) { + act := Action{HTTP: &ActionHTTP{ + Method: "POST", + Path: "/v1/chat/completions", + Body: json.RawMessage(`{"model":"{model}","max_tokens":1}`), + }} + + r, err := actionBody(act, json.RawMessage(`{"model":"mlx-community/x"}`)) + if err != nil { + t.Fatalf("actionBody: %v", err) + } + b, _ := io.ReadAll(r) + if string(b) != `{"model":"mlx-community/x","max_tokens":1}` { + t.Fatalf("body = %s", b) + } + + // A model name carrying a quote must be escaped into the template, not + // allowed to terminate the JSON string and rewrite the request. + r, err = actionBody(act, json.RawMessage(`{"model":"a\",\"max_tokens\":9999,\"x\":\"b"}`)) + if err != nil { + t.Fatalf("actionBody with quoted model: %v", err) + } + b, _ = io.ReadAll(r) + var got struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + } + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("resolved body is not valid JSON: %v (%s)", err, b) + } + if got.MaxTokens != 1 { + t.Fatalf("injected model name changed max_tokens to %d: %s", got.MaxTokens, b) + } + + // No body declared: the caller's params are the body, as before. + r, err = actionBody(Action{HTTP: &ActionHTTP{Method: "POST", Path: "/x"}}, json.RawMessage(`{"a":1}`)) + if err != nil { + t.Fatalf("actionBody without template: %v", err) + } + b, _ = io.ReadAll(r) + if string(b) != `{"a":1}` { + t.Fatalf("params body = %s", b) + } +} + +// The bundled manifest has to survive the same validation every manifest does, +// and has to keep the residency/catalogue split the routing policy depends on. +func TestBundledMLXManifest(t *testing.T) { + reg := NewRegistry() + if err := reg.LoadFS(bundledManifests, "manifests"); err != nil { + t.Fatalf("bundled manifests failed validation: %v", err) + } + m, ok := reg.Get("mlx") + if !ok { + t.Fatal("mlx manifest not bundled") + } + plat, ok := m.PlatformFor("darwin", "arm64") + if !ok { + t.Fatal("mlx must be installable on darwin/arm64") + } + if _, ok := m.PlatformFor("linux", "amd64"); ok { + t.Error("mlx claims a non-Apple-Silicon platform; MLX cannot run there") + } + // loaded_models reads a LIST now: mlx-pool can hold several models at once + // (MLX_MAX_MODELS), and reporting only the most recent would make PAIR route + // as if the others were not resident. + lm := m.Actions["loaded_models"].Result + if lm.Array != "models" || lm.Field != "id" { + t.Errorf("loaded_models must read models[].id, got array=%q field=%q", lm.Array, lm.Field) + } + if lm.Scalar != "" { + t.Error("loaded_models must not use the single-model scalar form any more") + } + // The launcher is what makes detect-vs-run differ: detection proves the + // virtualenv exists, but what PAIR starts is the pool. + if plat.Runtime.Launcher == "" { + t.Error("mlx must launch mlx-pool rather than the detected mlx_lm.server") + } + if plat.Runtime.Env["MLX_MAX_MODELS"] == "" { + t.Error("MLX_MAX_MODELS must be set in runtime.env so a per-user override can deep-merge it") + } + if m.Actions["list_models"].Result.Scalar != "" { + t.Error("list_models must stay the full downloaded catalogue, not the resident model") + } + if len(m.Actions["load_model"].HTTP.Body) == 0 { + t.Error("load_model needs a templated body: mlx-lm has no load endpoint") + } +} + +// runtime.cli for MLX is itself templated ("{install_dir}/venv/bin/hf"), unlike +// LM Studio's fixed ~/.lmstudio/bin/lms. resolveArgs substitutes once, so an +// unresolved nested placeholder reaches exec as a literal path — which is +// exactly how pull_model and delete_model were silently broken. +func TestTemplatedCLIResolvesForCmdActions(t *testing.T) { + reg := NewRegistry() + if err := reg.LoadFS(bundledManifests, "manifests"); err != nil { + t.Fatalf("bundled manifests: %v", err) + } + m, _ := reg.Get("mlx") + plat, ok := m.PlatformFor("darwin", "arm64") + if !ok { + t.Fatal("no darwin/arm64 platform") + } + if !strings.Contains(plat.Runtime.CLI, "{install_dir}") { + t.Skip("cli is no longer templated; this regression cannot occur") + } + resolved, err := resolvePlaceholders(plat.Runtime.CLI, map[string]string{ + "install_dir": "/tmp/enginedir", "port": "8081", + }) + if err != nil { + t.Fatalf("cli must resolve from runner-owned vars: %v", err) + } + if strings.Contains(resolved, "{") { + t.Errorf("cli still holds an unresolved placeholder: %s", resolved) + } + if resolved != "/tmp/enginedir/venv/bin/hf" { + t.Errorf("resolved cli = %s", resolved) + } +} diff --git a/services/nvpair-engine-manager/pairbin.go b/services/nvpair-engine-manager/pairbin.go new file mode 100644 index 00000000..c70d3690 --- /dev/null +++ b/services/nvpair-engine-manager/pairbin.go @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "path/filepath" +) + +// pairBinDir is the directory holding PAIR's own binaries, resolved from this +// process's own path rather than configured. +// +// It backs the {pair_bin} manifest placeholder, which exists so a manifest can +// run a helper PAIR ships instead of one the engine vendor publishes. The MLX +// manifest uses it to start mlx-pool -- a front end that keeps several +// mlx_lm.server processes alive and evicts the least recently used -- on the +// port PAIR already treats as the engine. +// +// Deriving it from os.Executable keeps it correct across the two layouts the +// binaries live in (services/build/bin when built from source, the app's +// cli-bin when packaged) with nothing to configure and nothing to get stale. +func pairBinDir() string { + exe, err := os.Executable() + if err != nil { + return "" + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + return filepath.Dir(exe) +} diff --git a/services/nvpair-engine-manager/registry.go b/services/nvpair-engine-manager/registry.go index b44ec6e0..cc67d189 100644 --- a/services/nvpair-engine-manager/registry.go +++ b/services/nvpair-engine-manager/registry.go @@ -37,6 +37,13 @@ var allowedPlaceholders = map[string]bool{ "download": true, "install_dir": true, "models_dir": true, + // pair_bin is the directory holding PAIR's own binaries. It lets a manifest + // run a helper PAIR ships rather than one the engine vendor publishes -- + // the MLX manifest starts mlx-pool, which supervises the vendor's + // mlx_lm.server processes. Runner-owned like the rest: a caller param can + // never set it, so a manifest cannot be tricked into executing an arbitrary + // path through it. + "pair_bin": true, } var placeholderRe = regexp.MustCompile(`\{([a-zA-Z_][a-zA-Z0-9_]*)\}`) @@ -115,6 +122,20 @@ type Fetch struct { type Runtime struct { Mode string `json:"mode,omitempty"` Bin string `json:"bin,omitempty"` + // Launcher runs INSTEAD of the detected/declared Bin, with Bin still + // available to it as {bin}. + // + // It exists because for one engine the thing that proves installation and + // the thing to execute are not the same file. MLX is installed as a Python + // virtualenv, so detection finds mlx_lm.server -- but what PAIR starts is + // mlx-pool, which supervises several mlx_lm.server processes and evicts the + // least recently used. Without this the detected binary always wins (see + // bringUpProcess: a detected path is cached as st.binPath and preferred, so + // an Ollama the user installed themselves is adopted rather than ignored), + // and the pool's flags would be handed to mlx_lm.server. + // + // Unset for every other engine, so nothing else changes behaviour. + Launcher string `json:"launcher,omitempty"` Args []string `json:"args,omitempty"` Env map[string]string `json:"env,omitempty"` Port int `json:"port"` // 0 => auto-assign a free loopback port @@ -183,12 +204,28 @@ type Action struct { // only a restart makes the deletion visible to clients. A stopped engine is // left stopped; a restart failure fails the action. RestartAfter bool `json:"restart_after,omitempty"` + // LongRunning marks an HTTP action whose first response header can be + // minutes away because the engine is doing real work before it answers -- + // loading multi-gigabyte weights, in practice. It selects a client with a + // ten-minute response-header timeout instead of the thirty seconds that + // suits a control call. It does not extend the overall action deadline, + // which is the executor's actionTimeout either way. + LongRunning bool `json:"long_running,omitempty"` } // ActionResult is the list-extraction spec on an Action (see Action.Result). type ActionResult struct { Array string `json:"array"` // top-level array field, e.g. "models" / "data" Field string `json:"field"` // string field per element, e.g. "name" / "id" + // Scalar names a top-level string field to read instead of an array, + // yielding a zero- or one-element list. It is set INSTEAD of + // Array+Field, for an engine that reports a single current model rather + // than a list: mlx-lm's GET /health answers {"status":"ok","model":"X"} + // and holds exactly one model at a time. A JSON null is an + // authoritative empty ("running, nothing loaded"), matching the + // present-but-empty-array case; a missing or wrong-typed field is + // unknown. + Scalar string `json:"scalar,omitempty"` // Match, when set, keeps only array elements that pass the ResultMatch // filter. It lets loaded_models reuse the same extractor as list_models // across engines whose list endpoint tags residency (LM Studio's @@ -223,6 +260,14 @@ type ActionHTTP struct { Method string `json:"method"` Path string `json:"path"` BodySchema json.RawMessage `json:"body_schema,omitempty"` + // Body is a fixed JSON request body declared by the manifest, with + // {placeholder} tokens filled from the caller's params (JSON-escaped). + // It exists for an engine whose only way to perform an operation is a + // request the caller cannot be asked to compose: mlx-lm has no load + // endpoint, so its load_model is a one-token chat completion whose + // shape belongs in the manifest, not in every caller. When set, it + // replaces the caller's params as the body; BodySchema does not apply. + Body json.RawMessage `json:"body,omitempty"` } // ModeOrDefault returns the effective install mode ("user" when unset). @@ -647,8 +692,18 @@ func (a *Action) validate(name string) error { if hasHTTP && (strings.TrimSpace(a.HTTP.Method) == "" || strings.TrimSpace(a.HTTP.Path) == "") { return fmt.Errorf("action %q: http.method and http.path are required", name) } - if a.Result != nil && (strings.TrimSpace(a.Result.Array) == "" || strings.TrimSpace(a.Result.Field) == "") { - return fmt.Errorf("action %q: result.array and result.field are required when result is set", name) + if a.Result != nil { + hasScalar := strings.TrimSpace(a.Result.Scalar) != "" + hasArray := strings.TrimSpace(a.Result.Array) != "" || strings.TrimSpace(a.Result.Field) != "" + if hasScalar && hasArray { + return fmt.Errorf("action %q: result.scalar cannot be combined with result.array/result.field", name) + } + if !hasScalar && (strings.TrimSpace(a.Result.Array) == "" || strings.TrimSpace(a.Result.Field) == "") { + return fmt.Errorf("action %q: result.array and result.field are required when result is set", name) + } + if hasScalar && a.Result.Match != nil { + return fmt.Errorf("action %q: result.match does not apply to result.scalar", name) + } } if a.Result != nil && a.Result.Match != nil { m := a.Result.Match From 15dd57f2666e076c65f1d88f9eb03c34dfc4236e Mon Sep 17 00:00:00 2001 From: Denis Akimov Date: Mon, 14 Sep 2026 11:47:39 -0700 Subject: [PATCH 06/12] feat(engine-manager): transfer cache and directory models between nodes A model you quantized yourself never enters the Hugging Face cache: it has no repo id and is addressed by absolute path, which the cache transfer could not carry. Directory models now transfer over the cluster's mTLS link with every file verified against a digest before it lands, resumable, over several connections (4 by measurement: 1 stream 24 MiB/s, 4 and 8 both 40, 16 37). Signed-off-by: Denis Akimov --- .../nvpair-engine-manager/cachetransfer.go | 328 ++++++++++++++++++ .../cachetransfer_test.go | 104 ++++++ services/nvpair-engine-manager/dirmodel.go | 228 ++++++++++++ .../nvpair-engine-manager/dirmodel_test.go | 232 +++++++++++++ services/nvpair-engine-manager/hfcache.go | 232 +++++++++++++ .../nvpair-engine-manager/hfcache_test.go | 135 +++++++ services/nvpair-engine-manager/main.go | 2 +- .../mirror_manual_test.go | 42 +++ services/nvpair-engine-manager/modelops.go | 9 + services/nvpair-engine-manager/models.go | 29 ++ services/nvpair-engine-manager/remote.go | 50 +++ .../nvpair-engine-manager/remoteclient.go | 56 +++ services/nvpair-engine-manager/tofu.go | 211 +++++++++++ services/nvpair-engine-manager/tofu_test.go | 110 ++++++ .../nvpair-engine-manager/transferpool.go | 160 +++++++++ .../transferpool_test.go | 178 ++++++++++ 16 files changed, 2105 insertions(+), 1 deletion(-) create mode 100644 services/nvpair-engine-manager/cachetransfer.go create mode 100644 services/nvpair-engine-manager/cachetransfer_test.go create mode 100644 services/nvpair-engine-manager/dirmodel.go create mode 100644 services/nvpair-engine-manager/dirmodel_test.go create mode 100644 services/nvpair-engine-manager/hfcache.go create mode 100644 services/nvpair-engine-manager/hfcache_test.go create mode 100644 services/nvpair-engine-manager/mirror_manual_test.go create mode 100644 services/nvpair-engine-manager/tofu.go create mode 100644 services/nvpair-engine-manager/tofu_test.go create mode 100644 services/nvpair-engine-manager/transferpool.go create mode 100644 services/nvpair-engine-manager/transferpool_test.go diff --git a/services/nvpair-engine-manager/cachetransfer.go b/services/nvpair-engine-manager/cachetransfer.go new file mode 100644 index 00000000..a7a20593 --- /dev/null +++ b/services/nvpair-engine-manager/cachetransfer.go @@ -0,0 +1,328 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" +) + +// LAN model transfer: copying a model from a node that already holds it to one +// that does not, over the same pin-gated cluster mTLS the other remote engine +// operations use. +// +// Why not just have the far node download it: because the far node may have no +// internet, because the bytes are already on this network, and because on a +// gigabit LAN this is roughly an order of magnitude faster than the Hub is from +// here. It is also the only option that works offline. +// +// Only the CACHE is transferred, never a model directory outside it. That is a +// deliberate limit: the cache is content-addressed, so every byte can be +// verified against the name it is stored under, and the layout is the one +// huggingface_hub's scan_cache_dir reads — which is what makes the copy show up +// in the destination's model list rather than merely existing on its disk. + +const ( + controlCacheManifestPath = "/v1/models/cache-manifest" + controlCacheBlobPath = "/v1/models/cache-blob" +) + +// hubRoot is the Hugging Face hub cache this node reads and writes. +func hubRoot() string { + if v := strings.TrimSpace(os.Getenv("HF_HUB_CACHE")); v != "" { + return v + } + if v := strings.TrimSpace(os.Getenv("HF_HOME")); v != "" { + return filepath.Join(v, "hub") + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".cache", "huggingface", "hub") +} + +// hubDir is the cache this node writes copies into. +func (e *Executor) hubDir() string { + if e.hub != "" { + return e.hub + } + return hubRoot() +} + +// handleCacheManifest answers "what would it take to copy this model from you". +// Pin-gated by the caller, like every other ec route. +func (s *controlServer) handleCacheManifest(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", "GET") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + repo := r.URL.Query().Get("repo") + if repo == "" { + http.Error(w, "repo is required", http.StatusBadRequest) + return + } + var ( + m *cacheManifest + err error + ) + if isDirModelRef(repo) { + // A locally built model, addressed by path. resolveModelDir is the + // boundary that keeps a peer from naming an arbitrary directory. + var dir string + if dir, err = resolveModelDir(repo); err == nil { + m, err = readDirManifest(dir) + if m != nil { + m.Repo = repo // answer under the name the caller asked for + } + } + } else { + m, err = readCacheManifest(s.hubDir(), repo) + } + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(m) +} + +// handleCacheBlob streams one blob. The blob id is validated as a bare hex +// digest before it is used as a path, so a crafted id cannot read outside the +// repo's blobs directory. +func (s *controlServer) handleCacheBlob(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", "GET") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + repo, oid := r.URL.Query().Get("repo"), r.URL.Query().Get("oid") + var ( + path string + err error + ) + if isDirModelRef(repo) { + // No blobs directory to address into: a directory model's files are + // named by their path within it, and the receiver verifies each against + // the digest the manifest gave. + path, err = dirModelFilePath(repo, r.URL.Query().Get("path")) + } else { + path, err = blobPath(s.hubDir(), repo, oid) + } + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + f, err := os.Open(path) + if err != nil { + http.Error(w, "blob not found", http.StatusNotFound) + return + } + defer f.Close() + info, err := f.Stat() + if err != nil { + http.Error(w, "blob unreadable", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10)) + _, _ = io.Copy(w, f) +} + +// fetchCacheManifest asks a peer what it holds for repo. +func (c *remoteClient) fetchCacheManifest(ctx context.Context, repo string) (*cacheManifest, error) { + // A directory model's manifest is computed by hashing every file, which for + // a multi-gigabyte model outlasts the ordinary response-header budget. + fetch := c.get + if isDirModelRef(repo) { + fetch = c.getSlow + } + raw, err := fetch(ctx, controlCacheManifestPath+"?repo="+url.QueryEscape(repo)) + if err != nil { + return nil, err + } + var m cacheManifest + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("peer sent an unreadable manifest: %w", err) + } + if m.Repo != repo || m.Revision == "" || len(m.Files) == 0 { + return nil, fmt.Errorf("peer sent a manifest for %q with no usable files", m.Repo) + } + return &m, nil +} + +// PullModelFromPeer copies a model from a peer's cache into this node's. +// +// Files are fetched over a few connections at once, and any file already present +// and digest-matching is skipped. +// +// The original serial loop argued the link was the bottleneck so parallelism +// could only cost. Measured, it is worth 1.7x: 24 MiB/s on one stream against +// 40 MiB/s on four, between two laptops on 802.11ac (see defaultCopyStreams). +// One flow cannot keep a Wi-Fi link busy on its own. +// +// Each file is independently verified and atomically renamed, so concurrency +// costs nothing in integrity -- and a run that dies leaves only complete, +// digest-checked files, which the next run skips instead of refetching. +func (e *Executor) PullModelFromPeer(ctx context.Context, c *remoteClient, repo string, onProgress func(stage string, percent int, message string)) error { + m, err := c.fetchCacheManifest(ctx, repo) + if err != nil { + return err + } + dirModel := isDirModelRef(repo) + var root, destDir string + if dirModel { + roots := modelRoots() + if len(roots) == 0 { + return fmt.Errorf("no model directory on this node to copy into") + } + // Same basename, this node's own root: the source path is the sender's + // and means nothing here, but the catalogue keys on the path, so keeping + // the leaf name is what makes the two nodes agree on the model's id. + destDir = filepath.Join(roots[0], filepath.Base(filepath.Clean(repo))) + } else { + root = e.hubDir() + if root == "" { + return fmt.Errorf("no Hugging Face cache directory on this node") + } + } + + total := m.TotalBytes() + var done int64 + onProgress("starting", 0, fmt.Sprintf("%d files, %.1f MiB", len(m.Files), float64(total)/(1<<20))) + + // One entry per transfer. A cache model points two paths at one blob, so it + // moves once; a directory model has real files at both paths and needs both. + seen := map[string]bool{} + todo := make([]cacheFile, 0, len(m.Files)) + for _, f := range m.Files { + if !dirModel { + if seen[f.OID] { + continue + } + seen[f.OID] = true + } + // Already here and already correct: a re-run after a failed transfer + // resumes instead of re-fetching gigabytes it can verify locally. + if dirModel && fileAlreadyPresent(filepath.Join(destDir, filepath.FromSlash(f.Path)), m.Sizes[f.OID], f.OID) { + done += m.Sizes[f.OID] + continue + } + todo = append(todo, f) + } + + fetchOne := func(ctx context.Context, f cacheFile) error { + size := m.Sizes[f.OID] + q := controlCacheBlobPath + "?repo=" + url.QueryEscape(repo) + "&oid=" + url.QueryEscape(f.OID) + if dirModel { + q += "&path=" + url.QueryEscape(f.Path) + } + body, err := c.getStream(ctx, q) + if err != nil { + return fmt.Errorf("fetch %s: %w", f.Path, err) + } + defer body.Close() + if dirModel { + return writeDirModelFile(filepath.Join(destDir, filepath.FromSlash(f.Path)), size, f.OID, body) + } + return writeCacheBlob(root, repo, f.OID, size, body) + } + + if err := runTransfers(ctx, todo, fetchOne, func(f cacheFile) { + // Progress is reported from every worker, so the running total and the + // callback are both taken under the lock: two workers finishing together + // must not race the percentage backwards. + done += m.Sizes[f.OID] + pct := 0 + if total > 0 { + pct = int(done * 100 / total) + } + onProgress("downloading", pct, f.Path) + }); err != nil { + return err + } + + if !dirModel { + if err := linkSnapshot(root, m); err != nil { + return err + } + } + slog.Info("copied model from peer", "repo", repo, "dest", destDir, "files", len(m.Files), "bytes", total) + onProgress("success", 100, repo) + return nil +} + +// controlCopyFromPath lets a peer ask THIS node to copy a model from a THIRD +// node. It is what makes "sitting at laptop 1, give laptop 2 this model" work: +// laptop 1 calls this on laptop 2, and laptop 2 pulls from laptop 1. +// +// The two-party endpoints above are the transport; this is the trigger. Both +// hops are pin-gated mTLS, so a node can only be asked to copy by a peer it has +// paired with, and can only copy from a peer it has paired with. +const controlCopyFromPath = "/v1/models/copy-from" + +type copyFromRequest struct { + OpID string `json:"opId"` + SourceNode string `json:"sourceNode"` + Engine string `json:"engine"` + Model string `json:"model"` +} + +func (s *controlServer) handleCopyFrom(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req copyFromRequest + if err := json.NewDecoder(io.LimitReader(r.Body, maxControlBody)).Decode(&req); err != nil { + http.Error(w, "invalid JSON body", http.StatusBadRequest) + return + } + if req.SourceNode == "" || req.Model == "" { + http.Error(w, `"sourceNode" and "model" are required`, http.StatusBadRequest) + return + } + if s.copyFrom == nil { + http.Error(w, "this node cannot copy from a peer", http.StatusServiceUnavailable) + return + } + // streamOp relays whatever the executor's progress hub publishes for this + // engine, so the transfer's frames reach the caller with no extra plumbing. + s.streamOp(w, r, req.OpID, req.Engine, "copy", func(ctx context.Context) (streamFrame, error) { + if err := s.copyFrom(ctx, req.SourceNode, req.Engine, req.Model); err != nil { + return streamFrame{}, err + } + return streamFrame{Stage: "success", Percent: 100, Message: req.Model}, nil + }) +} + +// CopyModelFromPeer resolves sourceNode as a pinned peer and copies model from +// it into this node's cache, publishing progress on the engine's channel so a +// streaming caller sees it. +func (m *Manager) CopyModelFromPeer(ctx context.Context, sourceNode, engine, model string) error { + peer, ok := m.peers.lookup(sourceNode) + if !ok { + return fmt.Errorf("node %s is not a discovered ec peer", sourceNode) + } + client, err := m.remoteClient(ctx, peer) + if err != nil { + return err + } + return m.exec.PullModelFromPeer(ctx, client, model, func(stage string, pct int, message string) { + m.exec.progress.publish(ProgressEvent{ + Engine: engine, Op: "copy", Stage: stage, Percent: pct, Message: message, + }) + }) +} diff --git a/services/nvpair-engine-manager/cachetransfer_test.go b/services/nvpair-engine-manager/cachetransfer_test.go new file mode 100644 index 00000000..83107dc9 --- /dev/null +++ b/services/nvpair-engine-manager/cachetransfer_test.go @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// End to end over real HTTP: one node's cache is served, another node pulls it, +// and the result must be a cache entry in its own right. The pin gate and TLS +// are the ec surface's, exercised by its own tests; what is new here is the +// manifest/blob protocol and the writing. +func TestPullModelFromPeerCopiesACacheEntry(t *testing.T) { + srcHub, dstHub := t.TempDir(), t.TempDir() + const repo, rev = "mlx-community/Fake-1B", "deadbeef" + files := map[string][]byte{ + "config.json": []byte(`{"model_type":"fake"}`), + "tokenizer_config.json": []byte(`{"tok":true}`), + "model.safetensors": bytes.Repeat([]byte("W"), 8192), + "model.safetensors.index.json": []byte(`{"weight_map":{}}`), + } + buildFakeRepo(t, srcHub, repo, rev, files) + + // The server reads whatever hubRoot() resolves to, so point it at the source. + s := &controlServer{hub: srcHub} + mux := http.NewServeMux() + mux.HandleFunc(controlCacheManifestPath, s.handleCacheManifest) + mux.HandleFunc(controlCacheBlobPath, s.handleCacheBlob) + srv := httptest.NewServer(mux) + defer srv.Close() + + client := &remoteClient{http: srv.Client(), base: srv.URL, forget: func() {}} + + var stages []string + // The pull writes into the DESTINATION cache. Both "nodes" are this one + // process, which is exactly why the cache is a field and not an env lookup. + err := (&Executor{hub: dstHub}).PullModelFromPeer(context.Background(), client, repo, + func(stage string, _ int, _ string) { stages = append(stages, stage) }) + if err != nil { + t.Fatalf("PullModelFromPeer: %v", err) + } + + if len(stages) == 0 || stages[0] != "starting" || stages[len(stages)-1] != "success" { + t.Errorf("progress stages = %v, want starting…success", stages) + } + // The destination must be readable as a cache entry, which is what makes it + // visible to scan_cache_dir and therefore to mlx-lm's /v1/models. + got, err := readCacheManifest(dstHub, repo) + if err != nil { + t.Fatalf("copy is not a valid cache entry: %v", err) + } + if got.Revision != rev || len(got.Files) != len(files) { + t.Fatalf("copied manifest = %+v", got) + } + for name, want := range files { + p := filepath.Join(dstHub, repoDirName(repo), "snapshots", rev, name) + if data, err := os.ReadFile(p); err != nil || !bytes.Equal(data, want) { + t.Errorf("%s differs after transfer (%v)", name, err) + } + } + if ref, _ := os.ReadFile(filepath.Join(dstHub, repoDirName(repo), "refs", "main")); string(ref) != rev { + t.Errorf("refs/main = %q", ref) + } +} + +// A peer supplies repo and oid; both land in filesystem paths. +func TestCacheBlobHandlerRejectsTraversal(t *testing.T) { + s := &controlServer{hub: t.TempDir()} + for _, q := range []string{ + "?repo=org/name&oid=../../../../etc/passwd", + "?repo=org/name&oid=", + "?repo=../../etc&oid=0123456789abcdef0123456789abcdef01234567", + } { + req := httptest.NewRequest(http.MethodGet, controlCacheBlobPath+q, nil) + rec := httptest.NewRecorder() + s.handleCacheBlob(rec, req) + if rec.Code == http.StatusOK { + t.Errorf("%s served 200; it must be refused", q) + } + } +} + +// A model the node does not have must be a clean 404, not a panic or an empty +// manifest the caller would treat as "nothing to copy, done". +func TestCacheManifestMissingRepo(t *testing.T) { + s := &controlServer{hub: t.TempDir()} + req := httptest.NewRequest(http.MethodGet, controlCacheManifestPath+"?repo=org/absent", nil) + rec := httptest.NewRecorder() + s.handleCacheManifest(rec, req) + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", rec.Code) + } + if !strings.Contains(rec.Body.String(), "not in this cache") { + t.Errorf("body = %q", rec.Body.String()) + } +} diff --git a/services/nvpair-engine-manager/dirmodel.go b/services/nvpair-engine-manager/dirmodel.go new file mode 100644 index 00000000..406dc775 --- /dev/null +++ b/services/nvpair-engine-manager/dirmodel.go @@ -0,0 +1,228 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" +) + +// Transfer of a model that lives as a plain DIRECTORY rather than as a Hugging +// Face cache entry. +// +// A model you quantized yourself never enters the cache: it has no repo id, so +// mlx-pool advertises it by absolute path (see scanModelsDir) and the catalogue +// offers it like any other. The cache transfer cannot carry it -- it addresses +// blobs by the cache's own oids -- so "Get from " on such a model failed +// with the path mangled into a cache directory name that could never exist: +// +// models----Users--alice--models--Qwen3.8-27B-3bit/snapshots: no such file or directory +// +// The fix keeps the property that made the cache transfer safe: every byte is +// verified against a digest named in the manifest before it lands. A cache blob +// is self-naming, so nothing had to be computed; a directory has no such digest, +// so the sender hashes the files when it builds the manifest. That is one extra +// read of the model at manifest time, which is the price of being able to verify +// at all. + +// modelRoots are the directories this node will serve a directory model from +// and copy one into. +// +// Mirrors mlx-pool's --models-dir default so both agree on where a locally built +// model lives; MLX_MODELS_DIRS overrides it in the same os.PathListSeparator +// form. This list IS the security boundary for the transfer: a peer names the +// model by absolute path, so without it a peer could name any directory on this +// machine. +func modelRoots() []string { + if raw := strings.TrimSpace(os.Getenv("MLX_MODELS_DIRS")); raw != "" { + var out []string + for _, p := range strings.Split(raw, string(os.PathListSeparator)) { + if p = strings.TrimSpace(p); p != "" { + out = append(out, expandPath(p)) + } + } + if len(out) > 0 { + return out + } + } + home, err := os.UserHomeDir() + if err != nil { + return nil + } + return []string{filepath.Join(home, "models")} +} + +// engineModelsDir is the {models_dir} placeholder, per engine. +// +// It was LM Studio's directory unconditionally, which was harmless while LM +// Studio was the only engine declaring a path-based action. MLX now deletes by +// path too, and pointing its confinement root at another engine's directory +// would make every delete fail the containment check. +func engineModelsDir(engine string) string { + if engine == "mlx" { + if roots := modelRoots(); len(roots) > 0 { + return roots[0] + } + return "" + } + return lmstudioModelsDir() +} + +// isDirModelRef reports whether a catalogue id names a directory rather than a +// Hugging Face repo. Repo ids are "org/name" and never absolute. +func isDirModelRef(repo string) bool { + return filepath.IsAbs(repo) +} + +// withinRoot reports whether p is root itself or sits underneath it. Compared on +// cleaned paths with a separator boundary so "/Users/me/models-evil" does not +// pass for root "/Users/me/models". +func withinRoot(p, root string) bool { + p, root = filepath.Clean(p), filepath.Clean(root) + return p == root || strings.HasPrefix(p, root+string(filepath.Separator)) +} + +// resolveModelDir turns a peer-supplied absolute path into a directory this node +// is willing to serve, or an error. +// +// Symlinks are resolved BEFORE the containment check: a symlink inside a model +// root pointing at /etc would otherwise pass a prefix test while reading +// somewhere else entirely. +func resolveModelDir(p string) (string, error) { + if !filepath.IsAbs(p) { + return "", fmt.Errorf("model path %q is not absolute", p) + } + resolved, err := filepath.EvalSymlinks(filepath.Clean(p)) + if err != nil { + return "", fmt.Errorf("model %q is not on this node: %w", p, err) + } + info, err := os.Stat(resolved) + if err != nil || !info.IsDir() { + return "", fmt.Errorf("model %q is not a directory on this node", p) + } + roots := modelRoots() + for _, root := range roots { + if r, err := filepath.EvalSymlinks(root); err == nil && withinRoot(resolved, r) { + return resolved, nil + } + } + return "", fmt.Errorf("model %q is outside this node's model directories %v", p, roots) +} + +// readDirManifest describes a directory model well enough to rebuild it +// elsewhere, reusing the cache manifest shape so the transfer loop is shared. +// +// The oid is the SHA-256 of the file's contents, which is what the receiver +// verifies against. Revision is a constant: a directory has no revisions, and +// the field only has to be non-empty for the manifest to be accepted. +func readDirManifest(dir string) (*cacheManifest, error) { + m := &cacheManifest{Repo: dir, Revision: "local", Sizes: map[string]int64{}} + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + // Regular files only: a symlink or device node in a model directory is + // not something to reproduce on the far side. + info, err := d.Info() + if err != nil || !info.Mode().IsRegular() { + return nil + } + rel, err := filepath.Rel(dir, path) + if err != nil || !safeRelPath(rel) { + return nil + } + sum, err := fileSHA256(path) + if err != nil { + return err + } + m.Files = append(m.Files, cacheFile{Path: filepath.ToSlash(rel), OID: sum}) + m.Sizes[sum] = info.Size() + return nil + }) + if err != nil { + return nil, fmt.Errorf("read model directory %q: %w", dir, err) + } + if len(m.Files) == 0 { + return nil, fmt.Errorf("model directory %q holds no files", dir) + } + // Deterministic order so a transfer resumes over the same sequence. + sort.Slice(m.Files, func(i, j int) bool { return m.Files[i].Path < m.Files[j].Path }) + return m, nil +} + +func fileSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// dirModelFilePath resolves one file of a directory model for serving. Both the +// directory and the relative path arrive from a peer, so both are validated and +// the join is re-checked against the resolved directory. +func dirModelFilePath(repo, rel string) (string, error) { + dir, err := resolveModelDir(repo) + if err != nil { + return "", err + } + if !safeRelPath(filepath.FromSlash(rel)) { + return "", fmt.Errorf("invalid file path %q", rel) + } + full := filepath.Join(dir, filepath.FromSlash(rel)) + if !withinRoot(full, dir) { + return "", fmt.Errorf("invalid file path %q", rel) + } + return full, nil +} + +// writeDirModelFile writes one verified file of a directory model. +// +// Written to a temporary name and renamed only after the digest matches, so an +// interrupted transfer leaves no file that a catalogue scan would offer as a +// complete model. +func writeDirModelFile(dest string, size int64, wantOID string, r io.Reader) error { + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(dest), ".partial-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { + tmp.Close() + os.Remove(tmpName) + }() + + h := sha256.New() + n, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(r, size)) + if err != nil { + return err + } + if n != size { + return fmt.Errorf("%s: got %d bytes, expected %d", filepath.Base(dest), n, size) + } + if got := hex.EncodeToString(h.Sum(nil)); got != wantOID { + return fmt.Errorf("%s: content does not match its digest", filepath.Base(dest)) + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, dest) +} diff --git a/services/nvpair-engine-manager/dirmodel_test.go b/services/nvpair-engine-manager/dirmodel_test.go new file mode 100644 index 00000000..0231f476 --- /dev/null +++ b/services/nvpair-engine-manager/dirmodel_test.go @@ -0,0 +1,232 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// mkModel builds a directory model under root and returns its path. +func mkModel(t *testing.T, root, name string, files map[string]string) string { + t.Helper() + dir := filepath.Join(root, name) + for rel, body := range files { + full := filepath.Join(dir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + return dir +} + +// The model path arrives from a peer over the wire, so this is the boundary that +// decides which directories a paired node can read off this machine. Anything +// outside the configured model roots has to be refused, however it is spelled. +func TestResolveModelDirRefusesAnythingOutsideTheModelRoots(t *testing.T) { + root := t.TempDir() + t.Setenv("MLX_MODELS_DIRS", root) + good := mkModel(t, root, "Qwen3.8-27B-3bit", map[string]string{"config.json": "{}"}) + + if got, err := resolveModelDir(good); err != nil || got == "" { + t.Fatalf("resolveModelDir(%q) = %q, %v; want the directory", good, got, err) + } + + outside := t.TempDir() + if err := os.WriteFile(filepath.Join(outside, "secret"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + + // A sibling that merely shares the root's prefix must not pass: the + // containment test is on a separator boundary, not a string prefix. + sibling := root + "-evil" + if err := os.MkdirAll(sibling, 0o755); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(sibling) }) + + // A symlink INSIDE the root pointing out of it is the interesting case: a + // prefix check on the un-resolved path would let it through. + escape := filepath.Join(root, "escape") + if err := os.Symlink(outside, escape); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + for _, bad := range []string{ + outside, + sibling, + escape, + filepath.Join(good, "..", "..", "etc"), + "relative/path", + "", + } { + if _, err := resolveModelDir(bad); err == nil { + t.Errorf("resolveModelDir(%q) was allowed; it is outside the model roots", bad) + } + } +} + +// Every byte must be verifiable before it lands, the same property the cache +// transfer has. A directory carries no digest of its own, so the manifest is +// where it comes from. +func TestDirModelManifestRoundTripVerifiesContent(t *testing.T) { + root := t.TempDir() + t.Setenv("MLX_MODELS_DIRS", root) + src := mkModel(t, root, "Qwen3.8-27B-3bit", map[string]string{ + "config.json": `{"model_type":"qwen3"}`, + "tokenizer_config.json": `{"bos_token":""}`, + "model.safetensors": "weights-weights-weights", + "nested/extra.json": `{"a":1}`, + }) + + m, err := readDirManifest(src) + if err != nil { + t.Fatalf("readDirManifest: %v", err) + } + if len(m.Files) != 4 { + t.Fatalf("manifest lists %d files, want 4", len(m.Files)) + } + if m.Revision == "" { + t.Error("manifest revision is empty; fetchCacheManifest rejects that") + } + // Sorted, so an interrupted transfer resumes over the same sequence. + for i := 1; i < len(m.Files); i++ { + if m.Files[i-1].Path >= m.Files[i].Path { + t.Errorf("manifest is not sorted: %q before %q", m.Files[i-1].Path, m.Files[i].Path) + } + } + // Paths are slash-form on the wire, never absolute. + for _, f := range m.Files { + if strings.HasPrefix(f.Path, "/") || strings.Contains(f.Path, "..") { + t.Errorf("manifest path %q is not a safe relative path", f.Path) + } + } + + // Replay it into a second root, exactly as PullModelFromPeer does. + dstRoot := t.TempDir() + dst := filepath.Join(dstRoot, filepath.Base(src)) + for _, f := range m.Files { + body, err := os.ReadFile(filepath.Join(src, filepath.FromSlash(f.Path))) + if err != nil { + t.Fatal(err) + } + out := filepath.Join(dst, filepath.FromSlash(f.Path)) + if err := writeDirModelFile(out, m.Sizes[f.OID], f.OID, bytes.NewReader(body)); err != nil { + t.Fatalf("writeDirModelFile(%s): %v", f.Path, err) + } + got, err := os.ReadFile(out) + if err != nil || !bytes.Equal(got, body) { + t.Errorf("%s did not round-trip", f.Path) + } + } +} + +func TestWriteDirModelFileRejectsCorruptionAndLeavesNoPartial(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "model.safetensors") + good := []byte("the real weights") + m, err := readDirManifest(mkModel(t, dir, "m", map[string]string{"model.safetensors": string(good)})) + if err != nil { + t.Fatal(err) + } + oid, size := m.Files[0].OID, m.Sizes[m.Files[0].OID] + + if err := writeDirModelFile(dest, size, oid, bytes.NewReader([]byte("tampered bytes!!"))); err == nil { + t.Error("a file whose content does not match its digest was accepted") + } + if _, err := os.Stat(dest); !os.IsNotExist(err) { + t.Error("a rejected file was left on disk where a catalogue scan would find it") + } + + // A truncated transfer must fail too, not silently write a short file. + if err := writeDirModelFile(dest, size, oid, bytes.NewReader(good[:4])); err == nil { + t.Error("a truncated file was accepted") + } + if _, err := os.Stat(dest); !os.IsNotExist(err) { + t.Error("a truncated file was left on disk") + } + + if err := writeDirModelFile(dest, size, oid, bytes.NewReader(good)); err != nil { + t.Fatalf("the correct bytes were rejected: %v", err) + } +} + +func TestDirModelFilePathRejectsTraversal(t *testing.T) { + root := t.TempDir() + t.Setenv("MLX_MODELS_DIRS", root) + src := mkModel(t, root, "m", map[string]string{"config.json": "{}"}) + + if _, err := dirModelFilePath(src, "config.json"); err != nil { + t.Fatalf("a legitimate file was refused: %v", err) + } + for _, bad := range []string{"../../../etc/passwd", "/etc/passwd", "..", "a/../../b", ""} { + if _, err := dirModelFilePath(src, bad); err == nil { + t.Errorf("dirModelFilePath(%q) was allowed", bad) + } + } +} + +// The catalogue ids these models by path, so a repo id and a path have to be +// told apart before either is used to build a filesystem location. +func TestIsDirModelRef(t *testing.T) { + for _, p := range []string{"/Users/me/models/Qwen3.8-27B-3bit", "/tmp/m"} { + if !isDirModelRef(p) { + t.Errorf("%q should be treated as a directory model", p) + } + } + for _, r := range []string{"mlx-community/Llama-3.2-1B-Instruct-4bit", "bert-base-uncased"} { + if isDirModelRef(r) { + t.Errorf("%q is a Hugging Face repo id, not a path", r) + } + } +} + +// The delete that did not delete: a model under the models directory is found by +// scanning, never appears in the registry file, and so a delete that only edited +// that file removed nothing -- leaving 11 GB the UI could not get rid of. +func TestDeleteRoutesAPathModelToTheActionThatRemovesFiles(t *testing.T) { + action, params, err := modelActionWire("mlx", "delete", "/Users/me/models/Qwen3.8-27B-3bit") + if err != nil { + t.Fatal(err) + } + if action != "delete_model_path" { + t.Errorf("a path model routed to %q; the cache delete cannot remove it", action) + } + if !strings.Contains(string(params), "Qwen3.8-27B-3bit") { + t.Errorf("params %s lost the model", params) + } + + // A repo id still belongs to the cache delete. + action, _, err = modelActionWire("mlx", "delete", "mlx-community/Llama-3.2-1B-Instruct-4bit") + if err != nil { + t.Fatal(err) + } + if action != "delete_model" { + t.Errorf("a repo id routed to %q, want delete_model", action) + } + + // Other engines are untouched by the MLX branch. + if a, _, _ := modelActionWire("ollama", "delete", "llama3"); a != "delete_model" { + t.Errorf("ollama delete routed to %q", a) + } +} + +// {models_dir} is the confinement root for the delete. Pointing MLX at LM +// Studio's directory would make every MLX delete fail the containment check. +func TestEngineModelsDirIsPerEngine(t *testing.T) { + root := t.TempDir() + t.Setenv("MLX_MODELS_DIRS", root) + if got := engineModelsDir("mlx"); got != root { + t.Errorf("engineModelsDir(mlx) = %q, want %q", got, root) + } + if got := engineModelsDir("lm-studio"); got == root { + t.Error("lm-studio resolved to the MLX models directory") + } +} diff --git a/services/nvpair-engine-manager/hfcache.go b/services/nvpair-engine-manager/hfcache.go new file mode 100644 index 00000000..d4d4b1b3 --- /dev/null +++ b/services/nvpair-engine-manager/hfcache.go @@ -0,0 +1,232 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "fmt" + "hash" + "io" + "os" + "path/filepath" + "strings" +) + +// Reading and writing a Hugging Face cache entry, so a model one node already +// holds can be copied to another over the LAN instead of re-downloaded. +// +// The layout is content-addressed and must be reproduced exactly, not merely +// approximated: mlx-lm lists models via huggingface_hub's scan_cache_dir, which +// reads this structure. Writing the files as plain paths would serve inference +// but leave the model invisible in the catalogue. +// +// models----/ +// blobs/ the bytes, named by content +// snapshots// symlinks into ../../blobs/ +// refs/main the revision the branch points at +// +// The oid IS the integrity check, which is why none is transmitted: a 40-hex +// oid is git's blob SHA-1 (sha1("blob \0" + content)) and a 64-hex one is +// the LFS SHA-256 of the content. Both were verified against a real cache entry +// before this relied on them. Computing digests sender-side instead would mean +// reading every byte of an 11 GB model just to answer "what do you have". + +// cacheFile is one path inside a snapshot and the blob it resolves to. +type cacheFile struct { + Path string `json:"path"` + OID string `json:"oid"` +} + +// cacheManifest describes one cached repo well enough to rebuild it elsewhere. +type cacheManifest struct { + Repo string `json:"repo"` + Revision string `json:"revision"` + Files []cacheFile `json:"files"` + Sizes map[string]int64 `json:"sizes"` // oid -> bytes, for progress and preflight +} + +// TotalBytes is what a transfer will move: distinct blobs, so a repo that +// points two paths at one blob is not counted twice. +func (m *cacheManifest) TotalBytes() int64 { + var n int64 + for _, size := range m.Sizes { + n += size + } + return n +} + +// repoDirName converts "org/name" to the cache's "models--org--name". +func repoDirName(repo string) string { + return "models--" + strings.ReplaceAll(repo, "/", "--") +} + +// repoFromDirName is the inverse. +func repoFromDirName(dir string) string { + return strings.ReplaceAll(strings.TrimPrefix(dir, "models--"), "--", "/") +} + +// safeOID rejects anything that is not a bare hex digest. The oid arrives from a +// peer and is used as a filename, so this is the boundary that keeps a crafted +// value from escaping the blobs directory. +func safeOID(oid string) bool { + if len(oid) != 40 && len(oid) != 64 { + return false + } + _, err := hex.DecodeString(oid) + return err == nil +} + +// safeRelPath rejects a snapshot path that would escape its snapshot directory. +func safeRelPath(p string) bool { + if p == "" || filepath.IsAbs(p) || strings.HasPrefix(p, "..") { + return false + } + clean := filepath.Clean(p) + return clean == p && !strings.Contains(clean, ".."+string(filepath.Separator)) +} + +// readCacheManifest describes a repo held in the given hub root. +func readCacheManifest(hubRoot, repo string) (*cacheManifest, error) { + base := filepath.Join(hubRoot, repoDirName(repo)) + snapsDir := filepath.Join(base, "snapshots") + snaps, err := os.ReadDir(snapsDir) + if err != nil { + return nil, fmt.Errorf("repo %q is not in this cache: %w", repo, err) + } + var revision string + for _, s := range snaps { + if s.IsDir() { + revision = s.Name() // one revision per cached repo in practice; last wins + } + } + if revision == "" { + return nil, fmt.Errorf("repo %q has no snapshot", repo) + } + + m := &cacheManifest{Repo: repo, Revision: revision, Sizes: map[string]int64{}} + snapRoot := filepath.Join(snapsDir, revision) + err = filepath.WalkDir(snapRoot, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + rel, relErr := filepath.Rel(snapRoot, p) + if relErr != nil { + return relErr + } + // Resolve the symlink to learn which blob backs this path. A plain file + // (some tools materialise rather than link) is still transferable: it is + // named by its own content once hashed, so it is skipped rather than + // guessed at. + target, linkErr := os.Readlink(p) + if linkErr != nil { + return nil + } + oid := filepath.Base(target) + if !safeOID(oid) { + return nil + } + info, statErr := os.Stat(p) // follows the link: the blob's real size + if statErr != nil { + return nil + } + m.Files = append(m.Files, cacheFile{Path: filepath.ToSlash(rel), OID: oid}) + m.Sizes[oid] = info.Size() + return nil + }) + if err != nil { + return nil, err + } + if len(m.Files) == 0 { + return nil, fmt.Errorf("repo %q has no transferable files", repo) + } + return m, nil +} + +// blobPath is where a blob lives, refusing anything that is not a clean digest. +func blobPath(hubRoot, repo, oid string) (string, error) { + if !safeOID(oid) { + return "", fmt.Errorf("invalid blob id %q", oid) + } + return filepath.Join(hubRoot, repoDirName(repo), "blobs", oid), nil +} + +// hasherFor returns the digest the oid encodes, and the prefix git puts in front +// of a blob's contents before hashing it. +func hasherFor(oid string, size int64) (hash.Hash, []byte) { + if len(oid) == 64 { + return sha256.New(), nil + } + return sha1.New(), []byte(fmt.Sprintf("blob %d\x00", size)) +} + +// writeCacheBlob streams a blob in, verifying it against its own name, and only +// then puts it in place. A partial or corrupted transfer leaves a temp file that +// is removed, never a blob that looks complete. +func writeCacheBlob(hubRoot, repo, oid string, size int64, r io.Reader) error { + dst, err := blobPath(hubRoot, repo, oid) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + // Already present and intact: a re-run after an interrupted transfer skips + // what it already has. + if info, statErr := os.Stat(dst); statErr == nil && info.Size() == size { + return nil + } + tmp, err := os.CreateTemp(filepath.Dir(dst), ".blob-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + + h, prefix := hasherFor(oid, size) + h.Write(prefix) + written, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(r, size)) + tmp.Close() + if err != nil { + return fmt.Errorf("blob %s: %w", oid, err) + } + if written != size { + return fmt.Errorf("blob %s: got %d bytes, expected %d", oid, written, size) + } + if got := hex.EncodeToString(h.Sum(nil)); got != oid { + return fmt.Errorf("blob %s failed verification (computed %s); refusing to install it", oid, got) + } + return os.Rename(tmp.Name(), dst) +} + +// linkSnapshot rebuilds the snapshot symlinks and the branch ref, which is what +// makes the copied repo visible to scan_cache_dir and therefore to mlx-lm. +func linkSnapshot(hubRoot string, m *cacheManifest) error { + base := filepath.Join(hubRoot, repoDirName(m.Repo)) + snapRoot := filepath.Join(base, "snapshots", m.Revision) + for _, f := range m.Files { + if !safeRelPath(f.Path) || !safeOID(f.OID) { + return fmt.Errorf("refusing unsafe entry %q -> %q", f.Path, f.OID) + } + link := filepath.Join(snapRoot, filepath.FromSlash(f.Path)) + if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { + return err + } + // Relative, exactly as the Hugging Face client writes it, so the cache + // survives being moved and looks native to every tool that reads it. + rel, err := filepath.Rel(filepath.Dir(link), filepath.Join(base, "blobs", f.OID)) + if err != nil { + return err + } + _ = os.Remove(link) + if err := os.Symlink(rel, link); err != nil { + return err + } + } + refs := filepath.Join(base, "refs") + if err := os.MkdirAll(refs, 0o755); err != nil { + return err + } + return os.WriteFile(filepath.Join(refs, "main"), []byte(m.Revision), 0o644) +} diff --git a/services/nvpair-engine-manager/hfcache_test.go b/services/nvpair-engine-manager/hfcache_test.go new file mode 100644 index 00000000..56f04643 --- /dev/null +++ b/services/nvpair-engine-manager/hfcache_test.go @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "testing" +) + +// buildFakeRepo writes a cache entry the way the Hugging Face client does: +// content-addressed blobs plus snapshot symlinks. +func buildFakeRepo(t *testing.T, hub, repo, rev string, files map[string][]byte) { + t.Helper() + base := filepath.Join(hub, repoDirName(repo)) + for name, data := range files { + var oid string + if len(data) > 32 { // pretend the big ones are LFS: sha256-named + s := sha256.Sum256(data) + oid = hex.EncodeToString(s[:]) + } else { + h := sha1.New() + fmt.Fprintf(h, "blob %d\x00", len(data)) + h.Write(data) + oid = hex.EncodeToString(h.Sum(nil)) + } + blob := filepath.Join(base, "blobs", oid) + os.MkdirAll(filepath.Dir(blob), 0o755) + os.WriteFile(blob, data, 0o644) + link := filepath.Join(base, "snapshots", rev, name) + os.MkdirAll(filepath.Dir(link), 0o755) + rel, _ := filepath.Rel(filepath.Dir(link), blob) + os.Symlink(rel, link) + } +} + +func TestCacheManifestRoundTrip(t *testing.T) { + src, dst := t.TempDir(), t.TempDir() + const repo, rev = "mlx-community/Fake-1B", "abc123" + files := map[string][]byte{ + "config.json": []byte(`{"model_type":"fake"}`), + "model.safetensors": bytes.Repeat([]byte("W"), 4096), // "large": sha256-named + } + buildFakeRepo(t, src, repo, rev, files) + + m, err := readCacheManifest(src, repo) + if err != nil { + t.Fatalf("readCacheManifest: %v", err) + } + if m.Revision != rev || len(m.Files) != 2 { + t.Fatalf("manifest = %+v", m) + } + if m.TotalBytes() != int64(len(files["config.json"])+len(files["model.safetensors"])) { + t.Errorf("TotalBytes = %d", m.TotalBytes()) + } + + // Transfer every blob, then rebuild the links. + for _, f := range m.Files { + p, _ := blobPath(src, repo, f.OID) + data, _ := os.ReadFile(p) + if err := writeCacheBlob(dst, repo, f.OID, m.Sizes[f.OID], bytes.NewReader(data)); err != nil { + t.Fatalf("writeCacheBlob %s: %v", f.OID, err) + } + } + if err := linkSnapshot(dst, m); err != nil { + t.Fatalf("linkSnapshot: %v", err) + } + + // The copy must be readable as a cache entry in its own right. + back, err := readCacheManifest(dst, repo) + if err != nil { + t.Fatalf("re-reading the copy: %v", err) + } + if back.Revision != m.Revision || len(back.Files) != len(m.Files) { + t.Fatalf("round trip changed the manifest: %+v vs %+v", back, m) + } + for name, want := range files { + got, err := os.ReadFile(filepath.Join(dst, repoDirName(repo), "snapshots", rev, name)) + if err != nil || !bytes.Equal(got, want) { + t.Errorf("%s: content differs (%v)", name, err) + } + } + if ref, _ := os.ReadFile(filepath.Join(dst, repoDirName(repo), "refs", "main")); string(ref) != rev { + t.Errorf("refs/main = %q, want %q", ref, rev) + } +} + +// The blob name is the only integrity check on the wire, so corrupted bytes +// must be refused rather than installed. +func TestWriteCacheBlobRejectsCorruption(t *testing.T) { + dst := t.TempDir() + good := []byte("hello world") + s := sha256.Sum256(good) + oid := hex.EncodeToString(s[:]) + + if err := writeCacheBlob(dst, "org/name", oid, int64(len(good)), bytes.NewReader(good)); err != nil { + t.Fatalf("good blob rejected: %v", err) + } + bad := []byte("hello w0rld") + p, _ := blobPath(dst, "org/name", oid) + os.Remove(p) + err := writeCacheBlob(dst, "org/name", oid, int64(len(bad)), bytes.NewReader(bad)) + if err == nil { + t.Fatal("a blob that does not match its own name must be refused") + } + if _, statErr := os.Stat(p); statErr == nil { + t.Error("a failed transfer must not leave a blob in place") + } +} + +// A peer supplies these strings; they become filenames. +func TestCachePathsRejectTraversal(t *testing.T) { + for _, oid := range []string{"../etc/passwd", "abc", "", "zz" + "0123456789abcdef0123456789abcdef01234567"[2:]} { + if safeOID(oid) { + t.Errorf("safeOID(%q) = true", oid) + } + } + if !safeOID("0123456789abcdef0123456789abcdef01234567") { + t.Error("a 40-hex oid must be accepted") + } + for _, p := range []string{"../x", "/etc/passwd", "a/../../b", ""} { + if safeRelPath(p) { + t.Errorf("safeRelPath(%q) = true", p) + } + } + if !safeRelPath("weights/model.safetensors") { + t.Error("a normal nested path must be accepted") + } +} diff --git a/services/nvpair-engine-manager/main.go b/services/nvpair-engine-manager/main.go index 34650754..3a7f6b2d 100644 --- a/services/nvpair-engine-manager/main.go +++ b/services/nvpair-engine-manager/main.go @@ -134,7 +134,7 @@ func main() { // the process on a node that joins a cluster after engine-manager started — // exactly the window in which the broker mints the identity. if *controlPort > 0 { - go serveControl(ctx, *controlPort, exec, mesh) + go serveControl(ctx, *controlPort, exec, mesh, mgr.CopyModelFromPeer) } if err := mgr.Run(ctx); err != nil && ctx.Err() == nil { diff --git a/services/nvpair-engine-manager/mirror_manual_test.go b/services/nvpair-engine-manager/mirror_manual_test.go new file mode 100644 index 00000000..6a6f6fd3 --- /dev/null +++ b/services/nvpair-engine-manager/mirror_manual_test.go @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// Mirrors a REAL cached model into a scratch hub, so the result can be checked +// against huggingface_hub's own scan_cache_dir rather than against my beliefs. +func TestMirrorRealModelForInspection(t *testing.T) { + src := os.Getenv("MIRROR_SRC") + dst := os.Getenv("MIRROR_DST") + repo := os.Getenv("MIRROR_REPO") + if src == "" || dst == "" || repo == "" { + t.Skip("set MIRROR_SRC/MIRROR_DST/MIRROR_REPO") + } + m, err := readCacheManifest(src, repo) + if err != nil { + t.Fatalf("read: %v", err) + } + t.Logf("manifest: %d files, %.1f MiB", len(m.Files), float64(m.TotalBytes())/(1<<20)) + for _, f := range m.Files { + p, _ := blobPath(src, repo, f.OID) + in, err := os.Open(p) + if err != nil { + t.Fatalf("open %s: %v", f.OID, err) + } + err = writeCacheBlob(dst, repo, f.OID, m.Sizes[f.OID], in) + in.Close() + if err != nil { + t.Fatalf("write %s: %v", f.OID, err) + } + } + if err := linkSnapshot(dst, m); err != nil { + t.Fatalf("link: %v", err) + } + t.Logf("mirrored to %s", filepath.Join(dst, repoDirName(repo))) +} diff --git a/services/nvpair-engine-manager/modelops.go b/services/nvpair-engine-manager/modelops.go index abca7cb8..c3f8812e 100644 --- a/services/nvpair-engine-manager/modelops.go +++ b/services/nvpair-engine-manager/modelops.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "fmt" + "path/filepath" ) // modelActionRequest is the shared body for ec model load/unload/delete endpoints. @@ -65,6 +66,14 @@ func modelActionWire(engine, op, model string) (string, json.RawMessage, error) switch engine { case "ollama": return marshalModelAction("delete_model", map[string]string{"name": model}) + case "mlx": + // A model outside the Hugging Face cache is advertised by absolute + // path and has no repo id for `hf cache rm` to delete. It is removed + // from disk instead, confined to the engine's model directory. + if filepath.IsAbs(model) { + return marshalModelAction("delete_model_path", map[string]string{"model": model}) + } + return marshalModelAction("delete_model", map[string]string{"model": model}) default: return marshalModelAction("delete_model", map[string]string{"model": model}) } diff --git a/services/nvpair-engine-manager/models.go b/services/nvpair-engine-manager/models.go index de89064e..c3c9965e 100644 --- a/services/nvpair-engine-manager/models.go +++ b/services/nvpair-engine-manager/models.go @@ -188,6 +188,9 @@ func extractStringsResult(raw json.RawMessage, spec *ActionResult) ([]string, bo if err := json.Unmarshal(raw, &obj); err != nil { return nil, false } + if spec.Scalar != "" { + return extractScalarResult(obj, spec.Scalar) + } arrRaw, ok := obj[spec.Array] if !ok || bytes.Equal(bytes.TrimSpace(arrRaw), []byte("null")) { return nil, false @@ -216,6 +219,32 @@ func extractStringsResult(raw json.RawMessage, spec *ActionResult) ([]string, bo return out, true } +// extractScalarResult reads one top-level string field as a zero- or +// one-element inventory, for an engine that reports a single current model +// instead of a list (mlx-lm's GET /health answers +// {"status":"ok","model":""} and holds exactly one model at a time). +// A JSON null or empty string is the authoritative "running, nothing loaded" -- +// the scalar counterpart of a present empty array -- while a missing or +// wrong-typed field is unknown and returns ok=false, so a response we cannot +// read is never labelled an authoritative empty. +func extractScalarResult(obj map[string]json.RawMessage, field string) ([]string, bool) { + fv, ok := obj[field] + if !ok { + return nil, false + } + if bytes.Equal(bytes.TrimSpace(fv), []byte("null")) { + return []string{}, true + } + var s string + if err := json.Unmarshal(fv, &s); err != nil { + return nil, false + } + if s == "" { + return []string{}, true + } + return []string{s}, true +} + // matchRow reports whether an element passes an ActionResult row filter. // With Match.In set, Match.Field must decode as a JSON string equal to one of // In. With Match.Nonempty set, Match.Field must decode as a JSON array with diff --git a/services/nvpair-engine-manager/remote.go b/services/nvpair-engine-manager/remote.go index 5d296441..1c014cfa 100644 --- a/services/nvpair-engine-manager/remote.go +++ b/services/nvpair-engine-manager/remote.go @@ -25,6 +25,9 @@ type remoteParam struct { Start bool `json:"start,omitempty"` Port int `json:"port,omitempty"` Model string `json:"model,omitempty"` + // SourceNode is the node that ALREADY has the model, for + // engine:remote-copy-model. Node is the one told to fetch it. + SourceNode string `json:"sourceNode,omitempty"` Params json.RawMessage `json:"params,omitempty"` } @@ -107,6 +110,53 @@ func (m *Manager) runRemote(ctx context.Context, msg *Message) { } m.codec.Respond(msg.ID, map[string]any{"opId": opID, "result": terminal.Result}) + // engine:copy-model-from is the mirror image of engine:remote-pull-model. + // remote-pull tells a PEER to download from the Hub; this pulls from the + // peer INTO this node, over the same pinned mTLS. It is the one that works + // with no internet, and on a LAN it moves bytes far faster than the Hub + // does — the model is, after all, already on this network. + // + // It reuses the remote-progress stream so the desktop's existing progress + // bars work with no change, and it carries no engine: a Hugging Face cache + // entry is not owned by an engine, and any engine reading that cache sees + // the result. + case "engine:copy-model-from": + if p.Model == "" { + m.codec.RespondError(msg.ID, -32602, "model is required") + return + } + opID := newOpID() + progress := m.remoteProgressFn(opID, peer.nodeID) + err := m.exec.PullModelFromPeer(ctx, client, p.Model, func(stage string, pct int, message string) { + progress(streamFrame{Stage: stage, Percent: pct, Message: message}) + }) + if err != nil { + // A terminal error frame as well as the RPC error, so a UI that has + // already timed out its call still converges instead of showing a + // transfer that never ends. + progress(streamFrame{Stage: "error", Percent: -1, Message: err.Error()}) + m.codec.RespondError(msg.ID, -32000, err.Error()) + return + } + m.codec.Respond(msg.ID, map[string]any{"opId": opID, "model": p.Model}) + + // Tell the target node to copy a model from a third node. `node` is who + // does the copying, `sourceNode` is who already has it -- so from laptop 1 + // you can hand a model to laptop 2 without either of them touching the Hub. + case "engine:remote-copy-model": + if p.SourceNode == "" || p.Model == "" { + m.codec.RespondError(msg.ID, -32602, "sourceNode and model are required") + return + } + opID := newOpID() + body := copyFromRequest{OpID: opID, SourceNode: p.SourceNode, Engine: p.Engine, Model: p.Model} + terminal, err := client.stream(ctx, controlCopyFromPath, body, m.remoteProgressFn(opID, peer.nodeID)) + if err != nil { + m.codec.RespondError(msg.ID, -32000, err.Error()) + return + } + m.codec.Respond(msg.ID, map[string]any{"opId": opID, "stage": terminal.Stage, "model": p.Model}) + case "engine:remote-load-model", "engine:remote-unload-model", "engine:remote-delete-model": if p.Engine == "" { m.codec.RespondError(msg.ID, -32602, "engine is required") diff --git a/services/nvpair-engine-manager/remoteclient.go b/services/nvpair-engine-manager/remoteclient.go index 795caf28..4d034e82 100644 --- a/services/nvpair-engine-manager/remoteclient.go +++ b/services/nvpair-engine-manager/remoteclient.go @@ -122,6 +122,62 @@ func (c *remoteClient) getEngines(ctx context.Context) (json.RawMessage, error) return json.RawMessage(data), nil } +// get fetches a small JSON body from an ec route. +func (c *remoteClient) get(ctx context.Context, path string) (json.RawMessage, error) { + return c.getWith(ctx, path, c.http) +} + +// getSlow is get on the readiness budget, for a route whose peer legitimately +// takes minutes to produce its FIRST byte. Building a directory model's manifest +// hashes the whole model -- 33s for an 11 GB one here -- so on the ordinary 30s +// response-header budget the request dies before the peer has anything to say. +func (c *remoteClient) getSlow(ctx context.Context, path string) (json.RawMessage, error) { + client := c.http + if c.readyHTTP != nil { + client = c.readyHTTP + } + return c.getWith(ctx, path, client) +} + +func (c *remoteClient) getWith(ctx context.Context, path string, client *http.Client) (json.RawMessage, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + c.forgetAddress() + return nil, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("GET %s: HTTP %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data))) + } + return json.RawMessage(data), nil +} + +// getStream opens an ec route for streaming and hands the body to the caller, +// which must close it. Used for model blobs, which are gigabytes and must never +// be buffered. +func (c *remoteClient) getStream(ctx context.Context, path string) (io.ReadCloser, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil) + if err != nil { + return nil, err + } + resp, err := c.http.Do(req) + if err != nil { + c.forgetAddress() + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) + resp.Body.Close() + return nil, fmt.Errorf("GET %s: HTTP %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data))) + } + return resp.Body, nil +} + // postJSON POSTs body to a non-streaming ec endpoint and returns the raw JSON // response (e.g. an EngineStatus from start/stop). func (c *remoteClient) postJSON(ctx context.Context, path, engine string, body any) (json.RawMessage, error) { diff --git a/services/nvpair-engine-manager/tofu.go b/services/nvpair-engine-manager/tofu.go new file mode 100644 index 00000000..708a938d --- /dev/null +++ b/services/nvpair-engine-manager/tofu.go @@ -0,0 +1,211 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "errors" + "log/slog" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "nvpair-shared/appdir" +) + +// Trust-on-first-use pinning for manifest downloads that carry no sha256. +// +// A manifest SHOULD pin its download: the bundled Ollama and MLX manifests both +// name an immutable versioned URL and its published digest, so a changed byte +// is a failed install rather than a silent substitution. LM Studio cannot be +// pinned that way -- its installer lives at a versionless +// https://lmstudio.ai/install.sh with no published checksum, and the script is +// executed via bash -- so the choice there is between accepting whatever the +// URL serves, every time, forever, and this. +// +// TOFU does not make the first download trustworthy; nothing available can. +// What it does is convert every LATER change from invisible into an event the +// operator has to decide about. A CDN compromise or a MITM that arrives after +// the first successful install stops the install instead of running. +// +// It deliberately fails CLOSED. A vendor shipping a new installer trips it too, +// which is not a false positive: the bytes really did change, and for an +// artifact that is about to execute as the user that is worth one deliberate +// confirmation. The error names the record file, and deleting it is the +// confirmation -- no override flag, no new RPC field, and no way to click +// through it by accident. + +const installerPinsFile = "installer-pins.json" + +const ( + // How long to wait for another process's critical section (a read, a + // compare and a rename -- milliseconds in practice). + installerPinLockWait = 10 * time.Second + // Older than this and the holder is assumed dead. + installerPinLockStale = 2 * time.Minute +) + +// installerPin is one remembered download. Version and FirstSeen are recorded +// for the human reading the file after a refusal, not for the comparison. +type installerPin struct { + SHA256 string `json:"sha256"` + Engine string `json:"engine"` + FirstSeen string `json:"firstSeen"` +} + +var installerPinsMu sync.Mutex + +func installerPinsPath() (string, error) { return appdir.Path(installerPinsFile) } + +// loadInstallerPins reads the record. Only "the file does not exist" means "no +// pins": every other failure is reported, because treating a corrupt or +// unreadable record as an empty one is a trust reset, and one an attacker can +// cause on purpose by truncating the file. +func loadInstallerPins(path string) (map[string]installerPin, error) { + pins := map[string]installerPin{} + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return pins, nil + } + if err != nil { + return nil, fmt.Errorf("read installer pin record %s: %w", path, err) + } + if err := json.Unmarshal(data, &pins); err != nil { + return nil, fmt.Errorf("installer pin record %s is unreadable (%v); refusing to treat it as empty, "+ + "because that would re-trust every installer. Inspect or delete it deliberately", path, err) + } + return pins, nil +} + +// checkInstallerPin compares an unpinned download's digest against what this +// machine saw last time. It returns an error only when a remembered digest +// disagrees; a first sighting is recorded and allowed. +func checkInstallerPin(engine, url, sum string) error { + path, err := installerPinsPath() + if err != nil { + // No writable record location: fall back to the previous behaviour + // rather than blocking installs on a directory problem. Warned, not + // silent -- a control that quietly turns itself off is worse than one + // that was never claimed. + slog.Warn("installer TOFU pinning unavailable: no writable data dir; download not compared against a previous install", + "engine", engine, "url", url, "err", err) + return nil + } + + installerPinsMu.Lock() + defer installerPinsMu.Unlock() + + // The mutex above only serialises goroutines inside ONE engine-manager, and + // this fork routinely runs two: the broker supervises one while a headless + // command spawns another. Without a cross-process lock both can read "URL + // absent", accept different bytes, and race to overwrite -- so first-use + // TOFU would be defeated exactly when it matters. + unlock, err := lockInstallerPins(filepath.Dir(path)) + if err != nil { + return fmt.Errorf("installer pin record is busy: %w", err) + } + defer unlock() + + pins, err := loadInstallerPins(path) + if err != nil { + return err + } + if prev, ok := pins[url]; ok { + if prev.SHA256 == sum { + return nil + } + // Name the single entry to remove, not the file. The file holds every + // remembered installer, so "delete it and retry" would quietly discard + // the pins for every OTHER engine as the price of accepting one change. + return fmt.Errorf( + "installer for %q changed since it was first trusted on this machine.\n"+ + " url: %s\n"+ + " trusted: %s (first seen %s)\n"+ + " served now: %s\n"+ + "This URL carries no publisher checksum, so PAIR cannot tell a vendor "+ + "release apart from a tampered download. If you have confirmed the change "+ + "is the vendor's, remove the %q entry from %s and install again "+ + "(deleting the whole file would also discard every other engine's pin).", + engine, url, prev.SHA256, prev.FirstSeen, sum, url, path) + } + + pins[url] = installerPin{SHA256: sum, Engine: engine, FirstSeen: time.Now().UTC().Format(time.RFC3339)} + data, err := json.MarshalIndent(pins, "", " ") + if err != nil { + return fmt.Errorf("encode installer pin record: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create installer pin dir: %w", err) + } + // Write to a sibling and rename. os.WriteFile truncates in place, so a crash + // or a concurrent engine-manager mid-write would leave a torn file -- which + // loadInstallerPins reads as "no pins at all", silently resetting every + // engine's TOFU state. rename(2) is atomic within a directory, so a reader + // sees either the old file or the new one. + tmp, err := os.CreateTemp(filepath.Dir(path), ".installer-pins-*") + if err != nil { + return fmt.Errorf("create installer pin record: %w", err) + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return fmt.Errorf("write installer pin record: %w", err) + } + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return fmt.Errorf("secure installer pin record: %w", err) + } + if err := tmp.Close(); err != nil { + os.Remove(tmp.Name()) + return fmt.Errorf("close installer pin record: %w", err) + } + // A pin that was not persisted means the next install has nothing to compare + // against, so this install was effectively unpinned. Refuse rather than + // execute an artifact under a control that did not actually engage. + if err := os.Rename(tmp.Name(), path); err != nil { + os.Remove(tmp.Name()) + return fmt.Errorf("record installer pin: %w", err) + } + return nil +} + + +// lockInstallerPins takes an advisory cross-process lock around the pin record. +// +// An O_EXCL lock file rather than flock(2) so the behaviour is identical on +// Windows, where engine-manager also runs and flock does not exist. The cost of +// that choice is stale locks after a crash, which is why the holder's age is +// checked: a lock older than the longest an install can plausibly hold it is +// broken rather than deadlocking every future install. +func lockInstallerPins(dir string) (func(), error) { + lockPath := filepath.Join(dir, ".installer-pins.lock") + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + deadline := time.Now().Add(installerPinLockWait) + for { + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err == nil { + _ = f.Close() + return func() { _ = os.Remove(lockPath) }, nil + } + if !errors.Is(err, os.ErrExist) { + return nil, err + } + // Break a lock whose owner plainly died: the critical section is a read, + // a compare and a rename, so anything this old is not still running. + if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > installerPinLockStale { + slog.Warn("breaking a stale installer pin lock", "path", lockPath, "age", time.Since(info.ModTime())) + _ = os.Remove(lockPath) + continue + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("another install is holding %s", lockPath) + } + time.Sleep(50 * time.Millisecond) + } +} diff --git a/services/nvpair-engine-manager/tofu_test.go b/services/nvpair-engine-manager/tofu_test.go new file mode 100644 index 00000000..957c6952 --- /dev/null +++ b/services/nvpair-engine-manager/tofu_test.go @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +// TOFU is the only integrity control available for a versionless vendor URL +// with no published checksum (LM Studio's install.sh). It has to allow the +// first sighting, allow an unchanged repeat, and refuse a change. +func TestInstallerPinTOFU(t *testing.T) { + // appdir reads the per-user data dir from the environment; redirect it so + // the test never touches the real one. + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + + const url = "https://lmstudio.ai/install.sh" + const first = "aaaa000000000000000000000000000000000000000000000000000000000000" + const changed = "bbbb000000000000000000000000000000000000000000000000000000000000" + + path, err := installerPinsPath() + if err != nil { + t.Skipf("no writable app dir in this environment: %v", err) + } + os.Remove(path) + + if err := checkInstallerPin("lmstudio", url, first); err != nil { + t.Fatalf("first sighting must be allowed, got %v", err) + } + if err := checkInstallerPin("lmstudio", url, first); err != nil { + t.Fatalf("unchanged repeat must be allowed, got %v", err) + } + + err = checkInstallerPin("lmstudio", url, changed) + if err == nil { + t.Fatal("a changed installer must fail closed") + } + // The refusal is only actionable if it names both digests and the file to + // delete; without that the operator cannot tell a vendor release from an + // attack, or clear it. + for _, want := range []string{first, changed, url, path} { + if !strings.Contains(err.Error(), want) { + t.Errorf("refusal must mention %q; got: %v", want, err) + } + } + + // The refusal must point at the single entry, not the whole file: accepting + // one change must not cost every other engine its pin. + if !strings.Contains(err.Error(), "every other engine") { + t.Errorf("refusal should warn against deleting the whole file; got: %v", err) + } + + // Removing just that entry is the documented confirmation, so it must work. + pins, loadErr := loadInstallerPins(path) + if loadErr != nil { + t.Fatalf("load pins: %v", loadErr) + } + delete(pins, url) + data, _ := json.MarshalIndent(pins, "", " ") + os.WriteFile(path, data, 0o600) + if err := checkInstallerPin("lmstudio", url, changed); err != nil { + t.Fatalf("after clearing the record the new digest must be accepted, got %v", err) + } + + // A corrupt record must NOT read as "no pins": that would silently re-trust + // every installer, and it is a state an attacker can create by truncating + // the file. + os.WriteFile(path, []byte("{not json"), 0o600) + if err := checkInstallerPin("lmstudio", url, first); err == nil { + t.Error("an unreadable pin record must refuse, not reset trust") + } + os.Remove(path) + if err := checkInstallerPin("lmstudio", url, first); err != nil { + t.Fatalf("a missing record is the only 'no pins' case: %v", err) + } + + // A different URL is independent. + if err := checkInstallerPin("other", "https://example.test/x.sh", first); err != nil { + t.Fatalf("an unrelated url must not be affected, got %v", err) + } +} + +// A pinned manifest must never consult the TOFU record: its digest is the +// authority, and Ollama's six downloads are pinned precisely so a vendor +// release is a manifest update rather than a prompt on every machine. +func TestPinnedManifestsDoNotNeedTOFU(t *testing.T) { + reg := NewRegistry() + if err := reg.LoadFS(bundledManifests, "manifests"); err != nil { + t.Fatalf("bundled manifests: %v", err) + } + for _, engine := range []string{"ollama", "mlx"} { + m, ok := reg.Get(engine) + if !ok { + t.Fatalf("%s manifest missing", engine) + } + for key, plat := range m.Platforms { + if plat.Install == nil || plat.Install.Fetch == nil { + continue + } + if strings.TrimSpace(plat.Install.Fetch.SHA256) == "" { + t.Errorf("%s %s: fetch is unpinned; %s must stay pinned", engine, key, engine) + } + } + } +} diff --git a/services/nvpair-engine-manager/transferpool.go b/services/nvpair-engine-manager/transferpool.go new file mode 100644 index 00000000..f8401275 --- /dev/null +++ b/services/nvpair-engine-manager/transferpool.go @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "io" + "os" + "strconv" + "sync" +) + +// Concurrent model transfer, and a resume that skips what is already here. +// +// Concurrency is worth about 1.7x on Wi-Fi (see defaultCopyStreams for the +// measured curve). The resume matters at least as much: an 11 GB copy that dies +// at 90% used to start again from zero. +// +// Nothing about integrity changes. Each file is fetched, verified against the +// digest the manifest named, and renamed into place on its own; workers share no +// state but the progress counter. + +// defaultCopyStreams is the concurrency for a model transfer. +// +// Four is the knee of the measured curve, not a guess. Between two Apple Silicon +// laptops on 802.11ac 80MHz at -25/-34 dBm, over an otherwise idle link: +// +// 1 stream 24 MiB/s +// 4 streams 40 MiB/s <- knee +// 8 streams 40 MiB/s +// 16 streams 37 MiB/s +// +// One TCP flow leaves a third of the link unused: its window is bounded by +// round-trip time and halved by every loss, so the radio idles waiting for ACKs. +// Independent flows do not stall together, which recovers most of that. Past +// four there is nothing left to recover and the connection count starts costing +// more in contention than it returns. +// +// Measure before changing this. An earlier reading of the same link said +// concurrency HURT (1 stream 4 MiB/s, 4 streams 3 MiB/s) -- taken while an 11 GB +// transfer was saturating the link underneath the benchmark. A contended link +// makes every stream count look equally bad and inverts the conclusion. +// +// NVPAIR_MODEL_COPY_STREAMS overrides it for a link with a different shape. +const defaultCopyStreams = 4 + +// maxCopyStreams caps what the environment can ask for. Past this the connection +// count costs more in contention on both ends than it recovers in throughput, +// and a typo should not open hundreds of streams against a peer. +const maxCopyStreams = 32 + +func copyStreams() int { + if raw := os.Getenv("NVPAIR_MODEL_COPY_STREAMS"); raw != "" { + if n, err := strconv.Atoi(raw); err == nil && n >= 1 && n <= maxCopyStreams { + return n + } + } + return defaultCopyStreams +} + +// runTransfers fetches every item with a bounded pool, calling onDone for each +// success. It returns the first error and abandons the rest. +// +// The first failure cancels the shared context so in-flight streams stop pulling +// bytes for a transfer that is already lost, rather than running to completion +// against a peer that has gone away. +func runTransfers( + ctx context.Context, + items []cacheFile, + fetch func(context.Context, cacheFile) error, + onDone func(cacheFile), +) error { + if len(items) == 0 { + return nil + } + workers := copyStreams() + if workers > len(items) { + workers = len(items) + } + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + work := make(chan cacheFile) + var ( + mu sync.Mutex + firstErr error + wg sync.WaitGroup + ) + + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for f := range work { + if ctx.Err() != nil { + return + } + if err := fetch(ctx, f); err != nil { + mu.Lock() + if firstErr == nil { + firstErr = err + } + mu.Unlock() + cancel() + return + } + mu.Lock() + onDone(f) + mu.Unlock() + } + }() + } + + for _, f := range items { + select { + case work <- f: + case <-ctx.Done(): + // A worker failed; stop feeding and let the rest drain. + } + } + close(work) + wg.Wait() + + mu.Lock() + defer mu.Unlock() + if firstErr != nil { + return firstErr + } + // A cancelled parent (the user stopped the copy) is not a transfer failure + // the caller should report as corruption, but it is still not success. + return ctx.Err() +} + +// fileAlreadyPresent reports whether dest already holds exactly the bytes the +// manifest describes, so a re-run after a failed transfer can skip it. +// +// Size is checked first because it is free and rules out almost everything; the +// digest is only computed for a file that could plausibly be the right one. +// Reading a local file to avoid re-fetching it over the network is a good trade +// at any link speed. +func fileAlreadyPresent(dest string, size int64, wantOID string) bool { + info, err := os.Stat(dest) + if err != nil || !info.Mode().IsRegular() || info.Size() != size { + return false + } + f, err := os.Open(dest) + if err != nil { + return false + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return false + } + return hex.EncodeToString(h.Sum(nil)) == wantOID +} diff --git a/services/nvpair-engine-manager/transferpool_test.go b/services/nvpair-engine-manager/transferpool_test.go new file mode 100644 index 00000000..84f895b6 --- /dev/null +++ b/services/nvpair-engine-manager/transferpool_test.go @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "errors" + "os" + "path/filepath" + "strconv" + "sync" + "sync/atomic" + "testing" + "time" +) + +func files(n int) []cacheFile { + out := make([]cacheFile, n) + for i := range out { + out[i] = cacheFile{Path: "f" + strconv.Itoa(i), OID: "oid" + strconv.Itoa(i)} + } + return out +} + +// Every file must move exactly once. A transfer that drops one leaves a model +// that looks complete and cannot load; one that fetches twice wastes the link +// this change exists to use better. +func TestRunTransfersFetchesEveryItemExactlyOnce(t *testing.T) { + t.Setenv("NVPAIR_MODEL_COPY_STREAMS", "8") + items := files(50) + + var mu sync.Mutex + fetched := map[string]int{} + var doneCount int64 + + err := runTransfers(context.Background(), items, + func(_ context.Context, f cacheFile) error { + mu.Lock() + fetched[f.Path]++ + mu.Unlock() + return nil + }, + func(cacheFile) { atomic.AddInt64(&doneCount, 1) }) + if err != nil { + t.Fatalf("runTransfers: %v", err) + } + if len(fetched) != len(items) { + t.Fatalf("fetched %d distinct files, want %d", len(fetched), len(items)) + } + for path, n := range fetched { + if n != 1 { + t.Errorf("%s fetched %d times, want 1", path, n) + } + } + if doneCount != int64(len(items)) { + t.Errorf("progress reported %d times, want %d", doneCount, len(items)) + } +} + +// The whole point of the change: work has to actually overlap. +func TestRunTransfersRunsConcurrently(t *testing.T) { + t.Setenv("NVPAIR_MODEL_COPY_STREAMS", "8") + var inFlight, peak int64 + + err := runTransfers(context.Background(), files(32), + func(_ context.Context, _ cacheFile) error { + n := atomic.AddInt64(&inFlight, 1) + for { + old := atomic.LoadInt64(&peak) + if n <= old || atomic.CompareAndSwapInt64(&peak, old, n) { + break + } + } + time.Sleep(20 * time.Millisecond) + atomic.AddInt64(&inFlight, -1) + return nil + }, + func(cacheFile) {}) + if err != nil { + t.Fatalf("runTransfers: %v", err) + } + if peak < 2 { + t.Fatalf("peak concurrency was %d; transfers ran serially", peak) + } +} + +// A failure has to surface and stop the rest: continuing to pull gigabytes for a +// transfer that is already lost is exactly what the cancel is for. +func TestRunTransfersReturnsFirstErrorAndStops(t *testing.T) { + t.Setenv("NVPAIR_MODEL_COPY_STREAMS", "4") + boom := errors.New("peer went away") + var started int64 + + err := runTransfers(context.Background(), files(200), + func(ctx context.Context, f cacheFile) error { + atomic.AddInt64(&started, 1) + if f.Path == "f0" { + return boom + } + // Give the failing worker time to cancel before this one finishes. + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(10 * time.Millisecond): + return nil + } + }, + func(cacheFile) {}) + + if !errors.Is(err, boom) { + t.Fatalf("runTransfers returned %v, want the fetch error", err) + } + if n := atomic.LoadInt64(&started); n >= 200 { + t.Errorf("started %d of 200 transfers after a failure; the rest should be abandoned", n) + } +} + +func TestRunTransfersEmptyIsNotAnError(t *testing.T) { + if err := runTransfers(context.Background(), nil, + func(context.Context, cacheFile) error { return errors.New("must not be called") }, + func(cacheFile) {}); err != nil { + t.Fatalf("empty transfer returned %v", err) + } +} + +func TestCopyStreamsClampsTheEnvironment(t *testing.T) { + for _, tc := range []struct { + set string + want int + }{ + {"", defaultCopyStreams}, + {"16", 16}, + {"1", 1}, + {"0", defaultCopyStreams}, + {"-4", defaultCopyStreams}, + {"9999", defaultCopyStreams}, + {"lots", defaultCopyStreams}, + } { + t.Setenv("NVPAIR_MODEL_COPY_STREAMS", tc.set) + if tc.set == "" { + os.Unsetenv("NVPAIR_MODEL_COPY_STREAMS") + } + if got := copyStreams(); got != tc.want { + t.Errorf("copyStreams() with %q = %d, want %d", tc.set, got, tc.want) + } + } +} + +// Resume: an 11 GB transfer that dies partway must not start from zero. +func TestFileAlreadyPresentOnlyAcceptsAnExactMatch(t *testing.T) { + root := t.TempDir() + t.Setenv("MLX_MODELS_DIRS", root) + src := mkModel(t, root, "m", map[string]string{"model.safetensors": "the real weights"}) + m, err := readDirManifest(src) + if err != nil { + t.Fatal(err) + } + f := m.Files[0] + dest := filepath.Join(src, filepath.FromSlash(f.Path)) + + if !fileAlreadyPresent(dest, m.Sizes[f.OID], f.OID) { + t.Error("an identical file was not recognised; the transfer would refetch it") + } + if fileAlreadyPresent(dest, m.Sizes[f.OID], "0000000000000000000000000000000000000000000000000000000000000000") { + t.Error("a file with the wrong digest was accepted as already present") + } + if fileAlreadyPresent(dest, m.Sizes[f.OID]+1, f.OID) { + t.Error("a file with the wrong size was accepted as already present") + } + if fileAlreadyPresent(filepath.Join(src, "absent"), 1, f.OID) { + t.Error("a missing file was reported as present") + } + // A directory must never be mistaken for a completed file. + if fileAlreadyPresent(src, m.Sizes[f.OID], f.OID) { + t.Error("a directory was reported as a present file") + } +} From 206237a50a73929b6f130eca8efc4f851e66c592 Mon Sep 17 00:00:00 2001 From: Denis Akimov Date: Mon, 14 Sep 2026 11:47:39 -0700 Subject: [PATCH 07/12] feat(node-info): answer only known peers Node info describes the machine, so it should not answer any LAN device that asks. Readers are restricted to the addresses of nodes already in the cluster. Signed-off-by: Denis Akimov --- .../nvpair-node-info/cluster_mtls_test.go | 2 +- services/nvpair-node-info/main.go | 13 +- services/nvpair-node-info/trustedreaders.go | 129 ++++++++++++++++++ .../nvpair-node-info/trustedreaders_test.go | 104 ++++++++++++++ 4 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 services/nvpair-node-info/trustedreaders.go create mode 100644 services/nvpair-node-info/trustedreaders_test.go diff --git a/services/nvpair-node-info/cluster_mtls_test.go b/services/nvpair-node-info/cluster_mtls_test.go index 2a793bd6..798ce822 100644 --- a/services/nvpair-node-info/cluster_mtls_test.go +++ b/services/nvpair-node-info/cluster_mtls_test.go @@ -101,7 +101,7 @@ func TestNodeInfoHandler_MTLSGate(t *testing.T) { const wantBody = `{"GPUs":[]}` mux := http.NewServeMux() - mux.HandleFunc("/v1/node-info", nodeInfoHandler(meshA, func() []byte { return []byte(wantBody) })) + mux.HandleFunc("/v1/node-info", nodeInfoHandler(meshA, nil, func() []byte { return []byte(wantBody) })) srv := httptest.NewUnstartedServer(mux) srv.TLS = meshA.ServerTLSConfig() srv.StartTLS() diff --git a/services/nvpair-node-info/main.go b/services/nvpair-node-info/main.go index c126755e..737f1234 100644 --- a/services/nvpair-node-info/main.go +++ b/services/nvpair-node-info/main.go @@ -258,7 +258,7 @@ func mergeGPUInventory(static, recovered []GPUInfo) []GPUInfo { // host's GPU inventory in the clear, and neither can a plain-HTTP caller on the // shared port. Refresh picks up a membership change or a peer paired after // startup. -func nodeInfoHandler(mesh *clustertrust.Mesh, body func() []byte) http.HandlerFunc { +func nodeInfoHandler(mesh *clustertrust.Mesh, readers *trustedReaders, body func() []byte) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { mesh.Refresh() if mesh.Clustered() { @@ -266,6 +266,12 @@ func nodeInfoHandler(mesh *clustertrust.Mesh, body func() []byte) http.HandlerFu http.Error(w, "forbidden: not a pinned cluster peer", http.StatusForbidden) return } + } else if !readers.allows(r.RemoteAddr) { + // Not clustered, so the mTLS gate above is inert and this is the + // only control. Answer loopback and known PAIR peers; a device that + // has not announced itself as a node gets nothing. + denyUntrustedReader(w, r.RemoteAddr) + return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write(body()) @@ -393,6 +399,8 @@ func main() { // push the answer is genuinely unknown and the field is omitted. The two // sources are mutually exclusive by construction. identity := &clusterIdentity{} + // Fail-open until the broker pushes; see trustedreaders.go. + readers := newTrustedReaders() clusterPrincipal := func() *string { if !clusterGated { uuid, told := identity.get() @@ -409,7 +417,7 @@ func main() { } mux := http.NewServeMux() - mux.HandleFunc("/v1/node-info", nodeInfoHandler(mesh, func() []byte { + mux.HandleFunc("/v1/node-info", nodeInfoHandler(mesh, readers, func() []byte { return buildResponse(gpus, cpu, memTotal, collector.Snapshot(), hostUUID, clusterPrincipal()) })) @@ -556,6 +564,7 @@ func main() { go applog.StdinRPC(notifier, func(msg applog.StdinMessage) { handleClusterIdentity(msg, identity) + handleTrustedReaders(msg, readers) }, func() { log.Print("stdin closed, shutting down") cancel() diff --git a/services/nvpair-node-info/trustedreaders.go b/services/nvpair-node-info/trustedreaders.go new file mode 100644 index 00000000..f46630c6 --- /dev/null +++ b/services/nvpair-node-info/trustedreaders.go @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "log/slog" + "net" + "net/http" + "net/netip" + "sync" + + "nvpair-shared/applog" + "nvpair-shared/noderec" +) + +// trustedReaders is the set of peer addresses the broker currently sees a PAIR +// node on. It gates the PLAINTEXT inventory only; the cluster-gated mTLS path is +// unchanged and stricter. +// +// Fail-open until told. `told` separates "the broker says there are no peers" +// from "no broker has ever pushed", which are the same empty set and must not be +// the same answer: gating on the second would break every deployment whose +// supervisor does not push, including node-info run standalone. +type trustedReaders struct { + mu sync.RWMutex + addrs map[string]struct{} + told bool +} + +func newTrustedReaders() *trustedReaders { + return &trustedReaders{addrs: map[string]struct{}{}} +} + +// normalizeAddr parses an address into the one form both sides of the +// comparison must agree on. netip rather than net.ParseIP because ParseIP +// returns nil for a zoned IPv6 literal ("fe80::1%en0"), which is exactly the +// form a link-local LAN caller arrives as -- and an address that fails to parse +// used to fall through to "allow", so link-local traffic walked past this gate +// entirely. Unmap folds ::ffff:1.2.3.4 onto 1.2.3.4; WithZone("") drops the +// interface scope, which names the receiver's own NIC and cannot identify a peer. +func normalizeAddr(s string) (netip.Addr, bool) { + a, err := netip.ParseAddr(s) + if err != nil { + return netip.Addr{}, false + } + return a.Unmap().WithZone(""), true +} + +func (t *trustedReaders) set(addresses []string) { + next := make(map[string]struct{}, len(addresses)) + dropped := 0 + for _, a := range addresses { + if addr, ok := normalizeAddr(a); ok { + next[addr.String()] = struct{}{} + continue + } + // Silently dropping is the dangerous case: the broker believes it + // installed policy while a real peer is now refused. + dropped++ + slog.Warn("trusted-readers push contained an address that is not an IP; that peer will be refused", "address", a) + } + if dropped > 0 { + slog.Warn("some trusted reader addresses were unusable", "dropped", dropped, "kept", len(next)) + } + t.mu.Lock() + defer t.mu.Unlock() + t.addrs = next + t.told = true +} + +// allows reports whether a remote address may read the plaintext inventory. +// Loopback is always allowed: the local broker, scanner and desktop app all read +// over it, and a caller already on this host has no need of the endpoint to +// learn the host's own hardware. +// allows is nil-safe on purpose: a nil set is "no policy has been installed", +// which is the same documented fail-open as `!told`. It is NOT a way to skip the +// check -- the handler used to test `readers != nil`, which meant any path that +// forgot to construct one served the inventory to the whole LAN. +func (t *trustedReaders) allows(remoteAddr string) bool { + if t == nil { + return true + } + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + host = remoteAddr + } + addr, ok := normalizeAddr(host) + if !ok { + // A TCP RemoteAddr always carries a parseable IP, so reaching here means + // something we cannot classify. Deny: the previous fail-open here was + // the link-local bypass described on normalizeAddr. + return false + } + if addr.IsLoopback() { + return true + } + t.mu.RLock() + defer t.mu.RUnlock() + if !t.told { + return true + } + _, known := t.addrs[addr.String()] + return known +} + +// handleTrustedReaders applies a MethodSetTrustedReaders notification. A +// malformed payload is dropped rather than latching a wrong set: the broker +// re-pushes on every discovery change, so the next one corrects us. +func handleTrustedReaders(msg applog.StdinMessage, readers *trustedReaders) { + if msg.Method != noderec.MethodSetTrustedReaders { + return + } + var params noderec.TrustedReadersParams + if err := json.Unmarshal(msg.Params, ¶ms); err != nil { + slog.Warn("ignoring malformed trusted-readers push", "err", err) + return + } + readers.set(params.Addresses) + slog.Debug("trusted readers updated", "count", len(params.Addresses)) +} + +// denyUntrustedReader writes the 403 for a caller outside the set. Split out so +// the handler reads as one decision. +func denyUntrustedReader(w http.ResponseWriter, remoteAddr string) { + slog.Debug("refused node-info read from a non-peer address", "remote", remoteAddr) + http.Error(w, "forbidden: not a known PAIR node on this network", http.StatusForbidden) +} diff --git a/services/nvpair-node-info/trustedreaders_test.go b/services/nvpair-node-info/trustedreaders_test.go new file mode 100644 index 00000000..31de3980 --- /dev/null +++ b/services/nvpair-node-info/trustedreaders_test.go @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "nvpair-shared/clustertrust" +) + +func TestTrustedReadersAllows(t *testing.T) { + r := newTrustedReaders() + + // Fail-open until the broker has ever pushed: node-info run standalone, or + // under a supervisor that does not push, must not lock everyone out. + if !r.allows("192.168.1.99:5000") { + t.Error("before any push, a LAN caller must still be answered") + } + + r.set([]string{"192.168.1.21", "10.0.0.7"}) + + for _, tc := range []struct { + remote string + want bool + why string + }{ + {"192.168.1.21:51000", true, "a known peer"}, + {"10.0.0.7:9", true, "another known peer"}, + {"192.168.1.99:51000", false, "a LAN device that is not a PAIR node"}, + {"127.0.0.1:51000", true, "loopback is always allowed"}, + {"[::1]:51000", true, "loopback over v6"}, + // A v4-mapped v6 remote must match the v4 address it was pushed as, or + // a dual-stack peer is refused for a formatting reason. + {"[::ffff:192.168.1.21]:51000", true, "v4-mapped form of a known peer"}, + // Regression: net.ParseIP returns nil for a zoned IPv6 literal, and the + // first version of this gate fell through to "allow" on a parse + // failure -- so any link-local LAN caller walked straight past it. + {"[fe80::1cd4:beef:1:2%en0]:51000", false, "zoned link-local from a non-peer must be refused"}, + {"garbage", false, "an address we cannot classify is refused, not allowed"}, + } { + if got := r.allows(tc.remote); got != tc.want { + t.Errorf("allows(%q) = %v, want %v (%s)", tc.remote, got, tc.want, tc.why) + } + } + + // ...and the zoned form of an address that IS a peer must still match, or + // dropping the zone would have traded one bug for another. + r.set([]string{"fe80::1cd4:beef:1:2", "192.168.1.21"}) + if !r.allows("[fe80::1cd4:beef:1:2%en0]:51000") { + t.Error("a known peer reached over zoned link-local must be allowed") + } + r.set([]string{"192.168.1.21", "10.0.0.7"}) + + // An empty push is meaningful: this node knows of no peers, so only + // loopback should get through. + r.set(nil) + if r.allows("192.168.1.21:51000") { + t.Error("after an empty push, a former peer must lose access") + } + if !r.allows("127.0.0.1:51000") { + t.Error("loopback must survive an empty push") + } +} + +// The gate must sit only on the plaintext path and must not weaken or bypass +// the cluster-gated one. +func TestHandlerRefusesUntrustedPlaintextReader(t *testing.T) { + mesh := clustertrust.Open("") // not clustered: the mTLS gate is inert + readers := newTrustedReaders() + readers.set([]string{"192.168.1.21"}) + + h := nodeInfoHandler(mesh, readers, func() []byte { return []byte(`{"ok":true}`) }) + + for _, tc := range []struct { + remote string + want int + }{ + {"192.168.1.21:1234", http.StatusOK}, + {"127.0.0.1:1234", http.StatusOK}, + {"192.168.1.99:1234", http.StatusForbidden}, + } { + req := httptest.NewRequest(http.MethodGet, "/v1/node-info", nil) + req.RemoteAddr = tc.remote + rec := httptest.NewRecorder() + h(rec, req) + if rec.Code != tc.want { + t.Errorf("remote %s: status %d, want %d", tc.remote, rec.Code, tc.want) + } + } + + // A nil set means "no policy installed yet" -- the same fail-open as before + // the first push -- and must NOT read as "skip the check entirely". + open := nodeInfoHandler(mesh, nil, func() []byte { return []byte(`{"ok":true}`) }) + req := httptest.NewRequest(http.MethodGet, "/v1/node-info", nil) + req.RemoteAddr = "192.168.1.99:1234" + rec := httptest.NewRecorder() + open(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("with no reader set wired, status %d, want 200", rec.Code) + } +} From 1360de0d4a9b779f64edb5505a3f61e247172fee Mon Sep 17 00:00:00 2001 From: Denis Akimov Date: Mon, 14 Sep 2026 11:47:39 -0700 Subject: [PATCH 08/12] fix(desktop): stop an arriving invite stealing the open modal Each invitation carries its own PIN and an inviter that cancels and retries mints a fresh one, so rebinding the modal mid-flow means the PIN being read off the other machine belongs to a session that is no longer the one about to consume it. The completion exchange then fails as an EAP-NOOB Noob mismatch, reported as "Incorrect PIN", and the user hunts for a typo that never happened. A second invite no longer repoints a modal already being answered, and both screens now show a short label derived from the invite id. Signed-off-by: Denis Akimov --- .../src/ui/components/InviteApprovalModal.tsx | 41 +++++- .../src/ui/components/InvitePairingPanel.tsx | 8 ++ .../ui/stores/cluster-invitations.store.ts | 22 +++- desktop/src/ui/utils/cluster-invite-error.ts | 14 ++- desktop/src/ui/utils/invite-label.ts | 37 ++++++ .../modular/invite-modal-binding.test.ts | 118 ++++++++++++++++++ 6 files changed, 233 insertions(+), 7 deletions(-) create mode 100644 desktop/src/ui/utils/invite-label.ts create mode 100644 desktop/tests/modular/invite-modal-binding.test.ts diff --git a/desktop/src/ui/components/InviteApprovalModal.tsx b/desktop/src/ui/components/InviteApprovalModal.tsx index c52b60e5..d85fd86d 100644 --- a/desktop/src/ui/components/InviteApprovalModal.tsx +++ b/desktop/src/ui/components/InviteApprovalModal.tsx @@ -22,12 +22,14 @@ import { } from '@/ui/utils/cluster-invite-error' import type { Invite } from '@/shared/types/cluster' import { useClusterInvitationsStore } from '@/ui/stores/cluster-invitations.store' +import { inviteLabel } from '@/ui/utils/invite-label' export function InviteApprovalModal() { const activeInviteId = useClusterInvitationsStore(state => state.activeInviteId) const pendingInvites = useClusterInvitationsStore(state => state.pendingInvites) const respondToInvite = useClusterInvitationsStore(state => state.respondToInvite) const clearActiveInvite = useClusterInvitationsStore(state => state.clearActiveInvite) + const setActiveInvite = useClusterInvitationsStore(state => state.setActiveInvite) const activeInvite = useMemo( () => @@ -37,6 +39,17 @@ export function InviteApprovalModal() { [activeInviteId, pendingInvites] ) + /** + * Invitations waiting behind the one on screen. The store no longer switches + * the modal to a new arrival on its own, so these are surfaced here and + * switched to only when the user asks — swapping the invitation underneath a + * half-entered PIN is what made a correct PIN read as wrong. + */ + const otherPending = useMemo( + () => pendingInvites.filter(invite => invite.inviteId !== activeInviteId), + [pendingInvites, activeInviteId] + ) + const [open, setOpen] = useState(false) // A latched copy of the invite being acted on, so a terminal "no longer // valid" message still renders after main prunes the invite from the set. @@ -170,11 +183,37 @@ export function InviteApprovalModal() { {shown.fromNodeName || shown.fromNodeId} + + + Invite + + + {inviteLabel(shown.inviteId)} + + + {!terminal && otherPending.length > 0 && ( + + + {otherPending.length === 1 + ? 'Another invitation arrived and is waiting.' + : `${otherPending.length} more invitations arrived and are waiting.`} + + + + )} {!terminal && ( Enter the 6-digit PIN shown on{' '} - {shown.fromNodeName || 'the inviting node'}. + {shown.fromNodeName || 'the inviting node'} for invite{' '} + {inviteLabel(shown.inviteId)}. Each invitation has its own PIN. {invite.pin ?? '------'} + {/* Same label the joiner's modal shows, so the person carrying the + PIN can see both screens mean the same invitation. Cancelling + and inviting again changes both the PIN and this label. */} + + Invite{' '} + {inviteLabel(invite.inviteId)} + Waiting for the other node to confirm... diff --git a/desktop/src/ui/stores/cluster-invitations.store.ts b/desktop/src/ui/stores/cluster-invitations.store.ts index 35f40a14..7010a9ae 100644 --- a/desktop/src/ui/stores/cluster-invitations.store.ts +++ b/desktop/src/ui/stores/cluster-invitations.store.ts @@ -58,11 +58,25 @@ export const useClusterInvitationsStore = create((set, if (!window.pairApi) return unsubs.push( - // A fresh arrival: surface it in the modal. Main also emits the full - // list via `cluster:pending-invites-changed`, so this only picks which - // invite the modal shows. + // A fresh arrival surfaces in the modal ONLY when nothing is already + // selected. Main also emits the full list via + // `cluster:pending-invites-changed`, so this only picks which invite + // the modal shows. + // + // It must never repoint an open modal. Each invitation has its own + // PIN, and an inviter that cancels and retries mints a new one, so + // silently rebinding mid-flow means the PIN the user is reading off + // the other screen no longer belongs to the session that will consume + // it. That surfaces as an EAP-NOOB Noob mismatch reported as + // "Incorrect PIN" — indistinguishable, to the user, from a typo. + // + // Nothing is stranded by declining to switch: every pending invite is + // listed in cluster settings (`PendingInviteCard`), which calls + // `setActiveInvite` to open one deliberately. window.pairApi.cluster.onInviteReceived(invite => { - if (invite.state === 'pending') set({ activeInviteId: invite.inviteId }) + if (invite.state !== 'pending') return + if (get().activeInviteId) return + set({ activeInviteId: invite.inviteId }) }), window.pairApi.cluster.onPendingInvitesChanged(invites => { set(state => ({ diff --git a/desktop/src/ui/utils/cluster-invite-error.ts b/desktop/src/ui/utils/cluster-invite-error.ts index e97708c8..ff125693 100644 --- a/desktop/src/ui/utils/cluster-invite-error.ts +++ b/desktop/src/ui/utils/cluster-invite-error.ts @@ -13,8 +13,18 @@ const DEFAULT_INVITE_ERROR = `Could not invite that node. Check the IP address, const INVITE_SESSION_ENDED_MESSAGE = 'This invitation is no longer valid — ask the inviting node to send a new one.' -/** Wrong-PIN copy for the joiner (the node that entered the PIN). */ -export const INCORRECT_PIN_RECEIVER_MESSAGE = `Incorrect PIN. ${INVITE_SESSION_ENDED_MESSAGE}` +/** + * Wrong-PIN copy for the joiner (the node that entered the PIN). + * + * Names the invite label as well as the PIN: the backend cannot tell a mistyped + * PIN from a correct PIN belonging to a DIFFERENT invitation — both reach it as + * the same EAP-NOOB Noob mismatch — and the second is the likelier of the two + * whenever the inviter has retried, because a retry mints a new PIN. + */ +export const INCORRECT_PIN_RECEIVER_MESSAGE = + 'That PIN did not match this invitation. Check that the invite label on both ' + + 'machines is the same — each invitation has its own PIN. ' + + INVITE_SESSION_ENDED_MESSAGE /** Wrong-PIN copy mirrored on the inviter (the node that issued the PIN). */ export const INCORRECT_PIN_SENDER_MESSAGE = diff --git a/desktop/src/ui/utils/invite-label.ts b/desktop/src/ui/utils/invite-label.ts new file mode 100644 index 00000000..ca3c05b7 --- /dev/null +++ b/desktop/src/ui/utils/invite-label.ts @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +/** + * A short, human-comparable label for a pairing invitation. + * + * Shown on BOTH sides — next to the PIN on the inviter and in the approval + * modal on the joiner — so the person carrying the PIN between two machines can + * see that both screens are talking about the same invitation. + * + * This exists because each invitation carries its OWN PIN, and an inviter that + * cancels and retries mints a fresh one. Without a visible handle, two + * invitations are indistinguishable ("From sender.local" either way), and a + * PIN read from one screen can be typed into a modal bound to the other. That + * fails as an EAP-NOOB Noob mismatch and is reported as "Incorrect PIN", which + * sends the user looking for a typo that never happened. + * + * NOT a secret and NOT an authenticator: the invite id already crosses the LAN + * in the clear and appears in logs, while the PIN is the only secret in the + * exchange. This is a selector that lets a human notice they are looking at the + * wrong invitation — the cryptographic binding is EAP-NOOB's Hoob/NoobId, which + * is unaffected by anything shown here. + * + * Derived from the invite id rather than generated separately, so it needs no + * new field on the wire and cannot drift out of sync with the invitation it + * names. Six characters is ample for the handful of invitations a node holds at + * once; a word list would read better aloud, but it would need a new field + * carried by the backend to stay stable across both sides. + */ +export function inviteLabel(inviteId: string | null | undefined): string { + const compact = (inviteId ?? '').replace(/[^0-9a-zA-Z]/g, '').toUpperCase() + if (!compact) return '------' + // Right-hand characters: invite ids share a common prefix, so the tail is + // where two concurrent invitations actually differ. + const tail = compact.slice(-6).padStart(6, '0') + return `${tail.slice(0, 3)}-${tail.slice(3)}` +} diff --git a/desktop/tests/modular/invite-modal-binding.test.ts b/desktop/tests/modular/invite-modal-binding.test.ts new file mode 100644 index 00000000..4a55259c --- /dev/null +++ b/desktop/tests/modular/invite-modal-binding.test.ts @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { inviteLabel } from '@/ui/utils/invite-label' +import { useClusterInvitationsStore } from '@/ui/stores/cluster-invitations.store' +import type { Invite } from '@/shared/types/cluster' + +function invite(inviteId: string, partial: Partial = {}): Invite { + return { + inviteId, + fromNodeId: 'sender.local', + fromNodeUuid: 'd9634062-7199-486d-8b8f-c515dbe358b2', + fromNodeName: 'sender.local', + toNodeId: null, + clusterId: 'cluster-1', + clusterFriendlyName: 'sender.local', + pin: null, + state: 'pending', + reason: '', + ...partial + } as Invite +} + +/** + * The label is what lets a person holding a PIN see that the screen they read it + * from and the screen they type it into mean the same invitation. Two invites + * that differ must not share one. + */ +describe('inviteLabel', () => { + it('distinguishes the two invites from the reported pairing failure', () => { + expect(inviteLabel('inv-a16f42cd0055')).not.toBe(inviteLabel('inv-9b52fa024225')) + }) + + it('is stable for the same invite id', () => { + expect(inviteLabel('inv-a16f42cd0055')).toBe(inviteLabel('inv-a16f42cd0055')) + }) + + it('renders a grouped, uppercase, fixed-width label', () => { + expect(inviteLabel('inv-a16f42cd0055')).toBe('CD0-055') + expect(inviteLabel('inv-9b52fa024225')).toBe('024-225') + }) + + it('degrades instead of throwing on a missing id', () => { + expect(inviteLabel(null)).toBe('------') + expect(inviteLabel('')).toBe('------') + }) +}) + +/** + * A second invitation must never repoint an approval modal the user is already + * answering. + * + * Each invitation carries its own PIN, and an inviter that cancels and retries + * mints a fresh one, so rebinding mid-flow means the PIN being read off the + * other machine belongs to a session that is no longer the one about to consume + * it. The completion exchange then fails as an EAP-NOOB Noob mismatch, which the + * backend correctly reports as `incorrect-pin` — leaving the user hunting for a + * typo that never happened. + */ +describe('cluster invitations store: arriving invite does not steal the modal', () => { + let onInviteReceived: (invite: Invite) => void + + beforeEach(async () => { + onInviteReceived = () => {} + const pairApi = { + cluster: { + getInitial: vi.fn(async () => ({ pendingInvites: [], members: [] })), + onInviteReceived: (cb: (invite: Invite) => void) => { + onInviteReceived = cb + return () => {} + }, + onPendingInvitesChanged: () => () => {}, + respondToInvite: vi.fn() + }, + nodes: { onMembersChanged: () => () => {} } + } + ;(globalThis as unknown as { window: unknown }).window = { pairApi } + useClusterInvitationsStore.setState({ + pendingInvites: [], + members: [], + activeInviteId: null + }) + await useClusterInvitationsStore + .getState() + .initialize({ pendingInvites: [], members: [] } as never) + }) + + it('surfaces the first invite when nothing is being answered', () => { + onInviteReceived(invite('inv-a16f42cd0055')) + expect(useClusterInvitationsStore.getState().activeInviteId).toBe('inv-a16f42cd0055') + }) + + it('leaves the open invite bound when a second one arrives', () => { + onInviteReceived(invite('inv-a16f42cd0055')) + onInviteReceived(invite('inv-9b52fa024225')) + expect(useClusterInvitationsStore.getState().activeInviteId).toBe('inv-a16f42cd0055') + }) + + it('still ignores non-pending arrivals', () => { + onInviteReceived(invite('inv-a16f42cd0055', { state: 'failed', reason: 'incorrect-pin' })) + expect(useClusterInvitationsStore.getState().activeInviteId).toBeNull() + }) + + it('lets the user switch deliberately', () => { + onInviteReceived(invite('inv-a16f42cd0055')) + onInviteReceived(invite('inv-9b52fa024225')) + useClusterInvitationsStore.getState().setActiveInvite('inv-9b52fa024225') + expect(useClusterInvitationsStore.getState().activeInviteId).toBe('inv-9b52fa024225') + }) + + it('accepts a new arrival again once the user closes the modal', () => { + onInviteReceived(invite('inv-a16f42cd0055')) + useClusterInvitationsStore.getState().clearActiveInvite() + onInviteReceived(invite('inv-9b52fa024225')) + expect(useClusterInvitationsStore.getState().activeInviteId).toBe('inv-9b52fa024225') + }) +}) From 57decbe7d0eb6372283eb9b23e415b13c814ceca Mon Sep 17 00:00:00 2001 From: Denis Akimov Date: Mon, 14 Sep 2026 11:47:39 -0700 Subject: [PATCH 09/12] fix(desktop): treat MLX as a proxy engine everywhere PROXY_ENGINES and the ProxyEngine type both listed mlx, but isProxyEngine compared against ollama and lm-studio by hand -- a type guard's body is not checked against its own predicate, so TypeScript could not catch it. The node card sat at "Initializing..." forever. Model delete also had three dispatch paths, one of which hardcoded delete_model and could not remove a model held by path. Signed-off-by: Denis Akimov --- desktop/src/electron/ipc/window.ipc.ts | 3 +- desktop/src/electron/model-hub/index.ts | 15 +- .../electron/service-bridge/empty-handlers.ts | 23 + .../service-bridge/model-delete-action.ts | 32 + .../electron/service-bridge/modular-state.ts | 68 +- .../service-bridge/modular-supervisor.ts | 153 +- desktop/src/shared/constants/engines.ts | 15 +- desktop/src/shared/types/engine-api.ts | 6 + desktop/src/ui/api/engine-api.ts | 15 + desktop/src/ui/components/EngineIcon.tsx | 27 + .../components/ModelHub/ModelHubContent.tsx | 40 +- .../components/ModelManager/ModelManager.tsx | 61 + .../components/ModelManager/PeerModelRow.tsx | 46 + .../src/ui/constants/engine-capabilities.ts | 29 + desktop/src/ui/constants/welcome.ts | 7 +- .../kaizen-ui-foundations/base-external.css | 1459 +-- .../lib/kaizen-ui-foundations/components.css | 9348 +---------------- desktop/src/ui/types/engine-manifest.ts | 6 + .../tests/modular/mlx-delete-action.test.ts | 30 + .../tests/modular/model-hub-warm-gate.test.ts | 49 + .../modular/proxy-engine-membership.test.ts | 42 + 21 files changed, 622 insertions(+), 10852 deletions(-) create mode 100644 desktop/src/electron/service-bridge/model-delete-action.ts create mode 100644 desktop/src/ui/components/ModelManager/PeerModelRow.tsx create mode 100644 desktop/tests/modular/mlx-delete-action.test.ts create mode 100644 desktop/tests/modular/model-hub-warm-gate.test.ts create mode 100644 desktop/tests/modular/proxy-engine-membership.test.ts diff --git a/desktop/src/electron/ipc/window.ipc.ts b/desktop/src/electron/ipc/window.ipc.ts index b017c6fc..f70b766e 100644 --- a/desktop/src/electron/ipc/window.ipc.ts +++ b/desktop/src/electron/ipc/window.ipc.ts @@ -6,6 +6,7 @@ import { safeHandle } from '@/electron/ipc/safe-handle' import { openExternalSafe } from '@/electron/open-external' import { createOverviewWindow, focusNodeInOverview, markOverviewReady } from '@/electron/window' import { warmEngineHubs } from '@/electron/model-hub' +import { getModularBridgeState } from '@/electron/service-bridge/modular-state' import { APP_DISPLAY_NAME } from '@/shared/constants/app' import { resizeTrayWindow } from '@/electron/tray' import { saveDebugLogs } from './debug-log-export' @@ -29,7 +30,7 @@ export function registerWindowIpc(): void { // hanging catalog fetch off the startup path the window's first paint shares. safeHandle('overview:ready', () => { markOverviewReady() - warmEngineHubs() + warmEngineHubs(engine => getModularBridgeState().isEngineInstalledLocally(engine)) }) safeHandle('window:open-external', (_event, url) => { diff --git a/desktop/src/electron/model-hub/index.ts b/desktop/src/electron/model-hub/index.ts index d1fda727..f0cb0fd9 100644 --- a/desktop/src/electron/model-hub/index.ts +++ b/desktop/src/electron/model-hub/index.ts @@ -66,7 +66,20 @@ export async function getEngineHubModels(engineType: EngineType): Promise boolean): void { + if (!isEngineInstalledLocally('lm-studio')) return lmStudioCatalogCache.refresh() } diff --git a/desktop/src/electron/service-bridge/empty-handlers.ts b/desktop/src/electron/service-bridge/empty-handlers.ts index dc5c2dd8..5e5a8c7c 100644 --- a/desktop/src/electron/service-bridge/empty-handlers.ts +++ b/desktop/src/electron/service-bridge/empty-handlers.ts @@ -523,6 +523,18 @@ function routeEngineManagerCommand(payload: WsInvokeRequest<'engine:command'>): void supervisor.pullModel(engine, payload.engineType, payload.model) } break + case 'copyModelFrom': + // On the local card this means "fetch it from that peer", so this + // node does the copying. The remote branch below is the mirror: + // "you fetch it from that peer". + if (payload.model && payload.sourceNodeId) { + void supervisor.copyModelFromPeer( + payload.sourceNodeId, + payload.engineType, + payload.model + ) + } + break case 'deleteModel': if (payload.model) { void supervisor.deleteModel(engine, payload.engineType, payload.model) @@ -637,6 +649,17 @@ function routeRemoteEngineCommand(payload: WsInvokeRequest<'engine:command'>): v void supervisor.pullModelRemote(nodeId, engine, payload.engineType, payload.model) } break + case 'copyModelFrom': + if (payload.model && payload.sourceNodeId) { + void supervisor.copyModelToRemote( + nodeId, + payload.sourceNodeId, + engine, + payload.engineType, + payload.model + ) + } + break case 'uninstall': case 'update': refuseRemote( diff --git a/desktop/src/electron/service-bridge/model-delete-action.ts b/desktop/src/electron/service-bridge/model-delete-action.ts new file mode 100644 index 00000000..1e2bd8c0 --- /dev/null +++ b/desktop/src/electron/service-bridge/model-delete-action.ts @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +/** + * Which delete action an engine needs for a given model. + * + * MLX lists two kinds of model. A Hugging Face repo id lives in the shared cache + * and is deleted from it. A locally built model -- a quantization, say -- never + * enters the cache, has no repo id, and is advertised by absolute path; it is + * deleted from disk instead. + * + * Sending a path to the cache delete asks `hf` to remove a repo that was never + * cached: + * + * Failed to delete /Users/…/models/Qwen3.8-27B-3bit: + * Error: Cache directory not found: /Users/…/.cache/huggingface/hub + * + * and the model stays in the list forever, because the models-directory scan + * keeps finding it on disk. There is no other way to remove it. + * + * `nvpair-engine-manager` makes the same choice for callers that go through its + * own model ops (the remote path does). This is the local path, which names the + * action directly and so has to decide for itself. + * + * Its own module, rather than a private function in the supervisor, only so a + * test can reach it without pulling the Electron module graph in behind it. + */ +export function deleteModelAction(engineManagerEngine: string, model: string): string { + return engineManagerEngine === 'mlx' && model.startsWith('/') + ? 'delete_model_path' + : 'delete_model' +} diff --git a/desktop/src/electron/service-bridge/modular-state.ts b/desktop/src/electron/service-bridge/modular-state.ts index ab6c5fb5..a824063b 100644 --- a/desktop/src/electron/service-bridge/modular-state.ts +++ b/desktop/src/electron/service-bridge/modular-state.ts @@ -33,20 +33,33 @@ import { serviceLogLevel } from './service-log-level' // Live node sources are the two reverse proxies, relayed through the broker, // and the broker's consolidated discovery snapshot. Electron does not consume // worker discovery protocols directly. -type ProxyNodeSource = 'ollama-proxy' | 'lmstudio-proxy' +type ProxyNodeSource = 'ollama-proxy' | 'lmstudio-proxy' | 'mlx-proxy' type BrokerNodeSource = ProxyNodeSource | 'broker' /** * Engines surfaced by the broker's proxy plane. Other engine-manager engines * are not currently routed across nodes. */ -export type ProxyEngine = Extract -export const PROXY_ENGINES: readonly ProxyEngine[] = ['ollama', 'lm-studio'] +export type ProxyEngine = Extract +export const PROXY_ENGINES: readonly ProxyEngine[] = ['ollama', 'lm-studio', 'mlx'] /** Map a proxy node source onto the engine it describes. */ const PROXY_SOURCE_ENGINE: Record = { 'ollama-proxy': 'ollama', - 'lmstudio-proxy': 'lm-studio' + 'lmstudio-proxy': 'lm-studio', + 'mlx-proxy': 'mlx' +} + +/** + * Inverse of {@link PROXY_SOURCE_ENGINE}. A Record rather than a conditional so + * adding an engine is a compile error here until it is answered, which is how + * the third engine was found: the two-way ternaries this replaced silently + * called everything that was not Ollama an LM Studio node. + */ +const PROXY_ENGINE_SOURCE: Record = { + ollama: 'ollama-proxy', + 'lm-studio': 'lmstudio-proxy', + mlx: 'mlx-proxy' } /** Per-engine presence on a node — each proxy reports its own engine. */ @@ -164,7 +177,7 @@ function emptyPresence(): EnginePresence { } function emptyEngines(): Record { - return { ollama: emptyPresence(), 'lm-studio': emptyPresence() } + return { ollama: emptyPresence(), 'lm-studio': emptyPresence(), mlx: emptyPresence() } } /** Immutably set one engine's presence, preserving the other. */ @@ -173,10 +186,7 @@ function setEngine( engine: ProxyEngine, presence: EnginePresence ): Record { - return { - ollama: engine === 'ollama' ? presence : engines.ollama, - 'lm-studio': engine === 'lm-studio' ? presence : engines['lm-studio'] - } + return { ...engines, [engine]: presence } } /** @@ -388,9 +398,22 @@ export function parseWorkloadsInitial(value: JsonValue | undefined): Workload[] return workloads } -/** True for an engine fronted by a broker-supervised reverse proxy. */ +/** + * True for an engine fronted by a broker-supervised reverse proxy. + * + * Derived from {@link PROXY_ENGINES} rather than re-listing the members: this + * predicate and that list ARE the same fact, and when they were written twice + * they drifted. MLX was added to the type and to the list but not here, so + * `isProxyEngine('mlx')` answered false while `ProxyEngine` included it. A type + * guard's body is unchecked by definition -- the signature claims to decide + * membership, so TypeScript cannot notice the omission -- and the cost was a + * peer's MLX status never being pushed to the renderer + * ({@link emitRemoteEngineStatus} returns early for a non-proxy engine), leaving + * the card stuck on the `initializing` placeholder while Ollama and LM Studio, + * which the list happened to name, rendered correctly. + */ export function isProxyEngine(engine: EngineType): engine is ProxyEngine { - return engine === 'ollama' || engine === 'lm-studio' + return (PROXY_ENGINES as readonly EngineType[]).includes(engine) } const PENDING_OP_IDLE_TIMEOUT_MS = 90_000 @@ -728,7 +751,7 @@ function parseProxyNode(params: JsonValue | undefined, engine: ProxyEngine): Mod } return { id, - sources: [engine === 'ollama' ? 'ollama-proxy' : 'lmstudio-proxy'], + sources: [PROXY_ENGINE_SOURCE[engine]], // `Node.Host` is the hostname; empty for the self-bridge manual node, // in which case the broker discovery entry supplies the display name on // merge (see mergeNode). Never fall back to the UUID id here. @@ -899,8 +922,11 @@ class ModularBridgeState { private logs: LogEntry[] = [] // Per-engine bound proxy port reported by the broker. 0 = not reported yet; // we never fabricate a default — an unknown port surfaces as null, not a - // guess. `ollama` is the `ollama-proxy`, `lm-studio` is the `lmstudio-proxy`. - private proxyPorts: Record = { ollama: 0, 'lm-studio': 0 } + // guess. `ollama` is the `ollama-proxy`, `lm-studio` is the `lmstudio-proxy`, + // `mlx` is the `mlx-proxy`. Never assume a default here for MLX in + // particular: its :8080 is contended often enough that the proxy commonly + // ends up somewhere else entirely (see mlxProxyFallbackStart in the broker). + private proxyPorts: Record = { ollama: 0, 'lm-studio': 0, mlx: 0 } private selfId: string | null = null /** * Authoritative local-engine facts from `nvpair-engine-manager`, keyed by @@ -1361,6 +1387,16 @@ class ModularBridgeState { } } + /** + * Whether `nvpair-engine-manager` reports this engine installed on THIS + * node. False when no fact has arrived yet, which is the honest answer: + * callers use it to decide whether to do optional work, and doing that work + * for an engine that may not exist is the thing being avoided. + */ + isEngineInstalledLocally(engine: EngineType): boolean { + return this.engineManagerFacts.get(engine)?.installed ?? false + } + /** * Record a `nvpair-engine-manager` `engine:state-changed` (or hydrated * `engine:get-installed` entry). Facts are kept regardless of whether the @@ -2332,7 +2368,7 @@ class ModularBridgeState { if (notification.method === 'node/discovered' || notification.method === 'node/updated') { const node = parseProxyNode(notification.params, engine) if (!node) return - this.upsertNode(node, engine === 'ollama' ? 'ollama-proxy' : 'lmstudio-proxy') + this.upsertNode(node, PROXY_ENGINE_SOURCE[engine]) } } @@ -2344,7 +2380,7 @@ class ModularBridgeState { private clearNodeEngine(nodeId: string, engine: ProxyEngine): void { const existing = this.nodes.get(nodeId) if (!existing) return - const source: BrokerNodeSource = engine === 'ollama' ? 'ollama-proxy' : 'lmstudio-proxy' + const source: BrokerNodeSource = PROXY_ENGINE_SOURCE[engine] const sources = removeSource(existing.sources, source) if (sources.length === 0 && !existing.nodeInfoUp) { this.removeNodeEntry(nodeId) diff --git a/desktop/src/electron/service-bridge/modular-supervisor.ts b/desktop/src/electron/service-bridge/modular-supervisor.ts index 943d0f84..e9de4d63 100644 --- a/desktop/src/electron/service-bridge/modular-supervisor.ts +++ b/desktop/src/electron/service-bridge/modular-supervisor.ts @@ -14,6 +14,7 @@ import { } from './json-rpc-subprocess' import { createStructuredLogger } from '@/shared/utils/log' import getErrorString from '@/shared/utils/get-error-string' +import { deleteModelAction } from '@/electron/service-bridge/model-delete-action' import { currentPlatform } from '@/shared/utils/platform' import { getModularBridgeState, @@ -201,17 +202,20 @@ function normalizeLogLevel(value: string | undefined): ModularLogLevel { } /** - * Shape the `pull_model` action params per engine. Ollama's `pull_model` body is - * sent verbatim to `/api/pull` (reads `name`); LM Studio's CLI action templates - * `{model}` into `lms get {model} --yes`. Sending the wrong key leaves the - * placeholder unresolved and the engine-manager rejects the call. + * Engines whose model actions are CLI commands that template `{model}`, rather + * than an HTTP body sent verbatim. LM Studio runs `lms get {model} --yes`; MLX + * runs `hf download {model}` and `hf cache rm -y {model}`. Ollama posts its + * params straight to `/api/pull`, which reads `name`. Sending the wrong key + * leaves the placeholder unresolved and the engine-manager rejects the call. */ +const MODEL_KEY_ENGINES = new Set(['lmstudio', 'mlx']) + function pullModelParams(engineManagerEngine: string, model: string): JsonObject { - return engineManagerEngine === 'lmstudio' ? { model } : { name: model } + return MODEL_KEY_ENGINES.has(engineManagerEngine) ? { model } : { name: model } } function deleteModelParams(engineManagerEngine: string, model: string): JsonObject { - return engineManagerEngine === 'lmstudio' ? { model } : { name: model } + return MODEL_KEY_ENGINES.has(engineManagerEngine) ? { model } : { name: model } } /** @@ -299,12 +303,33 @@ function engineManagerId(engine: ProxyEngine): string { function proxyEngineFromManagerId(id: string): ProxyEngine | null { if (id === 'ollama') return 'ollama' if (id === 'lmstudio') return 'lm-studio' + if (id === 'mlx') return 'mlx' return null } -/** The broker relay namespace fronting an engine's reverse proxy. */ +/** + * The broker relay namespace fronting an engine's reverse proxy, which doubles + * as the notification `source` for frames from that proxy. A Record rather than + * a conditional: `JsonRpcNotification.source` is a bare `string`, so a two-way + * ternary here mislabelled every non-Ollama frame as LM Studio's with nothing + * to catch it. Adding an engine is now a compile error until it is answered. + */ +const PROXY_RELAY_PREFIX: Record = { + ollama: 'proxy', + 'lm-studio': 'lmstudio-proxy', + mlx: 'mlx-proxy' +} + function proxyRelayPrefix(engine: ProxyEngine): string { - return engine === 'ollama' ? 'proxy' : 'lmstudio-proxy' + return PROXY_RELAY_PREFIX[engine] +} + +/** Inverse of {@link PROXY_RELAY_PREFIX}: a notification source to its engine. */ +function proxyEngineFromSource(source: string): ProxyEngine | null { + for (const engine of PROXY_ENGINES) { + if (PROXY_RELAY_PREFIX[engine] === source) return engine + } + return null } /** @@ -827,6 +852,7 @@ class ModularSupervisor { passPath('--node-info-path', 'node-info') passPath('--proxy-path', 'proxy') passPath('--lmstudio-proxy-path', 'lmstudio-proxy') + passPath('--mlx-proxy-path', 'mlx-proxy') passPath('--workload-manager-path', 'workload-manager') passPath('--cluster-manager-path', 'cluster-manager') passPath('--settings-path', 'node-settings') @@ -878,6 +904,7 @@ class ModularSupervisor { await subscribe('discovery:subscribe', 'subscribe to broker discovery') await subscribe('proxy:subscribe', 'subscribe to broker ollama-proxy relay') await subscribe('lmstudio-proxy:subscribe', 'subscribe to broker lmstudio-proxy relay') + await subscribe('mlx-proxy:subscribe', 'subscribe to broker mlx-proxy relay') // Engine events are opt-in and replay no baseline — subscribe then hydrate. await subscribe('engine:subscribe', 'subscribe to broker engine relay') await subscribe('workloads:subscribe', 'subscribe to broker workloads stream') @@ -1079,7 +1106,7 @@ class ModularSupervisor { const obj = objectValue(result) if (obj && booleanValue(obj.ready)) { getModularBridgeState().handleNotification({ - source: engine === 'ollama' ? 'proxy' : 'lmstudio-proxy', + source: proxyRelayPrefix(engine), method: 'ready', params: { port: numberValue(obj.port) } }) @@ -1100,7 +1127,7 @@ class ModularSupervisor { if (!obj || !Array.isArray(obj.nodes)) return for (const node of obj.nodes) { getModularBridgeState().handleNotification({ - source: engine === 'ollama' ? 'proxy' : 'lmstudio-proxy', + source: proxyRelayPrefix(engine), method: 'node/discovered', params: node }) @@ -1266,12 +1293,7 @@ class ModularSupervisor { this.scheduleRemoteEngineStatusRefresh() } - const proxyEngine: ProxyEngine | null = - event.source === 'proxy' - ? 'ollama' - : event.source === 'lmstudio-proxy' - ? 'lm-studio' - : null + const proxyEngine: ProxyEngine | null = proxyEngineFromSource(event.source) if (proxyEngine && event.method === 'ready') { // A (re)bound proxy starts with an empty manual-node set, so forget // what we think we bridged and re-push the local node if applicable. @@ -1316,14 +1338,19 @@ class ModularSupervisor { this.readinessWaiters.clear() } - /** Rewrite broker `proxy:`/`lmstudio-proxy:` relay frames into proxy-source events. */ + /** Rewrite broker `proxy:`/`lmstudio-proxy:`/`mlx-proxy:` relay frames into proxy-source events. */ private normalizeBrokerProxy(notification: JsonRpcNotification): JsonRpcNotification { if (notification.source !== 'broker') return notification - if (notification.method.startsWith('lmstudio-proxy:')) { - return { - source: 'lmstudio-proxy', - method: notification.method.slice('lmstudio-proxy:'.length), - params: notification.params + // The engine-specific prefixes are tested before the bare `proxy:` one. + // They do not actually overlap, but keeping each namespace explicit is + // what makes a new one visible here rather than silently falling through. + for (const prefix of ['lmstudio-proxy:', 'mlx-proxy:']) { + if (notification.method.startsWith(prefix)) { + return { + source: prefix.slice(0, -1), + method: notification.method.slice(prefix.length), + params: notification.params + } } } if (notification.method.startsWith('proxy:')) { @@ -1736,7 +1763,11 @@ class ModularSupervisor { await this.callProcess( 'broker', 'engine:action', - { engine, action: 'delete_model', params: deleteModelParams(engine, model) }, + { + engine, + action: deleteModelAction(engine, model), + params: deleteModelParams(engine, model) + }, MODULAR_MODEL_ACTION_TIMEOUT_MS ) } catch (err) { @@ -1760,6 +1791,82 @@ class ModularSupervisor { await this.refreshEngineModels(engine, engineType) } + /** + * Copy a model from a peer that already holds it INTO this node, over the + * cluster's pinned mTLS rather than from the Hub. + * + * The distinction from {@link pullModelRemote} is which machine downloads: + * that one tells a peer to fetch from the internet, this one moves bytes + * that are already on the LAN. It is the only variant that works with no + * internet at all, and on a gigabit link it is far faster than the Hub. + */ + async copyModelFromPeer( + sourceNodeId: string, + engineType: EngineType, + model: string + ): Promise { + const state = getModularBridgeState() + const selfId = state.getSelfId() + if (!selfId) return + if (state.isRemoteModelPullActive(selfId, engineType, model)) return + state.beginRemoteModelPull(selfId, engineType, model) + try { + await this.callProcess( + 'broker', + 'engine:copy-model-from', + { node: sourceNodeId, model }, + PULL_TIMEOUT_MS + ) + } catch (err) { + this.reportError( + `Could not copy ${model} from that node: ${getErrorString(err)}`, + 'error', + `engine-copy:${sourceNodeId}:${model}`, + { engineType, nodeId: selfId, operation: 'pull' } + ) + } finally { + state.finishRemoteModelPull(selfId, engineType, model) + this.emitStateRefreshIfHydrated() + } + } + + /** + * Tell `nodeId` to copy `model` from `sourceNodeId`. The mirror of + * {@link copyModelFromPeer}: neither machine is the one running the UI, so + * from one laptop you can hand a model to another without either of them + * contacting the Hub. + */ + async copyModelToRemote( + nodeId: string, + sourceNodeId: string, + engine: string, + engineType: EngineType, + model: string + ): Promise { + const state = getModularBridgeState() + if (state.isRemoteModelPullActive(nodeId, engineType, model)) return + state.beginRemoteModelPull(nodeId, engineType, model) + try { + await this.callProcess( + 'broker', + 'engine:remote-copy-model', + { node: nodeId, sourceNode: sourceNodeId, engine, model }, + PULL_TIMEOUT_MS + ) + await this.refreshRemoteEngineStatus(nodeId) + } catch (err) { + this.reportError( + `Could not copy ${model} to that node: ${getErrorString(err)}`, + 'error', + `engine-remote-copy:${nodeId}:${model}`, + { engineType, nodeId, operation: 'pull' } + ) + } finally { + state.finishRemoteModelPull(nodeId, engineType, model) + this.emitStateRefreshIfHydrated() + } + } + /** * Download a model on a remote peer via `engine:remote-pull-model` * (`nvpair-engine-manager` ec surface). Unlike a local pull, the backend emits diff --git a/desktop/src/shared/constants/engines.ts b/desktop/src/shared/constants/engines.ts index 3a1472c8..d5b7cd6f 100644 --- a/desktop/src/shared/constants/engines.ts +++ b/desktop/src/shared/constants/engines.ts @@ -9,23 +9,30 @@ import { EngineType, ModelExpiry } from '@/shared/types/engines' // never-enabled placeholders; they were removed with the chat window, which was // their only in-app consumer. Adding an engine back means shipping its manifest // first -- an engine row without one renders commands that fail with `-32000`. -export const EngineTypes = ['ollama', 'lm-studio'] as const +export const EngineTypes = ['ollama', 'lm-studio', 'mlx'] as const // Kept as a distinct export so a future engine can ship behind it rather than // appearing the moment its type exists. -export const EnabledEngineTypes: EngineType[] = ['ollama', 'lm-studio'] as const +export const EnabledEngineTypes: EngineType[] = ['ollama', 'lm-studio', 'mlx'] as const export const EngineSources = ['bundled', 'detected', 'installed'] as const export const EngineDisplayNames: Record = { ollama: 'Ollama', - 'lm-studio': 'LM Studio' + 'lm-studio': 'LM Studio', + mlx: 'MLX' } as const /** Default docs/install URLs for built-in backends. Single source of truth for UI and adapter buildInfo(). */ export const EngineDefaultLinks: Record = { ollama: { docsUrl: 'https://docs.ollama.com/', installUrl: 'https://ollama.com/download' }, - 'lm-studio': { docsUrl: 'https://lmstudio.ai/docs', installUrl: 'https://lmstudio.ai/' } + 'lm-studio': { docsUrl: 'https://lmstudio.ai/docs', installUrl: 'https://lmstudio.ai/' }, + // MLX has no installer to link to: PAIR builds the engine itself, as a + // Python environment (see services/nvpair-engine-manager/manifests/mlx.json). + mlx: { + docsUrl: 'https://github.com/ml-explore/mlx-lm', + installUrl: 'https://github.com/ml-explore/mlx-lm' + } } as const export const ModelItemStatuses = ['idle', 'loading', 'loaded', 'ejecting', 'pulling'] as const diff --git a/desktop/src/shared/types/engine-api.ts b/desktop/src/shared/types/engine-api.ts index 351f5c5b..0bf5e429 100644 --- a/desktop/src/shared/types/engine-api.ts +++ b/desktop/src/shared/types/engine-api.ts @@ -46,6 +46,10 @@ export type EngineCommandType = | 'update' | 'setPorts' | 'pullModel' + // Copy a model this node lacks from a peer that already has it, over the + // LAN rather than from the Hub. Remote-only: it is issued against the node + // that should RECEIVE the model, with sourceNodeId naming the one that has it. + | 'copyModelFrom' | 'loadModel' | 'unloadModel' | 'deleteModel' @@ -57,6 +61,8 @@ export interface EngineCommandPayload { engineType: EngineType nodeId: string model?: string + /** `copyModelFrom` only: the node that already holds `model`. */ + sourceNodeId?: string /** * `setPorts` only. The engine HTTP server port to apply. Omitted when the * server port did not change so the bridge sends only what the user edited. diff --git a/desktop/src/ui/api/engine-api.ts b/desktop/src/ui/api/engine-api.ts index 69fd71a2..237d033c 100644 --- a/desktop/src/ui/api/engine-api.ts +++ b/desktop/src/ui/api/engine-api.ts @@ -39,6 +39,13 @@ export interface IEngineApi { ): void /** Pull (download) a model on a node. */ pullModel(engineType: EngineType, nodeId: string, model: string): void + /** + * Copy a model onto `nodeId` from `sourceNodeId`, which already has it, + * over the cluster's LAN link instead of the Hub. Works with no internet, + * and on a local network moves the bytes far faster than downloading them + * a second time would. + */ + copyModelFrom(engineType: EngineType, nodeId: string, model: string, sourceNodeId: string): void /** Load a model into memory on a node. */ loadModel(engineType: EngineType, nodeId: string, model: string): void /** Unload a model from memory on a node. */ @@ -88,6 +95,14 @@ export function createEngineApi(transport: ServiceTransport): IEngineApi { }), pullModel: (engineType, nodeId, model) => fireCommand(transport, { command: 'pullModel', engineType, nodeId, model }), + copyModelFrom: (engineType, nodeId, model, sourceNodeId) => + fireCommand(transport, { + command: 'copyModelFrom', + engineType, + nodeId, + model, + sourceNodeId + }), loadModel: (engineType, nodeId, model) => fireCommand(transport, { command: 'loadModel', engineType, nodeId, model }), unloadModel: (engineType, nodeId, model) => diff --git a/desktop/src/ui/components/EngineIcon.tsx b/desktop/src/ui/components/EngineIcon.tsx index 4ee81367..f44e4824 100644 --- a/desktop/src/ui/components/EngineIcon.tsx +++ b/desktop/src/ui/components/EngineIcon.tsx @@ -39,5 +39,32 @@ export default function EngineIcon({ type, size = 32 }: { type: EngineType; size ) } + if (type === 'mlx') { + // Drawn inline rather than shipped as an asset: MLX publishes no icon + // for third parties to bundle, and a lettermark states what the engine + // is without borrowing someone's mark. Same white rounded square as the + // other two so the row reads as one set. + return ( +
+ + + + MLX + + +
+ ) + } + return null } diff --git a/desktop/src/ui/components/ModelHub/ModelHubContent.tsx b/desktop/src/ui/components/ModelHub/ModelHubContent.tsx index 061b25f6..7c2f1e8b 100644 --- a/desktop/src/ui/components/ModelHub/ModelHubContent.tsx +++ b/desktop/src/ui/components/ModelHub/ModelHubContent.tsx @@ -12,6 +12,7 @@ import { searchEngineHub } from '@/ui/utils/model-hub-search' import { resolveStoredSort, writeStoredSort } from '@/ui/utils/model-hub-content-storage' import { isHubEntryDownloaded } from '@/ui/utils/match-downloaded-model' import { EngineType } from '@/shared/types/engines' +import { EngineCapabilities } from '@/ui/constants/engine-capabilities' import type { ModelItem } from '@/ui/types/engine-info' import getErrorString from '@/shared/utils/get-error-string' @@ -106,11 +107,48 @@ export const ModelHubContent = ({ return allModels.filter(m => m.name.toLowerCase().includes(q)) }, [allModels, query]) - const visibleModels = useMemo(() => { + const catalogueModels = useMemo(() => { if (!engine || !downloadedModels || downloadedModels.length === 0) return queryFiltered return queryFiltered.filter(m => !isHubEntryDownloaded(engine, m, downloadedModels)) }, [engine, queryFiltered, downloadedModels]) + /** + * The identifier the user typed, offered as a row when the catalogue cannot + * offer it. + * + * MLX has no catalogue at all -- `getEngineHubModels` returns `[]` for it -- + * so without this the modal is permanently "No models found" and there is no + * way to add anything. A locally built model (a quantization) has no repo id + * either, and can only ever be named by its path. + * + * Deliberately not validated here beyond containing a `/`, which separates a + * repo id or a path from a stray word. Whether the thing exists is the + * engine's question, and it answers it properly: `pull_model` checks a path + * really is a servable model directory before recording it, and reports a + * specific error if not. Guessing in the renderer would only duplicate that + * check and disagree with it. + */ + const typedEntry = useMemo(() => { + if (!engine || !EngineCapabilities[engine]?.acceptsTypedModelId) return null + const typed = query.trim() + if (!typed || !typed.includes('/')) return null + if (allModels.some(m => m.name === typed)) return null + const entry: ModelEntry = { + id: typed, + name: typed, + author: '', + url: '', + updatedAt: new Date(0) + } + if (downloadedModels && isHubEntryDownloaded(engine, entry, downloadedModels)) return null + return entry + }, [engine, query, allModels, downloadedModels]) + + const visibleModels = useMemo( + () => (typedEntry ? [typedEntry, ...catalogueModels] : catalogueModels), + [typedEntry, catalogueModels] + ) + const handleSortPersist = useCallback( (next: SortState) => { setSort(next) diff --git a/desktop/src/ui/components/ModelManager/ModelManager.tsx b/desktop/src/ui/components/ModelManager/ModelManager.tsx index e63dacf2..ae38d4f7 100644 --- a/desktop/src/ui/components/ModelManager/ModelManager.tsx +++ b/desktop/src/ui/components/ModelManager/ModelManager.tsx @@ -15,6 +15,9 @@ import { usePendingActionsStore } from '@/ui/stores/pending-actions.store' import { isEnginePullInProgress } from '@/shared/utils/engine-progress' import ModelRow from './ModelRow' +import { PeerModelRow } from './PeerModelRow' +import { useEngineModelsStore } from '@/ui/stores/engine-models.store' +import { useNodesStore } from '@/ui/stores/nodes.store' import { IncomingSyncPullRow } from './IncomingSyncPullRow' import { TransientModelStatusRow } from './TransientModelStatusRow' import type { IncomingSyncRow } from '@/ui/types/model-manager' @@ -22,6 +25,30 @@ import type { ModelEntry } from '@/ui/types/model-hub' export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId: string }) { const [openModelHubModal, setOpenModelHubModal] = useState(false) + const engineModels = useEngineModelsStore(s => s.models) + const nodes = useNodesStore(s => s.nodes) + + // Models a PEER holds for this engine that this node does not. Offering + // them here is what turns "laptop 2 has nothing" into one click, instead of + // downloading gigabytes a second time over a link that already carried them. + const peerOffers = useMemo(() => { + const mine = new Set((backend.models ?? []).map(m => m.name)) + const offers: Array<{ model: string; sourceNodeId: string; sourceNodeName: string }> = [] + const seen = new Set() + for (const entry of engineModels.values()) { + if (entry.engineType !== backend.type || entry.nodeId === nodeId) continue + for (const m of entry.models) { + if (mine.has(m.name) || seen.has(m.name)) continue + seen.add(m.name) + offers.push({ + model: m.name, + sourceNodeId: entry.nodeId, + sourceNodeName: nodes.get(entry.nodeId)?.name ?? entry.nodeId + }) + } + } + return offers.sort((a, b) => a.model.localeCompare(b.model)) + }, [engineModels, nodes, backend.models, backend.type, nodeId]) const [modelPendingDelete, setModelPendingDelete] = useState(null) const models = (backend.models ?? []).sort((a, b) => formatModelDisplayName(a.name, backend.type).localeCompare( @@ -250,6 +277,40 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId onConfirm={handleConfirmDelete} /> + {peerOffers.length > 0 && ( + + + Available from another node + + {peerOffers.map(offer => ( + + pr.engineType === backend.type && + pr.model === offer.model + ) + )} + onCopy={(model, sourceNodeId) => + window.pairApi.engines.copyModelFrom( + backend.type, + nodeId, + model, + sourceNodeId + ) + } + /> + ))} + + )} {supportsSearch && ( + + ) +} diff --git a/desktop/src/ui/constants/engine-capabilities.ts b/desktop/src/ui/constants/engine-capabilities.ts index 77e6f2bf..7f5b1fc1 100644 --- a/desktop/src/ui/constants/engine-capabilities.ts +++ b/desktop/src/ui/constants/engine-capabilities.ts @@ -43,5 +43,34 @@ export const EngineCapabilities: Record = { // server. Deleting therefore interrupts inference and needs a warning. restartsOnModelDelete: true, engineHub: { label: 'LM Studio', url: 'https://lmstudio.ai/models' } + }, + mlx: { + hasExpiry: false, + // mlx-lm still has no unload of its own -- a model leaves memory only + // when the process ends. mlx-pool makes that actionable: it runs one + // model per child process, so ending the child releases the weights. + // Eject is therefore backed by something real, and is refused (409) + // while the model is serving a request rather than cutting it off. + hasEject: true, + // Apple Silicon only. MLX is a Metal framework; there is no MLX on + // Windows or Linux to install. + hasInstall: ['darwin'], + hasEnginePort: true, + hasInstallPath: false, + hasProxyWebUI: false, + hasPreferredNode: false, + hasCrashAlert: false, + hasModelSearchOnlyWhenRunning: true, + modelOpsWhenStopped: false, + hasDeleteModel: true, + // There is no MLX catalogue to list, so the hub search returns nothing + // and the only way to add a model is to name it: a Hugging Face repo id + // to download, or an absolute path to a model directory built locally + // (a quantization has no repo id and is invisible to any catalogue). + // The engine validates the path before recording it. + acceptsTypedModelId: true, + // No restart on delete, unlike LM Studio: mlx-lm rescans the Hugging + // Face cache on every /v1/models, so a deletion is visible immediately. + engineHub: { label: 'MLX Community', url: 'https://huggingface.co/mlx-community' } } } diff --git a/desktop/src/ui/constants/welcome.ts b/desktop/src/ui/constants/welcome.ts index 4f9fa04a..839fcf7b 100644 --- a/desktop/src/ui/constants/welcome.ts +++ b/desktop/src/ui/constants/welcome.ts @@ -13,7 +13,12 @@ export const WELCOME_STEP_SUB_HEADINGS = ['', 'You can update later by clicking export const WELCOME_ENGINE_DEFAULT_SELECTED: Record = { ollama: true, - 'lm-studio': true + 'lm-studio': true, + // Off by default: installing MLX builds a Python environment and downloads + // several hundred megabytes, which is not a reasonable thing to do to + // someone who just clicked through a welcome screen. getWelcomeEngineCandidates + // still offers it on macOS; this only decides whether it starts ticked. + mlx: false } export function getWelcomeEngineCandidates(os: PlatformDisplayName): EngineType[] { diff --git a/desktop/src/ui/lib/kaizen-ui-foundations/base-external.css b/desktop/src/ui/lib/kaizen-ui-foundations/base-external.css index e3cdd2b2..5bc70d30 100644 --- a/desktop/src/ui/lib/kaizen-ui-foundations/base-external.css +++ b/desktop/src/ui/lib/kaizen-ui-foundations/base-external.css @@ -22,1461 +22,4 @@ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ -*, -:after, -:before, -::backdrop { - box-sizing: border-box; - border: 0 solid; - margin: 0; - padding: 0; -} -::file-selector-button { - box-sizing: border-box; - border: 0 solid; - margin: 0; - padding: 0; -} -html, -:host { - -webkit-text-size-adjust: 100%; - tab-size: 4; - line-height: 1.5; - font-family: var( - --font-sans, - ui-sans-serif, - system-ui, - sans-serif, - 'Apple Color Emoji', - 'Segoe UI Emoji', - 'Segoe UI Symbol', - 'Noto Color Emoji' - ); - -webkit-tap-highlight-color: transparent; -} -hr { - height: 0; - color: inherit; - border-top-width: 1px; -} -abbr:where([title]) { - -webkit-text-decoration: underline dotted; - text-decoration: underline dotted; -} -h1, -h2, -h3, -h4, -h5, -h6 { - font-size: inherit; - font-weight: inherit; -} -a { - color: inherit; - -webkit-text-decoration: inherit; - -webkit-text-decoration: inherit; - text-decoration: inherit; -} -b, -strong { - font-weight: bolder; -} -code, -kbd, -samp, -pre { - font-family: var( - --font-mono, - ui-monospace, - SFMono-Regular, - Menlo, - Monaco, - Consolas, - 'Liberation Mono', - 'Courier New', - monospace - ); -} -small { - font-size: 80%; -} -sub, -sup { - vertical-align: baseline; - font-size: 75%; - line-height: 0; - position: relative; -} -sub { - bottom: -0.25em; -} -sup { - top: -0.5em; -} -table { - text-indent: 0; - border-color: inherit; - border-collapse: collapse; -} -:-moz-focusring { - outline: auto; -} -progress { - vertical-align: baseline; -} -summary { - display: list-item; -} -ol, -ul, -menu { - list-style: none; -} -img, -svg, -video, -canvas, -audio, -iframe, -embed, -object { - vertical-align: middle; - display: block; -} -img, -video { - max-width: 100%; - height: auto; -} -button, -input, -select, -optgroup, -textarea { - font: inherit; - font-feature-settings: inherit; - font-variation-settings: inherit; - letter-spacing: inherit; - color: inherit; - opacity: 1; - background-color: #0000; - border-radius: 0; -} -::file-selector-button { - font: inherit; - font-feature-settings: inherit; - font-variation-settings: inherit; - letter-spacing: inherit; - color: inherit; - opacity: 1; - background-color: #0000; - border-radius: 0; -} -:where(select:is([multiple], [size])) optgroup { - font-weight: bolder; -} -:where(select:is([multiple], [size])) optgroup option { - padding-inline-start: 20px; -} -::file-selector-button { - margin-inline-end: 4px; -} -::placeholder { - opacity: 1; - color: var(--text-color-placeholder); -} -@supports (not ((-webkit-appearance: -apple-pay-button))) or (contain-intrinsic-size: 1px) { - ::placeholder { - color: var(--text-color-placeholder, currentcolor); - } - @supports (color: color-mix(in lab, red, red)) { - ::placeholder { - color: var( - --text-color-placeholder, - color-mix(in oklab, currentcolor 50%, transparent) - ); - } - } -} -textarea { - resize: vertical; -} -textarea, -input { - font: inherit; - letter-spacing: inherit; - word-spacing: inherit; -} -::-webkit-search-decoration { - -webkit-appearance: none; - display: none; -} -::-webkit-search-cancel-button { - -webkit-appearance: none; - display: none; -} -::-webkit-date-and-time-value { - min-height: 1lh; - text-align: inherit; -} -::-webkit-datetime-edit { - display: inline-flex; -} -::-webkit-datetime-edit-fields-wrapper { - padding: 0; -} -::-webkit-datetime-edit { - padding-block: 0; -} -::-webkit-datetime-edit-year-field { - padding-block: 0; -} -::-webkit-datetime-edit-month-field { - padding-block: 0; -} -::-webkit-datetime-edit-day-field { - padding-block: 0; -} -::-webkit-datetime-edit-hour-field { - padding-block: 0; -} -::-webkit-datetime-edit-minute-field { - padding-block: 0; -} -::-webkit-datetime-edit-second-field { - padding-block: 0; -} -::-webkit-datetime-edit-millisecond-field { - padding-block: 0; -} -::-webkit-datetime-edit-meridiem-field { - padding-block: 0; -} -:-moz-ui-invalid { - box-shadow: none; -} -button, -input:where([type='button'], [type='reset'], [type='submit']) { - appearance: button; -} -::file-selector-button { - appearance: button; -} -::-webkit-inner-spin-button { - height: auto; -} -::-webkit-outer-spin-button { - height: auto; -} -[hidden]:where(:not([hidden='until-found'])) { - display: none !important; -} -@supports (interpolate-size: allow-keywords) { - :root { - interpolate-size: allow-keywords; - } -} -.nv-motion-disabled [class*='nv-']:not([class*='nv-spinner-root']) { - transition-duration: 0s !important; - transition-delay: 0s !important; - animation-duration: 0s !important; - animation-delay: 0s !important; -} -@keyframes pulse { - 50% { - opacity: 0.5; - } -} -@keyframes spin { - to { - transform: rotate(360deg); - } -} -@keyframes modal-in { - 0% { - opacity: 0; - } - to { - opacity: 1; - } -} -@keyframes modal-out { - 0% { - opacity: 1; - } - to { - opacity: 0; - } -} -@keyframes accordion-out { - 0% { - height: 0; - padding-block: 0; - overflow: hidden; - } - 99% { - overflow: hidden; - } - to { - height: var( - --nv-accordion-content-height, - var(--radix-accordion-content-height, var(--radix-collapsible-content-height)) - ); - overflow: visible; - } -} -@keyframes accordion-in { - 0% { - height: var( - --nv-accordion-content-height, - var(--radix-accordion-content-height, var(--radix-collapsible-content-height)) - ); - overflow: hidden; - } - to { - height: 0; - padding-block: 0; - overflow: hidden; - } -} -@keyframes nv-popover-in { - 0% { - opacity: 0; - translate: var(--nv-popover-translate-start); - } - to { - opacity: 1; - translate: 0; - } -} -@keyframes nv-tooltip-in { - 0% { - opacity: 0; - translate: var(--nv-tooltip-translate-start); - } - to { - opacity: 1; - translate: 0; - } -} -@font-face { - font-family: NVIDIA Sans Fallback; - font-style: normal; - font-weight: 300; - ascent-override: 93.59%; - descent-override: 26.74%; - line-gap-override: 0%; - size-adjust: 104.71%; - src: - local(Arial), local(Inter), local(Helvetica), local(DejaVu Sans), local(Liberation Sans), - local(Noto Sans), local(Ubuntu), local(FreeSans), local('sans-serif'); -} -@font-face { - font-family: NVIDIA Sans Fallback; - font-style: italic; - font-weight: 300; - ascent-override: 97.18%; - descent-override: 27.77%; - line-gap-override: 0%; - size-adjust: 100.84%; - src: - local(Arial Italic), local(Inter Italic), local(Helvetica Oblique), - local(DejaVu Sans Oblique), local(Liberation Sans Italic), local(Noto Sans Italic), - local(Ubuntu Italic), local(FreeSans Oblique), local('sans-serif'); -} -@font-face { - font-family: NVIDIA Sans Fallback; - font-style: normal; - font-weight: 400; - ascent-override: 92.7%; - descent-override: 26.49%; - line-gap-override: 0%; - size-adjust: 105.71%; - src: - local(Arial), local(Inter), local(Helvetica), local(DejaVu Sans), local(Liberation Sans), - local(Noto Sans), local(Ubuntu), local(FreeSans), local('sans-serif'); -} -@font-face { - font-family: NVIDIA Sans Fallback; - font-style: italic; - font-weight: 400; - ascent-override: 96.18%; - descent-override: 27.48%; - line-gap-override: 0%; - size-adjust: 101.89%; - src: - local(Arial Italic), local(Inter Italic), local(Helvetica Oblique), - local(DejaVu Sans Oblique), local(Liberation Sans Italic), local(Noto Sans Italic), - local(Ubuntu Italic), local(FreeSans Oblique), local('sans-serif'); -} -@font-face { - font-family: NVIDIA Sans Fallback; - font-style: normal; - font-weight: 500; - ascent-override: 91.53%; - descent-override: 26.15%; - line-gap-override: 0%; - size-adjust: 107.07%; - src: - local(Arial), local(Inter), local(Helvetica), local(DejaVu Sans), local(Liberation Sans), - local(Noto Sans), local(Ubuntu), local(FreeSans), local('sans-serif'); -} -@font-face { - font-family: NVIDIA Sans Fallback; - font-style: italic; - font-weight: 500; - ascent-override: 94.81%; - descent-override: 27.09%; - line-gap-override: 0%; - size-adjust: 103.36%; - src: - local(Arial Italic), local(Inter Italic), local(Helvetica Oblique), - local(DejaVu Sans Oblique), local(Liberation Sans Italic), local(Noto Sans Italic), - local(Ubuntu Italic), local(FreeSans Oblique), local('sans-serif'); -} -@font-face { - font-family: NVIDIA Sans Fallback; - font-style: normal; - font-weight: 700; - ascent-override: 97.74%; - descent-override: 27.93%; - line-gap-override: 0%; - size-adjust: 100.26%; - src: - local(Arial Bold), local(Inter Bold), local(Helvetica Bold), local(DejaVu Sans Bold), - local(Liberation Sans Bold), local(Noto Sans Bold), local(Ubuntu Bold), - local(FreeSans Bold), local('sans-serif'); -} -@font-face { - font-family: NVIDIA Sans Fallback; - font-style: italic; - font-weight: 700; - ascent-override: 101.13%; - descent-override: 28.9%; - line-gap-override: 0%; - size-adjust: 96.9%; - src: - local(Arial Bold Italic), local(Inter Bold Italic), local(Helvetica Bold Oblique), - local(DejaVu Sans Bold Oblique), local(Liberation Sans Bold Italic), - local(Noto Sans Bold Italic), local(Ubuntu Bold Italic), local(FreeSans Bold Oblique), - local('sans-serif'); -} -@font-face { - font-family: JetBrains Mono Fallback; - font-style: normal; - font-weight: 300; - ascent-override: 102.02%; - descent-override: 30%; - line-gap-override: 0%; - size-adjust: 99.98%; - src: local(Courier New); -} -@font-face { - font-family: JetBrains Mono Fallback; - font-style: italic; - font-weight: 300; - ascent-override: 102.02%; - descent-override: 30%; - line-gap-override: 0%; - size-adjust: 99.98%; - src: local(Courier New Italic); -} -@font-face { - font-family: JetBrains Mono Fallback; - font-style: normal; - font-weight: 400; - ascent-override: 102.02%; - descent-override: 30%; - line-gap-override: 0%; - size-adjust: 99.98%; - src: local(Courier New); -} -@font-face { - font-family: JetBrains Mono Fallback; - font-style: italic; - font-weight: 400; - ascent-override: 102.02%; - descent-override: 30%; - line-gap-override: 0%; - size-adjust: 99.98%; - src: local(Courier New Italic); -} -@font-face { - font-family: JetBrains Mono Fallback; - font-style: normal; - font-weight: 500; - ascent-override: 102.02%; - descent-override: 30%; - line-gap-override: 0%; - size-adjust: 99.98%; - src: local(Courier New); -} -@font-face { - font-family: JetBrains Mono Fallback; - font-style: italic; - font-weight: 500; - ascent-override: 102.02%; - descent-override: 30%; - line-gap-override: 0%; - size-adjust: 99.98%; - src: local(Courier New Italic); -} -@font-face { - font-family: JetBrains Mono Fallback; - font-style: normal; - font-weight: 700; - ascent-override: 102.02%; - descent-override: 30%; - line-gap-override: 0%; - size-adjust: 99.98%; - src: local(Courier New Bold); -} -@font-face { - font-family: JetBrains Mono Fallback; - font-style: italic; - font-weight: 700; - ascent-override: 102.02%; - descent-override: 30%; - line-gap-override: 0%; - size-adjust: 99.98%; - src: local(Courier New Bold Italic); -} -@font-face { - font-family: missing-ligature-font; - src: local(Arial), local(Verdana), local(Tahoma), local(Trebuchet MS); - size-adjust: 0%; -} -:where(.nv-icon) { - width: 1em; - height: 1em; - display: block; -} -.nv-icon:before { - content: ''; - background-color: currentColor; - width: 100%; - height: 100%; - display: block; -} -.nv-icon-bell:before { - -webkit-mask: var(--nv-icon-bell) no-repeat center; - mask: var(--nv-icon-bell) no-repeat center; - -webkit-mask-size: contain; - mask-size: contain; -} -.nv-icon-calendar:before { - -webkit-mask: var(--nv-icon-calendar) no-repeat center; - mask: var(--nv-icon-calendar) no-repeat center; - -webkit-mask-size: contain; - mask-size: contain; -} -.nv-icon-check:before { - -webkit-mask: var(--nv-icon-check) no-repeat center; - mask: var(--nv-icon-check) no-repeat center; - -webkit-mask-size: contain; - mask-size: contain; -} -.nv-icon-check-circle:before { - -webkit-mask: var(--nv-icon-check-circle) no-repeat center; - mask: var(--nv-icon-check-circle) no-repeat center; - -webkit-mask-size: contain; - mask-size: contain; -} -.nv-icon-clock:before { - -webkit-mask: var(--nv-icon-clock) no-repeat center; - mask: var(--nv-icon-clock) no-repeat center; - -webkit-mask-size: contain; - mask-size: contain; -} -.nv-icon-chevron-double-left:before { - -webkit-mask: var(--nv-icon-chevron-double-left) no-repeat center; - mask: var(--nv-icon-chevron-double-left) no-repeat center; - -webkit-mask-size: contain; - mask-size: contain; -} -.nv-icon-chevron-double-right:before { - -webkit-mask: var(--nv-icon-chevron-double-right) no-repeat center; - mask: var(--nv-icon-chevron-double-right) no-repeat center; - -webkit-mask-size: contain; - mask-size: contain; -} -.nv-icon-chevron-down:before { - -webkit-mask: var(--nv-icon-chevron-down) no-repeat center; - mask: var(--nv-icon-chevron-down) no-repeat center; - -webkit-mask-size: contain; - mask-size: contain; -} -.nv-icon-chevron-left:before { - -webkit-mask: var(--nv-icon-chevron-left) no-repeat center; - mask: var(--nv-icon-chevron-left) no-repeat center; - -webkit-mask-size: contain; - mask-size: contain; -} -.nv-icon-chevron-right:before { - -webkit-mask: var(--nv-icon-chevron-right) no-repeat center; - mask: var(--nv-icon-chevron-right) no-repeat center; - -webkit-mask-size: contain; - mask-size: contain; -} -.nv-icon-chevron-up:before { - -webkit-mask: var(--nv-icon-chevron-up) no-repeat center; - mask: var(--nv-icon-chevron-up) no-repeat center; - -webkit-mask-size: contain; - mask-size: contain; -} -.nv-icon-circle-tick:before { - -webkit-mask: var(--nv-icon-circle-tick) no-repeat center; - mask: var(--nv-icon-circle-tick) no-repeat center; - -webkit-mask-size: contain; - mask-size: contain; -} -.nv-icon-close:before { - -webkit-mask: var(--nv-icon-close) no-repeat center; - mask: var(--nv-icon-close) no-repeat center; - -webkit-mask-size: contain; - mask-size: contain; -} -.nv-icon-error:before { - -webkit-mask: var(--nv-icon-error) no-repeat center; - mask: var(--nv-icon-error) no-repeat center; - -webkit-mask-size: contain; - mask-size: contain; -} -.nv-icon-filter:before { - -webkit-mask: var(--nv-icon-filter) no-repeat center; - mask: var(--nv-icon-filter) no-repeat center; - -webkit-mask-size: contain; - mask-size: contain; -} -.nv-icon-info-circle:before { - -webkit-mask: var(--nv-icon-info-circle) no-repeat center; - mask: var(--nv-icon-info-circle) no-repeat center; - -webkit-mask-size: contain; - mask-size: contain; -} -.nv-icon-menu:before { - -webkit-mask: var(--nv-icon-menu) no-repeat center; - mask: var(--nv-icon-menu) no-repeat center; - -webkit-mask-size: contain; - mask-size: contain; -} -.nv-icon-warning:before { - -webkit-mask: var(--nv-icon-warning) no-repeat center; - mask: var(--nv-icon-warning) no-repeat center; - -webkit-mask-size: contain; - mask-size: contain; -} -.nv-icon-retry:before { - -webkit-mask: var(--nv-icon-retry) no-repeat center; - mask: var(--nv-icon-retry) no-repeat center; - -webkit-mask-size: contain; - mask-size: contain; -} -.nv-icon-document:before { - -webkit-mask: var(--nv-icon-document) no-repeat center; - mask: var(--nv-icon-document) no-repeat center; - -webkit-mask-size: contain; - mask-size: contain; -} -.nv-icon-trash:before { - -webkit-mask: var(--nv-icon-trash) no-repeat center; - mask: var(--nv-icon-trash) no-repeat center; - -webkit-mask-size: contain; - mask-size: contain; -} -.nv-icon-copy-doc:before { - -webkit-mask: var(--nv-icon-copy-doc) no-repeat center; - mask: var(--nv-icon-copy-doc) no-repeat center; - -webkit-mask-size: contain; - mask-size: contain; -} -.nv-icon { - --nv-icon-bell: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='M10.268 21a2 2 0 0 0 3.464 0' /%3e %3cpath d='M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326' /%3e %3c/svg%3e"); - --nv-icon-calendar: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='M8 2v4' /%3e %3cpath d='M16 2v4' /%3e %3crect width='18' height='18' x='3' y='4' rx='2' /%3e %3cpath d='M3 10h18' /%3e %3c/svg%3e"); - --nv-icon-check: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='M20 6 9 17l-5-5' /%3e %3c/svg%3e"); - --nv-icon-check-circle: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='M21.801 10A10 10 0 1 1 17 3.335' /%3e %3cpath d='m9 11 3 3L22 4' /%3e %3c/svg%3e"); - --nv-icon-clock: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='M12 6v6l4 2' /%3e %3ccircle cx='12' cy='12' r='10' /%3e %3c/svg%3e"); - --nv-icon-chevron-double-left: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='m11 17-5-5 5-5' /%3e %3cpath d='m18 17-5-5 5-5' /%3e %3c/svg%3e"); - --nv-icon-chevron-double-right: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='m6 17 5-5-5-5' /%3e %3cpath d='m13 17 5-5-5-5' /%3e %3c/svg%3e"); - --nv-icon-chevron-down: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='m6 9 6 6 6-6' /%3e %3c/svg%3e"); - --nv-icon-chevron-left: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='m15 18-6-6 6-6' /%3e %3c/svg%3e"); - --nv-icon-chevron-right: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='m9 18 6-6-6-6' /%3e %3c/svg%3e"); - --nv-icon-chevron-up: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='m18 15-6-6-6 6' /%3e %3c/svg%3e"); - --nv-icon-circle-tick: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3ccircle cx='12' cy='12' r='10' /%3e %3cpath d='m9 12 2 2 4-4' /%3e %3c/svg%3e"); - --nv-icon-close: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='M18 6 6 18' /%3e %3cpath d='m6 6 12 12' /%3e %3c/svg%3e"); - --nv-icon-error: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3ccircle cx='12' cy='12' r='10' /%3e %3cline x1='12' x2='12' y1='8' y2='12' /%3e %3cline x1='12' x2='12.01' y1='16' y2='16' /%3e %3c/svg%3e"); - --nv-icon-filter: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z' /%3e %3c/svg%3e"); - --nv-icon-info-circle: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3ccircle cx='12' cy='12' r='10' /%3e %3cpath d='M12 16v-4' /%3e %3cpath d='M12 8h.01' /%3e %3c/svg%3e"); - --nv-icon-menu: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='M4 12h16' /%3e %3cpath d='M4 18h16' /%3e %3cpath d='M4 6h16' /%3e %3c/svg%3e"); - --nv-icon-warning: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3' /%3e %3cpath d='M12 9v4' /%3e %3cpath d='M12 17h.01' /%3e %3c/svg%3e"); - --nv-icon-retry: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8' /%3e %3cpath d='M3 3v5h5' /%3e %3c/svg%3e"); - --nv-icon-document: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' /%3e %3cpath d='M14 2v4a2 2 0 0 0 2 2h4' /%3e %3c/svg%3e"); - --nv-icon-trash: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='M10 11v6' /%3e %3cpath d='M14 11v6' /%3e %3cpath d='M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6' /%3e %3cpath d='M3 6h18' /%3e %3cpath d='M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2' /%3e %3c/svg%3e"); - --nv-icon-copy-doc: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3crect width='14' height='14' x='8' y='8' rx='2' ry='2' /%3e %3cpath d='M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2' /%3e %3c/svg%3e"); -} -:root { - --spacing: 4px; - --color-black: #000; - --color-white: #fff; - --color-brand: #76b900; - --color-red-050: #ffe9e9; - --color-red-100: #ffd7d7; - --color-red-200: #fbb; - --color-red-300: #ff8181; - --color-red-400: #fe3f3f; - --color-red-500: #e52020; - --color-red-600: #c21e1e; - --color-red-700: #961515; - --color-red-800: #650b0b; - --color-red-900: #4b0404; - --color-red-950: #2d0100; - --color-yellow-050: #feeeb2; - --color-yellow-100: #fcde7b; - --color-yellow-200: #f9c500; - --color-yellow-300: #ef9100; - --color-yellow-400: #df6500; - --color-yellow-500: #d73d00; - --color-yellow-600: #b93100; - --color-yellow-700: #8d2600; - --color-yellow-800: #601600; - --color-yellow-900: #441000; - --color-yellow-950: #2d0b00; - --color-green-025: #dafb7d; - --color-green-050: #cfff40; - --color-green-100: #bff230; - --color-green-200: #a5de15; - --color-green-300: #76b900; - --color-green-400: #549a00; - --color-green-500: #3f8500; - --color-green-600: #327100; - --color-green-700: #265600; - --color-green-800: #193800; - --color-green-900: #142700; - --color-green-950: #0d1a00; - --color-teal-050: #adfcf8; - --color-teal-100: #9aefe5; - --color-teal-200: #3ae3c9; - --color-teal-300: #1dbba4; - --color-teal-400: #139a86; - --color-teal-500: #0d8473; - --color-teal-600: #097061; - --color-teal-700: #04554b; - --color-teal-800: #033831; - --color-teal-900: #022723; - --color-teal-950: #011a19; - --color-blue-050: #cbf5ff; - --color-blue-100: #ace; - --color-blue-200: #7cd7fe; - --color-blue-300: #10b1fb; - --color-blue-400: #008af9; - --color-blue-500: #0074df; - --color-blue-600: #0060c7; - --color-blue-700: #0046a4; - --color-blue-800: #002781; - --color-blue-900: #002050; - --color-blue-950: #00112c; - --color-purple-050: #fae9ff; - --color-purple-100: #f9d4ff; - --color-purple-200: #f0b9fd; - --color-purple-300: #cd8ef0; - --color-purple-400: #c359ef; - --color-purple-500: #a846db; - --color-purple-600: #952fc6; - --color-purple-700: #741d9d; - --color-purple-800: #4d1368; - --color-purple-900: #331344; - --color-purple-950: #1f0f27; - --color-fuchsia-050: #ffe8f9; - --color-fuchsia-100: #ffd3f2; - --color-fuchsia-200: #feb5ee; - --color-fuchsia-300: #fc79ca; - --color-fuchsia-400: #e050b7; - --color-fuchsia-500: #d2308e; - --color-fuchsia-600: #b62475; - --color-fuchsia-700: #8c1c55; - --color-fuchsia-800: #5d1337; - --color-fuchsia-900: #420d25; - --color-fuchsia-950: #2d0919; - --color-gray-000: #fff; - --color-gray-025: #f7f7f7; - --color-gray-050: #eee; - --color-gray-100: #e0e0e0; - --color-gray-1000: #000; - --color-gray-200: #ccc; - --color-gray-300: #a7a7a7; - --color-gray-400: #898989; - --color-gray-500: #757575; - --color-gray-600: #636363; - --color-gray-700: #4b4b4b; - --color-gray-800: #313131; - --color-gray-900: #222; - --color-gray-950: #161616; - --color-gray-975: #0c0c0c; - --color-translucent-black-000: #0000; - --color-translucent-black-050: #0000000d; - --color-translucent-black-100: #0000001a; - --color-translucent-black-120: #0000001f; - --color-translucent-black-150: #00000026; - --color-translucent-black-200: #0003; - --color-translucent-black-300: #0000004d; - --color-translucent-black-400: #0006; - --color-translucent-black-500: #00000080; - --color-translucent-black-600: #0009; - --color-translucent-black-700: #000000b2; - --color-translucent-black-800: #000c; - --color-translucent-black-900: #000000e5; - --color-translucent-white-000: #fff0; - --color-translucent-white-050: #ffffff0d; - --color-translucent-white-100: #ffffff1a; - --color-translucent-white-120: #ffffff1f; - --color-translucent-white-200: #fff3; - --color-translucent-white-250: #ffffff40; - --color-translucent-white-300: #ffffff4d; - --color-translucent-white-400: #fff6; - --color-translucent-white-500: #ffffff80; - --color-translucent-white-600: #fff9; - --color-translucent-white-700: #ffffffb2; - --color-translucent-white-800: #fffc; - --color-translucent-white-900: #ffffffe5; - --font-sans: - NVIDIA Sans, NVIDIA Sans Fallback, ui-sans-serif, system-ui, sans-serif, - 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - --font-mono: - JetBrains Mono, JetBrains Mono Fallback, ui-monospace, monospace, 'Apple Color Emoji', - 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - --text-10: 0.625rem; - --text-12: 0.75rem; - --text-14: 0.875rem; - --text-16: 1rem; - --text-18: 1.125rem; - --text-20: 1.25rem; - --text-22: 1.375rem; - --text-24: 1.5rem; - --text-28: 1.75rem; - --text-32: 2rem; - --text-36: 2.25rem; - --text-40: 2.5rem; - --text-44: 2.75rem; - --text-48: 3rem; - --text-50: 3.125rem; - --text-56: 3.5rem; - --text-60: 3.75rem; - --text-64: 4rem; - --text-72: 4.5rem; - --text-80: 5rem; - --font-weight-light: 300; - --font-weight-regular: 400; - --font-weight-semibold: 500; - --font-weight-bold: 700; - --leading-lh-100: 1; - --leading-lh-125: 1.25; - --leading-lh-150: 1.5; - --leading-lh-175: 1.75; - --breakpoint-xs: 20rem; - --breakpoint-sm: 36rem; - --breakpoint-md: 48rem; - --breakpoint-lg: 62rem; - --breakpoint-xl: 75rem; - --breakpoint-2xl: 100rem; - --container-3xs: 16rem; - --container-2xs: 18rem; - --container-xs: 20rem; - --container-sm: 24rem; - --container-md: 28rem; - --container-lg: 32rem; - --container-xl: 36rem; - --container-2xl: 42rem; - --container-3xl: 48rem; - --container-4xl: 56rem; - --container-5xl: 64rem; - --container-6xl: 72rem; - --container-7xl: 80rem; - --border-width-none: 0px; - --border-width-1: 1px; - --border-width-2: 2px; - --border-width-3: 3px; - --border-width-4: 4px; - --radius-none: 0px; - --radius-sm: 2px; - --radius-md: 4px; - --radius-lg: 8px; - --radius-xl: 16px; - --radius-2xl: 24px; - --radius-3xl: 32px; - --radius-round: 9999px; - --shadow-lg: 0px 8px 12px 0px var(--color-translucent-black-150); - --shadow-md: 0px 4px 6px 0px var(--color-translucent-black-120); - --shadow-sm: 0px 2px 4px 0px var(--color-translucent-black-120); - --animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; - --animate-spin: spin 1s linear infinite; - --transition-duration-150: 0.15s; - --transition-duration-200: 0.2s; - --transition-duration-250: 0.25s; - --transition-duration-300: 0.3s; - --ease-out: cubic-bezier(0.4, 0, 0.2, 1); - --radius-density-xl: var(--radius-xl); - --spacing-density-xxs: 2px; - --spacing-density-xs: 4px; - --spacing-density-sm: 6px; - --spacing-density-md: 8px; - --spacing-density-lg: 12px; - --spacing-density-xl: 16px; - --spacing-density-2xl: 24px; - --spacing-density-3xl: 32px; - --spacing-density-4xl: 48px; - --spacing-density-5xl: 64px; - --nv-app-bar-height: 48px; - --nv-scrollbar-color: var(--color-gray-400) transparent; -} -:where(:root), -.nv-light, -.light { - --lightningcss-light: initial; - --lightningcss-dark: ; - color-scheme: light; - --background-color-accent-blue: var(--color-blue-050); - --background-color-accent-blue-hover: var(--color-blue-050); - --background-color-accent-blue-selected: var(--color-blue-200); - --background-color-accent-blue-strong: var(--color-blue-700); - --background-color-accent-blue-subtle: var(--color-blue-050); - --background-color-accent-blue-subtle-hover: var(--color-blue-200); - --background-color-accent-blue-subtle-selected: var(--color-blue-600); - --background-color-accent-gray: var(--color-gray-200); - --background-color-accent-gray-hover: var(--color-gray-300); - --background-color-accent-gray-selected: var(--color-gray-700); - --background-color-accent-gray-strong: var(--color-gray-700); - --background-color-accent-gray-subtle: var(--color-gray-100); - --background-color-accent-gray-subtle-hover: var(--color-gray-300); - --background-color-accent-gray-subtle-selected: var(--color-gray-700); - --background-color-accent-green: var(--color-green-025); - --background-color-accent-green-hover: var(--color-green-025); - --background-color-accent-green-selected: var(--color-green-050); - --background-color-accent-green-strong: var(--color-green-800); - --background-color-accent-green-subtle: var(--color-green-100); - --background-color-accent-green-subtle-hover: var(--color-green-200); - --background-color-accent-green-subtle-selected: var(--color-green-600); - --background-color-accent-purple: var(--color-purple-200); - --background-color-accent-purple-hover: var(--color-purple-050); - --background-color-accent-purple-selected: var(--color-purple-100); - --background-color-accent-purple-strong: var(--color-purple-700); - --background-color-accent-purple-subtle: var(--color-purple-050); - --background-color-accent-purple-subtle-hover: var(--color-purple-100); - --background-color-accent-purple-subtle-selected: var(--color-purple-600); - --background-color-accent-red: var(--color-red-100); - --background-color-accent-red-hover: var(--color-red-050); - --background-color-accent-red-selected: var(--color-red-100); - --background-color-accent-red-strong: var(--color-red-700); - --background-color-accent-red-subtle: var(--color-red-100); - --background-color-accent-red-subtle-hover: var(--color-red-200); - --background-color-accent-red-subtle-selected: var(--color-red-600); - --background-color-accent-teal: var(--color-teal-100); - --background-color-accent-teal-hover: var(--color-teal-050); - --background-color-accent-teal-selected: var(--color-teal-100); - --background-color-accent-teal-strong: var(--color-teal-800); - --background-color-accent-teal-subtle: var(--color-teal-100); - --background-color-accent-teal-subtle-hover: var(--color-teal-200); - --background-color-accent-teal-subtle-selected: var(--color-teal-600); - --background-color-accent-yellow: var(--color-yellow-100); - --background-color-accent-yellow-hover: var(--color-yellow-050); - --background-color-accent-yellow-selected: var(--color-yellow-100); - --background-color-accent-yellow-strong: var(--color-yellow-700); - --background-color-accent-yellow-subtle: var(--color-yellow-100); - --background-color-accent-yellow-subtle-hover: var(--color-yellow-200); - --background-color-accent-yellow-subtle-selected: var(--color-yellow-600); - --background-color-background-contrast: var(--color-gray-900); - --background-color-background-default: var(--color-gray-000); - --background-color-background-emphasis: var(--color-gray-050); - --background-color-background-highlight: var(--color-gray-100); - --background-color-background-subtle: var(--color-gray-025); - --background-color-component-skeleton: var(--color-gray-100); - --background-color-component-skeleton-subtle: var(--color-gray-200); - --background-color-component-tooltip: var(--color-gray-800); - --background-color-component-track: var(--color-translucent-black-100); - --background-color-component-track-inverse: var(--color-translucent-black-100); - --background-color-feedback-danger: var(--color-red-300); - --background-color-feedback-danger-hover: var(--color-red-600); - --background-color-feedback-danger-pressed: var(--color-red-700); - --background-color-feedback-danger-strong: var(--color-red-500); - --background-color-feedback-danger-subtle-hover: var(--color-red-100); - --background-color-feedback-danger-subtle-pressed: var(--color-red-200); - --background-color-feedback-info: var(--color-blue-050); - --background-color-feedback-success: var(--color-green-025); - --background-color-feedback-warning: var(--color-yellow-100); - --background-color-interaction-base: var(--color-gray-000); - --background-color-interaction-disabled: var(--color-translucent-black-050); - --background-color-interaction-disabled-checked: var(--color-translucent-black-400); - --background-color-interaction-hover: var(--color-translucent-black-050); - --background-color-interaction-inverse: var(--color-gray-900); - --background-color-interaction-inverse-hover: var(--color-gray-950); - --background-color-interaction-inverse-pressed: var(--color-gray-975); - --background-color-interaction-pressed: var(--color-translucent-black-100); - --background-color-interaction-primary-base: var(--color-green-300); - --background-color-interaction-primary-hover: var(--color-green-400); - --background-color-interaction-primary-selected: var(--color-green-500); - --background-color-interaction-selected: var(--color-gray-000); - --background-color-surface-base: var(--color-gray-000); - --background-color-surface-blanket: var(--color-translucent-black-700); - --background-color-surface-glass: var(--color-translucent-white-800); - --background-color-surface-navigation: var(--color-gray-000); - --background-color-surface-overlay: var(--color-gray-000); - --background-color-surface-raised: var(--color-gray-000); - --background-color-surface-sunken: var(--color-gray-025); - --border-color-accent-black: var(--color-gray-1000); - --border-color-accent-blue: var(--color-blue-600); - --border-color-accent-gray: var(--color-gray-700); - --border-color-accent-green: var(--color-green-700); - --border-color-accent-purple: var(--color-purple-600); - --border-color-accent-red: var(--color-red-600); - --border-color-accent-teal: var(--color-teal-600); - --border-color-accent-white: var(--color-gray-000); - --border-color-accent-yellow: var(--color-yellow-600); - --border-color-base: var(--color-translucent-black-200); - --border-color-brand: var(--color-brand); - --border-color-component-tooltip: var(--color-gray-600); - --border-color-disabled: var(--color-translucent-black-400); - --border-color-feedback-danger: var(--color-red-500); - --border-color-feedback-danger-hover: var(--color-red-600); - --border-color-feedback-danger-strong: var(--color-red-700); - --border-color-feedback-danger-subtle: var(--color-red-500); - --border-color-feedback-info: var(--color-blue-400); - --border-color-feedback-success: var(--color-green-400); - --border-color-feedback-warning: var(--color-yellow-400); - --border-color-interaction-base: var(--color-translucent-black-300); - --border-color-interaction-disabled: var(--color-translucent-black-100); - --border-color-interaction-hover: var(--color-translucent-black-900); - --border-color-interaction-inverse: var(--color-gray-900); - --border-color-interaction-inverse-hover: var(--color-gray-950); - --border-color-interaction-inverse-pressed: var(--color-gray-975); - --border-color-interaction-pressed: var(--color-translucent-black-900); - --border-color-interaction-primary-base: var(--color-green-300); - --border-color-interaction-primary-hover: var(--color-green-400); - --border-color-interaction-primary-selected: var(--color-green-500); - --border-color-interaction-selected: var(--text-color-brand); - --border-color-interaction-strong: var(--color-translucent-black-700); - --nv-current-theme: light; - --text-color-accent-black: var(--color-gray-1000); - --text-color-accent-blue: var(--color-blue-700); - --text-color-accent-blue-strong: var(--color-blue-700); - --text-color-accent-blue-subtle: var(--color-blue-200); - --text-color-accent-gray: var(--color-gray-700); - --text-color-accent-green: var(--color-green-700); - --text-color-accent-green-strong: var(--color-green-700); - --text-color-accent-green-subtle: var(--color-green-200); - --text-color-accent-purple: var(--color-purple-700); - --text-color-accent-purple-strong: var(--color-purple-700); - --text-color-accent-purple-subtle: var(--color-purple-200); - --text-color-accent-red: var(--color-red-700); - --text-color-accent-red-strong: var(--color-red-700); - --text-color-accent-red-subtle: var(--color-red-200); - --text-color-accent-teal: var(--color-teal-700); - --text-color-accent-teal-strong: var(--color-teal-700); - --text-color-accent-teal-subtle: var(--color-teal-200); - --text-color-accent-white: var(--color-gray-000); - --text-color-accent-yellow: var(--color-yellow-700); - --text-color-accent-yellow-strong: var(--color-yellow-700); - --text-color-accent-yellow-subtle: var(--color-yellow-200); - --text-color-base: var(--color-gray-600); - --text-color-brand: var(--color-brand); - --text-color-component-nvidia-logo: var(--color-gray-1000); - --text-color-disabled: var(--color-gray-400); - --text-color-feedback-danger: var(--color-red-500); - --text-color-feedback-danger-inverse: var(--color-gray-900); - --text-color-feedback-danger-strong: var(--color-red-700); - --text-color-feedback-danger-subtle: var(--color-red-600); - --text-color-feedback-info: var(--color-blue-500); - --text-color-feedback-info-inverse: var(--color-gray-900); - --text-color-feedback-success: var(--color-green-500); - --text-color-feedback-success-inverse: var(--color-gray-900); - --text-color-feedback-warning: var(--color-yellow-500); - --text-color-feedback-warning-inverse: var(--color-gray-900); - --text-color-interaction-disabled-checked: var(--color-translucent-black-300); - --text-color-interaction-selected: var(--color-gray-000); - --text-color-inverse: var(--color-gray-000); - --text-color-inverse-brand: var(--color-gray-950); - --text-color-placeholder: var(--color-gray-500); - --text-color-primary: var(--color-gray-1000); - --text-color-secondary: var(--color-gray-600); - --text-color-strong: var(--color-gray-900); - --text-color-subtle: var(--color-gray-400); -} -.dark, -.nv-dark { - --lightningcss-light: ; - --lightningcss-dark: initial; - color-scheme: dark; - --background-color-accent-blue: var(--color-blue-900); - --background-color-accent-blue-hover: var(--color-blue-900); - --background-color-accent-blue-selected: var(--color-blue-800); - --background-color-accent-blue-strong: var(--color-blue-900); - --background-color-accent-blue-subtle: var(--color-blue-900); - --background-color-accent-blue-subtle-hover: var(--color-blue-800); - --background-color-accent-blue-subtle-selected: var(--color-blue-500); - --background-color-accent-gray: var(--color-gray-600); - --background-color-accent-gray-hover: var(--color-gray-1000); - --background-color-accent-gray-selected: var(--color-gray-200); - --background-color-accent-gray-strong: var(--color-gray-700); - --background-color-accent-gray-subtle: var(--color-gray-700); - --background-color-accent-gray-subtle-hover: var(--color-gray-200); - --background-color-accent-gray-subtle-selected: var(--color-gray-600); - --background-color-accent-green: var(--color-green-900); - --background-color-accent-green-hover: var(--color-green-900); - --background-color-accent-green-selected: var(--color-green-800); - --background-color-accent-green-strong: var(--color-green-900); - --background-color-accent-green-subtle: var(--color-green-900); - --background-color-accent-green-subtle-hover: var(--color-green-800); - --background-color-accent-green-subtle-selected: var(--color-green-600); - --background-color-accent-purple: var(--color-purple-900); - --background-color-accent-purple-hover: var(--color-purple-900); - --background-color-accent-purple-selected: var(--color-purple-800); - --background-color-accent-purple-strong: var(--color-purple-900); - --background-color-accent-purple-subtle: var(--color-purple-900); - --background-color-accent-purple-subtle-hover: var(--color-purple-800); - --background-color-accent-purple-subtle-selected: var(--color-purple-700); - --background-color-accent-red: var(--color-red-900); - --background-color-accent-red-hover: var(--color-red-900); - --background-color-accent-red-selected: var(--color-red-800); - --background-color-accent-red-strong: var(--color-red-900); - --background-color-accent-red-subtle: var(--color-red-900); - --background-color-accent-red-subtle-hover: var(--color-red-800); - --background-color-accent-red-subtle-selected: var(--color-red-700); - --background-color-accent-teal: var(--color-teal-900); - --background-color-accent-teal-hover: var(--color-teal-900); - --background-color-accent-teal-selected: var(--color-teal-800); - --background-color-accent-teal-strong: var(--color-teal-900); - --background-color-accent-teal-subtle: var(--color-teal-900); - --background-color-accent-teal-subtle-hover: var(--color-teal-800); - --background-color-accent-teal-subtle-selected: var(--color-teal-500); - --background-color-accent-yellow: var(--color-yellow-900); - --background-color-accent-yellow-hover: var(--color-yellow-900); - --background-color-accent-yellow-selected: var(--color-yellow-800); - --background-color-accent-yellow-strong: var(--color-yellow-900); - --background-color-accent-yellow-subtle: var(--color-yellow-900); - --background-color-accent-yellow-subtle-hover: var(--color-yellow-800); - --background-color-accent-yellow-subtle-selected: var(--color-yellow-600); - --background-color-background-contrast: var(--color-gray-000); - --background-color-background-default: var(--color-gray-900); - --background-color-background-emphasis: var(--color-gray-700); - --background-color-background-highlight: var(--color-gray-600); - --background-color-background-subtle: var(--color-gray-800); - --background-color-component-skeleton: var(--color-gray-800); - --background-color-component-skeleton-subtle: var(--color-gray-900); - --background-color-component-tooltip: var(--color-gray-800); - --background-color-component-track: var(--color-translucent-white-200); - --background-color-component-track-inverse: var(--color-translucent-black-400); - --background-color-feedback-danger: var(--color-red-900); - --background-color-feedback-danger-hover: var(--color-red-600); - --background-color-feedback-danger-pressed: var(--color-red-700); - --background-color-feedback-danger-strong: var(--color-red-500); - --background-color-feedback-danger-subtle-hover: var(--color-red-900); - --background-color-feedback-danger-subtle-pressed: var(--color-red-800); - --background-color-feedback-info: var(--color-blue-950); - --background-color-feedback-success: var(--color-green-950); - --background-color-feedback-warning: var(--color-yellow-950); - --background-color-interaction-base: var(--color-translucent-black-600); - --background-color-interaction-disabled: var(--color-translucent-white-100); - --background-color-interaction-disabled-checked: var(--color-translucent-white-500); - --background-color-interaction-hover: var(--color-translucent-white-100); - --background-color-interaction-inverse: var(--color-gray-000); - --background-color-interaction-inverse-hover: var(--color-gray-100); - --background-color-interaction-inverse-pressed: var(--color-gray-200); - --background-color-interaction-pressed: var(--color-translucent-white-200); - --background-color-interaction-primary-base: var(--color-green-300); - --background-color-interaction-primary-hover: var(--color-green-400); - --background-color-interaction-primary-selected: var(--color-green-500); - --background-color-interaction-selected: var(--color-gray-1000); - --background-color-surface-base: var(--color-gray-1000); - --background-color-surface-blanket: var(--color-translucent-black-700); - --background-color-surface-glass: var(--color-translucent-black-600); - --background-color-surface-navigation: var(--color-gray-1000); - --background-color-surface-overlay: var(--color-gray-900); - --background-color-surface-raised: var(--color-gray-950); - --background-color-surface-sunken: var(--color-gray-975); - --border-color-accent-black: var(--color-gray-1000); - --border-color-accent-blue: var(--color-blue-300); - --border-color-accent-gray: var(--color-gray-300); - --border-color-accent-green: var(--color-green-300); - --border-color-accent-purple: var(--color-purple-300); - --border-color-accent-red: var(--color-red-300); - --border-color-accent-teal: var(--color-teal-300); - --border-color-accent-white: var(--color-gray-000); - --border-color-accent-yellow: var(--color-yellow-300); - --border-color-base: var(--color-translucent-white-200); - --border-color-brand: var(--color-brand); - --border-color-component-tooltip: var(--color-gray-600); - --border-color-disabled: var(--color-translucent-white-300); - --border-color-feedback-danger: var(--color-red-500); - --border-color-feedback-danger-hover: var(--color-red-600); - --border-color-feedback-danger-strong: var(--color-red-700); - --border-color-feedback-danger-subtle: var(--color-red-300); - --border-color-feedback-info: var(--color-blue-400); - --border-color-feedback-success: var(--color-green-400); - --border-color-feedback-warning: var(--color-yellow-200); - --border-color-interaction-base: var(--color-translucent-white-200); - --border-color-interaction-disabled: var(--color-translucent-white-100); - --border-color-interaction-hover: var(--color-translucent-white-400); - --border-color-interaction-inverse: var(--color-gray-000); - --border-color-interaction-inverse-hover: var(--color-gray-100); - --border-color-interaction-inverse-pressed: var(--color-gray-200); - --border-color-interaction-pressed: var(--color-translucent-white-400); - --border-color-interaction-primary-base: var(--color-green-300); - --border-color-interaction-primary-hover: var(--color-green-400); - --border-color-interaction-primary-selected: var(--color-green-500); - --border-color-interaction-selected: var(--text-color-brand); - --border-color-interaction-strong: var(--color-translucent-white-700); - --nv-current-theme: dark; - --text-color-accent-black: var(--color-gray-1000); - --text-color-accent-blue: var(--color-blue-300); - --text-color-accent-blue-strong: var(--color-blue-200); - --text-color-accent-blue-subtle: var(--color-blue-200); - --text-color-accent-gray: var(--color-gray-050); - --text-color-accent-green: var(--color-green-300); - --text-color-accent-green-strong: var(--color-green-200); - --text-color-accent-green-subtle: var(--color-green-200); - --text-color-accent-purple: var(--color-purple-200); - --text-color-accent-purple-strong: var(--color-purple-200); - --text-color-accent-purple-subtle: var(--color-purple-200); - --text-color-accent-red: var(--color-red-300); - --text-color-accent-red-strong: var(--color-red-200); - --text-color-accent-red-subtle: var(--color-red-200); - --text-color-accent-teal: var(--color-teal-300); - --text-color-accent-teal-strong: var(--color-teal-200); - --text-color-accent-teal-subtle: var(--color-teal-200); - --text-color-accent-white: var(--color-gray-000); - --text-color-accent-yellow: var(--color-yellow-300); - --text-color-accent-yellow-strong: var(--color-yellow-200); - --text-color-accent-yellow-subtle: var(--color-yellow-200); - --text-color-base: var(--color-gray-200); - --text-color-brand: var(--color-brand); - --text-color-component-nvidia-logo: var(--color-gray-000); - --text-color-disabled: var(--color-translucent-white-300); - --text-color-feedback-danger: var(--color-red-400); - --text-color-feedback-danger-inverse: var(--color-red-300); - --text-color-feedback-danger-strong: var(--color-red-300); - --text-color-feedback-danger-subtle: var(--color-red-400); - --text-color-feedback-info: var(--color-blue-500); - --text-color-feedback-info-inverse: var(--color-blue-300); - --text-color-feedback-success: var(--color-green-400); - --text-color-feedback-success-inverse: var(--color-green-300); - --text-color-feedback-warning: var(--color-yellow-300); - --text-color-feedback-warning-inverse: var(--color-yellow-200); - --text-color-interaction-disabled-checked: var(--color-translucent-black-600); - --text-color-interaction-selected: var(--color-translucent-white-200); - --text-color-inverse: var(--color-gray-1000); - --text-color-inverse-brand: var(--text-color-brand); - --text-color-placeholder: var(--color-gray-400); - --text-color-primary: var(--color-gray-000); - --text-color-secondary: var(--color-gray-300); - --text-color-strong: var(--color-gray-000); - --text-color-subtle: var(--color-gray-400); -} -@media (prefers-color-scheme: dark) { - :where(:root) { - --lightningcss-light: ; - --lightningcss-dark: initial; - color-scheme: dark; - --background-color-accent-blue: var(--color-blue-900); - --background-color-accent-blue-hover: var(--color-blue-900); - --background-color-accent-blue-selected: var(--color-blue-800); - --background-color-accent-blue-strong: var(--color-blue-900); - --background-color-accent-blue-subtle: var(--color-blue-900); - --background-color-accent-blue-subtle-hover: var(--color-blue-800); - --background-color-accent-blue-subtle-selected: var(--color-blue-500); - --background-color-accent-gray: var(--color-gray-600); - --background-color-accent-gray-hover: var(--color-gray-1000); - --background-color-accent-gray-selected: var(--color-gray-200); - --background-color-accent-gray-strong: var(--color-gray-700); - --background-color-accent-gray-subtle: var(--color-gray-700); - --background-color-accent-gray-subtle-hover: var(--color-gray-200); - --background-color-accent-gray-subtle-selected: var(--color-gray-600); - --background-color-accent-green: var(--color-green-900); - --background-color-accent-green-hover: var(--color-green-900); - --background-color-accent-green-selected: var(--color-green-800); - --background-color-accent-green-strong: var(--color-green-900); - --background-color-accent-green-subtle: var(--color-green-900); - --background-color-accent-green-subtle-hover: var(--color-green-800); - --background-color-accent-green-subtle-selected: var(--color-green-600); - --background-color-accent-purple: var(--color-purple-900); - --background-color-accent-purple-hover: var(--color-purple-900); - --background-color-accent-purple-selected: var(--color-purple-800); - --background-color-accent-purple-strong: var(--color-purple-900); - --background-color-accent-purple-subtle: var(--color-purple-900); - --background-color-accent-purple-subtle-hover: var(--color-purple-800); - --background-color-accent-purple-subtle-selected: var(--color-purple-700); - --background-color-accent-red: var(--color-red-900); - --background-color-accent-red-hover: var(--color-red-900); - --background-color-accent-red-selected: var(--color-red-800); - --background-color-accent-red-strong: var(--color-red-900); - --background-color-accent-red-subtle: var(--color-red-900); - --background-color-accent-red-subtle-hover: var(--color-red-800); - --background-color-accent-red-subtle-selected: var(--color-red-700); - --background-color-accent-teal: var(--color-teal-900); - --background-color-accent-teal-hover: var(--color-teal-900); - --background-color-accent-teal-selected: var(--color-teal-800); - --background-color-accent-teal-strong: var(--color-teal-900); - --background-color-accent-teal-subtle: var(--color-teal-900); - --background-color-accent-teal-subtle-hover: var(--color-teal-800); - --background-color-accent-teal-subtle-selected: var(--color-teal-500); - --background-color-accent-yellow: var(--color-yellow-900); - --background-color-accent-yellow-hover: var(--color-yellow-900); - --background-color-accent-yellow-selected: var(--color-yellow-800); - --background-color-accent-yellow-strong: var(--color-yellow-900); - --background-color-accent-yellow-subtle: var(--color-yellow-900); - --background-color-accent-yellow-subtle-hover: var(--color-yellow-800); - --background-color-accent-yellow-subtle-selected: var(--color-yellow-600); - --background-color-background-contrast: var(--color-gray-000); - --background-color-background-default: var(--color-gray-900); - --background-color-background-emphasis: var(--color-gray-700); - --background-color-background-highlight: var(--color-gray-600); - --background-color-background-subtle: var(--color-gray-800); - --background-color-component-skeleton: var(--color-gray-800); - --background-color-component-skeleton-subtle: var(--color-gray-900); - --background-color-component-tooltip: var(--color-gray-800); - --background-color-component-track: var(--color-translucent-white-200); - --background-color-component-track-inverse: var(--color-translucent-black-400); - --background-color-feedback-danger: var(--color-red-900); - --background-color-feedback-danger-hover: var(--color-red-600); - --background-color-feedback-danger-pressed: var(--color-red-700); - --background-color-feedback-danger-strong: var(--color-red-500); - --background-color-feedback-danger-subtle-hover: var(--color-red-900); - --background-color-feedback-danger-subtle-pressed: var(--color-red-800); - --background-color-feedback-info: var(--color-blue-950); - --background-color-feedback-success: var(--color-green-950); - --background-color-feedback-warning: var(--color-yellow-950); - --background-color-interaction-base: var(--color-translucent-black-600); - --background-color-interaction-disabled: var(--color-translucent-white-100); - --background-color-interaction-disabled-checked: var(--color-translucent-white-500); - --background-color-interaction-hover: var(--color-translucent-white-100); - --background-color-interaction-inverse: var(--color-gray-000); - --background-color-interaction-inverse-hover: var(--color-gray-100); - --background-color-interaction-inverse-pressed: var(--color-gray-200); - --background-color-interaction-pressed: var(--color-translucent-white-200); - --background-color-interaction-primary-base: var(--color-green-300); - --background-color-interaction-primary-hover: var(--color-green-400); - --background-color-interaction-primary-selected: var(--color-green-500); - --background-color-interaction-selected: var(--color-gray-1000); - --background-color-surface-base: var(--color-gray-1000); - --background-color-surface-blanket: var(--color-translucent-black-700); - --background-color-surface-glass: var(--color-translucent-black-600); - --background-color-surface-navigation: var(--color-gray-1000); - --background-color-surface-overlay: var(--color-gray-900); - --background-color-surface-raised: var(--color-gray-950); - --background-color-surface-sunken: var(--color-gray-975); - --border-color-accent-black: var(--color-gray-1000); - --border-color-accent-blue: var(--color-blue-300); - --border-color-accent-gray: var(--color-gray-300); - --border-color-accent-green: var(--color-green-300); - --border-color-accent-purple: var(--color-purple-300); - --border-color-accent-red: var(--color-red-300); - --border-color-accent-teal: var(--color-teal-300); - --border-color-accent-white: var(--color-gray-000); - --border-color-accent-yellow: var(--color-yellow-300); - --border-color-base: var(--color-translucent-white-200); - --border-color-brand: var(--color-brand); - --border-color-component-tooltip: var(--color-gray-600); - --border-color-disabled: var(--color-translucent-white-300); - --border-color-feedback-danger: var(--color-red-500); - --border-color-feedback-danger-hover: var(--color-red-600); - --border-color-feedback-danger-strong: var(--color-red-700); - --border-color-feedback-danger-subtle: var(--color-red-300); - --border-color-feedback-info: var(--color-blue-400); - --border-color-feedback-success: var(--color-green-400); - --border-color-feedback-warning: var(--color-yellow-200); - --border-color-interaction-base: var(--color-translucent-white-200); - --border-color-interaction-disabled: var(--color-translucent-white-100); - --border-color-interaction-hover: var(--color-translucent-white-400); - --border-color-interaction-inverse: var(--color-gray-000); - --border-color-interaction-inverse-hover: var(--color-gray-100); - --border-color-interaction-inverse-pressed: var(--color-gray-200); - --border-color-interaction-pressed: var(--color-translucent-white-400); - --border-color-interaction-primary-base: var(--color-green-300); - --border-color-interaction-primary-hover: var(--color-green-400); - --border-color-interaction-primary-selected: var(--color-green-500); - --border-color-interaction-selected: var(--text-color-brand); - --border-color-interaction-strong: var(--color-translucent-white-700); - --nv-current-theme: dark; - --text-color-accent-black: var(--color-gray-1000); - --text-color-accent-blue: var(--color-blue-300); - --text-color-accent-blue-strong: var(--color-blue-200); - --text-color-accent-blue-subtle: var(--color-blue-200); - --text-color-accent-gray: var(--color-gray-050); - --text-color-accent-green: var(--color-green-300); - --text-color-accent-green-strong: var(--color-green-200); - --text-color-accent-green-subtle: var(--color-green-200); - --text-color-accent-purple: var(--color-purple-200); - --text-color-accent-purple-strong: var(--color-purple-200); - --text-color-accent-purple-subtle: var(--color-purple-200); - --text-color-accent-red: var(--color-red-300); - --text-color-accent-red-strong: var(--color-red-200); - --text-color-accent-red-subtle: var(--color-red-200); - --text-color-accent-teal: var(--color-teal-300); - --text-color-accent-teal-strong: var(--color-teal-200); - --text-color-accent-teal-subtle: var(--color-teal-200); - --text-color-accent-white: var(--color-gray-000); - --text-color-accent-yellow: var(--color-yellow-300); - --text-color-accent-yellow-strong: var(--color-yellow-200); - --text-color-accent-yellow-subtle: var(--color-yellow-200); - --text-color-base: var(--color-gray-200); - --text-color-brand: var(--color-brand); - --text-color-component-nvidia-logo: var(--color-gray-000); - --text-color-disabled: var(--color-translucent-white-300); - --text-color-feedback-danger: var(--color-red-400); - --text-color-feedback-danger-inverse: var(--color-red-300); - --text-color-feedback-danger-strong: var(--color-red-300); - --text-color-feedback-danger-subtle: var(--color-red-400); - --text-color-feedback-info: var(--color-blue-500); - --text-color-feedback-info-inverse: var(--color-blue-300); - --text-color-feedback-success: var(--color-green-400); - --text-color-feedback-success-inverse: var(--color-green-300); - --text-color-feedback-warning: var(--color-yellow-300); - --text-color-feedback-warning-inverse: var(--color-yellow-200); - --text-color-interaction-disabled-checked: var(--color-translucent-black-600); - --text-color-interaction-selected: var(--color-translucent-white-200); - --text-color-inverse: var(--color-gray-1000); - --text-color-inverse-brand: var(--text-color-brand); - --text-color-placeholder: var(--color-gray-400); - --text-color-primary: var(--color-gray-000); - --text-color-secondary: var(--color-gray-300); - --text-color-strong: var(--color-gray-000); - --text-color-subtle: var(--color-gray-400); - } -} -.nv-density-standard { - --radius-density-xl: var(--radius-xl); - --spacing-density-xxs: 2px; - --spacing-density-xs: 4px; - --spacing-density-sm: 6px; - --spacing-density-md: 8px; - --spacing-density-lg: 12px; - --spacing-density-xl: 16px; - --spacing-density-2xl: 24px; - --spacing-density-3xl: 32px; - --spacing-density-4xl: 48px; - --spacing-density-5xl: 64px; -} -.nv-density-compact { - --radius-density-xl: var(--radius-lg); - --spacing-density-xxs: 1px; - --spacing-density-xs: 2px; - --spacing-density-sm: 4px; - --spacing-density-md: 6px; - --spacing-density-lg: 8px; - --spacing-density-xl: 12px; - --spacing-density-2xl: 16px; - --spacing-density-3xl: 24px; - --spacing-density-4xl: 32px; - --spacing-density-5xl: 48px; -} -.nv-density-spacious { - --radius-density-xl: var(--radius-xl); - --spacing-density-xxs: 4px; - --spacing-density-xs: 6px; - --spacing-density-sm: 8px; - --spacing-density-md: 12px; - --spacing-density-lg: 16px; - --spacing-density-xl: 24px; - --spacing-density-2xl: 32px; - --spacing-density-3xl: 48px; - --spacing-density-4xl: 64px; - --spacing-density-5xl: 80px; -} +*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--font-sans,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--font-mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace)}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1;color:var(--text-color-placeholder)}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:var(--text-color-placeholder,currentcolor)}@supports (color:color-mix(in lab, red, red)){::placeholder{color:var(--text-color-placeholder,color-mix(in oklab,currentcolor 50.0%,transparent))}}}textarea{resize:vertical}textarea,input{font:inherit;letter-spacing:inherit;word-spacing:inherit}::-webkit-search-decoration{-webkit-appearance:none;display:none}::-webkit-search-cancel-button{-webkit-appearance:none;display:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}@supports (interpolate-size:allow-keywords){:root{interpolate-size:allow-keywords}}.nv-motion-disabled [class*=nv-]:not([class*=nv-spinner-root]){transition-duration:0s!important;transition-delay:0s!important;animation-duration:0s!important;animation-delay:0s!important}@keyframes pulse{50%{opacity:.5}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes modal-in{0%{opacity:0}to{opacity:1}}@keyframes modal-out{0%{opacity:1}to{opacity:0}}@keyframes accordion-out{0%{height:0;padding-block:0;overflow:hidden}99%{overflow:hidden}to{height:var(--nv-accordion-content-height,var(--radix-accordion-content-height,var(--radix-collapsible-content-height)));overflow:visible}}@keyframes accordion-in{0%{height:var(--nv-accordion-content-height,var(--radix-accordion-content-height,var(--radix-collapsible-content-height)));overflow:hidden}to{height:0;padding-block:0;overflow:hidden}}@keyframes nv-popover-in{0%{opacity:0;translate:var(--nv-popover-translate-start)}to{opacity:1;translate:0}}@keyframes nv-tooltip-in{0%{opacity:0;translate:var(--nv-tooltip-translate-start)}to{opacity:1;translate:0}}@font-face{font-family:NVIDIA Sans Fallback;font-style:normal;font-weight:300;ascent-override:93.59%;descent-override:26.74%;line-gap-override:0%;size-adjust:104.71%;src:local(Arial),local(Inter),local(Helvetica),local(DejaVu Sans),local(Liberation Sans),local(Noto Sans),local(Ubuntu),local(FreeSans),local("sans-serif")}@font-face{font-family:NVIDIA Sans Fallback;font-style:italic;font-weight:300;ascent-override:97.18%;descent-override:27.77%;line-gap-override:0%;size-adjust:100.84%;src:local(Arial Italic),local(Inter Italic),local(Helvetica Oblique),local(DejaVu Sans Oblique),local(Liberation Sans Italic),local(Noto Sans Italic),local(Ubuntu Italic),local(FreeSans Oblique),local("sans-serif")}@font-face{font-family:NVIDIA Sans Fallback;font-style:normal;font-weight:400;ascent-override:92.7%;descent-override:26.49%;line-gap-override:0%;size-adjust:105.71%;src:local(Arial),local(Inter),local(Helvetica),local(DejaVu Sans),local(Liberation Sans),local(Noto Sans),local(Ubuntu),local(FreeSans),local("sans-serif")}@font-face{font-family:NVIDIA Sans Fallback;font-style:italic;font-weight:400;ascent-override:96.18%;descent-override:27.48%;line-gap-override:0%;size-adjust:101.89%;src:local(Arial Italic),local(Inter Italic),local(Helvetica Oblique),local(DejaVu Sans Oblique),local(Liberation Sans Italic),local(Noto Sans Italic),local(Ubuntu Italic),local(FreeSans Oblique),local("sans-serif")}@font-face{font-family:NVIDIA Sans Fallback;font-style:normal;font-weight:500;ascent-override:91.53%;descent-override:26.15%;line-gap-override:0%;size-adjust:107.07%;src:local(Arial),local(Inter),local(Helvetica),local(DejaVu Sans),local(Liberation Sans),local(Noto Sans),local(Ubuntu),local(FreeSans),local("sans-serif")}@font-face{font-family:NVIDIA Sans Fallback;font-style:italic;font-weight:500;ascent-override:94.81%;descent-override:27.09%;line-gap-override:0%;size-adjust:103.36%;src:local(Arial Italic),local(Inter Italic),local(Helvetica Oblique),local(DejaVu Sans Oblique),local(Liberation Sans Italic),local(Noto Sans Italic),local(Ubuntu Italic),local(FreeSans Oblique),local("sans-serif")}@font-face{font-family:NVIDIA Sans Fallback;font-style:normal;font-weight:700;ascent-override:97.74%;descent-override:27.93%;line-gap-override:0%;size-adjust:100.26%;src:local(Arial Bold),local(Inter Bold),local(Helvetica Bold),local(DejaVu Sans Bold),local(Liberation Sans Bold),local(Noto Sans Bold),local(Ubuntu Bold),local(FreeSans Bold),local("sans-serif")}@font-face{font-family:NVIDIA Sans Fallback;font-style:italic;font-weight:700;ascent-override:101.13%;descent-override:28.9%;line-gap-override:0%;size-adjust:96.9%;src:local(Arial Bold Italic),local(Inter Bold Italic),local(Helvetica Bold Oblique),local(DejaVu Sans Bold Oblique),local(Liberation Sans Bold Italic),local(Noto Sans Bold Italic),local(Ubuntu Bold Italic),local(FreeSans Bold Oblique),local("sans-serif")}@font-face{font-family:JetBrains Mono Fallback;font-style:normal;font-weight:300;ascent-override:102.02%;descent-override:30%;line-gap-override:0%;size-adjust:99.98%;src:local(Courier New)}@font-face{font-family:JetBrains Mono Fallback;font-style:italic;font-weight:300;ascent-override:102.02%;descent-override:30%;line-gap-override:0%;size-adjust:99.98%;src:local(Courier New Italic)}@font-face{font-family:JetBrains Mono Fallback;font-style:normal;font-weight:400;ascent-override:102.02%;descent-override:30%;line-gap-override:0%;size-adjust:99.98%;src:local(Courier New)}@font-face{font-family:JetBrains Mono Fallback;font-style:italic;font-weight:400;ascent-override:102.02%;descent-override:30%;line-gap-override:0%;size-adjust:99.98%;src:local(Courier New Italic)}@font-face{font-family:JetBrains Mono Fallback;font-style:normal;font-weight:500;ascent-override:102.02%;descent-override:30%;line-gap-override:0%;size-adjust:99.98%;src:local(Courier New)}@font-face{font-family:JetBrains Mono Fallback;font-style:italic;font-weight:500;ascent-override:102.02%;descent-override:30%;line-gap-override:0%;size-adjust:99.98%;src:local(Courier New Italic)}@font-face{font-family:JetBrains Mono Fallback;font-style:normal;font-weight:700;ascent-override:102.02%;descent-override:30%;line-gap-override:0%;size-adjust:99.98%;src:local(Courier New Bold)}@font-face{font-family:JetBrains Mono Fallback;font-style:italic;font-weight:700;ascent-override:102.02%;descent-override:30%;line-gap-override:0%;size-adjust:99.98%;src:local(Courier New Bold Italic)}@font-face{font-family:missing-ligature-font;src:local(Arial),local(Verdana),local(Tahoma),local(Trebuchet MS);size-adjust:0%}:where(.nv-icon){width:1em;height:1em;display:block}.nv-icon:before{content:"";background-color:currentColor;width:100%;height:100%;display:block}.nv-icon-bell:before{-webkit-mask:var(--nv-icon-bell)no-repeat center;mask:var(--nv-icon-bell)no-repeat center;-webkit-mask-size:contain;mask-size:contain}.nv-icon-calendar:before{-webkit-mask:var(--nv-icon-calendar)no-repeat center;mask:var(--nv-icon-calendar)no-repeat center;-webkit-mask-size:contain;mask-size:contain}.nv-icon-check:before{-webkit-mask:var(--nv-icon-check)no-repeat center;mask:var(--nv-icon-check)no-repeat center;-webkit-mask-size:contain;mask-size:contain}.nv-icon-check-circle:before{-webkit-mask:var(--nv-icon-check-circle)no-repeat center;mask:var(--nv-icon-check-circle)no-repeat center;-webkit-mask-size:contain;mask-size:contain}.nv-icon-clock:before{-webkit-mask:var(--nv-icon-clock)no-repeat center;mask:var(--nv-icon-clock)no-repeat center;-webkit-mask-size:contain;mask-size:contain}.nv-icon-chevron-double-left:before{-webkit-mask:var(--nv-icon-chevron-double-left)no-repeat center;mask:var(--nv-icon-chevron-double-left)no-repeat center;-webkit-mask-size:contain;mask-size:contain}.nv-icon-chevron-double-right:before{-webkit-mask:var(--nv-icon-chevron-double-right)no-repeat center;mask:var(--nv-icon-chevron-double-right)no-repeat center;-webkit-mask-size:contain;mask-size:contain}.nv-icon-chevron-down:before{-webkit-mask:var(--nv-icon-chevron-down)no-repeat center;mask:var(--nv-icon-chevron-down)no-repeat center;-webkit-mask-size:contain;mask-size:contain}.nv-icon-chevron-left:before{-webkit-mask:var(--nv-icon-chevron-left)no-repeat center;mask:var(--nv-icon-chevron-left)no-repeat center;-webkit-mask-size:contain;mask-size:contain}.nv-icon-chevron-right:before{-webkit-mask:var(--nv-icon-chevron-right)no-repeat center;mask:var(--nv-icon-chevron-right)no-repeat center;-webkit-mask-size:contain;mask-size:contain}.nv-icon-chevron-up:before{-webkit-mask:var(--nv-icon-chevron-up)no-repeat center;mask:var(--nv-icon-chevron-up)no-repeat center;-webkit-mask-size:contain;mask-size:contain}.nv-icon-circle-tick:before{-webkit-mask:var(--nv-icon-circle-tick)no-repeat center;mask:var(--nv-icon-circle-tick)no-repeat center;-webkit-mask-size:contain;mask-size:contain}.nv-icon-close:before{-webkit-mask:var(--nv-icon-close)no-repeat center;mask:var(--nv-icon-close)no-repeat center;-webkit-mask-size:contain;mask-size:contain}.nv-icon-error:before{-webkit-mask:var(--nv-icon-error)no-repeat center;mask:var(--nv-icon-error)no-repeat center;-webkit-mask-size:contain;mask-size:contain}.nv-icon-filter:before{-webkit-mask:var(--nv-icon-filter)no-repeat center;mask:var(--nv-icon-filter)no-repeat center;-webkit-mask-size:contain;mask-size:contain}.nv-icon-info-circle:before{-webkit-mask:var(--nv-icon-info-circle)no-repeat center;mask:var(--nv-icon-info-circle)no-repeat center;-webkit-mask-size:contain;mask-size:contain}.nv-icon-menu:before{-webkit-mask:var(--nv-icon-menu)no-repeat center;mask:var(--nv-icon-menu)no-repeat center;-webkit-mask-size:contain;mask-size:contain}.nv-icon-warning:before{-webkit-mask:var(--nv-icon-warning)no-repeat center;mask:var(--nv-icon-warning)no-repeat center;-webkit-mask-size:contain;mask-size:contain}.nv-icon-retry:before{-webkit-mask:var(--nv-icon-retry)no-repeat center;mask:var(--nv-icon-retry)no-repeat center;-webkit-mask-size:contain;mask-size:contain}.nv-icon-document:before{-webkit-mask:var(--nv-icon-document)no-repeat center;mask:var(--nv-icon-document)no-repeat center;-webkit-mask-size:contain;mask-size:contain}.nv-icon-trash:before{-webkit-mask:var(--nv-icon-trash)no-repeat center;mask:var(--nv-icon-trash)no-repeat center;-webkit-mask-size:contain;mask-size:contain}.nv-icon-copy-doc:before{-webkit-mask:var(--nv-icon-copy-doc)no-repeat center;mask:var(--nv-icon-copy-doc)no-repeat center;-webkit-mask-size:contain;mask-size:contain}.nv-icon{--nv-icon-bell:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='M10.268 21a2 2 0 0 0 3.464 0' /%3e %3cpath d='M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326' /%3e %3c/svg%3e");--nv-icon-calendar:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='M8 2v4' /%3e %3cpath d='M16 2v4' /%3e %3crect width='18' height='18' x='3' y='4' rx='2' /%3e %3cpath d='M3 10h18' /%3e %3c/svg%3e");--nv-icon-check:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='M20 6 9 17l-5-5' /%3e %3c/svg%3e");--nv-icon-check-circle:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='M21.801 10A10 10 0 1 1 17 3.335' /%3e %3cpath d='m9 11 3 3L22 4' /%3e %3c/svg%3e");--nv-icon-clock:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='M12 6v6l4 2' /%3e %3ccircle cx='12' cy='12' r='10' /%3e %3c/svg%3e");--nv-icon-chevron-double-left:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='m11 17-5-5 5-5' /%3e %3cpath d='m18 17-5-5 5-5' /%3e %3c/svg%3e");--nv-icon-chevron-double-right:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='m6 17 5-5-5-5' /%3e %3cpath d='m13 17 5-5-5-5' /%3e %3c/svg%3e");--nv-icon-chevron-down:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='m6 9 6 6 6-6' /%3e %3c/svg%3e");--nv-icon-chevron-left:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='m15 18-6-6 6-6' /%3e %3c/svg%3e");--nv-icon-chevron-right:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='m9 18 6-6-6-6' /%3e %3c/svg%3e");--nv-icon-chevron-up:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='m18 15-6-6-6 6' /%3e %3c/svg%3e");--nv-icon-circle-tick:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3ccircle cx='12' cy='12' r='10' /%3e %3cpath d='m9 12 2 2 4-4' /%3e %3c/svg%3e");--nv-icon-close:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='M18 6 6 18' /%3e %3cpath d='m6 6 12 12' /%3e %3c/svg%3e");--nv-icon-error:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3ccircle cx='12' cy='12' r='10' /%3e %3cline x1='12' x2='12' y1='8' y2='12' /%3e %3cline x1='12' x2='12.01' y1='16' y2='16' /%3e %3c/svg%3e");--nv-icon-filter:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z' /%3e %3c/svg%3e");--nv-icon-info-circle:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3ccircle cx='12' cy='12' r='10' /%3e %3cpath d='M12 16v-4' /%3e %3cpath d='M12 8h.01' /%3e %3c/svg%3e");--nv-icon-menu:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='M4 12h16' /%3e %3cpath d='M4 18h16' /%3e %3cpath d='M4 6h16' /%3e %3c/svg%3e");--nv-icon-warning:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3' /%3e %3cpath d='M12 9v4' /%3e %3cpath d='M12 17h.01' /%3e %3c/svg%3e");--nv-icon-retry:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8' /%3e %3cpath d='M3 3v5h5' /%3e %3c/svg%3e");--nv-icon-document:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' /%3e %3cpath d='M14 2v4a2 2 0 0 0 2 2h4' /%3e %3c/svg%3e");--nv-icon-trash:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3cpath d='M10 11v6' /%3e %3cpath d='M14 11v6' /%3e %3cpath d='M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6' /%3e %3cpath d='M3 6h18' /%3e %3cpath d='M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2' /%3e %3c/svg%3e");--nv-icon-copy-doc:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3e %3crect width='14' height='14' x='8' y='8' rx='2' ry='2' /%3e %3cpath d='M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2' /%3e %3c/svg%3e")}:root{--spacing:4px;--color-black:#000;--color-white:#fff;--color-brand:#76b900;--color-red-050:#ffe9e9;--color-red-100:#ffd7d7;--color-red-200:#fbb;--color-red-300:#ff8181;--color-red-400:#fe3f3f;--color-red-500:#e52020;--color-red-600:#c21e1e;--color-red-700:#961515;--color-red-800:#650b0b;--color-red-900:#4b0404;--color-red-950:#2d0100;--color-yellow-050:#feeeb2;--color-yellow-100:#fcde7b;--color-yellow-200:#f9c500;--color-yellow-300:#ef9100;--color-yellow-400:#df6500;--color-yellow-500:#d73d00;--color-yellow-600:#b93100;--color-yellow-700:#8d2600;--color-yellow-800:#601600;--color-yellow-900:#441000;--color-yellow-950:#2d0b00;--color-green-025:#dafb7d;--color-green-050:#cfff40;--color-green-100:#bff230;--color-green-200:#a5de15;--color-green-300:#76b900;--color-green-400:#549a00;--color-green-500:#3f8500;--color-green-600:#327100;--color-green-700:#265600;--color-green-800:#193800;--color-green-900:#142700;--color-green-950:#0d1a00;--color-teal-050:#adfcf8;--color-teal-100:#9aefe5;--color-teal-200:#3ae3c9;--color-teal-300:#1dbba4;--color-teal-400:#139a86;--color-teal-500:#0d8473;--color-teal-600:#097061;--color-teal-700:#04554b;--color-teal-800:#033831;--color-teal-900:#022723;--color-teal-950:#011a19;--color-blue-050:#cbf5ff;--color-blue-100:#ace;--color-blue-200:#7cd7fe;--color-blue-300:#10b1fb;--color-blue-400:#008af9;--color-blue-500:#0074df;--color-blue-600:#0060c7;--color-blue-700:#0046a4;--color-blue-800:#002781;--color-blue-900:#002050;--color-blue-950:#00112c;--color-purple-050:#fae9ff;--color-purple-100:#f9d4ff;--color-purple-200:#f0b9fd;--color-purple-300:#cd8ef0;--color-purple-400:#c359ef;--color-purple-500:#a846db;--color-purple-600:#952fc6;--color-purple-700:#741d9d;--color-purple-800:#4d1368;--color-purple-900:#331344;--color-purple-950:#1f0f27;--color-fuchsia-050:#ffe8f9;--color-fuchsia-100:#ffd3f2;--color-fuchsia-200:#feb5ee;--color-fuchsia-300:#fc79ca;--color-fuchsia-400:#e050b7;--color-fuchsia-500:#d2308e;--color-fuchsia-600:#b62475;--color-fuchsia-700:#8c1c55;--color-fuchsia-800:#5d1337;--color-fuchsia-900:#420d25;--color-fuchsia-950:#2d0919;--color-gray-000:#fff;--color-gray-025:#f7f7f7;--color-gray-050:#eee;--color-gray-100:#e0e0e0;--color-gray-1000:#000;--color-gray-200:#ccc;--color-gray-300:#a7a7a7;--color-gray-400:#898989;--color-gray-500:#757575;--color-gray-600:#636363;--color-gray-700:#4b4b4b;--color-gray-800:#313131;--color-gray-900:#222;--color-gray-950:#161616;--color-gray-975:#0c0c0c;--color-translucent-black-000:#0000;--color-translucent-black-050:#0000000d;--color-translucent-black-100:#0000001a;--color-translucent-black-120:#0000001f;--color-translucent-black-150:#00000026;--color-translucent-black-200:#0003;--color-translucent-black-300:#0000004d;--color-translucent-black-400:#0006;--color-translucent-black-500:#00000080;--color-translucent-black-600:#0009;--color-translucent-black-700:#000000b2;--color-translucent-black-800:#000c;--color-translucent-black-900:#000000e5;--color-translucent-white-000:#fff0;--color-translucent-white-050:#ffffff0d;--color-translucent-white-100:#ffffff1a;--color-translucent-white-120:#ffffff1f;--color-translucent-white-200:#fff3;--color-translucent-white-250:#ffffff40;--color-translucent-white-300:#ffffff4d;--color-translucent-white-400:#fff6;--color-translucent-white-500:#ffffff80;--color-translucent-white-600:#fff9;--color-translucent-white-700:#ffffffb2;--color-translucent-white-800:#fffc;--color-translucent-white-900:#ffffffe5;--font-sans:NVIDIA Sans,NVIDIA Sans Fallback,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:JetBrains Mono,JetBrains Mono Fallback,ui-monospace,monospace,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--text-10:.625rem;--text-12:.75rem;--text-14:.875rem;--text-16:1rem;--text-18:1.125rem;--text-20:1.25rem;--text-22:1.375rem;--text-24:1.5rem;--text-28:1.75rem;--text-32:2rem;--text-36:2.25rem;--text-40:2.5rem;--text-44:2.75rem;--text-48:3rem;--text-50:3.125rem;--text-56:3.5rem;--text-60:3.75rem;--text-64:4rem;--text-72:4.5rem;--text-80:5rem;--font-weight-light:300;--font-weight-regular:400;--font-weight-semibold:500;--font-weight-bold:700;--leading-lh-100:1;--leading-lh-125:1.25;--leading-lh-150:1.5;--leading-lh-175:1.75;--breakpoint-xs:20rem;--breakpoint-sm:36rem;--breakpoint-md:48rem;--breakpoint-lg:62rem;--breakpoint-xl:75rem;--breakpoint-2xl:100rem;--container-3xs:16rem;--container-2xs:18rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--border-width-none:0px;--border-width-1:1px;--border-width-2:2px;--border-width-3:3px;--border-width-4:4px;--radius-none:0px;--radius-sm:2px;--radius-md:4px;--radius-lg:8px;--radius-xl:16px;--radius-2xl:24px;--radius-3xl:32px;--radius-round:9999px;--shadow-lg:0px 8px 12px 0px var(--color-translucent-black-150);--shadow-md:0px 4px 6px 0px var(--color-translucent-black-120);--shadow-sm:0px 2px 4px 0px var(--color-translucent-black-120);--animate-pulse:pulse 2s cubic-bezier(.4,0,.6,1)infinite;--animate-spin:spin 1s linear infinite;--transition-duration-150:.15s;--transition-duration-200:.2s;--transition-duration-250:.25s;--transition-duration-300:.3s;--ease-out:cubic-bezier(.4,0,.2,1);--radius-density-xl:var(--radius-xl);--spacing-density-xxs:2px;--spacing-density-xs:4px;--spacing-density-sm:6px;--spacing-density-md:8px;--spacing-density-lg:12px;--spacing-density-xl:16px;--spacing-density-2xl:24px;--spacing-density-3xl:32px;--spacing-density-4xl:48px;--spacing-density-5xl:64px;--nv-app-bar-height:48px;--nv-scrollbar-color:var(--color-gray-400)transparent}:where(:root),.nv-light,.light{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--background-color-accent-blue:var(--color-blue-050);--background-color-accent-blue-hover:var(--color-blue-050);--background-color-accent-blue-selected:var(--color-blue-200);--background-color-accent-blue-strong:var(--color-blue-700);--background-color-accent-blue-subtle:var(--color-blue-050);--background-color-accent-blue-subtle-hover:var(--color-blue-200);--background-color-accent-blue-subtle-selected:var(--color-blue-600);--background-color-accent-gray:var(--color-gray-200);--background-color-accent-gray-hover:var(--color-gray-300);--background-color-accent-gray-selected:var(--color-gray-700);--background-color-accent-gray-strong:var(--color-gray-700);--background-color-accent-gray-subtle:var(--color-gray-100);--background-color-accent-gray-subtle-hover:var(--color-gray-300);--background-color-accent-gray-subtle-selected:var(--color-gray-700);--background-color-accent-green:var(--color-green-025);--background-color-accent-green-hover:var(--color-green-025);--background-color-accent-green-selected:var(--color-green-050);--background-color-accent-green-strong:var(--color-green-800);--background-color-accent-green-subtle:var(--color-green-100);--background-color-accent-green-subtle-hover:var(--color-green-200);--background-color-accent-green-subtle-selected:var(--color-green-600);--background-color-accent-purple:var(--color-purple-200);--background-color-accent-purple-hover:var(--color-purple-050);--background-color-accent-purple-selected:var(--color-purple-100);--background-color-accent-purple-strong:var(--color-purple-700);--background-color-accent-purple-subtle:var(--color-purple-050);--background-color-accent-purple-subtle-hover:var(--color-purple-100);--background-color-accent-purple-subtle-selected:var(--color-purple-600);--background-color-accent-red:var(--color-red-100);--background-color-accent-red-hover:var(--color-red-050);--background-color-accent-red-selected:var(--color-red-100);--background-color-accent-red-strong:var(--color-red-700);--background-color-accent-red-subtle:var(--color-red-100);--background-color-accent-red-subtle-hover:var(--color-red-200);--background-color-accent-red-subtle-selected:var(--color-red-600);--background-color-accent-teal:var(--color-teal-100);--background-color-accent-teal-hover:var(--color-teal-050);--background-color-accent-teal-selected:var(--color-teal-100);--background-color-accent-teal-strong:var(--color-teal-800);--background-color-accent-teal-subtle:var(--color-teal-100);--background-color-accent-teal-subtle-hover:var(--color-teal-200);--background-color-accent-teal-subtle-selected:var(--color-teal-600);--background-color-accent-yellow:var(--color-yellow-100);--background-color-accent-yellow-hover:var(--color-yellow-050);--background-color-accent-yellow-selected:var(--color-yellow-100);--background-color-accent-yellow-strong:var(--color-yellow-700);--background-color-accent-yellow-subtle:var(--color-yellow-100);--background-color-accent-yellow-subtle-hover:var(--color-yellow-200);--background-color-accent-yellow-subtle-selected:var(--color-yellow-600);--background-color-background-contrast:var(--color-gray-900);--background-color-background-default:var(--color-gray-000);--background-color-background-emphasis:var(--color-gray-050);--background-color-background-highlight:var(--color-gray-100);--background-color-background-subtle:var(--color-gray-025);--background-color-component-skeleton:var(--color-gray-100);--background-color-component-skeleton-subtle:var(--color-gray-200);--background-color-component-tooltip:var(--color-gray-800);--background-color-component-track:var(--color-translucent-black-100);--background-color-component-track-inverse:var(--color-translucent-black-100);--background-color-feedback-danger:var(--color-red-300);--background-color-feedback-danger-hover:var(--color-red-600);--background-color-feedback-danger-pressed:var(--color-red-700);--background-color-feedback-danger-strong:var(--color-red-500);--background-color-feedback-danger-subtle-hover:var(--color-red-100);--background-color-feedback-danger-subtle-pressed:var(--color-red-200);--background-color-feedback-info:var(--color-blue-050);--background-color-feedback-success:var(--color-green-025);--background-color-feedback-warning:var(--color-yellow-100);--background-color-interaction-base:var(--color-gray-000);--background-color-interaction-disabled:var(--color-translucent-black-050);--background-color-interaction-disabled-checked:var(--color-translucent-black-400);--background-color-interaction-hover:var(--color-translucent-black-050);--background-color-interaction-inverse:var(--color-gray-900);--background-color-interaction-inverse-hover:var(--color-gray-950);--background-color-interaction-inverse-pressed:var(--color-gray-975);--background-color-interaction-pressed:var(--color-translucent-black-100);--background-color-interaction-primary-base:var(--color-green-300);--background-color-interaction-primary-hover:var(--color-green-400);--background-color-interaction-primary-selected:var(--color-green-500);--background-color-interaction-selected:var(--color-gray-000);--background-color-surface-base:var(--color-gray-000);--background-color-surface-blanket:var(--color-translucent-black-700);--background-color-surface-glass:var(--color-translucent-white-800);--background-color-surface-navigation:var(--color-gray-000);--background-color-surface-overlay:var(--color-gray-000);--background-color-surface-raised:var(--color-gray-000);--background-color-surface-sunken:var(--color-gray-025);--border-color-accent-black:var(--color-gray-1000);--border-color-accent-blue:var(--color-blue-600);--border-color-accent-gray:var(--color-gray-700);--border-color-accent-green:var(--color-green-700);--border-color-accent-purple:var(--color-purple-600);--border-color-accent-red:var(--color-red-600);--border-color-accent-teal:var(--color-teal-600);--border-color-accent-white:var(--color-gray-000);--border-color-accent-yellow:var(--color-yellow-600);--border-color-base:var(--color-translucent-black-200);--border-color-brand:var(--color-brand);--border-color-component-tooltip:var(--color-gray-600);--border-color-disabled:var(--color-translucent-black-400);--border-color-feedback-danger:var(--color-red-500);--border-color-feedback-danger-hover:var(--color-red-600);--border-color-feedback-danger-strong:var(--color-red-700);--border-color-feedback-danger-subtle:var(--color-red-500);--border-color-feedback-info:var(--color-blue-400);--border-color-feedback-success:var(--color-green-400);--border-color-feedback-warning:var(--color-yellow-400);--border-color-interaction-base:var(--color-translucent-black-300);--border-color-interaction-disabled:var(--color-translucent-black-100);--border-color-interaction-hover:var(--color-translucent-black-900);--border-color-interaction-inverse:var(--color-gray-900);--border-color-interaction-inverse-hover:var(--color-gray-950);--border-color-interaction-inverse-pressed:var(--color-gray-975);--border-color-interaction-pressed:var(--color-translucent-black-900);--border-color-interaction-primary-base:var(--color-green-300);--border-color-interaction-primary-hover:var(--color-green-400);--border-color-interaction-primary-selected:var(--color-green-500);--border-color-interaction-selected:var(--text-color-brand);--border-color-interaction-strong:var(--color-translucent-black-700);--nv-current-theme:light;--text-color-accent-black:var(--color-gray-1000);--text-color-accent-blue:var(--color-blue-700);--text-color-accent-blue-strong:var(--color-blue-700);--text-color-accent-blue-subtle:var(--color-blue-200);--text-color-accent-gray:var(--color-gray-700);--text-color-accent-green:var(--color-green-700);--text-color-accent-green-strong:var(--color-green-700);--text-color-accent-green-subtle:var(--color-green-200);--text-color-accent-purple:var(--color-purple-700);--text-color-accent-purple-strong:var(--color-purple-700);--text-color-accent-purple-subtle:var(--color-purple-200);--text-color-accent-red:var(--color-red-700);--text-color-accent-red-strong:var(--color-red-700);--text-color-accent-red-subtle:var(--color-red-200);--text-color-accent-teal:var(--color-teal-700);--text-color-accent-teal-strong:var(--color-teal-700);--text-color-accent-teal-subtle:var(--color-teal-200);--text-color-accent-white:var(--color-gray-000);--text-color-accent-yellow:var(--color-yellow-700);--text-color-accent-yellow-strong:var(--color-yellow-700);--text-color-accent-yellow-subtle:var(--color-yellow-200);--text-color-base:var(--color-gray-600);--text-color-brand:var(--color-brand);--text-color-component-nvidia-logo:var(--color-gray-1000);--text-color-disabled:var(--color-gray-400);--text-color-feedback-danger:var(--color-red-500);--text-color-feedback-danger-inverse:var(--color-gray-900);--text-color-feedback-danger-strong:var(--color-red-700);--text-color-feedback-danger-subtle:var(--color-red-600);--text-color-feedback-info:var(--color-blue-500);--text-color-feedback-info-inverse:var(--color-gray-900);--text-color-feedback-success:var(--color-green-500);--text-color-feedback-success-inverse:var(--color-gray-900);--text-color-feedback-warning:var(--color-yellow-500);--text-color-feedback-warning-inverse:var(--color-gray-900);--text-color-interaction-disabled-checked:var(--color-translucent-black-300);--text-color-interaction-selected:var(--color-gray-000);--text-color-inverse:var(--color-gray-000);--text-color-inverse-brand:var(--color-gray-950);--text-color-placeholder:var(--color-gray-500);--text-color-primary:var(--color-gray-1000);--text-color-secondary:var(--color-gray-600);--text-color-strong:var(--color-gray-900);--text-color-subtle:var(--color-gray-400)}.dark,.nv-dark{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;--background-color-accent-blue:var(--color-blue-900);--background-color-accent-blue-hover:var(--color-blue-900);--background-color-accent-blue-selected:var(--color-blue-800);--background-color-accent-blue-strong:var(--color-blue-900);--background-color-accent-blue-subtle:var(--color-blue-900);--background-color-accent-blue-subtle-hover:var(--color-blue-800);--background-color-accent-blue-subtle-selected:var(--color-blue-500);--background-color-accent-gray:var(--color-gray-600);--background-color-accent-gray-hover:var(--color-gray-1000);--background-color-accent-gray-selected:var(--color-gray-200);--background-color-accent-gray-strong:var(--color-gray-700);--background-color-accent-gray-subtle:var(--color-gray-700);--background-color-accent-gray-subtle-hover:var(--color-gray-200);--background-color-accent-gray-subtle-selected:var(--color-gray-600);--background-color-accent-green:var(--color-green-900);--background-color-accent-green-hover:var(--color-green-900);--background-color-accent-green-selected:var(--color-green-800);--background-color-accent-green-strong:var(--color-green-900);--background-color-accent-green-subtle:var(--color-green-900);--background-color-accent-green-subtle-hover:var(--color-green-800);--background-color-accent-green-subtle-selected:var(--color-green-600);--background-color-accent-purple:var(--color-purple-900);--background-color-accent-purple-hover:var(--color-purple-900);--background-color-accent-purple-selected:var(--color-purple-800);--background-color-accent-purple-strong:var(--color-purple-900);--background-color-accent-purple-subtle:var(--color-purple-900);--background-color-accent-purple-subtle-hover:var(--color-purple-800);--background-color-accent-purple-subtle-selected:var(--color-purple-700);--background-color-accent-red:var(--color-red-900);--background-color-accent-red-hover:var(--color-red-900);--background-color-accent-red-selected:var(--color-red-800);--background-color-accent-red-strong:var(--color-red-900);--background-color-accent-red-subtle:var(--color-red-900);--background-color-accent-red-subtle-hover:var(--color-red-800);--background-color-accent-red-subtle-selected:var(--color-red-700);--background-color-accent-teal:var(--color-teal-900);--background-color-accent-teal-hover:var(--color-teal-900);--background-color-accent-teal-selected:var(--color-teal-800);--background-color-accent-teal-strong:var(--color-teal-900);--background-color-accent-teal-subtle:var(--color-teal-900);--background-color-accent-teal-subtle-hover:var(--color-teal-800);--background-color-accent-teal-subtle-selected:var(--color-teal-500);--background-color-accent-yellow:var(--color-yellow-900);--background-color-accent-yellow-hover:var(--color-yellow-900);--background-color-accent-yellow-selected:var(--color-yellow-800);--background-color-accent-yellow-strong:var(--color-yellow-900);--background-color-accent-yellow-subtle:var(--color-yellow-900);--background-color-accent-yellow-subtle-hover:var(--color-yellow-800);--background-color-accent-yellow-subtle-selected:var(--color-yellow-600);--background-color-background-contrast:var(--color-gray-000);--background-color-background-default:var(--color-gray-900);--background-color-background-emphasis:var(--color-gray-700);--background-color-background-highlight:var(--color-gray-600);--background-color-background-subtle:var(--color-gray-800);--background-color-component-skeleton:var(--color-gray-800);--background-color-component-skeleton-subtle:var(--color-gray-900);--background-color-component-tooltip:var(--color-gray-800);--background-color-component-track:var(--color-translucent-white-200);--background-color-component-track-inverse:var(--color-translucent-black-400);--background-color-feedback-danger:var(--color-red-900);--background-color-feedback-danger-hover:var(--color-red-600);--background-color-feedback-danger-pressed:var(--color-red-700);--background-color-feedback-danger-strong:var(--color-red-500);--background-color-feedback-danger-subtle-hover:var(--color-red-900);--background-color-feedback-danger-subtle-pressed:var(--color-red-800);--background-color-feedback-info:var(--color-blue-950);--background-color-feedback-success:var(--color-green-950);--background-color-feedback-warning:var(--color-yellow-950);--background-color-interaction-base:var(--color-translucent-black-600);--background-color-interaction-disabled:var(--color-translucent-white-100);--background-color-interaction-disabled-checked:var(--color-translucent-white-500);--background-color-interaction-hover:var(--color-translucent-white-100);--background-color-interaction-inverse:var(--color-gray-000);--background-color-interaction-inverse-hover:var(--color-gray-100);--background-color-interaction-inverse-pressed:var(--color-gray-200);--background-color-interaction-pressed:var(--color-translucent-white-200);--background-color-interaction-primary-base:var(--color-green-300);--background-color-interaction-primary-hover:var(--color-green-400);--background-color-interaction-primary-selected:var(--color-green-500);--background-color-interaction-selected:var(--color-gray-1000);--background-color-surface-base:var(--color-gray-1000);--background-color-surface-blanket:var(--color-translucent-black-700);--background-color-surface-glass:var(--color-translucent-black-600);--background-color-surface-navigation:var(--color-gray-1000);--background-color-surface-overlay:var(--color-gray-900);--background-color-surface-raised:var(--color-gray-950);--background-color-surface-sunken:var(--color-gray-975);--border-color-accent-black:var(--color-gray-1000);--border-color-accent-blue:var(--color-blue-300);--border-color-accent-gray:var(--color-gray-300);--border-color-accent-green:var(--color-green-300);--border-color-accent-purple:var(--color-purple-300);--border-color-accent-red:var(--color-red-300);--border-color-accent-teal:var(--color-teal-300);--border-color-accent-white:var(--color-gray-000);--border-color-accent-yellow:var(--color-yellow-300);--border-color-base:var(--color-translucent-white-200);--border-color-brand:var(--color-brand);--border-color-component-tooltip:var(--color-gray-600);--border-color-disabled:var(--color-translucent-white-300);--border-color-feedback-danger:var(--color-red-500);--border-color-feedback-danger-hover:var(--color-red-600);--border-color-feedback-danger-strong:var(--color-red-700);--border-color-feedback-danger-subtle:var(--color-red-300);--border-color-feedback-info:var(--color-blue-400);--border-color-feedback-success:var(--color-green-400);--border-color-feedback-warning:var(--color-yellow-200);--border-color-interaction-base:var(--color-translucent-white-200);--border-color-interaction-disabled:var(--color-translucent-white-100);--border-color-interaction-hover:var(--color-translucent-white-400);--border-color-interaction-inverse:var(--color-gray-000);--border-color-interaction-inverse-hover:var(--color-gray-100);--border-color-interaction-inverse-pressed:var(--color-gray-200);--border-color-interaction-pressed:var(--color-translucent-white-400);--border-color-interaction-primary-base:var(--color-green-300);--border-color-interaction-primary-hover:var(--color-green-400);--border-color-interaction-primary-selected:var(--color-green-500);--border-color-interaction-selected:var(--text-color-brand);--border-color-interaction-strong:var(--color-translucent-white-700);--nv-current-theme:dark;--text-color-accent-black:var(--color-gray-1000);--text-color-accent-blue:var(--color-blue-300);--text-color-accent-blue-strong:var(--color-blue-200);--text-color-accent-blue-subtle:var(--color-blue-200);--text-color-accent-gray:var(--color-gray-050);--text-color-accent-green:var(--color-green-300);--text-color-accent-green-strong:var(--color-green-200);--text-color-accent-green-subtle:var(--color-green-200);--text-color-accent-purple:var(--color-purple-200);--text-color-accent-purple-strong:var(--color-purple-200);--text-color-accent-purple-subtle:var(--color-purple-200);--text-color-accent-red:var(--color-red-300);--text-color-accent-red-strong:var(--color-red-200);--text-color-accent-red-subtle:var(--color-red-200);--text-color-accent-teal:var(--color-teal-300);--text-color-accent-teal-strong:var(--color-teal-200);--text-color-accent-teal-subtle:var(--color-teal-200);--text-color-accent-white:var(--color-gray-000);--text-color-accent-yellow:var(--color-yellow-300);--text-color-accent-yellow-strong:var(--color-yellow-200);--text-color-accent-yellow-subtle:var(--color-yellow-200);--text-color-base:var(--color-gray-200);--text-color-brand:var(--color-brand);--text-color-component-nvidia-logo:var(--color-gray-000);--text-color-disabled:var(--color-translucent-white-300);--text-color-feedback-danger:var(--color-red-400);--text-color-feedback-danger-inverse:var(--color-red-300);--text-color-feedback-danger-strong:var(--color-red-300);--text-color-feedback-danger-subtle:var(--color-red-400);--text-color-feedback-info:var(--color-blue-500);--text-color-feedback-info-inverse:var(--color-blue-300);--text-color-feedback-success:var(--color-green-400);--text-color-feedback-success-inverse:var(--color-green-300);--text-color-feedback-warning:var(--color-yellow-300);--text-color-feedback-warning-inverse:var(--color-yellow-200);--text-color-interaction-disabled-checked:var(--color-translucent-black-600);--text-color-interaction-selected:var(--color-translucent-white-200);--text-color-inverse:var(--color-gray-1000);--text-color-inverse-brand:var(--text-color-brand);--text-color-placeholder:var(--color-gray-400);--text-color-primary:var(--color-gray-000);--text-color-secondary:var(--color-gray-300);--text-color-strong:var(--color-gray-000);--text-color-subtle:var(--color-gray-400)}@media (prefers-color-scheme:dark){:where(:root){--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;--background-color-accent-blue:var(--color-blue-900);--background-color-accent-blue-hover:var(--color-blue-900);--background-color-accent-blue-selected:var(--color-blue-800);--background-color-accent-blue-strong:var(--color-blue-900);--background-color-accent-blue-subtle:var(--color-blue-900);--background-color-accent-blue-subtle-hover:var(--color-blue-800);--background-color-accent-blue-subtle-selected:var(--color-blue-500);--background-color-accent-gray:var(--color-gray-600);--background-color-accent-gray-hover:var(--color-gray-1000);--background-color-accent-gray-selected:var(--color-gray-200);--background-color-accent-gray-strong:var(--color-gray-700);--background-color-accent-gray-subtle:var(--color-gray-700);--background-color-accent-gray-subtle-hover:var(--color-gray-200);--background-color-accent-gray-subtle-selected:var(--color-gray-600);--background-color-accent-green:var(--color-green-900);--background-color-accent-green-hover:var(--color-green-900);--background-color-accent-green-selected:var(--color-green-800);--background-color-accent-green-strong:var(--color-green-900);--background-color-accent-green-subtle:var(--color-green-900);--background-color-accent-green-subtle-hover:var(--color-green-800);--background-color-accent-green-subtle-selected:var(--color-green-600);--background-color-accent-purple:var(--color-purple-900);--background-color-accent-purple-hover:var(--color-purple-900);--background-color-accent-purple-selected:var(--color-purple-800);--background-color-accent-purple-strong:var(--color-purple-900);--background-color-accent-purple-subtle:var(--color-purple-900);--background-color-accent-purple-subtle-hover:var(--color-purple-800);--background-color-accent-purple-subtle-selected:var(--color-purple-700);--background-color-accent-red:var(--color-red-900);--background-color-accent-red-hover:var(--color-red-900);--background-color-accent-red-selected:var(--color-red-800);--background-color-accent-red-strong:var(--color-red-900);--background-color-accent-red-subtle:var(--color-red-900);--background-color-accent-red-subtle-hover:var(--color-red-800);--background-color-accent-red-subtle-selected:var(--color-red-700);--background-color-accent-teal:var(--color-teal-900);--background-color-accent-teal-hover:var(--color-teal-900);--background-color-accent-teal-selected:var(--color-teal-800);--background-color-accent-teal-strong:var(--color-teal-900);--background-color-accent-teal-subtle:var(--color-teal-900);--background-color-accent-teal-subtle-hover:var(--color-teal-800);--background-color-accent-teal-subtle-selected:var(--color-teal-500);--background-color-accent-yellow:var(--color-yellow-900);--background-color-accent-yellow-hover:var(--color-yellow-900);--background-color-accent-yellow-selected:var(--color-yellow-800);--background-color-accent-yellow-strong:var(--color-yellow-900);--background-color-accent-yellow-subtle:var(--color-yellow-900);--background-color-accent-yellow-subtle-hover:var(--color-yellow-800);--background-color-accent-yellow-subtle-selected:var(--color-yellow-600);--background-color-background-contrast:var(--color-gray-000);--background-color-background-default:var(--color-gray-900);--background-color-background-emphasis:var(--color-gray-700);--background-color-background-highlight:var(--color-gray-600);--background-color-background-subtle:var(--color-gray-800);--background-color-component-skeleton:var(--color-gray-800);--background-color-component-skeleton-subtle:var(--color-gray-900);--background-color-component-tooltip:var(--color-gray-800);--background-color-component-track:var(--color-translucent-white-200);--background-color-component-track-inverse:var(--color-translucent-black-400);--background-color-feedback-danger:var(--color-red-900);--background-color-feedback-danger-hover:var(--color-red-600);--background-color-feedback-danger-pressed:var(--color-red-700);--background-color-feedback-danger-strong:var(--color-red-500);--background-color-feedback-danger-subtle-hover:var(--color-red-900);--background-color-feedback-danger-subtle-pressed:var(--color-red-800);--background-color-feedback-info:var(--color-blue-950);--background-color-feedback-success:var(--color-green-950);--background-color-feedback-warning:var(--color-yellow-950);--background-color-interaction-base:var(--color-translucent-black-600);--background-color-interaction-disabled:var(--color-translucent-white-100);--background-color-interaction-disabled-checked:var(--color-translucent-white-500);--background-color-interaction-hover:var(--color-translucent-white-100);--background-color-interaction-inverse:var(--color-gray-000);--background-color-interaction-inverse-hover:var(--color-gray-100);--background-color-interaction-inverse-pressed:var(--color-gray-200);--background-color-interaction-pressed:var(--color-translucent-white-200);--background-color-interaction-primary-base:var(--color-green-300);--background-color-interaction-primary-hover:var(--color-green-400);--background-color-interaction-primary-selected:var(--color-green-500);--background-color-interaction-selected:var(--color-gray-1000);--background-color-surface-base:var(--color-gray-1000);--background-color-surface-blanket:var(--color-translucent-black-700);--background-color-surface-glass:var(--color-translucent-black-600);--background-color-surface-navigation:var(--color-gray-1000);--background-color-surface-overlay:var(--color-gray-900);--background-color-surface-raised:var(--color-gray-950);--background-color-surface-sunken:var(--color-gray-975);--border-color-accent-black:var(--color-gray-1000);--border-color-accent-blue:var(--color-blue-300);--border-color-accent-gray:var(--color-gray-300);--border-color-accent-green:var(--color-green-300);--border-color-accent-purple:var(--color-purple-300);--border-color-accent-red:var(--color-red-300);--border-color-accent-teal:var(--color-teal-300);--border-color-accent-white:var(--color-gray-000);--border-color-accent-yellow:var(--color-yellow-300);--border-color-base:var(--color-translucent-white-200);--border-color-brand:var(--color-brand);--border-color-component-tooltip:var(--color-gray-600);--border-color-disabled:var(--color-translucent-white-300);--border-color-feedback-danger:var(--color-red-500);--border-color-feedback-danger-hover:var(--color-red-600);--border-color-feedback-danger-strong:var(--color-red-700);--border-color-feedback-danger-subtle:var(--color-red-300);--border-color-feedback-info:var(--color-blue-400);--border-color-feedback-success:var(--color-green-400);--border-color-feedback-warning:var(--color-yellow-200);--border-color-interaction-base:var(--color-translucent-white-200);--border-color-interaction-disabled:var(--color-translucent-white-100);--border-color-interaction-hover:var(--color-translucent-white-400);--border-color-interaction-inverse:var(--color-gray-000);--border-color-interaction-inverse-hover:var(--color-gray-100);--border-color-interaction-inverse-pressed:var(--color-gray-200);--border-color-interaction-pressed:var(--color-translucent-white-400);--border-color-interaction-primary-base:var(--color-green-300);--border-color-interaction-primary-hover:var(--color-green-400);--border-color-interaction-primary-selected:var(--color-green-500);--border-color-interaction-selected:var(--text-color-brand);--border-color-interaction-strong:var(--color-translucent-white-700);--nv-current-theme:dark;--text-color-accent-black:var(--color-gray-1000);--text-color-accent-blue:var(--color-blue-300);--text-color-accent-blue-strong:var(--color-blue-200);--text-color-accent-blue-subtle:var(--color-blue-200);--text-color-accent-gray:var(--color-gray-050);--text-color-accent-green:var(--color-green-300);--text-color-accent-green-strong:var(--color-green-200);--text-color-accent-green-subtle:var(--color-green-200);--text-color-accent-purple:var(--color-purple-200);--text-color-accent-purple-strong:var(--color-purple-200);--text-color-accent-purple-subtle:var(--color-purple-200);--text-color-accent-red:var(--color-red-300);--text-color-accent-red-strong:var(--color-red-200);--text-color-accent-red-subtle:var(--color-red-200);--text-color-accent-teal:var(--color-teal-300);--text-color-accent-teal-strong:var(--color-teal-200);--text-color-accent-teal-subtle:var(--color-teal-200);--text-color-accent-white:var(--color-gray-000);--text-color-accent-yellow:var(--color-yellow-300);--text-color-accent-yellow-strong:var(--color-yellow-200);--text-color-accent-yellow-subtle:var(--color-yellow-200);--text-color-base:var(--color-gray-200);--text-color-brand:var(--color-brand);--text-color-component-nvidia-logo:var(--color-gray-000);--text-color-disabled:var(--color-translucent-white-300);--text-color-feedback-danger:var(--color-red-400);--text-color-feedback-danger-inverse:var(--color-red-300);--text-color-feedback-danger-strong:var(--color-red-300);--text-color-feedback-danger-subtle:var(--color-red-400);--text-color-feedback-info:var(--color-blue-500);--text-color-feedback-info-inverse:var(--color-blue-300);--text-color-feedback-success:var(--color-green-400);--text-color-feedback-success-inverse:var(--color-green-300);--text-color-feedback-warning:var(--color-yellow-300);--text-color-feedback-warning-inverse:var(--color-yellow-200);--text-color-interaction-disabled-checked:var(--color-translucent-black-600);--text-color-interaction-selected:var(--color-translucent-white-200);--text-color-inverse:var(--color-gray-1000);--text-color-inverse-brand:var(--text-color-brand);--text-color-placeholder:var(--color-gray-400);--text-color-primary:var(--color-gray-000);--text-color-secondary:var(--color-gray-300);--text-color-strong:var(--color-gray-000);--text-color-subtle:var(--color-gray-400)}}.nv-density-standard{--radius-density-xl:var(--radius-xl);--spacing-density-xxs:2px;--spacing-density-xs:4px;--spacing-density-sm:6px;--spacing-density-md:8px;--spacing-density-lg:12px;--spacing-density-xl:16px;--spacing-density-2xl:24px;--spacing-density-3xl:32px;--spacing-density-4xl:48px;--spacing-density-5xl:64px}.nv-density-compact{--radius-density-xl:var(--radius-lg);--spacing-density-xxs:1px;--spacing-density-xs:2px;--spacing-density-sm:4px;--spacing-density-md:6px;--spacing-density-lg:8px;--spacing-density-xl:12px;--spacing-density-2xl:16px;--spacing-density-3xl:24px;--spacing-density-4xl:32px;--spacing-density-5xl:48px}.nv-density-spacious{--radius-density-xl:var(--radius-xl);--spacing-density-xxs:4px;--spacing-density-xs:6px;--spacing-density-sm:8px;--spacing-density-md:12px;--spacing-density-lg:16px;--spacing-density-xl:24px;--spacing-density-2xl:32px;--spacing-density-3xl:48px;--spacing-density-4xl:64px;--spacing-density-5xl:80px} diff --git a/desktop/src/ui/lib/kaizen-ui-foundations/components.css b/desktop/src/ui/lib/kaizen-ui-foundations/components.css index 2e045cf3..6f2d67b2 100644 --- a/desktop/src/ui/lib/kaizen-ui-foundations/components.css +++ b/desktop/src/ui/lib/kaizen-ui-foundations/components.css @@ -2,9350 +2,4 @@ * Generated by scripts/vendor-kaizen-ui-foundations-css.ts. * Remote @font-face rules are stripped so the Electron app works offline. */ -.nv-accordion-item > summary { - list-style: none; -} -.nv-accordion-item > summary::-webkit-details-marker { - display: none; -} -.nv-accordion-item > summary::marker { - content: ''; - display: none; -} -.nv-accordion-root { - font-family: var(--font-sans); - background: var(--nv-accordion-root-bg, var(--background-color-surface-raised)); - border-radius: var(--nv-accordion-root-border-radius, 0); - color: var(--nv-accordion-root-color, var(--text-color-primary)); -} -.nv-accordion-root svg, -.nv-accordion-root .nv-icon { - color: var(--nv-accordion-icon-color, var(--text-color-base)); - flex-shrink: 0; -} -.nv-accordion-root [data-disabled], -.nv-accordion-root [aria-disabled='true'] { - color: var(--text-color-disabled); -} -.nv-accordion-item { - border-bottom: var(--nv-accordion-item-border, 1px solid var(--border-color-base)); - display: block; -} -.nv-accordion-item::details-content { - height: 0; - display: block; - overflow: clip; -} -@media (prefers-reduced-motion: no-preference) { - .nv-accordion-item::details-content { - transition: - height 0.2s var(--ease-out), - content-visibility 0.2s allow-discrete; - } -} -.nv-accordion-item[open]::details-content { - height: auto; -} -@media (prefers-reduced-motion: reduce) { - .nv-accordion-item::details-content { - transition-duration: 0.01ms; - } -} -.nv-accordion-trigger { - cursor: pointer; - background: var(--nv-accordion-trigger-bg, transparent); - width: 100%; - padding: var(--nv-accordion-trigger-padding, calc(var(--spacing) * 3)); - justify-content: space-between; - align-items: center; - gap: var(--nv-accordion-trigger-gap, calc(var(--spacing) * 1.5)); - color: var(--nv-accordion-trigger-color, inherit); - font-weight: var(--font-weight-bold); - font-size: var(--nv-accordion-label-font-size, var(--text-14)); - border: none; - line-height: 1.14286; - display: flex; -} -@media (prefers-reduced-motion: no-preference) { - .nv-accordion-trigger { - transition-property: - color, background-color, border-color, outline-color, text-decoration-color, fill, - stroke; - transition-duration: 0.2s; - transition-timing-function: var(--ease-out); - } -} -.nv-accordion-trigger:hover { - background: var(--nv-accordion-trigger-bg-hover, var(--background-color-interaction-hover)); -} -.nv-accordion-trigger[data-disabled], -.nv-accordion-trigger[aria-disabled='true'] { - cursor: not-allowed; - color: var(--text-color-disabled); - background: var(--nv-accordion-trigger-bg-disabled, transparent); -} -.nv-accordion-trigger.nv-accordion-trigger--chevron-end { - flex-direction: row; - justify-content: space-between; -} -.nv-accordion-trigger.nv-accordion-trigger--chevron-start { - flex-direction: row-reverse; - justify-content: flex-end; -} -.nv-accordion-trigger svg, -.nv-accordion-trigger .nv-icon { - width: calc(var(--spacing) * 3); - height: calc(var(--spacing) * 3); -} -.nv-accordion-trigger .nv-animated-chevron { - width: calc(var(--spacing) * 4); - height: calc(var(--spacing) * 4); -} -.nv-accordion-trigger .nv-accordion-label-text { - text-overflow: ellipsis; - white-space: nowrap; - gap: inherit; - overflow: hidden; -} -.nv-accordion-content { - background: var(--nv-accordion-content-bg, transparent); - color: var(--nv-accordion-content-color, inherit); - font-size: var(--nv-accordion-content-font-size, var(--text-14)); - line-height: var(--nv-accordion-content-line-height, var(--leading-lh-150)); - padding: var(--nv-accordion-content-padding, calc(var(--spacing) * 6) calc(var(--spacing) * 3)); -} -.nv-accordion-root - .nv-accordion-trigger--chevron-start - + .nv-accordion-content - .nv-accordion-root - .nv-accordion-trigger { - flex-direction: row-reverse; - justify-content: flex-end; -} -.nv-accordion-root .nv-accordion-root { - background-color: var(--background-color-surface-overlay); - --nv-accordion-label-font-size: var(--text-12); - line-height: 1.33333; -} -.nv-anchor { - cursor: pointer; - width: fit-content; - color: var(--text-color-primary); - display: inline; -} -.nv-anchor:not(.nv-anchor--kind-standalone) { - text-decoration: underline; - -webkit-text-decoration-color: var(--border-color-brand); - text-decoration-color: var(--border-color-brand); - text-underline-offset: 4px; - -webkit-text-decoration-skip-ink: auto; - text-decoration-skip-ink: auto; - text-decoration-thickness: 1px; -} -:is( - .nv-anchor:not(.nv-anchor--disabled):not(:disabled):hover, - .nv-anchor:not(.nv-anchor--disabled):not(:disabled):focus-visible -):not(:active) { - background-color: var(--background-color-interaction-hover); -} -.nv-anchor:not(.nv-anchor--disabled):not(:disabled):not(.nv-anchor--kind-standalone):hover, -.nv-anchor:not(.nv-anchor--disabled):not(:disabled):not(.nv-anchor--kind-standalone):focus-visible, -.nv-anchor:not(.nv-anchor--disabled):not(:disabled):not(.nv-anchor--kind-standalone):active { - -webkit-text-decoration-color: var(--border-color-interaction-hover); - text-decoration-color: var(--border-color-interaction-hover); -} -.nv-anchor:is(.nv-anchor--disabled, :disabled) { - cursor: not-allowed; - color: var(--text-color-disabled); -} -.nv-anchor:is(.nv-anchor--disabled, :disabled):not(.nv-anchor--kind-standalone) { - -webkit-text-decoration-color: var(--text-color-disabled); - text-decoration-color: var(--text-color-disabled); -} -.nv-anchor:is(.nv-anchor--disabled, :disabled):is(a) { - pointer-events: none; -} -@media (prefers-reduced-motion: no-preference) { - .nv-anchor { - transition-property: color, text-decoration-color; - transition-duration: 0.2s; - transition-timing-function: var(--ease-out); - } -} -.nv-anchor [data-nv-gui-icon-before], -.nv-anchor [data-nv-gui-icon-after], -.nv-anchor svg { - display: inline; -} -:is( - .nv-anchor [data-nv-gui-icon-before], - .nv-anchor [data-nv-gui-icon-after], - .nv-anchor svg -):before { - vertical-align: -0.3em; - display: inline; - position: static; -} -.nv-animated-chevron { - pointer-events: none; -} -@media (prefers-reduced-motion: no-preference) { - .nv-animated-chevron { - transition: rotate 0.2s var(--ease-out); - } -} -:where(select:open + .nv-animated-chevron), -:where([data-state='open'] .nv-animated-chevron), -.nv-animated-chevron[data-state='open'] { - rotate: 180deg; -} -:where([data-state='closed'] .nv-animated-chevron), -.nv-animated-chevron[data-state='closed'] { - rotate: none; -} -:where(select:open + .nv-animated-chevron), -:where([data-state='open'] .nv-animated-chevron), -.nv-animated-chevron[data-state='open'] { - rotate: 180deg; -} -:where([data-state='closed'] .nv-animated-chevron), -.nv-animated-chevron[data-state='closed'] { - rotate: none; -} -details[open] .nv-animated-chevron { - rotate: 180deg !important; -} -details:not([open]) .nv-animated-chevron { - rotate: none !important; -} -:has(select:open) .nv-animated-chevron { - rotate: 180deg; -} -.nv-app-bar-root { - border-bottom: var(--border-width-1) solid var(--border-color-base); - background-color: var(--background-color-surface-navigation); - width: 100%; - padding-inline: calc(var(--spacing) * 4); - font-family: var(--font-sans); - text-wrap: nowrap; - color: var(--text-color-primary); - align-items: center; - gap: calc(var(--spacing) * 4); - height: var(--nv-app-bar-height); - max-height: var(--nv-app-bar-height); - display: flex; - overflow: hidden; -} -.nv-app-bar-slot-start { - gap: inherit; - font-size: var(--text-14); - font-weight: var(--font-weight-bold); - line-height: var(--leading-lh-150); - flex-shrink: 0; - align-items: center; - display: flex; -} -.nv-app-bar-slot-center { - flex-grow: 1; - align-items: center; - height: 100%; - display: flex; - overflow: hidden; -} -.nv-app-bar-slot-end { - gap: inherit; - flex-shrink: 0; - align-items: center; - display: flex; -} -.nv-app-bar-expander-button.nv-button { - --nv-button-icon-size: var(--text-24); - --nv-button-icon-margin: -8px; -} -.nv-avatar-root { - width: calc(var(--spacing) * 12); - height: calc(var(--spacing) * 12); - background-color: var(--background-color-accent-teal-strong); - font-size: var(--text-20); - line-height: var(--leading-lh-150); - color: var(--text-color-accent-white); - font-weight: var(--font-weight-bold); - border-radius: 3.40282e38px; - flex-shrink: 0; - place-items: center; - display: grid; - position: relative; - overflow: hidden; -} -@media (prefers-reduced-motion: no-preference) { - .nv-avatar-root { - transition: background-color 0.2s var(--ease-out); - } -} -.nv-avatar-root--size-small { - width: calc(var(--spacing) * 6); - height: calc(var(--spacing) * 6); - font-size: var(--text-10); -} -.nv-avatar-root--size-medium { - width: calc(var(--spacing) * 8); - height: calc(var(--spacing) * 8); - font-size: var(--text-14); -} -.nv-avatar-root--size-large { - width: calc(var(--spacing) * 12); - height: calc(var(--spacing) * 12); -} -.nv-avatar-root--size-xlarge { - width: calc(var(--spacing) * 16); - height: calc(var(--spacing) * 16); - font-size: var(--text-28); -} -.nv-avatar-root--size-xxlarge { - width: calc(var(--spacing) * 32); - height: calc(var(--spacing) * 32); - font-size: var(--text-60); -} -.nv-avatar-root--interactive { - cursor: pointer; -} -.nv-avatar-root--interactive:hover:before { - border-color: var(--border-color-interaction-hover); -} -.nv-avatar-root--interactive:active:before { - border-color: var(--border-color-interaction-pressed); -} -.nv-avatar-root--interactive:not(.nv-avatar-root--kind-outline):before { - opacity: 0; -} -.nv-avatar-root--interactive:not(.nv-avatar-root--kind-outline):hover:before, -.nv-avatar-root--interactive:not(.nv-avatar-root--kind-outline):active:before { - opacity: 1; -} -:is(.nv-avatar-root--kind-outline, .nv-avatar-root--interactive):before { - content: ''; - inset: calc(var(--spacing) * 0); - border: 2px solid; - border-color: var(--border-color-base); - border-radius: 3.40282e38px; - position: absolute; -} -@media (prefers-reduced-motion: no-preference) { - :is(.nv-avatar-root--kind-outline, .nv-avatar-root--interactive):before { - transition: border-color 0.2s var(--ease-out); - } -} -:is(.nv-avatar-root--kind-outline, .nv-avatar-root--interactive).nv-avatar-root--size-small:before, -:is( - .nv-avatar-root--kind-outline, - .nv-avatar-root--interactive - ).nv-avatar-root--size-medium:before { - border-width: 1px; -} -:is( - .nv-avatar-root--kind-outline, - .nv-avatar-root--interactive - ).nv-avatar-root--size-xxlarge:before { - border-width: 4px; -} -.nv-avatar-image { - object-fit: cover; - width: 100%; - height: 100%; -} -.nv-avatar-image:not([data-loaded]), -.nv-avatar-root:has(.nv-avatar-image[data-loaded]) .nv-avatar-fallback { - display: none; -} -.nv-badge { - justify-content: center; - align-items: center; - gap: calc(var(--spacing) * 1); - border-radius: var(--radius-md); - width: fit-content; - max-width: 100%; - height: fit-content; - padding-inline: calc(var(--spacing) * 2); - font-family: var(--font-sans); - font-size: var(--text-12); - font-weight: var(--font-weight-bold); - vertical-align: middle; - --_bg-color: transparent; - --_border-color: var(--border-color-accent-blue); - --_text-color: var(--text-color-accent-blue); - background-color: var(--nv-badge-bg-color, var(--bg-color, var(--_bg-color))); - border: 1px solid var(--nv-badge-border-color, var(--border-color, var(--_border-color))); - color: var(--nv-badge-text-color, var(--text-color, var(--_text-color))); - flex-grow: 0; - flex-shrink: 0; - line-height: 1.33333; - display: inline-flex; -} -.nv-badge svg, -.nv-badge .nv-icon { - flex-shrink: 0; - width: 1em; - height: 1em; -} -.nv-badge.nv-badge--kind-solid { - --_bg-color: var(--background-color-accent-blue); - --_text-color: var(--text-color-accent-blue-strong); - --_border-color: var(--background-color-accent-blue); -} -.nv-badge.nv-badge--color-green { - --_border-color: var(--border-color-accent-green); - --_text-color: var(--text-color-accent-green); -} -.nv-badge.nv-badge--color-green.nv-badge--kind-solid { - --_bg-color: var(--background-color-accent-green); - --_text-color: var(--text-color-accent-green-strong); - --_border-color: var(--background-color-accent-green); -} -.nv-badge.nv-badge--color-red { - --_border-color: var(--border-color-accent-red); - --_text-color: var(--text-color-accent-red); -} -.nv-badge.nv-badge--color-red.nv-badge--kind-solid { - --_bg-color: var(--background-color-accent-red); - --_text-color: var(--text-color-accent-red-strong); - --_border-color: var(--background-color-accent-red); -} -.nv-badge.nv-badge--color-yellow { - --_border-color: var(--border-color-accent-yellow); - --_text-color: var(--text-color-accent-yellow); -} -.nv-badge.nv-badge--color-yellow.nv-badge--kind-solid { - --_bg-color: var(--background-color-accent-yellow); - --_text-color: var(--text-color-accent-yellow-strong); - --_border-color: var(--background-color-accent-yellow); -} -.nv-badge.nv-badge--color-purple { - --_border-color: var(--border-color-accent-purple); - --_text-color: var(--text-color-accent-purple); -} -.nv-badge.nv-badge--color-purple.nv-badge--kind-solid { - --_bg-color: var(--background-color-accent-purple); - --_text-color: var(--text-color-accent-purple-strong); - --_border-color: var(--background-color-accent-purple); -} -.nv-badge.nv-badge--color-teal { - --_border-color: var(--border-color-accent-teal); - --_text-color: var(--text-color-accent-teal); -} -.nv-badge.nv-badge--color-teal.nv-badge--kind-solid { - --_bg-color: var(--background-color-accent-teal); - --_text-color: var(--text-color-accent-teal-strong); - --_border-color: var(--background-color-accent-teal); -} -.nv-badge.nv-badge--color-gray { - --_border-color: var(--border-color-accent-gray); - --_text-color: var(--text-color-primary); -} -.nv-badge.nv-badge--color-gray.nv-badge--kind-solid { - --_bg-color: var(--background-color-accent-gray-subtle); - --_border-color: var(--background-color-accent-gray-subtle); -} -.nv-banner-root { - box-sizing: border-box; - border-radius: var(--radius-md); - background-color: var(--bg-color); - width: 100%; - min-height: 40px; - color: var(--text-color); - border: 1px solid var(--border-color); - padding: calc(var(--spacing) * 2); - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); - align-items: center; - display: flex; - container-type: inline-size; -} -.nv-banner-root, -.nv-banner-root:where(.nv-banner-root--status-info) { - --bg-color: var(--background-color-feedback-info); - --text-color: var(--text-color-feedback-info-inverse); - --border-color: var(--border-color-feedback-info); -} -.nv-banner-root.nv-banner-root--status-error { - --bg-color: var(--background-color-feedback-danger); - --text-color: var(--text-color-feedback-danger-inverse); - --border-color: var(--border-color-feedback-danger-subtle); -} -.nv-banner-root.nv-banner-root--status-warning { - --bg-color: var(--background-color-feedback-warning); - --text-color: var(--text-color-feedback-warning-inverse); - --border-color: var(--border-color-feedback-warning); -} -.nv-banner-root.nv-banner-root--status-success { - --bg-color: var(--background-color-feedback-success); - --text-color: var(--text-color-feedback-success-inverse); - --border-color: var(--border-color-feedback-success); -} -.nv-banner-root.nv-banner-root--kind-header { - padding: calc(var(--spacing) * 4); -} -.nv-banner-root.nv-banner-root--kind-header .nv-banner-icon { - align-self: flex-start; -} -.nv-banner-root.nv-banner-root--kind-header .nv-banner-heading { - font-family: var(--font-sans); - font-size: var(--text-16); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-bold); -} -.nv-banner-root.nv-banner-root--kind-global { - border-radius: var(--radius-none); - border: 0; -} -.nv-banner-root.nv-banner-root--kind-global .nv-banner-content { - justify-content: center; -} -.nv-banner-root .nv-banner-icon { - padding-block: calc(var(--spacing) * 1); - font-size: var(--text-16); - display: flex; -} -.nv-banner-root .nv-banner-header { - gap: calc(var(--spacing) * 1); - flex-direction: column; - display: flex; -} -.nv-banner-root .nv-banner-layout { - grid-template-columns: 1fr auto auto; - grid-template-areas: 'content actions close-button'; - width: 100%; - display: grid; -} -.nv-banner-root .nv-banner-layout .nv-banner-content { - align-items: center; - gap: calc(var(--spacing) * 2); - grid-area: content; - width: 100%; - display: flex; -} -.nv-banner-root .nv-banner-layout .nv-banner-actions-section { - justify-content: flex-end; - place-items: start; - gap: calc(var(--spacing) * 2); - padding-left: calc(var(--spacing) * 2); - flex-wrap: wrap; - grid-area: actions; - display: flex; -} -.nv-banner-root .nv-banner-layout .nv-banner-close-button-section { - padding-left: calc(var(--spacing) * 2); - grid-area: close-button; -} -@container (width<400px) { - .nv-banner-root .nv-banner-layout { - grid-template-columns: 1fr auto; - grid-template-areas: 'content close-button' 'actions actions'; - } - .nv-banner-root .nv-banner-layout .nv-banner-close-button-section { - place-content: start; - } - .nv-banner-root .nv-banner-layout .nv-banner-content { - justify-content: flex-start !important; - } - .nv-banner-root .nv-banner-layout .nv-banner-actions-section { - padding-top: calc(var(--spacing) * 2); - padding-left: calc(var(--spacing) * 0); - } -} -.nv-banner-root.nv-banner-root--actionsPosition-bottom .nv-banner-layout { - grid-template-columns: 1fr auto; - grid-template-areas: 'content close-button' 'actions actions'; -} -.nv-banner-root.nv-banner-root--actionsPosition-bottom - .nv-banner-layout - .nv-banner-close-button-section { - place-content: start; -} -.nv-banner-root.nv-banner-root--actionsPosition-bottom .nv-banner-layout .nv-banner-content { - justify-content: flex-start !important; -} -.nv-banner-root.nv-banner-root--actionsPosition-bottom - .nv-banner-layout - .nv-banner-actions-section { - padding-top: calc(var(--spacing) * 2); - padding-left: calc(var(--spacing) * 0); -} -.nv-block { - max-width: 100%; - max-height: 100%; - font-family: var(--font-sans); - display: block; -} -.nv-block--overflow-auto { - overflow: auto; -} -.nv-block--overflow-clip { - overflow: clip; -} -.nv-block--overflow-hidden { - overflow: hidden; -} -.nv-block--overflow-scroll { - overflow: scroll; -} -.nv-block--overflow-visible { - overflow: visible; -} -.nv-block--overflow-x-auto { - overflow-x: auto; -} -.nv-block--overflow-x-clip { - overflow-x: clip; -} -.nv-block--overflow-x-hidden { - overflow-x: hidden; -} -.nv-block--overflow-x-scroll { - overflow-x: scroll; -} -.nv-block--overflow-x-visible { - overflow-x: visible; -} -.nv-block--overflow-y-auto { - overflow-y: auto; -} -.nv-block--overflow-y-clip { - overflow-y: clip; -} -.nv-block--overflow-y-hidden { - overflow-y: hidden; -} -.nv-block--overflow-y-scroll { - overflow-y: scroll; -} -.nv-block--overflow-y-visible { - overflow-y: visible; -} -.nv-block--text-ellipsis { - text-overflow: ellipsis; -} -.nv-block--text-clip { - text-overflow: clip; -} -.nv-block--text-wrap { - text-wrap: wrap; -} -.nv-block--text-nowrap { - text-wrap: nowrap; -} -.nv-block--text-balance { - text-wrap: balance; -} -.nv-block--text-pretty { - text-wrap: pretty; -} -.nv-breadcrumbs-root { - align-items: center; - gap: calc(var(--spacing) * 1); - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: 1.21429; - font-weight: var(--font-weight-regular); - color: var(--text-color-secondary); - flex-wrap: wrap; - display: flex; -} -:is(.nv-breadcrumbs-root, .nv-breadcrumbs-root.nv-breadcrumbs-root--size-medium) - .nv-breadcrumbs-separator { - width: calc(var(--spacing) * 4); - height: calc(var(--spacing) * 4); - font-size: var(--text-16); -} -.nv-breadcrumbs-root.nv-breadcrumbs-root--size-small { - font-family: var(--font-sans); - font-size: var(--text-12); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-regular); -} -.nv-breadcrumbs-root.nv-breadcrumbs-root--size-large { - font-family: var(--font-sans); - font-size: var(--text-16); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-regular); -} -.nv-breadcrumbs-root.nv-breadcrumbs-root--size-large .nv-breadcrumbs-separator { - width: calc(var(--spacing) * 6); - height: calc(var(--spacing) * 6); - font-size: var(--text-24); -} -.nv-breadcrumbs-item { - align-items: center; - gap: calc(var(--spacing) * 1); - text-underline-offset: 25%; - text-underline-position: from-font; - text-decoration-thickness: 10%; - display: inline-flex; -} -.nv-breadcrumbs-item.nv-breadcrumbs-item--active { - color: var(--text-color-primary); - font-weight: var(--font-weight-bold); -} -.nv-breadcrumbs-item:not(.nv-breadcrumbs-item--active) { - text-decoration-line: underline; - text-decoration-color: #0000; -} -@media (hover: hover) { - :is( - .nv-breadcrumbs-item:not(.nv-breadcrumbs-item--active):hover, - .nv-breadcrumbs-item:not(.nv-breadcrumbs-item--active):focus - ) { - -webkit-text-decoration-color: var(--text-color-base); - text-decoration-color: var(--text-color-base); - } -} -@media (prefers-reduced-motion: no-preference) { - .nv-breadcrumbs-item { - transition-property: - color, background-color, border-color, text-decoration-color, fill, stroke; - transition-duration: 0.15s; - transition-timing-function: var(--ease-out); - } -} -.nv-breadcrumbs-separator { - color: var(--text-color-base); - line-height: var(--leading-lh-100); - place-items: center; - display: grid; -} -.nv-button { - cursor: pointer; - background: var(--nv-button-bg, var(--_bg, var(--background-color-interaction-inverse))); - border: var(--nv-button-border, var(--_border, 1px solid transparent)); - width: fit-content; - min-width: fit-content; - max-width: 100%; - height: fit-content; - color: var(--nv-button-color, var(--_color, var(--text-color-inverse))); - --_padding: var(--_padding, calc(var(--spacing) * 3)); - padding-inline: var(--nv-button-padding, var(--_padding)); - padding-block: calc(var(--_padding) - 2px); - justify-content: center; - align-items: center; - gap: var(--nv-button-gap, var(--spacing)); - border-radius: var(--nv-button-border-radius, var(--radius-md)); - font-size: var(--nv-button-font-size, var(--_font-size, var(--text-14))); - font-weight: var(--font-weight-bold); - font-family: var(--font-sans); - flex-grow: 0; - flex-shrink: 0; - display: inline-flex; -} -.nv-button:disabled { - cursor: not-allowed; -} -@media (prefers-reduced-motion: no-preference) { - .nv-button { - transition-property: background-color, color, scale, border-color; - transition-duration: 0.15s; - transition-timing-function: var(--ease-out); - } -} -.nv-button:focus-visible { - outline: 2px solid var(--text-color-inverse); - outline-offset: -2px; - box-shadow: 0 0 0 2px var(--text-color-primary); -} -.nv-button:active:not(:disabled) { - scale: 99%; -} -.nv-button:hover:not(:disabled) { - background: var( - --nv-button-bg-hover, - var(--_bg, var(--background-color-interaction-inverse-hover)) - ); - color: var(--nv-button-color-hover, var(--_color, var(--text-color-inverse))); - border: var(--nv-button-border-hover, var(--_border, 1px solid transparent)); -} -.nv-button:active:not(:disabled), -.nv-button[data-state='open']:not([data-active-state='disabled']), -.nv-button[aria-expanded='true']:not([data-active-state='disabled']) { - background: var( - --nv-button-bg-active, - var(--_bg, var(--background-color-interaction-inverse-pressed)) - ); - color: var(--nv-button-color-active, var(--_color, var(--text-color-inverse))); - border: var(--nv-button-border-active, var(--_border, 1px solid transparent)); -} -.nv-button:disabled { - background: var( - --nv-button-bg-disabled, - var(--_bg, var(--background-color-interaction-disabled)) - ); - color: var(--nv-button-color-disabled, var(--_color, var(--text-color-disabled))); - border: var(--nv-button-border-disabled, var(--_border, 1px solid transparent)); -} -.nv-button svg, -.nv-button .nv-icon { - color: var( - --nv-button-icon-color, - var(--_icon-color, var(--_color, var(--text-color-inverse))) - ); - font-size: var(--nv-button-icon-size, var(--_icon-size, var(--text-16))); - margin: var(--nv-button-icon-margin, var(--_icon-margin, 0)); - flex-shrink: 0; -} -.nv-button:hover:not(:disabled) svg, -.nv-button:hover:not(:disabled) .nv-icon { - color: var( - --nv-button-icon-color-hover, - var(--_icon-color, var(--_color, var(--text-color-inverse))) - ); -} -.nv-button:active:not(:disabled) svg, -.nv-button:active:not(:disabled) .nv-icon, -.nv-button[data-state='open']:not([data-active-state='disabled']) svg, -.nv-button[data-state='open']:not([data-active-state='disabled']) .nv-icon, -.nv-button[aria-expanded='true']:not([data-active-state='disabled']) svg, -.nv-button[aria-expanded='true']:not([data-active-state='disabled']) .nv-icon { - color: var( - --nv-button-icon-color-active, - var(--_icon-color, var(--_color, var(--text-color-inverse))) - ); -} -.nv-button:disabled svg, -.nv-button:disabled .nv-icon { - color: var(--nv-button-icon-color-disabled, var(--_icon-color, var(--text-color-disabled))); -} -:is( - .nv-button:not(.nv-button-group .nv-button), - .nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-primary > .nv-button) -), -:is( - .nv-button:not(.nv-button-group .nv-button), - .nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-primary > .nv-button) -):where(.nv-button--color-neutral) { - --_bg: var(--background-color-interaction-inverse); - --_color: var(--text-color-inverse); - --_border: 1px solid transparent; -} -:is( - .nv-button:not(.nv-button-group .nv-button), - .nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-primary > .nv-button) -):where(.nv-button--color-brand) { - --_bg: var(--background-color-interaction-primary-base); - --_color: var(--text-color-accent-black); - --_border: 1px solid transparent; -} -:is( - .nv-button:not(.nv-button-group .nv-button), - .nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-primary > .nv-button) -):where(.nv-button--color-danger) { - --_bg: var(--background-color-feedback-danger-strong); - --_color: var(--text-color-accent-white); - --_border: 1px solid var(--border-color-feedback-danger); -} -:is( - .nv-button:not(.nv-button-group .nv-button), - .nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-primary > .nv-button) - ):hover:not(:disabled), -:is( - .nv-button:not(.nv-button-group .nv-button), - .nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-primary > .nv-button) - ):hover:not(:disabled):where(.nv-button--color-neutral) { - --_bg: var(--background-color-interaction-inverse-hover); -} -:is( - .nv-button:not(.nv-button-group .nv-button), - .nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-primary > .nv-button) - ):hover:not(:disabled):where(.nv-button--color-brand) { - --_bg: var(--background-color-interaction-primary-hover); -} -:is( - .nv-button:not(.nv-button-group .nv-button), - .nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-primary > .nv-button) - ):hover:not(:disabled):where(.nv-button--color-danger) { - --_bg: var(--background-color-feedback-danger-hover); - --_border: 1px solid var(--border-color-feedback-danger-hover); -} -:is( - :is( - .nv-button:not(.nv-button-group .nv-button), - .nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-primary > .nv-button) - ):active:not(:disabled), - :is( - .nv-button:not(.nv-button-group .nv-button), - .nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-primary > .nv-button) - )[data-state='open']:not([data-active-state='disabled']):not(:disabled), - :is( - .nv-button:not(.nv-button-group .nv-button), - .nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-primary > .nv-button) - )[aria-expanded='true']:not([data-active-state='disabled']):not(:disabled) -), -:is( - :is( - .nv-button:not(.nv-button-group .nv-button), - .nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-primary > .nv-button) - ):active:not(:disabled), - :is( - .nv-button:not(.nv-button-group .nv-button), - .nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-primary > .nv-button) - )[data-state='open']:not([data-active-state='disabled']):not(:disabled), - :is( - .nv-button:not(.nv-button-group .nv-button), - .nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-primary > .nv-button) - )[aria-expanded='true']:not([data-active-state='disabled']):not(:disabled) -):where(.nv-button--color-neutral) { - --_bg: var(--background-color-interaction-inverse-pressed); -} -:is( - :is( - .nv-button:not(.nv-button-group .nv-button), - .nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-primary > .nv-button) - ):active:not(:disabled), - :is( - .nv-button:not(.nv-button-group .nv-button), - .nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-primary > .nv-button) - )[data-state='open']:not([data-active-state='disabled']):not(:disabled), - :is( - .nv-button:not(.nv-button-group .nv-button), - .nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-primary > .nv-button) - )[aria-expanded='true']:not([data-active-state='disabled']):not(:disabled) -):where(.nv-button--color-brand) { - --_bg: var(--background-color-interaction-primary-selected); -} -:is( - :is( - .nv-button:not(.nv-button-group .nv-button), - .nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-primary > .nv-button) - ):active:not(:disabled), - :is( - .nv-button:not(.nv-button-group .nv-button), - .nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-primary > .nv-button) - )[data-state='open']:not([data-active-state='disabled']):not(:disabled), - :is( - .nv-button:not(.nv-button-group .nv-button), - .nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-primary > .nv-button) - )[aria-expanded='true']:not([data-active-state='disabled']):not(:disabled) -):where(.nv-button--color-danger) { - --_bg: var(--background-color-feedback-danger-pressed); - --_border: 1px solid var(--border-color-feedback-danger-strong); -} -:is( - .nv-button:not(.nv-button-group .nv-button), - .nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-primary > .nv-button) -):disabled { - --_bg: var(--background-color-interaction-disabled); - --_color: var(--text-color-disabled); - --_border: 1px solid transparent; -} -:is( - .nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-secondary > .nv-button) -), -:is( - .nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-secondary > .nv-button) -):where(.nv-button--color-neutral) { - --_bg: transparent; - --_border: 1px solid var(--border-color-interaction-strong); - --_color: var(--text-color-primary); -} -:is( - .nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-secondary > .nv-button) -):where(.nv-button--color-brand) { - --_bg: transparent; - --_border: 1px solid var(--border-color-brand); - --_color: var(--text-color-primary); - --_icon-color: var(--text-color-brand); -} -:is( - .nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-secondary > .nv-button) -):where(.nv-button--color-danger) { - --_bg: transparent; - --_border: 1px solid var(--border-color-feedback-danger); - --_color: var(--text-color-feedback-danger); -} -:is( - .nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-secondary > .nv-button) - ):hover:not(:disabled), -:is( - .nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-secondary > .nv-button) - ):hover:not(:disabled):where(.nv-button--color-neutral), -:is( - .nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-secondary > .nv-button) - ):hover:not(:disabled):where(.nv-button--color-brand) { - --_bg: var(--background-color-interaction-hover); -} -:is( - .nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-secondary > .nv-button) - ):hover:not(:disabled):where(.nv-button--color-danger) { - --_bg: var(--background-color-feedback-danger-subtle-hover); - --_color: var(--text-color-feedback-danger-subtle); -} -:is( - :is( - .nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-secondary > .nv-button) - ):active:not(:disabled), - :is( - .nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-secondary > .nv-button) - )[data-state='open']:not([data-active-state='disabled']):not(:disabled), - :is( - .nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-secondary > .nv-button) - )[aria-expanded='true']:not([data-active-state='disabled']):not(:disabled) -), -:is( - :is( - .nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-secondary > .nv-button) - ):active:not(:disabled), - :is( - .nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-secondary > .nv-button) - )[data-state='open']:not([data-active-state='disabled']):not(:disabled), - :is( - .nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-secondary > .nv-button) - )[aria-expanded='true']:not([data-active-state='disabled']):not(:disabled) -):where(.nv-button--color-neutral), -:is( - :is( - .nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-secondary > .nv-button) - ):active:not(:disabled), - :is( - .nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-secondary > .nv-button) - )[data-state='open']:not([data-active-state='disabled']):not(:disabled), - :is( - .nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-secondary > .nv-button) - )[aria-expanded='true']:not([data-active-state='disabled']):not(:disabled) -):where(.nv-button--color-brand) { - --_bg: var(--background-color-interaction-pressed); -} -:is( - :is( - .nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-secondary > .nv-button) - ):active:not(:disabled), - :is( - .nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-secondary > .nv-button) - )[data-state='open']:not([data-active-state='disabled']):not(:disabled), - :is( - .nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-secondary > .nv-button) - )[aria-expanded='true']:not([data-active-state='disabled']):not(:disabled) -):where(.nv-button--color-danger) { - --_bg: var(--background-color-feedback-danger-subtle-pressed); - --_color: var(--text-color-feedback-danger-strong); - --_border: 1px solid var(--border-color-feedback-danger); -} -:is( - .nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-secondary > .nv-button) -):disabled { - --_bg: transparent; - --_border: 1px solid var(--border-color-interaction-disabled); - --_color: var(--text-color-disabled); - --_icon-color: var(--text-color-disabled); -} -.nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button), -.nv-button:where(.nv-button-group--kind-tertiary > .nv-button) { - --_bg: transparent; - --_border: 1px solid transparent; - --_color: var(--text-color-primary); -} -:is( - .nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-tertiary > .nv-button) -):where(.nv-button--color-brand) { - --_icon-color: var(--text-color-brand); -} -:is( - .nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-tertiary > .nv-button) -):where(.nv-button--color-danger) { - --_color: var(--text-color-feedback-danger); -} -:is( - .nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-tertiary > .nv-button) - ):hover:not(:disabled) { - --_bg: var(--background-color-interaction-hover); -} -:is( - .nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-tertiary > .nv-button) - ):hover:not(:disabled):where(.nv-button--color-danger) { - --_bg: var(--background-color-feedback-danger-subtle-hover); - --_color: var(--text-color-feedback-danger-subtle); - --_border: 1px solid var(--background-color-feedback-danger-subtle-hover); -} -:is( - :is( - .nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-tertiary > .nv-button) - ):active:not(:disabled), - :is( - .nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-tertiary > .nv-button) - )[data-state='open']:not([data-active-state='disabled']):not(:disabled), - :is( - .nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-tertiary > .nv-button) - )[aria-expanded='true']:not([data-active-state='disabled']):not(:disabled) -), -:is( - :is( - .nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-tertiary > .nv-button) - ):active:not(:disabled), - :is( - .nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-tertiary > .nv-button) - )[data-state='open']:not([data-active-state='disabled']):not(:disabled), - :is( - .nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-tertiary > .nv-button) - )[aria-expanded='true']:not([data-active-state='disabled']):not(:disabled) -):where(.nv-button--color-neutral), -:is( - :is( - .nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-tertiary > .nv-button) - ):active:not(:disabled), - :is( - .nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-tertiary > .nv-button) - )[data-state='open']:not([data-active-state='disabled']):not(:disabled), - :is( - .nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-tertiary > .nv-button) - )[aria-expanded='true']:not([data-active-state='disabled']):not(:disabled) -):where(.nv-button--color-brand) { - --_bg: var(--background-color-interaction-pressed); -} -:is( - :is( - .nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-tertiary > .nv-button) - ):active:not(:disabled), - :is( - .nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-tertiary > .nv-button) - )[data-state='open']:not([data-active-state='disabled']):not(:disabled), - :is( - .nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-tertiary > .nv-button) - )[aria-expanded='true']:not([data-active-state='disabled']):not(:disabled) -):where(.nv-button--color-danger) { - --_bg: var(--background-color-feedback-danger-subtle-pressed); - --_color: var(--text-color-feedback-danger-strong); - --_border: 1px solid transparent; -} -:is( - .nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button), - .nv-button:where(.nv-button-group--kind-tertiary > .nv-button) -):disabled { - --_bg: transparent; - --_color: var(--text-color-disabled); - --_icon-color: var(--text-color-disabled); -} -.nv-button, -.nv-button:where(.nv-button--size-medium):not(.nv-button-group .nv-button), -.nv-button:where(.nv-button-group--size-medium > .nv-button) { - --_padding: calc(var(--spacing) * 3); - --_font-size: var(--text-14); - --_icon-size: var(--text-16); - --_icon-margin: -1px; - line-height: var(--nv-button-line-height, var(--_line-height, calc(16 / 14))); - min-height: calc(var(--spacing) * 10); - min-width: calc(var(--spacing) * 10); -} -.nv-button:where(.nv-button--size-tiny):not(.nv-button-group .nv-button), -.nv-button:where(.nv-button-group--size-tiny > .nv-button) { - --_padding: var(--spacing); - --_font-size: var(--text-12); - --_icon-size: var(--text-12); - --_line-height: 1; - min-height: calc(var(--spacing) * 5); - min-width: calc(var(--spacing) * 5); -} -.nv-button:where(.nv-button--size-small):not(.nv-button-group .nv-button), -.nv-button:where(.nv-button-group--size-small > .nv-button) { - --_padding: calc(var(--spacing) * 2); - --_font-size: var(--text-12); - --_icon-size: var(--text-12); - --_line-height: 1; - min-height: calc(var(--spacing) * 7); - min-width: calc(var(--spacing) * 7); -} -.nv-button:where(.nv-button--size-large):not(.nv-button-group .nv-button), -.nv-button:where(.nv-button-group--size-large > .nv-button) { - --_padding: calc(var(--spacing) * 4); - --_font-size: var(--text-16); - --_icon-size: var(--text-16); - --_line-height: 1; - min-height: calc(var(--spacing) * 12); - min-width: calc(var(--spacing) * 12); -} -.nv-card-root { - border: 1px solid; - border-color: var(--border-color-base); - text-align: left; - height: 100%; - min-height: fit-content; - font-family: var(--font-sans); - color: var(--text-color-primary); - border-radius: var(--radius-density-xl); - background-color: var(--background-color-surface-raised); - box-shadow: var(--shadow-md); - background-clip: content-box; - flex-direction: column; - display: flex; - position: relative; - overflow: hidden; -} -.nv-card-root.nv-card-root--layout-horizontal { - flex-direction: row; -} -.nv-card-root .nv-card-content { - padding: var(--spacing-density-2xl); -} -.nv-card-root .nv-card-media { - aspect-ratio: 400/234; -} -.nv-card-root.nv-card-root--kind-float .nv-card-media { - border-radius: var(--radius-xl); - background-color: var(--background-color-surface-raised); - box-shadow: var(--shadow-md); - background-clip: content-box; - border: 1px solid #0000; - overflow: hidden; -} -@media (prefers-reduced-motion: no-preference) { - .nv-card-root { - transition-property: background-color, border-color, box-shadow; - transition-duration: 0.2s; - transition-timing-function: var(--ease-out); - } -} -.nv-card-root.nv-card-root--interactive:not(.nv-card-root--selected):hover, -.nv-card-root.nv-card-root--interactive:not(.nv-card-root--selected):focus-visible { - cursor: pointer; -} -:is( - .nv-card-root.nv-card-root--interactive:not(.nv-card-root--selected):hover, - .nv-card-root.nv-card-root--interactive:not(.nv-card-root--selected):focus-visible -):not(.nv-card-root--kind-float) { - background-color: var(--background-color-surface-overlay); -} -:is( - .nv-card-root.nv-card-root--interactive:not(.nv-card-root--selected):hover, - .nv-card-root.nv-card-root--interactive:not(.nv-card-root--selected):focus-visible -):not(.nv-card-root--kind-float), -:is( - .nv-card-root.nv-card-root--interactive:not(.nv-card-root--selected):hover, - .nv-card-root.nv-card-root--interactive:not(.nv-card-root--selected):focus-visible - ).nv-card-root--kind-float - .nv-card-media { - border: 1px solid; - border-color: var(--border-color-interaction-hover); - box-shadow: var(--shadow-lg); -} -.nv-card-root.nv-card-root--selected:not(.nv-card-root--kind-float), -.nv-card-root.nv-card-root--selected.nv-card-root--kind-float .nv-card-media { - border-color: var(--border-color-interaction-selected); - box-shadow: var(--shadow-lg); -} -.nv-card-root.nv-card-root--kind-gradient .nv-card-content { - padding: var(--spacing-density-2xl); -} -.nv-card-root.nv-card-root--kind-gradient .nv-card-media { - -webkit-mask-image: linear-gradient(#000 65%, #0000 98%); - mask-image: linear-gradient(#000 65%, #0000 98%); -} -.nv-card-root.nv-card-root--kind-gradient.nv-card-root--layout-horizontal .nv-card-media { - aspect-ratio: 400/186; - -webkit-mask-image: linear-gradient(90deg, #000 58%, #0000 87%); - mask-image: linear-gradient(90deg, #000 58%, #0000 87%); -} -.nv-card-root.nv-card-root--kind-float { - gap: var(--spacing-density-xl); - border-radius: var(--radius-none); - box-shadow: none; - background-color: #0000; - border: 0; - overflow: visible; -} -.nv-card-root.nv-card-root--kind-float .nv-card-content { - padding: calc(var(--spacing) * 0); -} -.nv-card-root.nv-card-root--kind-float.nv-card-root--layout-horizontal .nv-card-media { - aspect-ratio: 2; -} -.nv-card-root.nv-card-root--kind-float .nv-card-media { - aspect-ratio: 400/234; -} -.nv-card-root .nv-card-media { - flex: 1; - transition: inherit; - position: relative; - overflow: hidden; -} -.nv-card-root .nv-card-media > img, -.nv-card-root .nv-card-media > video { - object-fit: cover; - width: 100%; - height: 100%; - position: absolute; -} -.nv-card-root .nv-card-media-header { - inset: calc(var(--spacing) * 4); - position: absolute; -} -.nv-card-root .nv-card-content-header, -.nv-card-root .nv-card-media-header { - gap: calc(var(--spacing) * 2); - flex-wrap: wrap; - display: flex; -} -.nv-card-root .nv-card-content { - align-items: flex-start; - gap: var(--spacing-density-xl); - flex: 1; - height: fit-content; - display: grid; -} -.nv-checkbox-root { - align-items: flex-start; - gap: calc(var(--spacing) * 2); - width: fit-content; - display: flex; -} -.nv-checkbox-root .nv-label, -.nv-checkbox-root label { - line-height: 1.14286; -} -.nv-checkbox-root.nv-checkbox-root--label-left { - flex-direction: row-reverse; -} -.nv-checkbox-root:has(:disabled) { - cursor: not-allowed; -} -.nv-checkbox-input { - border: 2px solid var(--border-color-interaction-base); - width: calc(var(--spacing) * 4); - height: calc(var(--spacing) * 4); - cursor: pointer; - appearance: none; - border-radius: var(--radius-md); - background-color: var(--background-color-interaction-base); - flex-shrink: 0; - position: relative; -} -.nv-checkbox-input:before { - content: ''; - inset: calc(var(--spacing) * 0); - color: var(--text-color-accent-black); - opacity: 0; - clip-path: polygon(20% 100%, 20% 80%, 50% 80%, 50% 80%, 70% 80%, 70% 100%); - background-color: currentColor; - width: 70%; - height: 70%; - margin: auto; - display: block; - position: absolute; - rotate: 45deg; -} -.nv-checkbox-input:hover { - border-color: var(--border-color-interaction-hover); - background-color: var(--background-color-interaction-hover); -} -.nv-checkbox-input:active { - border-color: var(--border-color-interaction-pressed); - background-color: var(--background-color-interaction-selected); -} -.nv-checkbox-input:is(:disabled, [aria-disabled='true']) { - border-color: var(--border-color-interaction-disabled); - pointer-events: none; - cursor: not-allowed; - background-color: var(--background-color-interaction-disabled); -} -.nv-checkbox-input:is(:checked, [data-state='checked']) { - background-color: var(--background-color-interaction-primary-base); - border: 1px solid #0000; -} -.nv-checkbox-input:is(:checked, [data-state='checked']):before { - opacity: 1; - clip-path: polygon(20% 100%, 20% 80%, 50% 80%, 50% 0%, 70% 0%, 70% 100%); -} -.nv-checkbox-input:is(:checked, [data-state='checked']):hover { - background-color: var(--background-color-interaction-primary-hover); -} -.nv-checkbox-input:is(:checked, [data-state='checked']):active { - background-color: var(--background-color-interaction-primary-selected); -} -.nv-checkbox-input:is(:checked, [data-state='checked']):disabled, -.nv-checkbox-input:is(:checked, [data-state='checked'])[aria-disabled='true'] { - background-color: var(--background-color-interaction-disabled-checked); -} -:is( - .nv-checkbox-input:is(:checked, [data-state='checked']):disabled, - .nv-checkbox-input:is(:checked, [data-state='checked'])[aria-disabled='true'] -):before { - color: var(--text-color-inverse); -} -.nv-checkbox-input:indeterminate { - background-color: var(--background-color-interaction-primary-base); - border: 1px solid #0000; -} -.nv-checkbox-input:indeterminate:before { - opacity: 1; - clip-path: polygon(10% 60%, 10% 40%, 90% 40%, 90% 60%); - rotate: none; -} -.nv-checkbox-input:indeterminate:hover { - background-color: var(--background-color-interaction-primary-hover); -} -.nv-checkbox-input:indeterminate:active { - background-color: var(--background-color-interaction-primary-selected); -} -.nv-checkbox-input:indeterminate:disabled, -.nv-checkbox-input:indeterminate[aria-disabled='true'] { - background-color: var(--background-color-interaction-disabled-checked); -} -:is( - .nv-checkbox-input:indeterminate:disabled, - .nv-checkbox-input:indeterminate[aria-disabled='true'] -):before { - color: var(--text-color-inverse); -} -@media (prefers-reduced-motion: no-preference) { - .nv-checkbox-input { - transition-duration: 0.15s; - transition-timing-function: var(--ease-out); - transition-property: background-color, border-width, border-color; - } - .nv-checkbox-input:before { - transition: - clip-path var(--ease-out), - opacity var(--ease-out), - transform var(--ease-out); - } -} -@media (forced-colors: active) { - .nv-checkbox-input:is(:checked, :indeterminate):before { - clip-path: none; - background-color: #0000; - justify-content: center; - align-items: center; - font-size: 0.75rem; - line-height: 1; - display: flex; - rotate: none; - } - .nv-checkbox-input:is(:checked, [data-state='checked']):before { - content: '\2713'; - } - .nv-checkbox-input:indeterminate:before { - content: '\2013'; - } -} -@media print { - .nv-checkbox-input:is(:checked, [data-state='checked']):before { - content: '\2713'; - clip-path: none; - background-color: #0000; - rotate: none; - } - .nv-checkbox-input:indeterminate:before { - content: '\2013'; - clip-path: none; - background-color: #0000; - rotate: none; - } -} -.nv-checkbox-input--error.nv-checkbox-input:not(:disabled):not([aria-disabled='true']) { - border-color: var(--border-color-feedback-danger); -} -.nv-checkbox-input--error.nv-checkbox-input:not(:disabled):not([aria-disabled='true']):hover { - background-color: var(--background-color-feedback-danger-subtle-hover); -} -.nv-checkbox-input--error.nv-checkbox-input:not(:disabled):not([aria-disabled='true']):active { - background-color: var(--background-color-feedback-danger-subtle-pressed); -} -.nv-checkbox-input--error.nv-checkbox-input:not(:disabled):not([aria-disabled='true']):is( - :checked, - [data-state='checked'] - ) { - background-color: var(--background-color-feedback-danger-strong); - border-color: #0000; -} -.nv-checkbox-input--error.nv-checkbox-input:not(:disabled):not([aria-disabled='true']):is( - :checked, - [data-state='checked'] - ):hover { - background-color: var(--background-color-feedback-danger-hover); -} -.nv-checkbox-input--error.nv-checkbox-input:not(:disabled):not([aria-disabled='true']):is( - :checked, - [data-state='checked'] - ):active { - background-color: var(--background-color-feedback-danger-pressed); -} -.nv-checkbox-input--error.nv-checkbox-input:not(:disabled):not( - [aria-disabled='true'] - ):indeterminate { - background-color: var(--background-color-feedback-danger-strong); - border-color: #0000; -} -.nv-checkbox-input--error.nv-checkbox-input:not(:disabled):not( - [aria-disabled='true'] - ):indeterminate:hover { - background-color: var(--background-color-feedback-danger-hover); -} -.nv-checkbox-input--error.nv-checkbox-input:not(:disabled):not( - [aria-disabled='true'] - ):indeterminate:active { - background-color: var(--background-color-feedback-danger-pressed); -} -.nv-code-snippet-root { - --nv-code-snippet-custom-background: var(--background-color-surface-base); - gap: calc(var(--spacing) * 1); - border-radius: var(--radius-md); - min-height: fit-content; - font-family: var(--font-mono); - font-size: var(--text-14); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); - flex-direction: column; - display: flex; - position: relative; - overflow: hidden; -} -.nv-code-snippet-root .nv-code-snippet-actions { - justify-content: flex-end; - align-items: center; - display: flex; -} -.nv-code-snippet-root.nv-code-snippet-root--collapsible:not(.nv-code-snippet-root--open):after { - content: ''; - height: calc(var(--nv-code-snippet-rows, 4) * 1lh); - pointer-events: none; - background-color: var(--nv-code-snippet-custom-background); - position: absolute; - bottom: 1px; - left: 1px; - right: 1px; - -webkit-mask-image: linear-gradient(#0000 0% 0%, #0003 60%, #0009 80%, #000 100%); - mask-image: linear-gradient(#0000 0% 0%, #0003 60%, #0009 80%, #000 100%); -} -.nv-code-snippet-root.nv-code-snippet-root--kind-inline { - width: fit-content; - padding: calc(var(--spacing) * 0); - align-items: center; - display: inline-flex; -} -.nv-code-snippet-root.nv-code-snippet-root--kind-inline .nv-code-snippet-code { - padding: calc(var(--spacing) * 0); -} -.nv-code-snippet-root .nv-code-snippet-code { - border-radius: var(--radius-md); - border: 1px solid; - border-color: var(--border-color-base); - background-color: var(--nv-code-snippet-custom-background); - width: 100%; - padding-inline: calc(var(--spacing) * 4); - padding-block: calc(var(--spacing) * 2); - opacity: 1; - transition: opacity var(--ease-out) ease-in-out; - scrollbar-width: thin; - scrollbar-color: var(--nv-scrollbar-color); - align-content: center; - align-self: stretch; - overflow: auto; -} -.nv-code-snippet-root .nv-code-snippet-code.nv-code-snippet-code--loading { - opacity: 0; -} -.nv-code-snippet-root .nv-code-snippet-copy-button { - justify-content: flex-end; -} -.nv-code-snippet-root.nv-code-snippet-root--with-rows:not(.nv-code-snippet-root--open) - .nv-code-snippet-code { - max-height: calc(var(--nv-code-snippet-rows) * 1lh); - position: relative; - overflow-y: auto; -} -.nv-code-snippet-root.nv-code-snippet-root--collapsible { - position: relative; -} -.nv-code-snippet-root.nv-code-snippet-root--collapsible.nv-code-snippet-root--open - .nv-code-snippet-code { - max-height: none; -} -.nv-code-snippet-root.nv-code-snippet-root--collapsible - .nv-code-snippet-code.nv-code-snippet-code--collapsed { - max-height: calc(var(--nv-code-snippet-rows, 4) * 1lh); - position: relative; - overflow-y: auto; -} -.nv-collapsible-root > summary { - list-style: none; -} -.nv-collapsible-root > summary::-webkit-details-marker { - display: none; -} -.nv-collapsible-root > summary::marker { - content: ''; - display: none; -} -.nv-collapsible-root::details-content { - height: 0; - transition: - height 0.2s var(--ease-out), - content-visibility 0.2s allow-discrete; - display: block; - overflow: clip; -} -.nv-collapsible-root[open]::details-content { - height: auto; -} -@media (prefers-reduced-motion: reduce) { - .nv-collapsible-root::details-content { - transition-duration: 0.01ms; - } -} -.nv-collapsible-trigger-wrapper { - list-style: none; -} -.nv-collapsible-trigger-wrapper::-webkit-details-marker { - display: none; -} -.nv-collapsible-trigger-wrapper::marker { - content: ''; - display: none; -} -.nv-collapsible-trigger[data-disabled], -.nv-collapsible-trigger[aria-disabled='true'] { - pointer-events: none; - cursor: not-allowed; - opacity: 0.5; -} -.nv-collapsible-trigger[data-state='open'] .group-data-\[state\=open\]\:hidden { - display: none; -} -.nv-collapsible-trigger[data-state='closed'] .group-data-\[state\=closed\]\:hidden { - display: none; -} -.nv-collapsible-content-inner { - padding: var(--nv-collapsible-content-padding, 0); -} -@media (scripting: enabled) { - .nv-combobox-native-fallback { - display: none; - } -} -@media (scripting: none) { - [data-combobox-enhanced] { - display: none !important; - } -} -.nv-combobox-content[popover] { - color: inherit; - background: 0 0; - border: 0; - margin: 0; - padding: 0; - display: none; - position: fixed; - inset: auto; - overflow: visible; -} -.nv-combobox-content[popover]:popover-open { - display: block; -} -.nv-combobox-content[popover].\:popover-open { - display: block; -} -.nv-combobox-content { - --menu-translate-start: 0 calc(var(--transition-offset) * -1); - transform-origin: top; -} -@supports (position-anchor: --a) { - .nv-combobox-content { - --nv-combobox-offset: 4px; - width: anchor-size(width); - position-try-fallbacks: - flip-block, - flip-inline, - flip-block flip-inline; - margin: 0; - } - .nv-combobox-content[data-side='bottom'] { - margin-top: var(--nv-combobox-offset); - position-area: bottom; - } - .nv-combobox-content[data-side='top'] { - margin-bottom: var(--nv-combobox-offset); - position-area: top; - } - .nv-combobox-content[data-side='left'] { - margin-right: var(--nv-combobox-offset); - position-area: left; - } - .nv-combobox-content[data-side='right'] { - margin-left: var(--nv-combobox-offset); - position-area: right; - } -} -.nv-combobox-content[data-side='top'] { - --menu-translate-start: 0 calc(var(--transition-offset)); - transform-origin: bottom; -} -.nv-combobox-content[data-side='left'] { - --menu-translate-start: var(--transition-offset) 0; - transform-origin: 100%; -} -.nv-combobox-content[data-side='right'] { - --menu-translate-start: calc(var(--transition-offset) * -1) 0; - transform-origin: 0; -} -.nv-combobox-content .nv-menu-root { - overscroll-behavior: contain; - max-height: min(320px, 100vh - 2rem); -} -.nv-combobox-content .nv-menu-item[data-active-item] { - background-color: var(--background-color-interaction-hover); -} -@keyframes combobox-in { - 0% { - translate: var(--menu-translate-start); - opacity: 0; - } - to { - opacity: 1; - translate: 0; - } -} -@media (prefers-reduced-motion: no-preference) { - .nv-combobox-content:popover-open { - animation: combobox-in 0.25s var(--ease-out); - } -} -.nv-input-shell.nv-combobox-trigger--multiple { - --nv-combobox-trigger-max-height: calc(var(--nv-input-height) * 3.5); - height: auto; - min-height: var(--nv-input-height); - padding-block: calc(var(--nv-input-padding) / 2); -} -.nv-input-shell.nv-combobox-trigger--multiple input:disabled, -.nv-input-shell.nv-combobox-trigger--multiple input[readonly] { - flex: 0 0 0; - min-width: 0; -} -.nv-combobox-trigger-field { - align-items: center; - gap: inherit; - min-width: 0; - max-height: var(--nv-combobox-trigger-max-height); - scrollbar-width: thin; - scrollbar-color: var(--nv-scrollbar-color); - flex-wrap: wrap; - flex-grow: 1; - display: flex; - overflow: hidden auto; -} -.nv-combobox-trigger-field input { - flex: 1 0 6rem; - min-width: 2.5rem; -} -.nv-combobox-trigger-field .nv-combobox-selected-tag { - max-width: 100%; -} -.nv-combobox-trigger-field .nv-combobox-selected-tag-label { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; -} -.nv-combobox-native-fallback { - padding-inline: var(--nv-input-padding); -} -.nv-date-picker-content { - margin-block: calc(var(--spacing) * 1); - overflow-y: auto; - padding: calc(var(--spacing) * 0) !important; -} -.nv-date-picker-trigger { - width: 100%; -} -.nv-date-picker-trigger.nv-date-picker-trigger--kind-range { - width: 100%; -} -.nv-date-picker-trigger.nv-date-picker-trigger--kind-range.nv-date-picker-trigger.nv-date-picker-trigger--kind-range - > * { - height: var(--nv-input-height) !important; -} -@media (scripting: enabled) { - .nv-date-picker-native-fallback { - display: none; - } -} -@media (scripting: none) { - .nv-date-picker-trigger [data-date-picker-enhanced] { - display: none !important; - } - .nv-date-picker-native-fallback { - appearance: auto; - width: 100%; - height: 100%; - font: inherit; - letter-spacing: inherit; - word-spacing: inherit; - background-color: #0000; - border: 0; - outline: none; - } - .nv-date-picker-content { - display: none !important; - } -} -.nv-date-picker-calendar-dropdown-container { - justify-content: space-between; - align-items: center; - width: 100%; - display: flex; -} -.nv-date-picker-calendar-header-container { - justify-content: space-between; - align-items: center; - display: inline-flex; -} -.nv-date-picker-calendar-caption { - height: calc(var(--spacing) * 8); - width: 100%; - padding-top: calc(var(--spacing) * 4.5); - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: 1.21429; - font-weight: var(--font-weight-semibold); - justify-content: center; - align-items: center; - display: flex; - position: relative; -} -.nv-date-picker-calendar-caption.nv-date-picker-calendar-caption--range { - width: fit-content; - margin-inline: auto; -} -.nv-date-picker-calendar-weekday { - height: var(--spacing-density-3xl); - width: var(--spacing-density-3xl); - vertical-align: bottom; - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); - color: var(--text-color-placeholder); -} -.nv-date-picker-calendar-prev-button { - top: calc(var(--spacing) * 4); - left: calc(var(--spacing) * 4); - z-index: auto; - position: absolute; -} -.nv-date-picker-calendar-next-button { - top: calc(var(--spacing) * 4); - right: calc(var(--spacing) * 4); - z-index: auto; - position: absolute; -} -.nv-date-picker-calendar-prev-button, -.nv-date-picker-calendar-next-button > .nv-icon { - --icon-font-size: var(--text-16); - --icon-size: var(--text-16); -} -.nv-date-picker-calendar-table { - border-collapse: collapse; - gap: var(--spacing-density-xs); - width: 100%; -} -.nv-date-picker-calendar-month { - gap: var(--spacing-density-sm); - padding-inline: calc(var(--spacing) * 4); - padding-block: calc(var(--spacing) * 2); - flex-direction: column; - display: flex; -} -.nv-date-picker-calendar-month:not(:first-child) { - padding: calc(var(--spacing) * 0); -} -@media (width>=36rem) { - .nv-date-picker-calendar-month:not(:first-child) { - padding-inline: calc(var(--spacing) * 4); - padding-block: calc(var(--spacing) * 2); - } -} -.nv-date-picker-calendar-month:not(:first-child) .nv-date-picker-calendar-caption { - display: none; -} -@media (width>=36rem) { - .nv-date-picker-calendar-month:not(:first-child) .nv-date-picker-calendar-caption { - display: flex; - } -} -.nv-date-picker-calendar-month:not(:first-child) table { - display: none; -} -@media (width>=36rem) { - .nv-date-picker-calendar-month:not(:first-child) table { - display: table; - } -} -.nv-date-picker-calendar-months { - flex-direction: column; - display: flex; -} -@media (width>=36rem) { - .nv-date-picker-calendar-months { - gap: var(--spacing-density-sm); - flex-direction: row; - } -} -.nv-date-picker-calendar-footer { - padding-inline: calc(var(--spacing) * 4); - padding-bottom: calc(var(--spacing) * 4); -} -.nv-date-picker-calendar-week:not(:first-child) { - border-top: 1px solid #0000; -} -.nv-date-picker-calendar-cell { - height: var(--spacing-density-3xl); - width: var(--spacing-density-3xl); - border-radius: var(--radius-lg); - padding: calc(var(--spacing) * 0); - text-align: center; - position: relative; - overflow: hidden; -} -.nv-date-picker-calendar-cell:focus-within { - position: relative; -} -.nv-date-picker-calendar-cell[aria-selected] - > .nv-date-picker-calendar-day--selected:not(.nv-date-picker-calendar-day--disabled):not( - .nv-date-picker-calendar-day--outside - ) { - color: var(--text-color-primary); -} -.nv-date-picker-calendar-cell.nv-date-picker-calendar-cell--range.nv-date-picker-calendar-range-end { - border-top-left-radius: var(--radius-none); - border-bottom-left-radius: var(--radius-none); -} -.nv-date-picker-calendar-cell.nv-date-picker-calendar-cell--range.nv-date-picker-calendar-range-start { - border-top-right-radius: var(--radius-none); - border-bottom-right-radius: var(--radius-none); -} -.nv-date-picker-calendar-cell.nv-date-picker-calendar-cell--range.nv-date-picker-calendar-range-middle { - border-radius: var(--radius-none); -} -.nv-date-picker-calendar-cell.nv-date-picker-calendar-cell--range.nv-date-picker-calendar-range-start.nv-date-picker-calendar-range-end { - border-radius: var(--radius-lg); -} -.nv-date-picker-calendar-day { - height: var(--spacing-density-3xl); - width: var(--spacing-density-3xl); - cursor: pointer; - font-size: var(--text-14); - color: var(--text-color-primary); - justify-content: center; - align-items: center; - display: flex; -} -.nv-date-picker-calendar-day.nv-date-picker-calendar-day--outside { - pointer-events: none; - color: var(--text-color-placeholder); -} -.nv-date-picker-calendar-day.nv-date-picker-calendar-day--selected:not( - .nv-date-picker-calendar-day--disabled - ):not(.nv-date-picker-calendar-day--outside) { - background-color: var(--background-color-interaction-pressed); - font-weight: var(--font-weight-bold); -} -.nv-date-picker-calendar-day.nv-date-picker-calendar-day--disabled { - cursor: not-allowed; - color: var(--text-color-placeholder); -} -.nv-date-picker-calendar-day:not(.nv-date-picker-calendar-day--outside):not( - .nv-date-picker-calendar-day--disabled - ):not(.nv-date-picker-calendar-day--selected):hover { - background-color: var(--background-color-interaction-hover); -} -.nv-date-picker-calendar-dropdown { - visibility: hidden; - height: calc(var(--spacing) * 48); - width: calc(var(--spacing) * 46); -} -.nv-date-picker-calendar-dropdown[data-visible='true'] { - visibility: visible; -} -.nv-date-picker-calendar-dropdown .nv-menu-checkbox-item .nv-checkbox-input { - display: none; -} -.nv-divider-root { - flex-grow: 1; - flex-shrink: 0; - flex-basis: calc(var(--spacing) * 0); - align-items: stretch; - width: 100%; - height: 100%; - display: flex; -} -.nv-divider-root:has(.nv-divider-element--orientation-horizontal) { - align-items: center; -} -.nv-divider-root:has(.nv-divider-element--orientation-vertical) { - justify-content: center; -} -.nv-divider-element { - margin: calc(var(--spacing) * 0); - border-style: solid; - list-style-type: none; -} -.nv-divider-element.nv-divider-element--orientation-horizontal { - border-bottom: 1px solid; - border-bottom-color: var(--border-color-base); - width: 100%; -} -.nv-divider-element.nv-divider-element--orientation-horizontal.nv-divider-element--width-medium { - border-bottom-width: 2px; -} -.nv-divider-element.nv-divider-element--orientation-horizontal.nv-divider-element--width-large { - border-bottom-width: 4px; -} -.nv-divider-element.nv-divider-element--orientation-vertical { - border-left: 1px solid; - border-left-color: var(--border-color-base); - min-height: 1em; -} -.nv-divider-element.nv-divider-element--orientation-vertical.nv-divider-element--width-medium { - border-left-width: 2px; -} -.nv-divider-element.nv-divider-element--orientation-vertical.nv-divider-element--width-large { - border-left-width: 4px; -} -.nv-dropdown-content[popover] { - margin: 0; - display: none; - position: fixed; - inset: auto; -} -.nv-dropdown-content[popover]:popover-open { - flex-direction: column; - display: flex; -} -.nv-dropdown-content[popover].\:popover-open { - flex-direction: column; - display: flex; -} -.nv-dropdown-trigger { - align-items: center; - gap: var(--spacing); - display: inline-flex; -} -@supports (position-anchor: --a) { - .nv-dropdown-content { - --nv-dropdown-offset: 4px; - position-try-fallbacks: - flip-block, - flip-inline, - flip-block flip-inline; - margin: 0; - } - .nv-dropdown-content[data-side='bottom'] { - margin-top: var(--nv-dropdown-offset); - position-area: bottom span-right; - } - .nv-dropdown-content[data-side='bottom'][data-align='center'] { - position-area: bottom; - } - .nv-dropdown-content[data-side='bottom'][data-align='end'] { - position-area: bottom span-left; - } - .nv-dropdown-content[data-side='top'] { - margin-bottom: var(--nv-dropdown-offset); - position-area: top span-right; - } - .nv-dropdown-content[data-side='top'][data-align='center'] { - position-area: top; - } - .nv-dropdown-content[data-side='top'][data-align='end'] { - position-area: top span-left; - } - .nv-dropdown-content[data-side='left'] { - margin-right: var(--nv-dropdown-offset); - position-area: left span-bottom; - } - .nv-dropdown-content[data-side='left'][data-align='center'] { - position-area: left; - } - .nv-dropdown-content[data-side='left'][data-align='end'] { - position-area: left span-top; - } - .nv-dropdown-content[data-side='right'] { - margin-left: var(--nv-dropdown-offset); - position-area: right span-bottom; - } - .nv-dropdown-content[data-side='right'][data-align='center'] { - position-area: right; - } - .nv-dropdown-content[data-side='right'][data-align='end'] { - position-area: right span-top; - } -} -.nv-dropdown-sub { - width: 100%; - display: flex; - position: relative; -} -.nv-dropdown-sub-content[popover] { - margin: 0; - display: none; - position: fixed; -} -.nv-dropdown-sub-content[popover]:popover-open { - flex-direction: column; - display: flex; -} -.nv-dropdown-sub-content[popover].\:popover-open { - flex-direction: column; - display: flex; -} -@supports (position-anchor: --a) { - .nv-dropdown-sub-content { - position-area: right span-bottom; - position-try-fallbacks: - flip-inline, - flip-block, - flip-inline flip-block; - margin: 0; - } -} -@keyframes dropdown-in { - 0% { - translate: var(--menu-translate-start); - opacity: 0; - } - to { - opacity: 1; - translate: 0; - } -} -@keyframes dropdown-out { - 0% { - opacity: 1; - translate: 0; - } - to { - translate: var(--menu-translate-start); - opacity: 0; - } -} -.nv-dropdown-content { - overscroll-behavior: contain; - --menu-translate-start: 0 calc(var(--transition-offset) * -1); - transform-origin: top; -} -@supports (position-anchor: --a) { - .nv-dropdown-content { - max-height: calc(100% - var(--nv-dropdown-offset)); - } -} -.nv-dropdown-content[data-side='top'] { - --menu-translate-start: 0 var(--transition-offset); - transform-origin: bottom; -} -.nv-dropdown-content[data-side='left'] { - --menu-translate-start: var(--transition-offset) 0; - transform-origin: 100%; -} -.nv-dropdown-content[data-side='right'] { - --menu-translate-start: calc(var(--transition-offset) * -1) 0; - transform-origin: 0; -} -@media (prefers-reduced-motion: no-preference) { - .nv-dropdown-content:popover-open { - animation: dropdown-in 0.25s var(--ease-out); - } -} -@media (prefers-reduced-motion: no-preference) { - .nv-dropdown-content.\:popover-open { - animation: dropdown-in 0.25s var(--ease-out); - } -} -.nv-dropdown-sub-content { - --transition-offset: 8px; - --submenu-translate-start: calc(var(--transition-offset) * -1) 0; -} -.nv-dropdown-sub-content:popover-open { - transform-origin: 0; -} -@media (prefers-reduced-motion: no-preference) { - .nv-dropdown-sub-content:popover-open { - animation: submenu-in 0.25s var(--ease-out); - } -} -.nv-dropdown-sub-content.\:popover-open { - transform-origin: 0; -} -@media (prefers-reduced-motion: no-preference) { - .nv-dropdown-sub-content.\:popover-open { - animation: submenu-in 0.25s var(--ease-out); - } -} -@keyframes submenu-in { - 0% { - translate: var(--submenu-translate-start); - opacity: 0; - } - to { - opacity: 1; - translate: 0; - } -} -@keyframes submenu-out { - 0% { - opacity: 1; - translate: 0; - } - to { - translate: var(--submenu-translate-start); - opacity: 0; - } -} -.nv-flex { - flex-direction: row; - display: flex; -} -.nv-flex--direction-row { - flex-direction: row; -} -.nv-flex--direction-row-reverse { - flex-direction: row-reverse; -} -.nv-flex--direction-col-reverse { - flex-direction: column-reverse; -} -.nv-flex--direction-col { - flex-direction: column; -} -.nv-flex--align-start { - align-items: flex-start; -} -.nv-flex--align-end { - align-items: flex-end; -} -.nv-flex--align-center { - align-items: center; -} -.nv-flex--align-baseline { - align-items: baseline; -} -.nv-flex--align-stretch { - align-items: stretch; -} -.nv-flex--justify-normal { - justify-content: normal; -} -.nv-flex--justify-start { - justify-content: flex-start; -} -.nv-flex--justify-end { - justify-content: flex-end; -} -.nv-flex--justify-center { - justify-content: center; -} -.nv-flex--justify-between { - justify-content: space-between; -} -.nv-flex--justify-around { - justify-content: space-around; -} -.nv-flex--justify-evenly { - justify-content: space-evenly; -} -.nv-flex--justify-stretch { - justify-content: stretch; -} -.nv-flex--justify-stretch > * { - flex: 1; -} -.nv-flex--wrap-wrap { - flex-wrap: wrap; -} -.nv-flex--wrap-wrap-reverse { - flex-wrap: wrap-reverse; -} -.nv-flex--wrap-nowrap { - flex-wrap: nowrap; -} -.nv-form-field-root { - gap: var(--spacing-density-xs); - width: 100%; - font-family: var(--font-sans); - line-height: var(--leading-lh-125); - font-size: var(--text-12); - --nv-form-field-label-group-width: 160px; - --nv-form-field-helper-margin-left: 0px; - flex-direction: column; - display: flex; - container-type: inline-size; -} -@container (width>=320px) { - .nv-form-field-root.nv-form-field-root--label-position-left .nv-form-field-content-group { - align-items: center; - gap: calc(var(--spacing) * 3); - flex-direction: row; - } - .nv-form-field-root.nv-form-field-root--label-position-left - .nv-form-field-content-group - .nv-form-field-label-group { - width: var(--nv-form-field-label-group-width); - justify-content: flex-start; - } - .nv-form-field-root.nv-form-field-root--label-position-left .nv-form-field-helper { - --nv-form-field-helper-margin-left: calc(var(--nv-form-field-label-group-width) + 12px); - } -} -:is( - .nv-form-field-root.nv-form-field-root--required, - .nv-form-field-root:has(:required):not([data-required='false']) - ) - .nv-form-field-label-group - .nv-label { - padding-right: calc(var(--spacing) * 3); - position: relative; -} -:is( - .nv-form-field-root.nv-form-field-root--required, - .nv-form-field-root:has(:required):not([data-required='false']) - ) - .nv-form-field-label-group - .nv-label:after { - font-weight: var(--font-weight-regular); - right: calc(var(--spacing) * 0); - color: var(--text-color-feedback-danger-subtle); - content: '*'; - position: absolute; -} -.nv-form-field-label-group { - align-items: center; - gap: calc(var(--spacing) * 2); - padding-block: calc(var(--spacing) * 0.5); - flex-direction: row; - flex-shrink: 0; - display: flex; -} -.nv-form-field-label-group .nv-label { - font-weight: var(--font-weight-bold); - text-overflow: ellipsis; - white-space: nowrap; - font-size: var(--text-12); - overflow: hidden; -} -.nv-form-field-label-group > svg, -.nv-form-field-label-group > .nv-icon { - height: calc(var(--spacing) * 3); - width: calc(var(--spacing) * 3); - --icon-font-size: var(--text-12); - flex-shrink: 0; -} -.nv-form-field-label-group > .nv-popover-trigger { - cursor: pointer; - padding: calc(var(--spacing) * 0); - color: var(--text-color-secondary); - background-color: #0000; - border: 0; - flex-shrink: 0; - align-items: center; - display: flex; -} -.nv-form-field-label-group > .nv-popover-trigger > .nv-icon { - height: calc(var(--spacing) * 3); - width: calc(var(--spacing) * 3); - --icon-font-size: var(--text-12); -} -.nv-form-field-content-group { - gap: inherit; - flex-direction: column; - display: flex; -} -.nv-form-field-content-group:has(.nv-input-shell) > .nv-form-field-label-group { - justify-content: space-between; -} -.nv-form-field-helper { - padding-block: calc(var(--spacing) * 0.5); - margin-left: var(--nv-form-field-helper-margin-left); - color: var(--text-color-secondary); - font-weight: var(--font-weight-regular); -} -.nv-form-field-helper.nv-form-field-helper--kind-error { - color: var(--text-color-feedback-danger-subtle); -} -.nv-form-field-helper.nv-form-field-helper--kind-success { - color: var(--text-color-feedback-success); -} -.nv-form-field-sr-only { - clip-path: inset(50%); - white-space: nowrap; - border-width: 0; - width: 1px; - height: 1px; - margin: -1px; - padding: 0; - position: absolute; - overflow: hidden; -} -.nv-grid-item { - grid-column-start: var(--nv-grid-item-col-start, auto); - grid-column-end: var(--nv-grid-item-col-end, auto); - grid-row-start: var(--nv-grid-item-row-start, auto); - grid-row-end: var(--nv-grid-item-row-end, auto); -} -@media (width>=320px) { - .nv-grid-item { - grid-column-start: var(--nv-grid-item-col-start-xs, var(--nv-grid-item-col-start, auto)); - grid-column-end: var(--nv-grid-item-col-end-xs, var(--nv-grid-item-col-end, auto)); - grid-row-start: var(--nv-grid-item-row-start-xs, var(--nv-grid-item-row-start, auto)); - grid-row-end: var(--nv-grid-item-row-end-xs, var(--nv-grid-item-row-end, auto)); - } -} -@media (width>=576px) { - .nv-grid-item { - grid-column-start: var( - --nv-grid-item-col-start-sm, - var(--nv-grid-item-col-start-xs, var(--nv-grid-item-col-start, auto)) - ); - grid-column-end: var( - --nv-grid-item-col-end-sm, - var(--nv-grid-item-col-end-xs, var(--nv-grid-item-col-end, auto)) - ); - grid-row-start: var( - --nv-grid-item-row-start-sm, - var(--nv-grid-item-row-start-xs, var(--nv-grid-item-row-start, auto)) - ); - grid-row-end: var( - --nv-grid-item-row-end-sm, - var(--nv-grid-item-row-end-xs, var(--nv-grid-item-row-end, auto)) - ); - } -} -@media (width>=768px) { - .nv-grid-item { - grid-column-start: var( - --nv-grid-item-col-start-md, - var( - --nv-grid-item-col-start-sm, - var(--nv-grid-item-col-start-xs, var(--nv-grid-item-col-start, auto)) - ) - ); - grid-column-end: var( - --nv-grid-item-col-end-md, - var( - --nv-grid-item-col-end-sm, - var(--nv-grid-item-col-end-xs, var(--nv-grid-item-col-end, auto)) - ) - ); - grid-row-start: var( - --nv-grid-item-row-start-md, - var( - --nv-grid-item-row-start-sm, - var(--nv-grid-item-row-start-xs, var(--nv-grid-item-row-start, auto)) - ) - ); - grid-row-end: var( - --nv-grid-item-row-end-md, - var( - --nv-grid-item-row-end-sm, - var(--nv-grid-item-row-end-xs, var(--nv-grid-item-row-end, auto)) - ) - ); - } -} -@media (width>=992px) { - .nv-grid-item { - grid-column-start: var( - --nv-grid-item-col-start-lg, - var( - --nv-grid-item-col-start-md, - var( - --nv-grid-item-col-start-sm, - var(--nv-grid-item-col-start-xs, var(--nv-grid-item-col-start, auto)) - ) - ) - ); - grid-column-end: var( - --nv-grid-item-col-end-lg, - var( - --nv-grid-item-col-end-md, - var( - --nv-grid-item-col-end-sm, - var(--nv-grid-item-col-end-xs, var(--nv-grid-item-col-end, auto)) - ) - ) - ); - grid-row-start: var( - --nv-grid-item-row-start-lg, - var( - --nv-grid-item-row-start-md, - var( - --nv-grid-item-row-start-sm, - var(--nv-grid-item-row-start-xs, var(--nv-grid-item-row-start, auto)) - ) - ) - ); - grid-row-end: var( - --nv-grid-item-row-end-lg, - var( - --nv-grid-item-row-end-md, - var( - --nv-grid-item-row-end-sm, - var(--nv-grid-item-row-end-xs, var(--nv-grid-item-row-end, auto)) - ) - ) - ); - } -} -@media (width>=1200px) { - .nv-grid-item { - grid-column-start: var( - --nv-grid-item-col-start-xl, - var( - --nv-grid-item-col-start-lg, - var( - --nv-grid-item-col-start-md, - var( - --nv-grid-item-col-start-sm, - var(--nv-grid-item-col-start-xs, var(--nv-grid-item-col-start, auto)) - ) - ) - ) - ); - grid-column-end: var( - --nv-grid-item-col-end-xl, - var( - --nv-grid-item-col-end-lg, - var( - --nv-grid-item-col-end-md, - var( - --nv-grid-item-col-end-sm, - var(--nv-grid-item-col-end-xs, var(--nv-grid-item-col-end, auto)) - ) - ) - ) - ); - grid-row-start: var( - --nv-grid-item-row-start-xl, - var( - --nv-grid-item-row-start-lg, - var( - --nv-grid-item-row-start-md, - var( - --nv-grid-item-row-start-sm, - var(--nv-grid-item-row-start-xs, var(--nv-grid-item-row-start, auto)) - ) - ) - ) - ); - grid-row-end: var( - --nv-grid-item-row-end-xl, - var( - --nv-grid-item-row-end-lg, - var( - --nv-grid-item-row-end-md, - var( - --nv-grid-item-row-end-sm, - var(--nv-grid-item-row-end-xs, var(--nv-grid-item-row-end, auto)) - ) - ) - ) - ); - } -} -@media (width>=1600px) { - .nv-grid-item { - grid-column-start: var( - --nv-grid-item-col-start-xxl, - var( - --nv-grid-item-col-start-xl, - var( - --nv-grid-item-col-start-lg, - var( - --nv-grid-item-col-start-md, - var( - --nv-grid-item-col-start-sm, - var(--nv-grid-item-col-start-xs, var(--nv-grid-item-col-start, auto)) - ) - ) - ) - ) - ); - grid-column-end: var( - --nv-grid-item-col-end-xxl, - var( - --nv-grid-item-col-end-xl, - var( - --nv-grid-item-col-end-lg, - var( - --nv-grid-item-col-end-md, - var( - --nv-grid-item-col-end-sm, - var(--nv-grid-item-col-end-xs, var(--nv-grid-item-col-end, auto)) - ) - ) - ) - ) - ); - grid-row-start: var( - --nv-grid-item-row-start-xxl, - var( - --nv-grid-item-row-start-xl, - var( - --nv-grid-item-row-start-lg, - var( - --nv-grid-item-row-start-md, - var( - --nv-grid-item-row-start-sm, - var(--nv-grid-item-row-start-xs, var(--nv-grid-item-row-start, auto)) - ) - ) - ) - ) - ); - grid-row-end: var( - --nv-grid-item-row-end-xxl, - var( - --nv-grid-item-row-end-xl, - var( - --nv-grid-item-row-end-lg, - var( - --nv-grid-item-row-end-md, - var( - --nv-grid-item-row-end-sm, - var(--nv-grid-item-row-end-xs, var(--nv-grid-item-row-end, auto)) - ) - ) - ) - ) - ); - } -} -.nv-grid { - width: 100%; - font-family: var(--font-sans); - grid-template-columns: var(--nv-grid-template-columns, none); - grid-template-rows: var(--nv-grid-template-rows, none); - display: grid; -} -.nv-grid--flow-row { - grid-auto-flow: row; -} -.nv-grid--flow-col { - grid-auto-flow: column; -} -.nv-grid--flow-dense { - grid-auto-flow: dense; -} -.nv-grid--flow-row-dense { - grid-auto-flow: dense; -} -.nv-grid--flow-col-dense { - grid-auto-flow: column dense; -} -@media (width>=320px) { - .nv-grid { - grid-template-columns: var( - --nv-grid-template-columns-xs, - var(--nv-grid-template-columns, none) - ); - grid-template-rows: var(--nv-grid-template-rows-xs, var(--nv-grid-template-rows, none)); - } -} -@media (width>=576px) { - .nv-grid { - grid-template-columns: var( - --nv-grid-template-columns-sm, - var(--nv-grid-template-columns-xs, var(--nv-grid-template-columns, none)) - ); - grid-template-rows: var( - --nv-grid-template-rows-sm, - var(--nv-grid-template-rows-xs, var(--nv-grid-template-rows, none)) - ); - } -} -@media (width>=768px) { - .nv-grid { - grid-template-columns: var( - --nv-grid-template-columns-md, - var( - --nv-grid-template-columns-sm, - var(--nv-grid-template-columns-xs, var(--nv-grid-template-columns, none)) - ) - ); - grid-template-rows: var( - --nv-grid-template-rows-md, - var( - --nv-grid-template-rows-sm, - var(--nv-grid-template-rows-xs, var(--nv-grid-template-rows, none)) - ) - ); - } -} -@media (width>=992px) { - .nv-grid { - grid-template-columns: var( - --nv-grid-template-columns-lg, - var( - --nv-grid-template-columns-md, - var( - --nv-grid-template-columns-sm, - var(--nv-grid-template-columns-xs, var(--nv-grid-template-columns, none)) - ) - ) - ); - grid-template-rows: var( - --nv-grid-template-rows-lg, - var( - --nv-grid-template-rows-md, - var( - --nv-grid-template-rows-sm, - var(--nv-grid-template-rows-xs, var(--nv-grid-template-rows, none)) - ) - ) - ); - } -} -@media (width>=1200px) { - .nv-grid { - grid-template-columns: var( - --nv-grid-template-columns-xl, - var( - --nv-grid-template-columns-lg, - var( - --nv-grid-template-columns-md, - var( - --nv-grid-template-columns-sm, - var(--nv-grid-template-columns-xs, var(--nv-grid-template-columns, none)) - ) - ) - ) - ); - grid-template-rows: var( - --nv-grid-template-rows-xl, - var( - --nv-grid-template-rows-lg, - var( - --nv-grid-template-rows-md, - var( - --nv-grid-template-rows-sm, - var(--nv-grid-template-rows-xs, var(--nv-grid-template-rows, none)) - ) - ) - ) - ); - } -} -@media (width>=1600px) { - .nv-grid { - grid-template-columns: var( - --nv-grid-template-columns-xxl, - var( - --nv-grid-template-columns-xl, - var( - --nv-grid-template-columns-lg, - var( - --nv-grid-template-columns-md, - var( - --nv-grid-template-columns-sm, - var( - --nv-grid-template-columns-xs, - var(--nv-grid-template-columns, none) - ) - ) - ) - ) - ) - ); - grid-template-rows: var( - --nv-grid-template-rows-xxl, - var( - --nv-grid-template-rows-xl, - var( - --nv-grid-template-rows-lg, - var( - --nv-grid-template-rows-md, - var( - --nv-grid-template-rows-sm, - var(--nv-grid-template-rows-xs, var(--nv-grid-template-rows, none)) - ) - ) - ) - ) - ); - } -} -.nv-group { - align-items: stretch; - width: fit-content; - height: fit-content; - display: inline-flex; -} -.nv-group, -.nv-group.nv-group--kind-flush { - --group-item-overlap: 1px; -} -.nv-group.nv-group--kind-gap { - --group-item-overlap: 0; - gap: 1px; -} -.nv-group.nv-group--kind-border > *:where(:not(:first-child)) { - border-block-width: 0; - border-right-width: 0; - border-left-width: var(--border-width-1); - border-color: var(--border-color-base); -} -.nv-group.nv-group > * { - max-height: none; - height: auto !important; -} -.nv-group.nv-group > *:where(:not(:first-child)) { - margin-block-start: 0; - margin-inline-start: calc(var(--group-item-overlap) * -1); -} -.nv-group.nv-group > *:focus-within { - z-index: 1; -} -.nv-group.nv-group:has(> :nth-child(2)) > *:where(:first-child) { - border-top-right-radius: var(--radius-none) !important; - border-bottom-right-radius: var(--radius-none) !important; -} -.nv-group.nv-group:has(> :nth-child(2)) > *:where(:last-child) { - border-top-left-radius: var(--radius-none) !important; - border-bottom-left-radius: var(--radius-none) !important; -} -.nv-group.nv-group:has(> :nth-child(2)) > *:where(:not(:first-child):not(:last-child)) { - border-radius: var(--radius-none) !important; -} -.nv-hero-root { - --padding: calc(var(--spacing) * 8) calc(var(--spacing) * 6); - --max-width: 940px; - --text-color: var(--text-color-primary); - isolation: isolate; - width: 100%; - height: fit-content; - font-family: var(--font-sans); - padding: var(--padding); - color: var(--text-color); - font-style: normal; - font-weight: var(--font-weight-regular); - display: flex; - position: relative; - overflow: hidden; - container-type: inline-size; -} -.nv-hero-content { - width: clamp(100%, var(--max-width), 100%); - max-width: var(--max-width); - gap: calc(var(--spacing) * 6); - flex-direction: column; - margin-inline: auto; - display: flex; - position: relative; -} -@container (width>=48rem) { - .nv-hero-content { - padding-block: calc(var(--spacing) * 16); - } -} -@container (width>=64rem) { - .nv-hero-content { - padding-block: calc(var(--spacing) * 31); - } -} -.nv-hero-media { - inset: calc(var(--spacing) * 0); - object-fit: cover; - width: 100%; - height: 100%; - position: absolute; -} -.nv-hero-subheading { - -webkit-line-clamp: 1; - font-family: var(--font-sans); - font-size: var(--text-16); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-bold); - text-wrap: pretty; - -webkit-box-orient: vertical; - display: -webkit-box; - overflow: hidden; -} -@container (width>=48rem) { - .nv-hero-subheading { - font-family: var(--font-sans); - font-size: var(--text-24); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-bold); - } -} -@container (width>=64rem) { - .nv-hero-subheading { - font-family: var(--font-sans); - font-size: var(--text-32); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-bold); - } -} -.nv-hero-heading { - -webkit-line-clamp: 2; - font-family: var(--font-sans); - font-size: var(--text-44); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-bold); - text-wrap: pretty; - -webkit-box-orient: vertical; - display: -webkit-box; - overflow: hidden; -} -@container (width>=48rem) { - .nv-hero-heading { - font-family: var(--font-sans); - font-size: var(--text-44); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-bold); - } -} -@container (width>=64rem) { - .nv-hero-heading { - font-family: var(--font-sans); - font-size: var(--text-50); - line-height: 1.24; - font-weight: var(--font-weight-bold); - } -} -.nv-hero-body { - -webkit-line-clamp: 3; - font-size: var(--text-24); - line-height: var(--leading-lh-175); - text-wrap: pretty; - -webkit-box-orient: vertical; - display: -webkit-box; - overflow: hidden; -} -.nv-hero-footer { - gap: calc(var(--spacing) * 2); - padding-top: calc(var(--spacing) * 2); - display: inline-flex; -} -.nv-horizontal-nav-list { - isolation: isolate; - display: flex; -} -.nv-horizontal-nav-item { - --nav-padding-x: 1rem; - --nav-transition-duration: 0.2s; - --nav-edge-offset: calc(100% - var(--nav-padding-x)); - cursor: pointer; - align-items: center; - gap: calc(var(--spacing) * 2); - height: 32px; - padding-inline: calc(var(--spacing) * 4); - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: 1.21429; - font-weight: var(--font-weight-regular); - white-space: nowrap; - color: var(--text-color-secondary); - display: flex; - position: relative; -} -.nv-horizontal-nav-item:focus-visible { - outline-offset: -2px; - border-radius: 4px; - outline: 2px solid; -} -@media (prefers-reduced-motion: no-preference) { - .nv-horizontal-nav-item { - transition-property: - color, background-color, border-color, text-decoration-color, fill, stroke; - transition-duration: 0.2s; - transition-timing-function: var(--ease-out); - } -} -.nv-horizontal-nav-item:before, -.nv-horizontal-nav-item:after { - content: ''; - pointer-events: none; - bottom: calc(var(--spacing) * 0); - z-index: 10; - border-bottom: 2px solid #0000; - position: absolute; -} -.nv-horizontal-nav-item:before { - left: var(--nav-padding-x); - right: var(--nav-edge-offset); -} -.nv-horizontal-nav-item:after { - left: var(--nav-edge-offset); - right: var(--nav-padding-x); -} -@media (hover: hover) { - .nv-horizontal-nav-item:not( - :disabled, - .nv-horizontal-nav-item.nv-horizontal-nav-item--disabled, - .nv-horizontal-nav-item.nv-horizontal-nav-item--selected, - [data-active] - ):where(:hover, [data-hover]) { - color: var(--text-color-secondary); - } - .nv-horizontal-nav-item:not( - :disabled, - .nv-horizontal-nav-item.nv-horizontal-nav-item--disabled, - .nv-horizontal-nav-item.nv-horizontal-nav-item--selected, - [data-active] - ):where(:hover, [data-hover]):before, - .nv-horizontal-nav-item:not( - :disabled, - .nv-horizontal-nav-item.nv-horizontal-nav-item--disabled, - .nv-horizontal-nav-item.nv-horizontal-nav-item--selected, - [data-active] - ):where(:hover, [data-hover]):after { - border-bottom-color: var(--border-color-interaction-hover); - left: var(--nav-padding-x); - right: var(--nav-padding-x); - } -} -.nv-horizontal-nav-item:disabled, -.nv-horizontal-nav-item.nv-horizontal-nav-item--disabled { - cursor: not-allowed; - color: var(--text-color-disabled); -} -.nv-horizontal-nav-item.nv-horizontal-nav-item--selected, -.nv-horizontal-nav-item[data-active] { - color: var(--text-color-primary); - font-weight: var(--font-weight-bold); -} -:is( - .nv-horizontal-nav-item.nv-horizontal-nav-item--selected, - .nv-horizontal-nav-item[data-active] -):before, -:is( - .nv-horizontal-nav-item.nv-horizontal-nav-item--selected, - .nv-horizontal-nav-item[data-active] -):after { - border-bottom-color: var(--border-color-interaction-selected); - left: var(--nav-padding-x); - right: var(--nav-padding-x); -} -@media (prefers-reduced-motion: no-preference) { - .nv-horizontal-nav-item:before, - .nv-horizontal-nav-item:after { - transition: - left var(--nav-transition-duration) var(--ease-out), - right var(--nav-transition-duration) var(--ease-out), - border-color var(--nav-transition-duration) var(--ease-out); - } - .nv-horizontal-nav-item:before { - transition-duration: 0s, 0s, var(--nav-transition-duration); - } - .nv-horizontal-nav-item:not( - :disabled, - .nv-horizontal-nav-item.nv-horizontal-nav-item--disabled, - .nv-horizontal-nav-item.nv-horizontal-nav-item--selected, - [data-active] - ):where(:hover, [data-hover]):before { - transition-duration: - var(--nav-transition-duration), var(--nav-transition-duration), - var(--nav-transition-duration); - } - .nv-horizontal-nav-item:not( - :disabled, - .nv-horizontal-nav-item.nv-horizontal-nav-item--disabled, - .nv-horizontal-nav-item.nv-horizontal-nav-item--selected, - [data-active] - ):where(:hover, [data-hover]):after { - transition-delay: var(--nav-transition-duration), var(--nav-transition-duration), 0s; - transition-duration: 0s, 0s, var(--nav-transition-duration); - } - :is( - .nv-horizontal-nav-item.nv-horizontal-nav-item--selected, - .nv-horizontal-nav-item[data-active] - ):before, - :is( - .nv-horizontal-nav-item.nv-horizontal-nav-item--selected, - .nv-horizontal-nav-item[data-active] - ):after { - transition-duration: 0s, 0s, var(--nav-transition-duration); - } -} -.nv-input-shell textarea, -.nv-input-shell input { - font: inherit; - letter-spacing: inherit; - word-spacing: inherit; -} -.nv-input-shell { - --nv-input-height: 40px; - --nv-input-padding: 12px; - height: var(--nv-input-height); - --input-gap: 6px; - align-items: center; - gap: var(--input-gap); - border-radius: var(--radius-md); - width: 100%; - padding-inline: var(--nv-input-padding); - font: var(--font-sans); - font-size: var(--text-14); - font-style: normal; - font-weight: var(--font-weight-regular); - color: var(--text-color-primary); - display: flex; -} -.nv-input-shell select, -.nv-input-shell [data-input-slot] { - cursor: pointer; -} -.nv-input-shell:where(:not(.nv-input-shell--kind-floating)) { - border: 1px solid var(--border-color-interaction-base); - background: var(--background-color-interaction-base); -} -:where(.nv-input-shell svg, .nv-input-shell .nv-icon) { - --icon-font-size: var(--text-16); - width: calc(var(--spacing) * 4); - height: calc(var(--spacing) * 4); - color: var(--text-color-base); - flex-shrink: 0; -} -@media (hover: hover) { - .nv-input-shell:hover { - border-color: var(--border-color-interaction-hover); - } - .nv-input-shell:hover:has(input[type='file']) { - background-color: var(--background-color-interaction-hover); - } -} -.nv-input-shell:where(:has([data-status='error'])) { - --border-color: var(--border-color-feedback-danger); - border-color: var(--border-color-feedback-danger); -} -.nv-input-shell.nv-input-shell--validated:has(:user-invalid) { - --border-color: var(--border-color-feedback-danger); - border-color: var(--border-color-feedback-danger); -} -.nv-input-shell:where(:has([data-status='success'])) { - --border-color: var(--border-color-feedback-success); - border-color: var(--border-color-feedback-success); -} -.nv-input-shell.nv-input-shell--validated:has(:user-valid) { - --border-color: var(--border-color-feedback-success); - border-color: var(--border-color-feedback-success); -} -.nv-input-shell[data-state='open'], -.nv-input-shell:has([data-state='open']) { - border-color: var(--border-color-interaction-selected); -} -.nv-input-shell:has(select:open) { - border-color: var(--border-color-interaction-selected); -} -.nv-input-shell:has(:focus-visible), -.nv-input-shell[data-force-focus='true'] { - outline: 2px solid var(--border-color, currentColor); - outline-offset: var(--outline-offset, -2px); -} -.nv-input-shell:has([readonly]) { - background-color: #0000; - border-color: #0000; -} -.nv-input-shell:has([readonly]) > .nv-dismiss-button { - display: none; -} -.nv-input-shell:where(:has([data-disabled='true']), [data-disabled='true']), -.nv-input-shell:has( - input:disabled, - textarea:disabled, - select:disabled, - [data-input-slot]:disabled:not([readonly]), - [data-input-slot][aria-disabled='true']:not([readonly]) -) { - cursor: not-allowed; - border-color: var(--border-color-interaction-disabled); - background-color: var(--background-color-interaction-disabled); - color: var(--text-color-disabled); -} -:is( - .nv-input-shell:where(:has([data-disabled='true']), [data-disabled='true']), - .nv-input-shell:has( - input:disabled, - textarea:disabled, - select:disabled, - [data-input-slot]:disabled:not([readonly]), - [data-input-slot][aria-disabled='true']:not([readonly]) - ) - ) - ::file-selector-button { - -webkit-text-decoration-color: var(--border-color-interaction-disabled) !important; - text-decoration-color: var(--border-color-interaction-disabled) !important; -} -:is( - .nv-input-shell:where(:has([data-disabled='true']), [data-disabled='true']), - .nv-input-shell:has( - input:disabled, - textarea:disabled, - select:disabled, - [data-input-slot]:disabled:not([readonly]), - [data-input-slot][aria-disabled='true']:not([readonly]) - ) - ) - > .nv-dismiss-button { - display: none; -} -.nv-input-shell input, -.nv-input-shell textarea, -.nv-input-shell select, -.nv-input-shell > [data-input-slot] { - appearance: none; - text-align: left; - background-color: #0000; - border: none; - outline: none; - width: 100%; - height: 100%; -} -:is( - .nv-input-shell input, - .nv-input-shell textarea, - .nv-input-shell select, - .nv-input-shell > [data-input-slot] -)::placeholder, -:is( - .nv-input-shell input, - .nv-input-shell textarea, - .nv-input-shell select, - .nv-input-shell > [data-input-slot] -)[data-has-selected-value='false'] { - color: var(--text-color-placeholder); -} -.nv-input-shell > select:invalid { - color: var(--text-color-placeholder); -} -:is( - .nv-input-shell:has(:placeholder-shown), - .nv-input-shell:has(input[type='search']), - .nv-input-shell:has([data-has-selected-value='false']) - ) - > .nv-dismiss-button { - display: none; -} -.nv-input-shell:has(textarea) { - height: auto; - padding-block: 8px; -} -.nv-input-shell:has(textarea) textarea { - scrollbar-width: thin; - scrollbar-color: var(--nv-scrollbar-color); - min-height: 3lh; -} -.nv-input-shell:has(input[type='file']) { - cursor: pointer; - height: auto; - padding-block: 24px; -} -.nv-input-shell:has(input[type='file']) input[type='file'] { - width: auto; - margin: 0 auto; -} -.nv-input-shell:has(input[type='file']) ::file-selector-button { - text-decoration: underline; - -webkit-text-decoration-color: var(--border-color-brand); - text-decoration-color: var(--border-color-brand); - text-underline-offset: 4px; - background: 0 0; - border: 0; -} -.nv-input-shell:has(button[data-input-slot]) { - gap: 0; - padding-inline: 0; -} -.nv-input-shell:has(button[data-input-slot]) button[data-input-slot] { - padding-inline: var(--input-gap); - line-height: 1; -} -.nv-input-shell:has(button[data-input-slot]) button[data-input-slot]:first-child { - padding-inline-start: var(--nv-input-padding); -} -.nv-input-shell:has(button[data-input-slot]) button[data-input-slot]:last-child { - padding-inline-end: var(--nv-input-padding); -} -.nv-input-shell:has(button[data-input-slot]):has(button[data-input-slot]:not(:first-child)) { - padding-inline-start: var(--nv-input-padding); -} -.nv-input-shell:has(button[data-input-slot]) > :last-child:not(button[data-input-slot]) { - padding-inline-end: var(--nv-input-padding); -} -.nv-input-shell > .nv-dismiss-button { - --nv-button-icon-margin: -4px; - --nv-button-icon-color: var(--text-color-base); -} -.nv-input-shell > button[data-input-slot], -.nv-input-shell:is(button) { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; -} -@media (prefers-reduced-motion: no-preference) { - .nv-input-shell { - transition-property: border-color, color, background-color; - transition-duration: 0.25s; - transition-timing-function: var(--ease-out); - } -} -@media (scripting: none) { - .nv-input-shell .nv-dismiss-button { - display: none; - } -} -.nv-input-shell.nv-input-shell--size-small { - --nv-input-padding: 8px; - --nv-input-height: 28px; - font-size: var(--text-12); - padding-block: 6px; -} -:where( - .nv-input-shell.nv-input-shell--size-small svg, - .nv-input-shell.nv-input-shell--size-small .nv-icon -) { - --icon-font-size: var(--text-12); - width: calc(var(--spacing) * 3); - height: calc(var(--spacing) * 3); -} -.nv-input-shell.nv-input-shell--size-large { - --nv-input-padding: 16px; - --nv-input-height: 48px; - font-size: var(--text-16); - padding-block: 12px; -} -.nv-input-shell.nv-input-shell--layout-vertical { - --nv-input-height: auto; - padding-block: var(--nv-input-padding, 12px); - flex-direction: column; -} -.nv-input-shell input:is([type='time'], [type='date'], [type='datetime-local']) { - font-variant-numeric: tabular-nums; -} -.nv-input-shell - input:is([type='time'], [type='date'], [type='datetime-local']):not( - .nv-date-picker-native-fallback - )::-webkit-calendar-picker-indicator { - display: none; -} -.nv-input-shell ::-webkit-datetime-edit-hour-field:focus { - background: var(--background-color-interaction-hover); - border-radius: var(--spacing); -} -.nv-input-shell ::-webkit-datetime-edit-minute-field:focus { - background: var(--background-color-interaction-hover); - border-radius: var(--spacing); -} -.nv-input-shell ::-webkit-datetime-edit-second-field:focus { - background: var(--background-color-interaction-hover); - border-radius: var(--spacing); -} -.nv-input-shell ::-webkit-datetime-edit-ampm-field:focus { - background: var(--background-color-interaction-hover); - border-radius: var(--spacing); -} -.nv-input-shell ::-webkit-datetime-edit-year-field:focus { - background: var(--background-color-interaction-hover); - border-radius: var(--spacing); -} -.nv-input-shell ::-webkit-datetime-edit-month-field:focus { - background: var(--background-color-interaction-hover); - border-radius: var(--spacing); -} -.nv-input-shell ::-webkit-datetime-edit-day-field:focus { - background: var(--background-color-interaction-hover); - border-radius: var(--spacing); -} -.nv-input-shell-control { - flex-shrink: 0; - align-items: center; - height: 100%; - display: flex; -} -.nv-label { - width: fit-content; - font-family: var(--font-sans); - color: var(--text-color-primary); - font-weight: var(--nv-label-font-weight, var(--font-weight-regular)); - font-size: var(--nv-label-font-size, var(--text-14)); - line-height: var(--nv-label-line-height, var(--leading-lh-125)); - text-overflow: var(--nv-label-text-overflow, clip); - overflow: var(--nv-label-overflow, visible); - vertical-align: middle; -} -.nv-label--disabled { - cursor: not-allowed; - color: var(--text-color-disabled); -} -.nv-label--size-small { - font-size: var(--nv-label-font-size, var(--text-12)); -} -.nv-label--size-medium { - font-size: var(--nv-label-font-size, var(--text-14)); -} -.nv-label--size-large { - font-size: var(--nv-label-font-size, var(--text-16)); -} -.nv-label--required { - padding-right: calc(var(--spacing) * 3); - position: relative; -} -.nv-label--required:after { - font-weight: var(--font-weight-regular); - right: calc(var(--spacing) * 0); - color: var(--text-color-feedback-danger-subtle); - content: '*'; - position: absolute; -} -.nv-list-root { - margin: calc(var(--spacing) * 0); - gap: calc(var(--spacing) * 1); - width: 100%; - padding: calc(var(--spacing) * 0); - font-family: var(--font-sans); - font-size: var(--text-16); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); - color: var(--text-color-primary); - flex-direction: column; - list-style-type: none; - display: flex; -} -.nv-list-item { - align-items: flex-start; - gap: calc(var(--spacing) * 1); - width: 100%; - display: flex; -} -.nv-list-item-marker { - font-weight: var(--font-weight-bold); -} -.nv-list-root--kind-ordered .nv-list-item-marker { - justify-content: flex-end; -} -.nv-list-item-marker, -.nv-list-item-marker svg, -.nv-list-item-marker .nv-icon { - width: calc(var(--spacing) * 6); - height: calc(var(--spacing) * 6); - flex-shrink: 0; - justify-content: center; - align-items: center; - display: flex; -} -.nv-menu-root { - font-family: var(--font-sans); - font-size: var(--text-14); - color: var(--text-color-primary); - font-weight: var(--font-weight-regular); - z-index: 1060; - align-items: flex-start; - gap: calc(var(--spacing) * 0); - border-radius: var(--radius-md); - border: 1px solid; - border-color: var(--border-color-base); - background-color: var(--background-color-surface-raised); - box-shadow: var(--shadow-md); - scrollbar-width: thin; - scrollbar-color: var(--nv-scrollbar-color); - --transition-offset: 12px; - --menu-translate-start: 0 calc(var(--transition-offset) * -1); - flex-direction: column; - font-style: normal; - line-height: 1.14286; - display: flex; - overflow-y: auto; -} -.nv-menu-root.nv-menu--filterable:not(:has(.nv-menu-item)):after { - padding: var(--spacing-density-lg); - color: var(--text-color-placeholder); - content: attr(data-empty-message, 'No results found'); -} -.nv-menu-root.nv-menu--filterable:not(:has(.nv-menu-item)) .nv-divider-root { - display: none; -} -.nv-menu-list { - margin: calc(var(--spacing) * 0); - align-items: flex-start; - gap: calc(var(--spacing) * 0); - width: 100%; - padding: calc(var(--spacing) * 0); - flex-direction: column; - list-style-type: none; - display: flex; -} -.nv-menu-section { - align-items: center; - gap: calc(var(--spacing) * 0); - border-bottom: 1px solid; - border-bottom-color: var(--border-color-base); - background-color: var(--background-color-surface-raised); - flex-direction: column; - width: 100%; - display: flex; -} -.nv-menu-section:last-child { - border: none; -} -.nv-menu-section:not(:has(.nv-menu-item)) { - display: none; -} -.nv-menu-heading { - font-size: var(--text-14); - font-weight: var(--font-weight-bold); - align-items: flex-start; - gap: calc(var(--spacing) * 1.5); - text-overflow: ellipsis; - white-space: nowrap; - width: 100%; - padding: var(--spacing-density-lg); - line-height: 1.14286; - display: flex; - overflow: hidden; -} -.nv-menu-search.nv-input-shell { - margin: calc(var(--spacing) * 2); - width: calc(100% - var(--spacing) * 4); -} -.nv-menu-search.nv-input-shell svg, -.nv-menu-search.nv-input-shell .nv-icon { - color: var(--text-color-base); -} -.nv-menu-search.nv-input-shell input[type='search']::-webkit-search-decoration { - -webkit-appearance: none; - display: none; -} -.nv-menu-search.nv-input-shell input[type='search']::-webkit-search-cancel-button { - -webkit-appearance: none; - display: none; -} -.nv-menu-root > li[role='none'], -.nv-menu-list > li[role='none'], -.nv-menu-section > li[role='none'] { - width: 100%; - list-style-type: none; -} -.nv-menu-item { - cursor: pointer; - align-items: center; - gap: calc(var(--spacing) * 1.5); - width: 100%; - padding: var(--spacing-density-lg); - text-align: left; - outline-offset: -2px; - background-color: #0000; - border: none; - flex-shrink: 0; - list-style-type: none; - display: flex; -} -@media (prefers-reduced-motion: no-preference) { - .nv-menu-item { - transition-property: - color, background-color, border-color, outline-color, text-decoration-color, fill, - stroke; - transition-duration: 0.25s; - transition-timing-function: var(--ease-out); - } -} -.nv-menu-item.nv-menu-checkbox-item, -.nv-menu-item.nv-menu-radio-group-item { - gap: calc(var(--spacing) * 2); -} -.nv-menu-item:hover { - background-color: var(--background-color-interaction-hover); -} -.nv-menu-item:active, -.nv-menu-item[data-active-item] { - background-color: var(--background-color-interaction-pressed); -} -.nv-menu-item.nv-menu-item--danger { - color: var(--text-color-feedback-danger); -} -.nv-menu-item.nv-menu-item--danger:hover { - background-color: var(--background-color-feedback-danger-subtle-hover); - color: var(--text-color-feedback-danger-subtle); -} -.nv-menu-item.nv-menu-item--danger:active { - background-color: var(--background-color-feedback-danger-subtle-pressed); - color: var(--text-color-feedback-danger-strong); -} -.nv-menu-item .nv-menu-item-slot { - justify-content: center; - align-items: center; - gap: calc(var(--spacing) * 1.5); - flex-shrink: 0; - min-width: 1em; - display: flex; -} -.nv-menu-item svg, -.nv-menu-item .nv-icon { - flex-shrink: 0; -} -.nv-menu-item.nv-menu-item--disabled, -.nv-menu-item[data-disabled] { - cursor: not-allowed; - background-color: var(--background-color-interaction-disabled); - color: var(--text-color-disabled); -} -.nv-menu-item[data-state='unchecked'] [data-state-indicator] { - display: none; -} -.nv-menu-radio-group { - margin: calc(var(--spacing) * 0); - min-width: calc(var(--spacing) * 0); - padding: calc(var(--spacing) * 0); - border-top: 0; - border-left: 0; - border-right: 0; -} -.nv-menu-radio-group.nv-radio-group-root { - align-items: stretch; - gap: calc(var(--spacing) * 0); - width: 100%; -} -.nv-menu-item.nv-radio-group-item { - width: 100%; -} -.nv-menu-radio-group > li[role='none'] { - width: 100%; - list-style-type: none; - display: flex; -} -.nv-menu-item-label { - min-height: calc(var(--spacing) * 4); - text-overflow: ellipsis; - white-space: nowrap; - text-align: left; - align-content: center; - width: 100%; - display: inline-block; - overflow: hidden; -} -dialog.nv-modal-overlay, -dialog.nv-modal-dialog { - margin: calc(var(--spacing) * 0); - max-width: none; - max-height: none; - padding: calc(var(--spacing) * 0); - background-color: #0000; - border: none; -} -:is(dialog.nv-modal-overlay, dialog.nv-modal-dialog)[open] { - inset: calc(var(--spacing) * 0); - z-index: 1000; - position: fixed; -} -:is(dialog.nv-modal-overlay, dialog.nv-modal-dialog):not([open]) { - display: none; -} -:is(dialog.nv-modal-overlay, dialog.nv-modal-dialog) .nv-modal-content { - position: relative; - inset: auto; - translate: 0; -} -dialog.nv-modal-overlay[open] { - justify-content: center; - align-items: center; - width: 100vw; - height: 100vh; - display: flex; -} -dialog.nv-modal-overlay::backdrop { - background-color: var(--background-color-surface-blanket); -} -@media (prefers-reduced-motion: no-preference) { - dialog.nv-modal-overlay { - animation: modal-in 0.3s var(--ease-out); - } - dialog.nv-modal-overlay[data-state='closed'] { - animation: modal-out 0.2s var(--ease-out) forwards; - } - dialog.nv-modal-overlay::backdrop { - animation: modal-in 0.3s var(--ease-out); - } - dialog.nv-modal-overlay[data-state='closed']::backdrop { - animation: modal-out 0.2s var(--ease-out) forwards; - } -} -dialog.nv-modal-dialog[open] { - justify-content: center; - align-items: center; - width: 100vw; - height: 100vh; - display: flex; -} -dialog.nv-modal-dialog::backdrop { - background-color: #0000; -} -.nv-modal-overlay[popover] { - margin: calc(var(--spacing) * 0); - max-width: none; - max-height: none; - padding: calc(var(--spacing) * 0); - background-color: #0000; - border: none; -} -.nv-modal-overlay[popover]:popover-open { - inset: calc(var(--spacing) * 0); - z-index: 1000; - justify-content: center; - align-items: center; - width: 100vw; - height: 100vh; - display: flex; - position: fixed; -} -.nv-modal-overlay[popover]::backdrop { - background-color: var(--background-color-surface-blanket); -} -.nv-modal-overlay[popover] .nv-modal-content { - position: relative; - inset: auto; - translate: 0; -} -.nv-modal-content { - border: 1px solid var(--border-color-base); - z-index: 1050; - border-radius: var(--radius-xl); - background-color: var(--background-color-surface-overlay); - width: 420px; - max-width: 95%; - max-height: 90dvh; - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); - color: var(--text-color-primary); - gap: var(--spacing-density-2xl); - padding: var(--spacing-density-2xl); - flex-direction: column; - font-style: normal; - display: flex; - position: relative; -} -.nv-modal-heading { - align-items: center; - gap: calc(var(--spacing) * 2); - width: 100%; - padding-right: calc(var(--spacing) * 8); - font-family: var(--font-sans); - font-size: var(--text-18); - line-height: 1.22222; - font-weight: var(--font-weight-bold); - line-height: var(--leading-lh-100); - display: flex; - position: relative; -} -.nv-modal-heading > svg, -.nv-modal-heading > .nv-icon { - width: calc(var(--spacing) * 6); - height: calc(var(--spacing) * 6); - color: var(--text-color-base); - flex-shrink: 0; -} -.nv-modal-heading.nv-modal-heading--hidden { - clip-path: inset(50%); - white-space: nowrap; - border-width: 0; - width: 1px; - height: 1px; - margin: -1px; - padding: 0; - position: absolute; - overflow: hidden; -} -.nv-modal-heading.nv-modal-heading--invisible { - visibility: hidden; -} -.nv-modal-main { - min-height: calc(var(--spacing) * 0); - gap: calc(var(--spacing) * 4); - scrollbar-width: thin; - scrollbar-color: var(--nv-scrollbar-color); - flex-direction: column; - margin: -4px; - padding: 4px; - display: flex; - overflow-y: auto; -} -.nv-modal-footer { - justify-content: flex-end; - align-items: center; - gap: calc(var(--spacing) * 2); - margin-top: auto; - display: flex; -} -.nv-modal-close { - top: var(--spacing-density-2xl); - right: var(--spacing-density-lg); - position: absolute; - translate: 0 -25%; -} -.nv-modal-close > :not(svg):not(.nv-icon) { - clip-path: inset(50%); - white-space: nowrap; - border-width: 0; - width: 1px; - height: 1px; - margin: -1px; - padding: 0; - position: absolute; - overflow: hidden; -} -.nv-modal-portal { - pointer-events: none; - inset: calc(var(--spacing) * 0); - z-index: 1060; - position: fixed; -} -.nv-modal-portal > * { - pointer-events: auto; -} -.nv-notification-root { - border-radius: var(--radius-lg); - border-left: 4px solid; - border-left-color: var(--border-color-feedback-info); - background-color: var(--background-color-surface-overlay); - width: 100%; - padding: calc(var(--spacing) * 4); - font-family: var(--font-sans); - color: var(--text-color-primary); - box-shadow: var(--shadow-lg); - position: relative; -} -.nv-notification-root.nv-notification-root--status-error { - border-left-color: var(--border-color-feedback-danger); -} -.nv-notification-root.nv-notification-root--status-error .nv-notification-icon { - color: var(--text-color-feedback-danger); -} -.nv-notification-root.nv-notification-root--status-success { - border-left-color: var(--border-color-feedback-success); -} -.nv-notification-root.nv-notification-root--status-success .nv-notification-icon { - color: var(--text-color-feedback-success); -} -.nv-notification-root.nv-notification-root--status-warning { - border-left-color: var(--border-color-feedback-warning); -} -.nv-notification-root.nv-notification-root--status-warning .nv-notification-icon { - color: var(--text-color-feedback-warning); -} -.nv-notification-root.nv-notification-root--kind-inline .nv-notification-close-button-section { - align-content: center; - position: static; -} -.nv-notification-root.nv-notification-root--kind-inline - .nv-notification-close-button-section - button { - margin-right: calc(var(--spacing) * -3); -} -.nv-notification-root.nv-notification-root--kind-inline .nv-notification-content { - gap: calc(var(--spacing) * 3); - grid-template-rows: repeat(1, minmax(0, 1fr)); - grid-template-columns: auto 1fr auto auto; - grid-template-areas: 'icon header footer close-button'; -} -.nv-notification-root.nv-notification-root--kind-inline - .nv-notification-content:not(:has(.nv-notification-footer)) { - grid-template-columns: auto 1fr auto; - grid-template-areas: 'icon header close-button'; -} -.nv-notification-root.nv-notification-root--kind-inline - .nv-notification-content:not(:has(.nv-notification-close-button-section)) { - grid-template-columns: auto 1fr auto; - grid-template-areas: 'icon header footer'; -} -.nv-notification-root.nv-notification-root--kind-inline .nv-notification-footer { - align-items: center; -} -.nv-notification-root .nv-notification-content { - gap: calc(var(--spacing) * 3); - row-gap: calc(var(--spacing) * 0); - grid-template: 'icon header' 'footer footer' / auto 1fr; - display: grid; -} -.nv-notification-root .nv-notification-content:has(.nv-notification-footer) { - row-gap: calc(var(--spacing) * 3); -} -.nv-notification-root .nv-notification-close-button-section { - top: calc(var(--spacing) * 1); - right: calc(var(--spacing) * 1); - grid-area: close-button; - position: absolute; -} -.nv-notification-root .nv-notification-icon { - color: var(--text-color-feedback-info); - grid-area: icon; -} -.nv-notification-root .nv-notification-header { - gap: calc(var(--spacing) * 2); - flex-direction: column; - grid-area: header; - display: flex; -} -.nv-notification-root .nv-notification-heading { - font-family: var(--font-sans); - font-size: var(--text-16); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-bold); -} -.nv-notification-root .nv-notification-subheading { - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); -} -.nv-notification-root .nv-notification-footer { - justify-content: flex-end; - gap: calc(var(--spacing) * 2); - grid-area: footer; - display: flex; -} -.nv-notification-root .nv-notification-icon { - padding-block: calc(var(--spacing) * 1); -} -.nv-page-header-root { - width: 100%; - container-type: inline-size; -} -.nv-page-header-container { - font-family: var(--font-sans); - color: var(--text-color-primary); - font-style: normal; - font-weight: var(--font-weight-regular); - gap: calc(var(--spacing) * 4); - flex-direction: column; - width: 100%; - display: flex; -} -@container (width>=42rem) { - .nv-page-header-container { - flex-direction: row; - } -} -.nv-page-header-container .nv-page-header-content { - gap: var(--spacing-density-xl); - flex-direction: column; - flex: 1; - display: flex; -} -.nv-page-header-container .nv-page-header-header { - gap: calc(var(--spacing) * 2); - flex-direction: column; - display: flex; -} -.nv-page-header-container .nv-page-header-subheading { - font-family: var(--font-sans); - font-size: var(--text-18); - line-height: 1.22222; - font-weight: var(--font-weight-light); - margin-block: -2px; -} -.nv-page-header-container .nv-page-header-heading { - font-family: var(--font-sans); - font-size: var(--text-24); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-bold); -} -.nv-page-header-container .nv-page-header-description { - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); -} -.nv-page-header-container .nv-page-header-footer { - justify-content: flex-end; - align-items: flex-end; - gap: calc(var(--spacing) * 2); - flex-wrap: wrap; - display: flex; -} -@container (width>=42rem) { - .nv-page-header-container .nv-page-header-footer { - flex-wrap: nowrap; - } -} -@container (width<42rem) { - .nv-page-header-container .nv-page-header-footer > * { - flex: 1; - } -} -.nv-page-header-container.nv-page-header-container--kind-floating { - border-radius: var(--radius-xl); - border: 1px solid; - border-color: var(--border-color-base); - background-color: var(--background-color-surface-base); - padding: var(--spacing-density-2xl); - box-shadow: var(--shadow-md); -} -.nv-pagination-root { - align-items: center; - gap: calc(var(--spacing) * 2); - width: 100%; - font-size: var(--text-14); - flex-wrap: wrap; - display: flex; - overflow: hidden; - container-type: inline-size; -} -.nv-pagination-page-size-select { - margin-inline: calc(var(--spacing) * 1); - color: var(--text-color-primary); - width: 72px !important; -} -.nv-pagination-arrow-button { - flex-shrink: 0; -} -.nv-pagination-page-input { - flex-shrink: 0; - width: calc(var(--spacing) * 16) !important; -} -.nv-pagination-page-list { - width: fit-content !important; -} -.nv-pagination-page-list .nv-tabs-list { - border-bottom: none; -} -.nv-pagination-page-count-text { - white-space: nowrap; - color: var(--text-color-secondary); - font-weight: var(--font-weight-regular); - flex-shrink: 0; -} -.nv-pagination-divider { - align-self: stretch; - height: auto; -} -.nv-pagination-divider .nv-divider-element { - height: 100%; - min-height: calc(var(--spacing) * 0); -} -.nv-pagination-item-range-text { - text-overflow: ellipsis; - white-space: nowrap; - color: var(--text-color-secondary); - font-weight: var(--font-weight-regular); - overflow: hidden; -} -.nv-pagination-controls-group { - white-space: nowrap; - color: var(--text-color-secondary); - font-weight: var(--font-weight-regular); - flex-grow: 1; - flex-shrink: 1; - justify-content: flex-start; - align-items: center; - display: flex; - overflow: hidden; -} -.nv-pagination-controls-group .nv-divider-root { - margin-right: calc(var(--spacing) * 3.5); - flex-grow: 0; -} -.nv-pagination-navigation-group { - flex-shrink: 0; - align-items: center; - display: flex; -} -.nv-pagination--kind-input { - justify-content: flex-start; - gap: calc(var(--spacing) * 2); -} -.nv-pagination--kind-input:has(.nv-pagination-page-size-select) .nv-pagination-navigation-group { - margin-inline: auto; -} -.nv-pagination--kind-input .nv-pagination-navigation-group { - gap: calc(var(--spacing) * 2); -} -@container (width<=720px) { - .nv-pagination--kind-input .nv-pagination-controls-group { - justify-content: center; - width: 100%; - } -} -.nv-pagination--kind-input:not(:has(.nv-pagination-page-size-select)) { - justify-content: center; -} -.nv-pagination--kind-tabs { - align-items: center; - gap: calc(var(--spacing) * 2); - grid-template-columns: 1fr auto 1fr; - width: 100%; - display: grid; -} -.nv-pagination--kind-tabs .nv-pagination-controls-group--start { - grid-column-start: 1; - justify-content: center; - justify-self: flex-start; - align-items: center; - display: flex; -} -.nv-pagination--kind-tabs .nv-pagination-controls-group--start .nv-divider-root { - margin-right: calc(var(--spacing) * 0) !important; -} -@container (width<=720px) { - .nv-pagination--kind-tabs .nv-pagination-controls-group--start { - grid-column: 1/-1; - justify-self: center; - width: 100%; - } - .nv-pagination--kind-tabs .nv-pagination-controls-group--start .nv-divider-root { - display: none; - } -} -.nv-pagination--kind-tabs .nv-pagination-navigation-group--tabs { - grid-column-start: 2; - justify-self: center; - align-items: center; - display: flex; -} -@container (width<=720px) { - .nv-pagination--kind-tabs .nv-pagination-navigation-group--tabs { - grid-column: 1/-1; - } -} -.nv-pagination--kind-tabs .nv-pagination-navigation-group { - gap: calc(var(--spacing) * 0); -} -.nv-pagination--kind-tabs .nv-pagination-controls-group--end { - grid-column-start: 3; - justify-self: flex-end; - align-items: center; - display: flex; -} -.nv-pagination--kind-tabs .nv-pagination-controls-group--end .nv-pagination-page-input { - margin-right: calc(var(--spacing) * 2); -} -@container (width<=720px) { - .nv-pagination--kind-tabs .nv-pagination-controls-group--end { - grid-column: 1/-1; - justify-content: center; - width: 100%; - } - .nv-pagination--kind-tabs .nv-pagination-controls-group--end .nv-divider-root { - display: none; - } -} -.nv-pagination--kind-tabs:not(:has(.nv-pagination-page-size-select)) - .nv-pagination-navigation-group--tabs { - grid-column: 1/-1; -} -.nv-pagination--kind-simple { - justify-content: flex-start; - gap: calc(var(--spacing) * 2); -} -.nv-pagination--kind-simple:has(.nv-pagination-page-size-select) .nv-pagination-navigation-group { - margin-inline: auto; -} -.nv-pagination--kind-simple .nv-pagination-navigation-group { - gap: calc(var(--spacing) * 2); -} -@container (width<=720px) { - .nv-pagination--kind-simple .nv-pagination-controls-group { - justify-content: center; - width: 100%; - } -} -.nv-pagination--kind-simple:not(:has(.nv-pagination-page-size-select)) { - justify-content: center; -} -.nv-panel-root { - border: 1px solid; - border-color: var(--border-color-base); - background-color: var(--background-color-surface-base); - width: 100%; - font-family: var(--font-sans); - color: var(--text-color-primary); - gap: calc(var(--spacing) * 6); - border-radius: var(--radius-density-xl); - padding: var(--spacing-density-2xl); - flex-direction: column; - display: flex; -} -.nv-panel-root--elevation-low { - background-color: var(--background-color-surface-sunken); -} -.nv-panel-root--elevation-high { - background-color: var(--background-color-surface-raised); -} -.nv-panel-root--elevation-higher { - background-color: var(--background-color-surface-overlay); -} -.nv-panel-header { - justify-content: flex-start; - align-items: center; - gap: calc(var(--spacing) * 4); - display: flex; -} -.nv-panel-icon { - font-size: var(--text-24); - color: var(--text-color-base); -} -.nv-panel-header-heading { - width: 100%; - font-family: var(--font-sans); - font-size: var(--text-18); - line-height: 1.22222; - font-weight: var(--font-weight-bold); - margin-block: -2px; -} -.nv-panel-footer { - justify-content: flex-end; - align-items: center; - gap: calc(var(--spacing) * 2); - display: flex; -} -@keyframes nv-popover-in { - 0% { - opacity: 0; - translate: var(--nv-popover-translate-start); - } - to { - opacity: 1; - translate: 0; - } -} -.nv-popover-content { - --nv-popover-translate-start: 0 -4px; - isolation: isolate; - gap: calc(var(--spacing) * 4); - border-radius: var(--radius-md); - border: 1px solid; - border-color: var(--border-color-base); - background-color: var(--background-color-surface-overlay); - padding: calc(var(--spacing) * 4); - color: var(--text-color-primary); - box-shadow: var(--shadow-md); - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); - text-wrap: wrap; - z-index: 1070; - opacity: 0; - width: max-content; - max-width: min(100vw - 16px, 560px); - max-height: min(100vh - 16px, 560px); - translate: var(--nv-popover-translate-start); - margin: auto; - font-style: normal; - position: fixed; - inset: auto; -} -@media (prefers-reduced-motion: no-preference) { - .nv-popover-content { - transition: - opacity 0.25s var(--ease-out), - translate 0.25s var(--ease-out); - } -} -@supports (position-anchor: --a) { - .nv-popover-content { - --nv-popover-offset: 4px; - position-try-fallbacks: - flip-block, - flip-inline, - flip-block flip-inline; - margin: 0; - } - .nv-popover-content[data-side='top'] { - margin-bottom: var(--nv-popover-offset); - position-area: top; - } - .nv-popover-content[data-side='top'][data-align='start'] { - position-area: top span-right; - } - .nv-popover-content[data-side='top'][data-align='end'] { - position-area: top span-left; - } - .nv-popover-content[data-side='bottom'] { - margin-top: var(--nv-popover-offset); - position-area: bottom; - } - .nv-popover-content[data-side='bottom'][data-align='start'] { - position-area: bottom span-right; - } - .nv-popover-content[data-side='bottom'][data-align='end'] { - position-area: bottom span-left; - } - .nv-popover-content[data-side='left'] { - --nv-popover-translate-start: 4px 0; - margin-right: var(--nv-popover-offset); - position-area: left; - } - .nv-popover-content[data-side='left'][data-align='start'] { - position-area: left span-bottom; - } - .nv-popover-content[data-side='left'][data-align='end'] { - position-area: left span-top; - } - .nv-popover-content[data-side='right'] { - --nv-popover-translate-start: -4px 0; - margin-left: var(--nv-popover-offset); - position-area: right; - } - .nv-popover-content[data-side='right'][data-align='start'] { - position-area: right span-bottom; - } - .nv-popover-content[data-side='right'][data-align='end'] { - position-area: right span-top; - } -} -:is( - .nv-popover-content[data-state='open'], - .nv-popover-content:popover-open, - .nv-popover-content.\:popover-open -) { - opacity: 1; - translate: 0; -} -@media (prefers-reduced-motion: no-preference) { - :is( - .nv-popover-content[data-state='open'], - .nv-popover-content:popover-open, - .nv-popover-content.\:popover-open - ) { - animation: nv-popover-in 0.25s var(--ease-out); - } -} -.nv-popover-content[data-state='closed']:not(:popover-open):not(.\:popover-open) { - opacity: 0; - translate: var(--nv-popover-translate-start); -} -.nv-progress-bar-root { - height: calc(var(--spacing) * 2.5); - border-radius: var(--radius-xl); - background-color: var(--background-color-component-track); - width: 100%; - position: relative; - overflow: hidden; - transform: translateZ(0); - container-type: size; -} -.nv-progress-bar-root--size-small { - height: calc(var(--spacing) * 1); -} -.nv-progress-bar-root--size-large { - height: calc(var(--spacing) * 3.5); -} -.nv-progress-bar-indicator { - inset: calc(var(--spacing) * 0); - border-radius: inherit; - background-color: var(--background-color-interaction-primary-base); - height: 100%; - transition: width 0.5s cubic-bezier(0.65, 0, 0.35, 1); - position: absolute; -} -.nv-progress-bar-root--indeterminate { - --progress-bar-indicator-width: 50%; -} -.nv-progress-bar-root--indeterminate .nv-progress-bar-indicator { - width: var(--progress-bar-indicator-width); - animation: 1.5s linear infinite progressIndeterminatePosition; -} -@keyframes progressIndeterminatePosition { - 0% { - transform: translate(-100%); - } - 80%, - to { - transform: translate(100cqw); - } -} -.nv-radio-group-root { - gap: calc(var(--spacing) * 3); - flex-direction: column; - width: fit-content; - display: flex; -} -.nv-radio-group-root.nv-radio-group-root--orientation-horizontal { - flex-direction: row; -} -.nv-radio-group-input:not(.nv-radio-group-input--hidden) { - height: calc(var(--spacing) * 4); - width: calc(var(--spacing) * 4); - border: 2px solid; - border-color: var(--border-color-interaction-base); - background-color: var(--background-color-interaction-base); - color: var(--text-color-primary); - border-radius: 3.40282e38px; - flex-shrink: 0; - place-items: center; - display: grid; -} -input.nv-radio-group-input:not(.nv-radio-group-input--hidden) { - margin: calc(var(--spacing) * 0); - cursor: pointer; - appearance: none; -} -.nv-radio-group-item { - cursor: pointer; - align-items: center; - gap: calc(var(--spacing) * 2); - width: fit-content; - font-family: var(--font-sans); - color: var(--text-color-primary); - font-weight: var(--nv-label-font-weight, var(--font-weight-regular)); - font-size: var(--nv-label-font-size, var(--text-14)); - line-height: var(--nv-label-line-height, calc(16 / 14)); - display: flex; -} -@media (prefers-reduced-motion: no-preference) { - .nv-radio-group-item:not(:has(.nv-radio-group-input:disabled)) - .nv-radio-group-input:not(.nv-radio-group-input--hidden) { - transition-duration: 0.2s; - transition-timing-function: var(--ease-out); - transition-property: background-color, border-color, border-width; - } -} -.nv-radio-group-item:not(:has(.nv-radio-group-input:disabled)) - .nv-radio-group-input:not(.nv-radio-group-input--hidden):hover { - border-color: var(--border-color-interaction-hover); - background-color: var(--background-color-interaction-hover); -} -.nv-radio-group-item:not(:has(.nv-radio-group-input:disabled)) - .nv-radio-group-input:not(.nv-radio-group-input--hidden):active { - border-color: var(--border-color-interaction-pressed); - background-color: var(--background-color-interaction-selected); -} -.nv-radio-group-item:not(:has(.nv-radio-group-input:disabled)) .nv-radio-group-indicator { - opacity: 0; - background-color: #0000; - scale: 2; -} -@media (prefers-reduced-motion: no-preference) { - .nv-radio-group-item:not(:has(.nv-radio-group-input:disabled)) .nv-radio-group-indicator { - transition-duration: 0.25s; - transition-timing-function: var(--ease-out); - transition-property: background-color, opacity, scale; - } -} -.nv-radio-group-item:not(:has(.nv-radio-group-input:disabled)) - input.nv-radio-group-input:not(.nv-radio-group-input--hidden):before { - content: ''; - opacity: 0; - background-color: #0000; - border-radius: 3.40282e38px; - scale: 2; -} -@media (prefers-reduced-motion: no-preference) { - .nv-radio-group-item:not(:has(.nv-radio-group-input:disabled)) - input.nv-radio-group-input:not(.nv-radio-group-input--hidden):before { - transition-duration: 0.25s; - transition-timing-function: var(--ease-out); - transition-property: background-color, opacity, scale; - } -} -.nv-radio-group-item:has(input.nv-radio-group-input:checked):not( - :has(.nv-radio-group-input:disabled) - ) - .nv-radio-group-input:not(.nv-radio-group-input--hidden) { - border-color: var(--background-color-interaction-primary-base); - background-color: var(--text-color-accent-black); - border-width: 5px; - position: relative; -} -.nv-radio-group-item:has(input.nv-radio-group-input:checked):not( - :has(.nv-radio-group-input:disabled) - ) - .nv-radio-group-input:not(.nv-radio-group-input--hidden):hover { - border-color: var(--background-color-interaction-primary-hover); -} -.nv-radio-group-item:has(input.nv-radio-group-input:checked):not( - :has(.nv-radio-group-input:disabled) - ) - .nv-radio-group-input:not(.nv-radio-group-input--hidden):active { - border-color: var(--background-color-interaction-primary-selected); -} -.nv-radio-group-item:has(input.nv-radio-group-input:checked):has(.nv-radio-group-input:disabled) - .nv-radio-group-input:not(.nv-radio-group-input--hidden) { - background-color: var(--border-color-interaction-disabled); - border-color: #0000; -} -.nv-radio-group-item:has(input.nv-radio-group-input:checked) .nv-radio-group-indicator { - width: calc(var(--spacing) * 1.5); - height: calc(var(--spacing) * 1.5); - background-color: var(--text-color-accent-black); - opacity: 1; - border-radius: 3.40282e38px; - scale: 1; -} -input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked { - border-color: var(--background-color-interaction-primary-base); - background-color: var(--text-color-accent-black); - border-width: 5px; - position: relative; -} -input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked:hover { - border-color: var(--background-color-interaction-primary-hover); -} -input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked:active { - border-color: var(--background-color-interaction-primary-selected); -} -input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked:before { - content: ''; - width: calc(var(--spacing) * 1.5); - height: calc(var(--spacing) * 1.5); - background-color: var(--text-color-accent-black); - opacity: 1; - border-radius: 3.40282e38px; - scale: 1; -} -input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked:disabled { - background-color: var(--border-color-interaction-disabled); - border-color: #0000; -} -input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked:disabled:before { - background-color: var(--border-color-interaction-disabled); -} -.nv-radio-group-item:has(.nv-radio-group-input:disabled) { - cursor: not-allowed; - color: var(--text-color-disabled); -} -input.nv-radio-group-input:not(.nv-radio-group-input--hidden):disabled { - cursor: not-allowed; - border-color: var(--border-color-disabled); - background-color: var(--background-color-interaction-disabled); - color: var(--text-color-disabled); -} -.nv-radio-group-item:has(.nv-radio-group-input[data-danger]):not( - :has(.nv-radio-group-input:disabled) - ) - .nv-radio-group-input:not(.nv-radio-group-input--hidden), -.nv-radio-group-root--error - .nv-radio-group-item:not(:has(.nv-radio-group-input:disabled)) - .nv-radio-group-input:not(.nv-radio-group-input--hidden) { - border-color: var(--border-color-feedback-danger); - background-color: var(--background-color-interaction-base); -} -:is( - .nv-radio-group-item:has(.nv-radio-group-input[data-danger]):not( - :has(.nv-radio-group-input:disabled) - ) - .nv-radio-group-input:not(.nv-radio-group-input--hidden), - .nv-radio-group-root--error - .nv-radio-group-item:not(:has(.nv-radio-group-input:disabled)) - .nv-radio-group-input:not(.nv-radio-group-input--hidden) -):hover { - background-color: var(--background-color-feedback-danger-subtle-hover); -} -:is( - .nv-radio-group-item:has(.nv-radio-group-input[data-danger]):not( - :has(.nv-radio-group-input:disabled) - ) - .nv-radio-group-input:not(.nv-radio-group-input--hidden), - .nv-radio-group-root--error - .nv-radio-group-item:not(:has(.nv-radio-group-input:disabled)) - .nv-radio-group-input:not(.nv-radio-group-input--hidden) -):active { - background-color: var(--background-color-feedback-danger-subtle-pressed); -} -:is( - .nv-radio-group-item:has(.nv-radio-group-input[data-danger]):has( - input.nv-radio-group-input:checked - ), - .nv-radio-group-root--error .nv-radio-group-item:has(input.nv-radio-group-input:checked) - ):not(:has(.nv-radio-group-input:disabled)) - .nv-radio-group-input:not(.nv-radio-group-input--hidden) { - border-color: var(--border-color-feedback-danger); - background-color: var(--text-color-inverse); - border-width: 5px; -} -:is( - .nv-radio-group-item:has(.nv-radio-group-input[data-danger]):has( - input.nv-radio-group-input:checked - ), - .nv-radio-group-root--error .nv-radio-group-item:has(input.nv-radio-group-input:checked) - ):not(:has(.nv-radio-group-input:disabled)) - .nv-radio-group-input:not(.nv-radio-group-input--hidden):hover { - border-color: var(--background-color-feedback-danger-hover); -} -:is( - .nv-radio-group-item:has(.nv-radio-group-input[data-danger]):has( - input.nv-radio-group-input:checked - ), - .nv-radio-group-root--error .nv-radio-group-item:has(input.nv-radio-group-input:checked) - ):not(:has(.nv-radio-group-input:disabled)) - .nv-radio-group-input:not(.nv-radio-group-input--hidden):active { - border-color: var(--background-color-feedback-danger-pressed); -} -:is( - .nv-radio-group-item:has(.nv-radio-group-input[data-danger]):has( - input.nv-radio-group-input:checked - ), - .nv-radio-group-root--error .nv-radio-group-item:has(input.nv-radio-group-input:checked) - ):has(.nv-radio-group-input:disabled) - .nv-radio-group-input:not(.nv-radio-group-input--hidden) { - background-color: var(--border-color-interaction-disabled); - color: var(--text-color-disabled); - border-color: #0000; -} -:is( - .nv-radio-group-item:has(.nv-radio-group-input[data-danger]):has( - input.nv-radio-group-input:checked - ), - .nv-radio-group-root--error .nv-radio-group-item:has(input.nv-radio-group-input:checked) - ):not(:has(.nv-radio-group-input:disabled)) - .nv-radio-group-indicator { - width: calc(var(--spacing) * 1.5); - height: calc(var(--spacing) * 1.5); - background-color: var(--text-color-inverse); - border-radius: 3.40282e38px; -} -.nv-radio-group-item:has(.nv-radio-group-input[data-danger]) - input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked, -.nv-radio-group-root--error input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked { - border-color: var(--border-color-feedback-danger); - background-color: var(--text-color-inverse); - border-width: 5px; -} -:is( - .nv-radio-group-item:has(.nv-radio-group-input[data-danger]) - input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked, - .nv-radio-group-root--error - input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked -):hover { - border-color: var(--background-color-feedback-danger-hover); -} -:is( - .nv-radio-group-item:has(.nv-radio-group-input[data-danger]) - input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked, - .nv-radio-group-root--error - input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked -):active { - border-color: var(--background-color-feedback-danger-pressed); -} -:is( - .nv-radio-group-item:has(.nv-radio-group-input[data-danger]) - input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked, - .nv-radio-group-root--error - input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked -):before { - content: ''; - width: calc(var(--spacing) * 1.5); - height: calc(var(--spacing) * 1.5); - background-color: var(--text-color-inverse); - border-radius: 3.40282e38px; -} -:is( - .nv-radio-group-item:has(.nv-radio-group-input[data-danger]) - input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked, - .nv-radio-group-root--error - input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked -):disabled { - background-color: var(--border-color-interaction-disabled); - color: var(--text-color-disabled); - border-color: #0000; -} -:is( - .nv-radio-group-item:has(.nv-radio-group-input[data-danger]) - input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked, - .nv-radio-group-root--error - input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked - ):disabled:before { - background-color: var(--border-color-interaction-disabled); -} -.nv-radio-group-item:has(.nv-radio-group-input[data-danger]):has(.nv-radio-group-input:disabled) - .nv-radio-group-input:not(.nv-radio-group-input--hidden), -.nv-radio-group-root--error - .nv-radio-group-item:has(.nv-radio-group-input:disabled) - .nv-radio-group-input:not(.nv-radio-group-input--hidden), -.nv-radio-group-item:has(.nv-radio-group-input[data-danger]) - input.nv-radio-group-input:not(.nv-radio-group-input--hidden):disabled, -.nv-radio-group-root--error input.nv-radio-group-input:not(.nv-radio-group-input--hidden):disabled { - border-color: var(--border-color-disabled); - background-color: var(--background-color-interaction-disabled); - color: var(--text-color-disabled); -} -.nv-radio-input { - width: calc(var(--spacing) * 4); - height: calc(var(--spacing) * 4); - cursor: pointer; - appearance: none; - border-color: var(--border-color-interaction-base); - background-color: var(--background-color-interaction-base); - border-style: solid; - border-width: 2px; - border-radius: 3.40282e38px; - flex-shrink: 0; - position: relative; -} -.nv-radio-input:before { - content: ''; - inset: calc(var(--spacing) * 0); - width: calc(var(--spacing) * 1.5); - height: calc(var(--spacing) * 1.5); - background-color: var(--text-color-accent-black); - opacity: 0; - border-radius: 3.40282e38px; - margin: auto; - display: block; - position: absolute; - scale: 0; -} -.nv-radio-input:hover { - border-color: var(--border-color-interaction-hover); - background-color: var(--background-color-interaction-hover); -} -.nv-radio-input:active { - border-color: var(--border-color-interaction-pressed); - background-color: var(--background-color-interaction-selected); -} -.nv-radio-input:disabled { - pointer-events: none; - cursor: not-allowed; - border-color: var(--border-color-interaction-disabled); - background-color: var(--background-color-interaction-disabled); -} -.nv-radio-input:checked { - border-color: var(--background-color-interaction-primary-base); - background-color: var(--text-color-accent-black); - border-style: solid; - border-width: 5px; -} -.nv-radio-input:checked:before { - opacity: 1; - scale: 1; -} -.nv-radio-input:checked:hover { - border-color: var(--background-color-interaction-primary-hover); -} -.nv-radio-input:checked:active { - border-color: var(--background-color-interaction-primary-selected); -} -.nv-radio-input:checked:disabled { - background-color: var(--border-color-interaction-disabled); - border-color: #0000; -} -.nv-radio-input:checked:disabled:before { - background-color: var(--text-color-disabled); -} -@media (prefers-reduced-motion: no-preference) { - .nv-radio-input { - transition-property: background-color, border-width, border-color; - transition-duration: 0.2s; - transition-timing-function: var(--ease-out); - } - .nv-radio-input:before { - transition-property: opacity, scale; - transition-duration: 0.15s; - transition-timing-function: var(--ease-out); - } -} -.nv-radio-group-item:has([data-danger]) .nv-radio-input:not(:disabled), -.nv-menu-item--danger .nv-radio-input:not(:disabled), -.nv-radio-group-root--error .nv-radio-input:not(:disabled) { - border-color: var(--border-color-feedback-danger); -} -:is( - .nv-radio-group-item:has([data-danger]) .nv-radio-input:not(:disabled), - .nv-menu-item--danger .nv-radio-input:not(:disabled), - .nv-radio-group-root--error .nv-radio-input:not(:disabled) -):hover { - background-color: var(--background-color-feedback-danger-subtle-hover); -} -:is( - .nv-radio-group-item:has([data-danger]) .nv-radio-input:not(:disabled), - .nv-menu-item--danger .nv-radio-input:not(:disabled), - .nv-radio-group-root--error .nv-radio-input:not(:disabled) -):active { - background-color: var(--background-color-feedback-danger-subtle-pressed); -} -:is( - .nv-radio-group-item:has([data-danger]) .nv-radio-input:not(:disabled), - .nv-menu-item--danger .nv-radio-input:not(:disabled), - .nv-radio-group-root--error .nv-radio-input:not(:disabled) -):checked { - border-color: var(--border-color-feedback-danger); - background-color: var(--text-color-inverse); -} -:is( - .nv-radio-group-item:has([data-danger]) .nv-radio-input:not(:disabled), - .nv-menu-item--danger .nv-radio-input:not(:disabled), - .nv-radio-group-root--error .nv-radio-input:not(:disabled) - ):checked:hover { - border-color: var(--background-color-feedback-danger-hover); -} -:is( - .nv-radio-group-item:has([data-danger]) .nv-radio-input:not(:disabled), - .nv-menu-item--danger .nv-radio-input:not(:disabled), - .nv-radio-group-root--error .nv-radio-input:not(:disabled) - ):checked:active { - border-color: var(--background-color-feedback-danger-pressed); -} -.nv-radio-group-item:has(.nv-radio-group-input--hidden) { - position: relative; -} -input.nv-radio-group-input.nv-radio-group-input--hidden { - pointer-events: none; - inset: calc(var(--spacing) * 0); - margin: calc(var(--spacing) * 0); - appearance: none; - border-radius: inherit; - opacity: 0; - width: 100%; - height: 100%; - display: block; - position: absolute; -} -.nv-radio-group-item:has(input.nv-radio-group-input--hidden:focus-visible) { - outline: 2px solid var(--border-color-interaction-pressed); - outline-offset: 2px; -} -.nv-segmented-control-root { - gap: calc(var(--spacing) * 1); - border-radius: var(--radius-lg); - background-color: var(--background-color-component-track); - width: fit-content; - padding: calc(var(--spacing) * 1); - color: var(--text-color-primary); - font-family: var(--font-sans); - font-size: var(--text-14); - font-weight: var(--font-weight-bold); - display: flex; - position: relative; -} -.nv-segmented-control-root:has(:focus-visible) { - outline-offset: -2px; - outline: 2px solid; -} -.nv-segmented-control-root .nv-segmented-control-item { - padding-inline: calc(var(--spacing) * 3); - padding-block: 7px; - line-height: 1.28571; -} -.nv-segmented-control-root.nv-segmented-control-root--size-tiny .nv-segmented-control-item { - padding-inline: calc(var(--spacing) * 2); - font-size: var(--text-12); - padding-block: 2px; - line-height: 1.33333; -} -.nv-segmented-control-root.nv-segmented-control-root--size-small .nv-segmented-control-item { - padding-inline: calc(var(--spacing) * 2); - font-size: var(--text-12); - padding-block: 4px; - line-height: 1.33333; -} -.nv-segmented-control-root.nv-segmented-control-root--size-large .nv-segmented-control-item { - padding-inline: calc(var(--spacing) * 4); - font-size: var(--text-16); - padding-block: 11px; - line-height: 1.125; -} -.nv-segmented-control-item { - cursor: pointer; - justify-content: center; - align-items: center; - gap: calc(var(--spacing) * 1); - border-radius: var(--radius-md); - text-align: center; - flex-grow: 1; - display: flex; - position: relative; -} -.nv-segmented-control-item .nv-segmented-control-input { - clip-path: inset(50%); - white-space: nowrap; - border-width: 0; - width: 1px; - height: 1px; - margin: -1px; - padding: 0; - position: absolute; - overflow: hidden; -} -@media (hover: hover) { - .nv-segmented-control-item:hover { - background-color: var(--background-color-interaction-hover); - } -} -.nv-segmented-control-item:has(.nv-segmented-control-input:checked) { - background-color: var(--background-color-interaction-selected); - box-shadow: var(--shadow-sm); -} -@media (prefers-reduced-motion: no-preference) { - .nv-segmented-control-item { - transition-property: color, background-color; - transition-duration: 0.25s; - transition-timing-function: var(--ease-out); - } -} -@supports (anchor-name: --nv-segmented-control-selected) and - (position-anchor: --nv-segmented-control-selected) { - .nv-segmented-control-root { - isolation: isolate; - } - .nv-segmented-control-root:before { - content: ''; - border-radius: var(--radius-md); - background-color: var(--background-color-interaction-selected); - position-anchor: --nv-segmented-control-selected; - top: anchor(top); - left: anchor(left); - width: anchor-size(width); - height: anchor-size(height); - box-shadow: var(--shadow-sm); - pointer-events: none; - z-index: 0; - position: absolute; - } - .nv-segmented-control-item { - z-index: 1; - } - .nv-segmented-control-item:not(:has(.nv-segmented-control-input:checked)):hover { - background-color: var(--background-color-interaction-hover); - } - .nv-segmented-control-item:has(.nv-segmented-control-input:checked) { - anchor-name: --nv-segmented-control-selected; - box-shadow: none; - background-color: #0000; - } - @media (prefers-reduced-motion: no-preference) { - .nv-segmented-control-root:before { - transition-property: left, top, width, height; - transition-duration: 0.25s; - transition-timing-function: var(--ease-out); - } - } -} -.nv-select-toggle { - flex-shrink: 0; - align-items: center; - height: 100%; - display: flex; -} -@media (scripting: enabled) { - .nv-select-native-fallback { - display: none; - } -} -@media (scripting: none) { - [data-select-enhanced] { - display: none !important; - } -} -.nv-select-content[popover] { - color: inherit; - background: 0 0; - border: 0; - margin: 0; - padding: 0; - display: none; - position: fixed; - inset: auto; - overflow: visible; -} -.nv-select-content[popover]:popover-open { - display: block; -} -.nv-select-content[popover].\:popover-open { - display: block; -} -.nv-select-content { - --menu-translate-start: 0 calc(var(--transition-offset) * -1); - transform-origin: top; -} -@supports (position-anchor: --a) { - .nv-select-content { - --nv-select-offset: 4px; - width: anchor-size(width); - position-try-fallbacks: - flip-block, - flip-inline, - flip-block flip-inline; - margin: 0; - } - .nv-select-content[data-side='bottom'] { - margin-top: var(--nv-select-offset); - position-area: bottom span-right; - } - .nv-select-content[data-side='top'] { - margin-bottom: var(--nv-select-offset); - position-area: top span-right; - } - .nv-select-content[data-side='left'] { - margin-right: var(--nv-select-offset); - position-area: left span-bottom; - } - .nv-select-content[data-side='right'] { - margin-left: var(--nv-select-offset); - position-area: right span-bottom; - } -} -.nv-select-content[data-side='top'] { - --menu-translate-start: 0 var(--transition-offset); - transform-origin: bottom; -} -.nv-select-content[data-side='left'] { - --menu-translate-start: var(--transition-offset) 0; - transform-origin: 100%; -} -.nv-select-content[data-side='right'] { - --menu-translate-start: calc(var(--transition-offset) * -1) 0; - transform-origin: 0; -} -@keyframes select-in { - 0% { - translate: var(--menu-translate-start); - opacity: 0; - } - to { - opacity: 1; - translate: 0; - } -} -@media (prefers-reduced-motion: no-preference) { - .nv-select-content:popover-open { - animation: select-in 0.25s var(--ease-out); - } -} -.nv-select-content .nv-menu-root { - overscroll-behavior: contain; - max-height: min(320px, 100vh - 2rem); -} -.nv-select-native-fallback { - padding-inline: var(--nv-input-padding); -} -.nv-select-native-fallback[multiple] { - min-height: calc(var(--nv-input-height) * 4); - padding-block: calc(var(--nv-input-padding) / 2); -} -@media (scripting: none) { - .nv-select-trigger:has(.nv-select-native-fallback[multiple]) { - height: auto; - } - .nv-select-trigger:has(.nv-select-native-fallback[multiple]) - .nv-input-shell-control:has(.nv-animated-chevron) { - display: none; - } -} -@keyframes left-sidepanel-in { - 0% { - transform: translate(-100%); - } - to { - transform: translate(0); - } -} -@keyframes left-sidepanel-out { - 0% { - transform: translate(0); - } - to { - transform: translate(-100%); - } -} -@keyframes right-sidepanel-in { - 0% { - transform: translate(100%); - } - to { - transform: translate(0); - } -} -@keyframes right-sidepanel-out { - 0% { - transform: translate(0); - } - to { - transform: translate(100%); - } -} -dialog.nv-side-panel-overlay, -dialog.nv-side-panel-dialog { - margin: calc(var(--spacing) * 0); - max-width: none; - max-height: none; - padding: calc(var(--spacing) * 0); - background-color: #0000; - border: none; -} -:is(dialog.nv-side-panel-overlay, dialog.nv-side-panel-dialog):not([open]) { - display: none; -} -dialog.nv-side-panel-overlay { - width: 100vw; - height: 100vh; -} -dialog.nv-side-panel-overlay[open] { - inset: calc(var(--spacing) * 0); - z-index: 1000; - position: fixed; -} -dialog.nv-side-panel-overlay::backdrop { - background-color: var(--background-color-surface-blanket); -} -@media (prefers-reduced-motion: no-preference) { - dialog.nv-side-panel-overlay::backdrop { - animation: modal-in 0.3s var(--ease-out); - } - dialog.nv-side-panel-overlay[data-state='closed']::backdrop { - animation: modal-out 0.2s var(--ease-out) forwards; - } -} -.nv-side-panel-overlay[popover] { - margin: calc(var(--spacing) * 0); - width: 100vw; - max-width: none; - height: 100vh; - max-height: none; - padding: calc(var(--spacing) * 0); - background-color: #0000; - border: none; -} -.nv-side-panel-overlay[popover]:popover-open { - inset: calc(var(--spacing) * 0); - z-index: 1000; - position: fixed; -} -.nv-side-panel-overlay[popover]::backdrop { - background-color: var(--background-color-surface-blanket); -} -dialog.nv-side-panel-dialog { - pointer-events: none; - width: 100vw; - height: 100vh; -} -dialog.nv-side-panel-dialog[open] { - inset: calc(var(--spacing) * 0); - z-index: 1000; - position: fixed; -} -dialog.nv-side-panel-dialog::backdrop { - background-color: #0000; -} -dialog.nv-side-panel-dialog:has(> .nv-side-panel-content--relative) { - width: 100%; - height: 100%; -} -dialog.nv-side-panel-dialog:has(> .nv-side-panel-content--relative)[open] { - inset: calc(var(--spacing) * 0); - z-index: 1000; - position: absolute; -} -.nv-side-panel-content { - pointer-events: auto; - top: calc(var(--spacing) * 0); - bottom: calc(var(--spacing) * 0); - z-index: 1030; - width: var(--side-panel-width, 320px); - background-color: var(--background-color-surface-raised); - max-width: 100%; - box-shadow: var(--shadow-lg); - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); - color: var(--text-color-primary); - --heading-padding-block: calc(var(--spacing-density-xl) + calc(var(--spacing) * 3)); - flex-direction: column; - display: flex; - position: fixed; -} -.nv-side-panel-content.nv-side-panel-content--relative { - top: calc(var(--spacing) * 0); - bottom: calc(var(--spacing) * 0); - position: absolute; -} -.nv-side-panel-content.nv-side-panel-content--side-left { - left: calc(var(--spacing) * 0); -} -@media (prefers-reduced-motion: no-preference) { - .nv-side-panel-content.nv-side-panel-content--side-left[data-state='open'] { - animation: left-sidepanel-in 0.3s var(--ease-out); - animation-fill-mode: both; - } - .nv-side-panel-content.nv-side-panel-content--side-left[data-state='closed'] { - animation: left-sidepanel-out 0.2s var(--ease-out) forwards; - } -} -.nv-side-panel-content.nv-side-panel-content--side-right { - right: calc(var(--spacing) * 0); -} -@media (prefers-reduced-motion: no-preference) { - .nv-side-panel-content.nv-side-panel-content--side-right[data-state='open'] { - animation: right-sidepanel-in 0.3s var(--ease-out); - animation-fill-mode: both; - } - .nv-side-panel-content.nv-side-panel-content--side-right[data-state='closed'] { - animation: right-sidepanel-out 0.2s var(--ease-out) forwards; - } -} -.nv-side-panel-content.nv-side-panel-content--bordered { - border: 1px solid var(--border-color-base); -} -.nv-side-panel-content.nv-side-panel-content--bordered .nv-side-panel-heading { - border-bottom: 1px solid var(--border-color-base); -} -.nv-side-panel-content.nv-side-panel-content--bordered .nv-side-panel-footer { - border-top: 1px solid var(--border-color-base); -} -.nv-side-panel-heading { - align-items: center; - gap: calc(var(--spacing) * 2); - width: 100%; - padding-block: var(--heading-padding-block); - padding-right: calc(var(--spacing) * 14); - padding-left: calc(var(--spacing) * 4); - font-family: var(--font-sans); - font-size: var(--text-16); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-bold); - line-height: var(--leading-lh-100); - flex-shrink: 0; - display: flex; - position: relative; -} -.nv-side-panel-heading > svg, -.nv-side-panel-heading > .nv-icon { - width: calc(var(--spacing) * 6); - height: calc(var(--spacing) * 6); - color: var(--text-color-base); - flex-shrink: 0; -} -.nv-side-panel-heading.nv-side-panel-heading--hidden { - clip-path: inset(50%); - white-space: nowrap; - border-width: 0; - width: 1px; - height: 1px; - margin: -1px; - padding: 0; - position: absolute; - overflow: hidden; -} -.nv-side-panel-heading.nv-side-panel-heading--invisible { - visibility: hidden; -} -.nv-side-panel-navigation { - padding-inline: calc(var(--spacing) * 4); -} -.nv-side-panel-main { - min-height: calc(var(--spacing) * 0); - gap: var(--spacing-density-md); - padding: calc(var(--spacing) * 4); - scrollbar-width: thin; - scrollbar-color: var(--nv-scrollbar-color); - flex-direction: column; - flex: 1; - margin-block-end: -1px; - padding-block-end: calc(var(--spacing) * 4 + 1px); - display: flex; - overflow-y: auto; -} -.nv-side-panel-footer { - justify-content: flex-end; - align-items: center; - gap: calc(var(--spacing) * 2); - padding-inline: calc(var(--spacing) * 4); - padding-block: var(--spacing-density-xl); - flex-shrink: 0; - display: flex; -} -.nv-side-panel-close { - --close-button-top: calc(var(--heading-padding-block) - 1px); - top: var(--close-button-top); - right: calc(var(--spacing) * 4); - position: absolute; - translate: 0 -25%; -} -.nv-side-panel-close > :not(svg):not(.nv-icon) { - clip-path: inset(50%); - white-space: nowrap; - border-width: 0; - width: 1px; - height: 1px; - margin: -1px; - padding: 0; - position: absolute; - overflow: hidden; -} -.nv-side-panel-portal { - pointer-events: none; - inset: calc(var(--spacing) * 0); - z-index: 1060; - position: fixed; -} -.nv-side-panel-portal > * { - pointer-events: auto; -} -.nv-skeleton { - background-color: var(--background-color-component-skeleton); - width: 100%; - height: 1.3em; -} -@media (prefers-reduced-motion: no-preference) { - .nv-skeleton.nv-skeleton--animated { - animation: var(--animate-pulse); - } -} -.nv-skeleton.nv-skeleton--kind-pill { - width: calc(var(--spacing) * 16); - border-radius: var(--radius-xl); -} -.nv-skeleton.nv-skeleton--kind-circle { - width: calc(var(--spacing) * 8); - height: calc(var(--spacing) * 8); - border-radius: 3.40282e38px; -} -.nv-slider-root { - touch-action: none; - -webkit-user-select: none; - user-select: none; - grid-template-rows: calc(var(--nv-slider-thumb-size) * 2); - --nv-slider-thumb-size: calc(var(--spacing) * 3); - --slider-fill-color: var(--color-brand); - --slider-track-color: var(--background-color-component-track); - grid-template-columns: 1fr; - align-items: center; - width: 100%; - display: grid; - position: relative; -} -.nv-slider-root:where(.nv-slider-root--orientation-vertical) { - grid-template-rows: 1fr; - grid-template-columns: calc(var(--nv-slider-thumb-size) * 2); - width: fit-content; - height: 100%; -} -.nv-slider-root:has(.nv-slider-input:disabled) { - --slider-fill-color: var(--text-color-disabled); - --slider-track-color: var(--background-color-interaction-disabled); -} -.nv-slider-root:has(.nv-slider-steps--position-end) { - row-gap: var(--nv-slider-steps-gap, calc(var(--spacing) * 0.5)); - overflow: visible; -} -.nv-slider-root:has(.nv-slider-steps--position-start):not(.nv-slider-root--orientation-vertical) { - row-gap: var(--nv-slider-steps-gap, calc(var(--spacing) * 0.5)); - grid-template-rows: auto calc(var(--nv-slider-thumb-size) * 2); - overflow: visible; -} -.nv-slider-root--orientation-vertical:has(.nv-slider-steps--position-start) { - grid-template-columns: auto calc(var(--nv-slider-thumb-size) * 2); -} -.nv-slider-root--orientation-vertical:has(.nv-slider-steps--position-start) .nv-slider-input { - left: auto; - right: 0; -} -.nv-slider-root--orientation-vertical:has(.nv-slider-steps--position-end) { - grid-template-columns: calc(var(--nv-slider-thumb-size) * 2) auto; -} -.nv-slider-root .nv-slider-input { - appearance: none; - cursor: pointer; - width: 100%; - height: calc(var(--nv-slider-thumb-size) * 2); - z-index: 1; - background: 0 0; - margin: 0; - position: absolute; - top: 0; - left: 0; - right: 0; -} -.nv-slider-root .nv-slider-input::-webkit-slider-runnable-track { - height: calc(var(--spacing) * 1); - border-radius: var(--radius-xl); - background-color: var(--slider-track-color); -} -.nv-slider-root .nv-slider-input::-moz-range-track { - height: calc(var(--spacing) * 1); - border-radius: var(--radius-xl); - background-color: var(--slider-track-color); -} -.nv-slider-root .nv-slider-input::-webkit-slider-thumb { - border: 1px solid; - border-color: var(--border-color-interaction-strong); - background-color: var(--text-color-accent-black); - width: var(--nv-slider-thumb-size); - height: var(--nv-slider-thumb-size); - margin-top: calc((var(--spacing) - var(--nv-slider-thumb-size)) / 2); - border-radius: 3.40282e38px; -} -.nv-slider-root .nv-slider-input::-moz-range-thumb { - border: 1px solid; - border-color: var(--border-color-interaction-strong); - background-color: var(--text-color-accent-black); - width: var(--nv-slider-thumb-size); - height: var(--nv-slider-thumb-size); - margin-top: calc((var(--spacing) - var(--nv-slider-thumb-size)) / 2); - border-radius: 3.40282e38px; -} -.nv-slider-root .nv-slider-input::-webkit-slider-thumb { - -webkit-appearance: none; - margin-top: calc((var(--spacing) - var(--nv-slider-thumb-size)) / 2); -} -.nv-slider-root .nv-slider-input::-moz-range-thumb { - box-sizing: border-box; -} -@media (scripting: enabled) { - .nv-slider-root .nv-slider-input::-webkit-slider-runnable-track { - background: linear-gradient( - to right, - var(--slider-fill-color) var(--slider-percent, 0%), - var(--slider-track-color) var(--slider-percent, 0%) - ); - } - .nv-slider-root .nv-slider-input::-moz-range-progress { - background-color: var(--slider-fill-color); - border-radius: var(--radius-xl); - height: calc(var(--spacing) * 1); - } - .nv-slider-root--orientation-vertical .nv-slider-input::-webkit-slider-runnable-track { - background: linear-gradient( - to top, - var(--slider-fill-color) var(--slider-percent, 0%), - var(--slider-track-color) var(--slider-percent, 0%) - ); - } -} -.nv-slider-root .nv-slider-input:disabled { - cursor: default; -} -.nv-slider-root .nv-slider-input:disabled::-webkit-slider-thumb { - border-color: var(--border-color-interaction-disabled); - background-color: var(--background-color-accent-gray); -} -.nv-slider-root .nv-slider-input:disabled::-moz-range-thumb { - border-color: var(--border-color-interaction-disabled); - background-color: var(--background-color-accent-gray); -} -.nv-slider-root--orientation-vertical .nv-slider-input { - writing-mode: vertical-lr; - height: 100%; - width: calc(var(--nv-slider-thumb-size) * 2); - right: unset; - direction: rtl; -} -.nv-slider-root--orientation-vertical .nv-slider-input::-webkit-slider-thumb { - margin-left: calc((calc(var(--spacing) * 1) - var(--nv-slider-thumb-size)) / 2); -} -.nv-slider-root--orientation-vertical .nv-slider-input::-moz-range-thumb { - margin-left: calc((calc(var(--spacing) * 1) - var(--nv-slider-thumb-size)) / 2); -} -.nv-slider-root--orientation-vertical .nv-slider-input::-webkit-slider-runnable-track { - border-radius: var(--radius-xl); - width: calc(var(--spacing) * 1); - height: 100%; -} -.nv-slider-root--orientation-vertical .nv-slider-input::-moz-range-track { - border-radius: var(--radius-xl); - background-color: var(--slider-track-color); - width: calc(var(--spacing) * 1); - height: 100%; -} -.nv-slider-root--orientation-vertical .nv-slider-input::-moz-range-progress { - background-color: var(--slider-fill-color); - border-radius: var(--radius-xl); - width: calc(var(--spacing) * 1); -} -.nv-slider-steps.nv-slider-steps--position-end { - padding-inline: calc(var(--nv-slider-thumb-size) / 2); - grid-area: 2/1; - justify-content: space-between; - align-self: flex-start; - display: flex; -} -.nv-slider-steps.nv-slider-steps--position-start { - padding-inline: calc(var(--nv-slider-thumb-size) / 2); - grid-area: 1/1; - justify-content: space-between; - align-self: flex-end; - display: flex; -} -.nv-slider-root--orientation-vertical .nv-slider-steps.nv-slider-steps--position-start { - padding-inline: 0; - padding-block: calc(var(--nv-slider-thumb-size) / 2); - flex-direction: column-reverse; - grid-area: 1/1; - justify-content: space-between; - align-self: stretch; - align-items: flex-end; - display: flex; -} -.nv-slider-root--orientation-vertical - .nv-slider-steps.nv-slider-steps--position-start - .nv-slider-step { - height: calc(var(--spacing) * 0); - flex-direction: row-reverse; - width: fit-content; -} -.nv-slider-root--orientation-vertical .nv-slider-steps.nv-slider-steps--position-end { - padding-inline: 0; - padding-block: calc(var(--nv-slider-thumb-size) / 2); - flex-direction: column-reverse; - grid-area: 1/2; - justify-content: space-between; - align-self: stretch; - align-items: flex-start; - display: flex; -} -.nv-slider-root--orientation-vertical - .nv-slider-steps.nv-slider-steps--position-end - .nv-slider-step { - height: calc(var(--spacing) * 0); - flex-direction: row; - width: fit-content; -} -.nv-slider-step { - width: calc(var(--spacing) * 0); - align-items: center; - gap: var(--spacing-density-xs); - flex-direction: column; - display: flex; - overflow: visible; -} -.nv-slider-step-dot { - height: calc(var(--spacing) * 1); - width: calc(var(--spacing) * 1); - background-color: var(--text-color-secondary); - border-radius: 3.40282e38px; - display: block; -} -.nv-slider-step-label { - font-family: var(--font-sans); - font-size: var(--text-12); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-regular); - white-space: nowrap; - color: var(--text-color-secondary); -} -.nv-range-slider-root { - touch-action: none; - -webkit-user-select: none; - user-select: none; - grid-template-rows: calc(var(--nv-slider-thumb-size) * 2); - --nv-slider-thumb-size: calc(var(--spacing) * 3); - width: 100%; - height: calc(var(--nv-slider-thumb-size) * 2); - grid-template-columns: 1fr; - align-items: center; - display: grid; - position: relative; -} -.nv-range-slider-root:where(.nv-range-slider-root--orientation-vertical) { - grid-template-rows: 1fr; - grid-template-columns: calc(var(--nv-slider-thumb-size) * 2); - justify-items: center; - width: fit-content; - height: 100%; -} -.nv-range-slider-root--orientation-vertical:has(.nv-range-slider-steps--position-start) { - grid-template-columns: auto calc(var(--nv-slider-thumb-size) * 2); - column-gap: var(--nv-slider-steps-gap, calc(var(--spacing) * 0.5)); -} -.nv-range-slider-root--orientation-vertical:has(.nv-range-slider-steps--position-end) { - grid-template-columns: calc(var(--nv-slider-thumb-size) * 2) auto; - column-gap: var(--nv-slider-steps-gap, calc(var(--spacing) * 0.5)); -} -.nv-range-slider-track { - height: calc(var(--spacing) * 1); - border-radius: var(--radius-xl); - background-color: var(--background-color-component-track); - flex-grow: 1; - width: 100%; - position: relative; - overflow: hidden; -} -.nv-range-slider-range { - border-radius: var(--radius-xl); - background-color: var(--color-brand); - width: auto; - height: 100%; - position: absolute; -} -.nv-range-slider-range--orientation-vertical { - bottom: calc(var(--spacing) * 0); - background-color: var(--color-brand); - width: 100%; - height: auto; -} -.nv-range-slider-thumb { - box-shadow: var(--shadow-sm); - cursor: pointer; - border: 1px solid; - border-color: var(--border-color-interaction-strong); - background-color: var(--text-color-accent-black); - border-radius: 3.40282e38px; - display: block; - position: relative; -} -.nv-range-slider-thumb:focus { - outline-style: none; -} -.nv-range-slider-thumb:after { - content: ''; - pointer-events: none; - inset: calc(var(--spacing) * 0); - border-radius: inherit; - position: absolute; -} -.nv-range-slider-thumb:hover:after { - background-color: var(--background-color-interaction-hover); -} -.nv-range-slider-thumb:active:after { - background-color: var(--background-color-interaction-selected); -} -.nv-range-slider-root .nv-range-slider-thumb { - width: var(--nv-slider-thumb-size); - height: var(--nv-slider-thumb-size); -} -.nv-range-slider-root > .nv-range-slider-track, -.nv-range-slider-root - > :not(.nv-range-slider-track):not(.nv-range-slider-steps):not( - .nv-range-slider-native-fallback - ):not(.nv-range-slider-native-fallback-fields) { - grid-area: 1/1; -} -.nv-range-slider-root--orientation-vertical:has(.nv-range-slider-steps--position-start) - > .nv-range-slider-track, -.nv-range-slider-root--orientation-vertical:has(.nv-range-slider-steps--position-start) - > :not(.nv-range-slider-track):not(.nv-range-slider-steps):not( - .nv-range-slider-native-fallback - ):not(.nv-range-slider-native-fallback-fields) { - grid-column: 2; -} -.nv-range-slider-root--orientation-vertical .nv-range-slider-track { - height: 100%; - width: calc(var(--spacing) * 1); -} -.nv-range-slider-root--orientation-vertical - > :not(.nv-range-slider-track):not(.nv-range-slider-steps):not( - .nv-range-slider-native-fallback - ):not(.nv-range-slider-native-fallback-fields) { - left: calc(var(--nv-slider-thumb-size) / 2); -} -.nv-range-slider-root[data-disabled] .nv-range-slider-track { - background-color: var(--background-color-interaction-disabled); -} -.nv-range-slider-root[data-disabled] .nv-range-slider-range { - background-color: var(--text-color-disabled); - background-image: none; -} -.nv-range-slider-root[data-disabled] .nv-range-slider-thumb { - border-color: var(--border-color-interaction-disabled); - background-color: var(--background-color-accent-gray); -} -.nv-range-slider-native-fallback-fields { - gap: var(--spacing-density-sm); - min-width: 0; -} -.nv-range-slider-native-fallback-field { - gap: var(--spacing-density-xs); - min-width: 0; - display: grid; -} -.nv-range-slider-native-fallback { - width: 100%; - min-width: 0; -} -.nv-range-slider-native-fallback-label { - font-family: var(--font-sans); - font-size: var(--text-12); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-regular); - color: var(--text-color-secondary); -} -@media (scripting: enabled) { - .nv-range-slider-native-fallback, - .nv-range-slider-native-fallback-fields, - .nv-range-slider-native-fallback-field, - .nv-range-slider-native-fallback-label, - .nv-range-slider-native-fallback-steps { - display: none; - } -} -@media (scripting: none) { - .nv-range-slider-root { - touch-action: auto; - -webkit-user-select: auto; - user-select: auto; - height: auto; - display: block; - } - .nv-range-slider-track, - .nv-range-slider-thumb, - .nv-range-slider-range, - .nv-range-slider-steps, - .nv-range-slider-root - > :not(.nv-range-slider-track):not(.nv-range-slider-steps):not( - .nv-range-slider-native-fallback-fields - ) { - display: none !important; - } - .nv-range-slider-native-fallback-fields { - display: flex; - } - .nv-range-slider-native-fallback-field { - flex: 1 1 0; - } -} -.nv-range-slider-root:has(.nv-range-slider-steps--position-end):not( - .nv-range-slider-root--orientation-vertical - ) { - row-gap: var(--nv-slider-steps-gap, calc(var(--spacing) * 0.5)); - height: auto; -} -.nv-range-slider-root:has(.nv-range-slider-steps--position-end):not( - .nv-range-slider-root--orientation-vertical - ) - > :not(.nv-range-slider-track):not(.nv-range-slider-steps):not( - .nv-range-slider-native-fallback - ):not(.nv-range-slider-native-fallback-fields) { - top: calc(var(--nv-slider-thumb-size) / 2); -} -.nv-range-slider-root:has(.nv-range-slider-steps--position-start):not( - .nv-range-slider-root--orientation-vertical - ) { - row-gap: var(--nv-slider-steps-gap, calc(var(--spacing) * 0.5)); - grid-template-rows: auto calc(var(--nv-slider-thumb-size) * 2); - height: auto; -} -.nv-range-slider-root:has(.nv-range-slider-steps--position-start):not( - .nv-range-slider-root--orientation-vertical - ) - > :not(.nv-range-slider-track):not(.nv-range-slider-steps):not( - .nv-range-slider-native-fallback - ):not(.nv-range-slider-native-fallback-fields) { - top: calc(var(--nv-slider-thumb-size) / 2); -} -.nv-range-slider-steps { - position: relative; -} -.nv-range-slider-steps.nv-range-slider-steps--position-end { - padding-inline: calc(var(--nv-slider-thumb-size) / 2); - grid-area: 2/1; - justify-content: space-between; - display: flex; -} -.nv-range-slider-steps.nv-range-slider-steps--position-start { - padding-inline: calc(var(--nv-slider-thumb-size) / 2); - grid-area: 1/1; - justify-content: space-between; - display: flex; -} -.nv-range-slider-root--orientation-vertical - .nv-range-slider-steps.nv-range-slider-steps--position-start { - padding-inline: 0; - padding-block: calc(var(--nv-slider-thumb-size) / 2); - flex-direction: column-reverse; - grid-area: 1/1; - justify-content: space-between; - align-self: stretch; - align-items: flex-end; - display: flex; -} -.nv-range-slider-root--orientation-vertical - .nv-range-slider-steps.nv-range-slider-steps--position-start - .nv-range-slider-step { - height: calc(var(--spacing) * 0); - flex-direction: row-reverse; - width: fit-content; -} -.nv-range-slider-root--orientation-vertical - .nv-range-slider-steps.nv-range-slider-steps--position-end { - padding-inline: 0; - padding-block: calc(var(--nv-slider-thumb-size) / 2); - flex-direction: column-reverse; - grid-area: 1/2; - justify-content: space-between; - align-self: stretch; - align-items: flex-start; - display: flex; -} -.nv-range-slider-root--orientation-vertical - .nv-range-slider-steps.nv-range-slider-steps--position-end - .nv-range-slider-step { - height: calc(var(--spacing) * 0); - flex-direction: row; - width: fit-content; -} -.nv-range-slider-step { - width: calc(var(--spacing) * 0); - align-items: center; - gap: var(--spacing-density-xs); - flex-direction: column; - display: flex; - overflow: visible; -} -.nv-range-slider-step-dot { - height: calc(var(--spacing) * 1); - width: calc(var(--spacing) * 1); - background-color: var(--text-color-secondary); - border-radius: 3.40282e38px; - display: block; -} -.nv-range-slider-step-label { - font-family: var(--font-sans); - font-size: var(--text-12); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-regular); - white-space: nowrap; - color: var(--text-color-secondary); -} -.nv-spinner-root { - filter: drop-shadow(0 0 calc(var(--spinner-shadow-size) * 2) #76b90080); - flex-direction: column; - align-items: center; - display: flex; -} -@supports (color: color-mix(in lab, red, red)) { - .nv-spinner-root { - filter: drop-shadow( - 0 0 calc(var(--spinner-shadow-size) * 2) - color-mix( - in srgb, - var(--background-color-interaction-primary-base) 50%, - transparent - ) - ); - } -} -.nv-spinner-root svg { - width: auto; -} -.nv-spinner-root--size-small { - gap: calc(var(--spacing) * 2); - --spinner-shadow-size: calc(var(--spacing) * 1.5); -} -.nv-spinner-root--size-small > div:first-child { - height: calc(var(--spacing) * 8); -} -.nv-spinner-root--size-medium { - gap: calc(var(--spacing) * 3); - --spinner-shadow-size: calc(var(--spacing) * 2); -} -.nv-spinner-root--size-medium > div:first-child { - height: calc(var(--spacing) * 16); -} -.nv-spinner-root--size-large { - gap: calc(var(--spacing) * 6); - --spinner-shadow-size: calc(var(--spacing) * 3); -} -.nv-spinner-root--size-large > div:first-child { - height: calc(var(--spacing) * 32); -} -.nv-spinner-arrow { - fill: var(--background-color-interaction-primary-base); - width: 0; - height: 0; - animation: 1s infinite spinnerArrowOpacity; -} -.nv-spinner-description { - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); - color: var(--text-color-primary); -} -@keyframes spinnerArrowOpacity { - 0% { - opacity: 0.1; - } - 30% { - opacity: 1; - } - to { - opacity: 0.1; - } -} -.nv-stepper-root { - gap: calc(var(--spacing) * 3); - display: flex; -} -.nv-stepper-root:not(.nv-stepper-root--layout-vertical) { - width: 100%; - overflow: auto; -} -.nv-stepper-root.nv-stepper-root--layout-vertical { - flex-direction: column; - height: 100%; -} -.nv-stepper-root.nv-stepper-root--kind-compact { - gap: calc(var(--spacing) * 1); -} -.nv-stepper-root.nv-stepper-root--kind-compact.nv-stepper-root--layout-horizontal { - flex-direction: column; -} -.nv-stepper-root.nv-stepper-root--kind-compact.nv-stepper-root--layout-vertical { - flex-direction: column; - align-items: flex-start; - height: auto; -} -.nv-stepper-item { - flex-direction: column; - display: flex; -} -.nv-stepper-root--layout-horizontal > .nv-stepper-item { - flex: 1; -} -.nv-stepper-root--kind-compact .nv-stepper-item { - min-width: calc(var(--spacing) * 0); - flex: none; -} -.nv-stepper-node-row { - align-items: center; - gap: calc(var(--spacing) * 3); - flex-shrink: 0; - width: 100%; - display: flex; -} -.nv-stepper-node { - width: calc(var(--spacing) * 6); - height: calc(var(--spacing) * 6); - border: 2px solid; - border-color: var(--border-color-base); - border-radius: 3.40282e38px; - flex-shrink: 0; - justify-content: center; - align-items: center; - display: flex; - position: relative; - overflow: clip; -} -.nv-stepper-item[data-state='completed'] .nv-stepper-node { - background-color: var(--background-color-interaction-primary-base); - border-color: #0000; -} -.nv-stepper-item[data-state='active'] .nv-stepper-node { - border-color: var(--border-color-interaction-selected); -} -.nv-stepper-item[data-status='error'] .nv-stepper-node { - border-color: var(--border-color-feedback-danger); - background-color: #0000; -} -.nv-stepper-root--kind-compact .nv-stepper-node { - width: calc(var(--spacing) * 4); - height: calc(var(--spacing) * 4); -} -.nv-stepper-node:has(a, button) { - cursor: pointer; - position: relative; -} -.nv-stepper-node:has(a, button) a:after, -.nv-stepper-node:has(a, button) button:after { - content: ''; - position: absolute; - inset: 0; -} -.nv-stepper-node-icon { - width: calc(var(--spacing) * 3); - height: calc(var(--spacing) * 3); - justify-content: center; - align-items: center; - display: flex; -} -.nv-stepper-node-icon svg, -.nv-stepper-node-icon .nv-icon { - width: calc(var(--spacing) * 3); - height: calc(var(--spacing) * 3); - color: var(--text-color-accent-black); -} -.nv-stepper-item[data-status='error'] .nv-stepper-node-icon.nv-stepper-node-icon--error svg, -.nv-stepper-item[data-status='error'] .nv-stepper-node-icon.nv-stepper-node-icon--error .nv-icon { - color: var(--text-color-feedback-danger); -} -.nv-stepper-node-number { - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: 1.21429; - font-weight: var(--font-weight-regular); - color: var(--text-color-secondary); -} -.nv-stepper-item[data-state='active'] .nv-stepper-node-number { - color: var(--text-color-strong); -} -.nv-stepper-item[data-state='completed'] .nv-stepper-node-number { - inset: calc(var(--spacing) * 0); - color: #0000; - position: absolute; -} -.nv-stepper-item-heading { - font-family: var(--font-sans); - font-size: var(--text-12); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-bold); - white-space: nowrap; - color: var(--text-color-secondary); -} -.nv-stepper-item[data-state='active'] .nv-stepper-item-heading, -.nv-stepper-item[data-state='completed'] .nv-stepper-item-heading { - color: var(--text-color-primary); -} -.nv-stepper-item-body { - gap: calc(var(--spacing) * 3); - flex-direction: column; - display: flex; -} -.nv-stepper-root--layout-horizontal:not(.nv-stepper-root--kind-compact) - > .nv-stepper-item - > .nv-stepper-item-body { - padding-left: calc(var(--spacing) * 9); -} -.nv-stepper-item-description { - font-family: var(--font-sans); - font-size: var(--text-12); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); - color: var(--text-color-secondary); -} -.nv-stepper-root--kind-compact .nv-stepper-item-description { - font-family: var(--font-sans); - font-size: var(--text-12); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-regular); -} -.nv-stepper-item[data-state='default'] .nv-stepper-item-description { - color: var(--text-color-placeholder); -} -.nv-stepper-item-content { - display: flex; -} -.nv-stepper-root--layout-horizontal:not(.nv-stepper-root--kind-compact) - > .nv-stepper-item:not(:last-child) - > .nv-stepper-node-row:after { - content: ''; - height: calc(var(--spacing) * 0.5); - background-image: repeating-linear-gradient( - to right, - var(--border-color-base) 0, - var(--border-color-base) var(--spacing), - transparent var(--spacing), - transparent calc(var(--spacing) * 2) - ); - border-radius: 3.40282e38px; - flex: 1; - display: block; -} -.nv-stepper-root--layout-horizontal:not(.nv-stepper-root--kind-compact) - > .nv-stepper-item[data-state='completed']:not(:last-child) - > .nv-stepper-node-row:after { - background: var(--border-color-interaction-selected); -} -.nv-stepper-root--layout-vertical:not(.nv-stepper-root--kind-compact) - > .nv-stepper-item:not(:last-child) - > .nv-stepper-node-row:after { - content: ''; - min-height: calc(var(--spacing) * 6); - width: calc(var(--spacing) * 0.5); - background-image: repeating-linear-gradient( - to bottom, - var(--border-color-base) 0, - var(--border-color-base) var(--spacing), - transparent var(--spacing), - transparent calc(var(--spacing) * 2) - ); - border-radius: 3.40282e38px; - flex: 1; - display: block; -} -.nv-stepper-root--layout-vertical:not(.nv-stepper-root--kind-compact) - > .nv-stepper-item[data-state='completed']:not(:last-child) - > .nv-stepper-node-row:after { - background: var(--border-color-interaction-selected); -} -.nv-stepper-compact-dots { - align-items: center; - display: flex; -} -.nv-stepper-root--layout-vertical > .nv-stepper-compact-dots { - flex-direction: column; -} -.nv-stepper-compact-dots > .nv-stepper-item { - flex-direction: row; - align-items: center; - display: flex; -} -.nv-stepper-compact-dots > .nv-stepper-item > .nv-stepper-node-row { - width: auto; -} -.nv-stepper-compact-dots > .nv-stepper-item:not(:last-child):after { - content: ''; - height: calc(var(--spacing) * 0.5); - width: calc(var(--spacing) * 4); - background-image: repeating-linear-gradient( - to right, - var(--border-color-base) 0, - var(--border-color-base) var(--spacing), - transparent var(--spacing), - transparent calc(var(--spacing) * 2) - ); - border-radius: 3.40282e38px; - margin-inline: 4px; - display: block; -} -.nv-stepper-compact-dots > .nv-stepper-item[data-state='completed']:not(:last-child):after { - background: var(--border-color-interaction-selected); -} -.nv-stepper-root--layout-vertical .nv-stepper-compact-dots > .nv-stepper-item { - flex-direction: column; - align-items: center; - display: flex; -} -.nv-stepper-root--layout-vertical - .nv-stepper-compact-dots - > .nv-stepper-item:not(:last-child):after { - width: calc(var(--spacing) * 0.5); - background-image: repeating-linear-gradient( - to bottom, - var(--border-color-base) 0, - var(--border-color-base) var(--spacing), - transparent var(--spacing), - transparent calc(var(--spacing) * 2) - ); - height: 16px; - margin-block: 4px; -} -.nv-stepper-active-info { - flex-direction: column; - justify-content: center; - align-items: flex-start; - gap: 1px; - display: flex; -} -.nv-stepper-active-info > .nv-stepper-item-heading { - color: var(--text-color-primary); -} -.nv-stepper-item-text { - min-height: calc(var(--spacing) * 6); - flex-direction: column; - justify-content: center; - align-items: flex-start; - gap: 1px; - display: flex; -} -.nv-stepper-root--layout-vertical:not(.nv-stepper-root--kind-compact) > .nv-stepper-item { - gap: calc(var(--spacing) * 3); - flex-direction: row; - flex: 1; - width: 100%; - min-height: 60px; -} -.nv-stepper-root--layout-vertical:not(.nv-stepper-root--kind-compact) - > .nv-stepper-item - > .nv-stepper-node-row { - width: calc(var(--spacing) * 6); - align-items: center; - gap: calc(var(--spacing) * 3); - flex-direction: column; - align-self: stretch; -} -.nv-stepper-root--layout-vertical:not(.nv-stepper-root--kind-compact) - > .nv-stepper-item - > .nv-stepper-item-body { - gap: calc(var(--spacing) * 3); - padding-top: calc(var(--spacing) * 0); - padding-left: calc(var(--spacing) * 0); - flex: 1; -} -.nv-stepper-root--layout-vertical:not(.nv-stepper-root--kind-compact) - > .nv-stepper-item - > .nv-stepper-item-body - > .nv-stepper-item-heading { - white-space: normal; -} -.nv-status-indicator { - width: var(--size); - height: var(--size); - background-color: var(--color); - border-radius: 3.40282e38px; - display: inline-block; -} -.nv-status-indicator, -.nv-status-indicator.nv-status-indicator--size-medium { - --size: 8px; -} -.nv-status-indicator.nv-status-indicator--size-small { - --size: 6px; -} -.nv-status-indicator.nv-status-indicator--size-large { - --size: 12px; -} -.nv-status-indicator.nv-status-indicator--size-xlarge { - --size: 16px; -} -.nv-status-indicator.nv-status-indicator--size-xxlarge { - --size: 20px; -} -.nv-status-indicator, -.nv-status-indicator.nv-status-indicator--color-red { - --color: var(--text-color-feedback-danger); -} -.nv-status-indicator.nv-status-indicator--color-blue { - --color: var(--text-color-feedback-info); -} -.nv-status-indicator.nv-status-indicator--color-yellow { - --color: var(--text-color-feedback-warning); -} -.nv-status-indicator.nv-status-indicator--color-green { - --color: var(--text-color-feedback-success); -} -.nv-status-message-root { - justify-content: center; - align-items: center; - gap: calc(var(--spacing) * 4); - width: fit-content; - min-width: 200px; - font-family: var(--font-sans); - color: var(--text-color-primary); - flex-direction: column; - margin-inline: auto; - display: flex; -} -.nv-status-message-root .nv-status-message-heading { - font-family: var(--font-sans); - font-size: var(--text-24); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-bold); -} -.nv-status-message-root .nv-status-message-actions { - justify-content: center; - align-items: center; - gap: calc(var(--spacing) * 2); - width: 100%; - display: flex; -} -.nv-status-message-root .nv-status-message-header { - justify-content: center; - align-items: center; - gap: calc(var(--spacing) * 2); - text-align: center; - flex-direction: column; - width: 100%; - display: flex; -} -.nv-status-message-root .nv-status-message-footer { - justify-content: center; - align-items: center; - gap: calc(var(--spacing) * 2); - width: 100%; - display: flex; -} -.nv-status-message-root .nv-status-message-subheading { - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); - color: var(--text-color-secondary); -} -.nv-status-message-root .nv-status-message-media { - font-size: var(--text-64); - color: var(--text-color-base); - justify-content: center; - align-items: center; - display: flex; -} -.nv-status-message-root.nv-status-message-root--size-small .nv-status-message-heading { - font-family: var(--font-sans); - font-size: var(--text-18); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-bold); -} -.nv-status-message-root.nv-status-message-root--size-small .nv-status-message-media { - font-size: var(--text-32); -} -.nv-status-message-root.nv-status-message-root--size-small .nv-status-message-actions { - gap: calc(var(--spacing) * 1.5); -} -.nv-status-message-root.nv-status-message-root--size-small .nv-status-message-header { - gap: calc(var(--spacing) * 1); -} -.nv-switch-root { - align-items: center; - gap: calc(var(--spacing) * 2); - --nv-switch-track-height: calc(var(--spacing) * 6); - --nv-switch-track-width: calc(var(--spacing) * 11); - --nv-switch-thumb-size: calc(var(--spacing) * 4); - --nv-switch-thumb-margin: calc(var(--spacing) * 1); - --nv-switch-thumb-margin-checked: calc(var(--spacing) * 6); - width: fit-content; - display: inline-flex; -} -.nv-switch-root.nv-switch-root--size-small { - gap: calc(var(--spacing) * 1); - --nv-switch-track-height: calc(var(--spacing) * 4); - --nv-switch-track-width: calc(var(--spacing) * 7); - --nv-switch-thumb-size: calc(var(--spacing) * 2); - --nv-switch-thumb-margin-checked: calc(var(--spacing) * 4); -} -.nv-switch-root.nv-switch-root--size-large { - --nv-switch-track-height: calc(var(--spacing) * 8); - --nv-switch-track-width: calc(var(--spacing) * 15); - --nv-switch-thumb-size: calc(var(--spacing) * 6); - --nv-switch-thumb-margin-checked: calc(var(--spacing) * 8); -} -.nv-switch-root.nv-switch-root--side-start { - flex-direction: row-reverse; -} -.nv-switch-input { - appearance: none; - cursor: pointer; - border-radius: var(--radius-xl); - height: var(--nv-switch-track-height); - width: var(--nv-switch-track-width); - box-shadow: inset 0 0 0 2px var(--border-color-interaction-strong); - background-color: #0000; - position: relative; - overflow: hidden; - scale: 1; -} -.nv-switch-input:focus-visible { - outline: 2px solid -webkit-focus-ring-color; - outline-offset: 2px; -} -.nv-switch-input:before { - content: ''; - box-shadow: var(--shadow-sm); - z-index: 1; - border-radius: var(--radius-xl); - background-color: var(--background-color-interaction-inverse); - top: 50%; - left: var(--nv-switch-thumb-margin); - width: var(--nv-switch-thumb-size); - height: var(--nv-switch-thumb-size); - transition: - left 0.3s var(--ease-out), - background-color 0.3s var(--ease-out); - display: block; - position: absolute; - transform: translateY(-50%); -} -@media (prefers-reduced-motion: reduce) { - .nv-switch-input:before { - transition: none; - } -} -.nv-switch-input:after { - content: ''; - inset: calc(var(--spacing) * 0); - z-index: calc(1 * -1); - border-radius: var(--radius-xl); - background-color: var(--background-color-interaction-primary-base); - transition: transform 0.25s var(--ease-out); - position: absolute; - transform: translate(-100%); -} -@media (prefers-reduced-motion: reduce) { - .nv-switch-input:after { - transition: none; - } -} -.nv-switch-input:is(:checked, [data-state='checked']) { - box-shadow: none; -} -.nv-switch-input:is(:checked, [data-state='checked']):before { - left: var(--nv-switch-thumb-margin-checked); - background-color: var(--text-color-accent-black); -} -.nv-switch-input:is(:checked, [data-state='checked']):after { - transform: translate(0); -} -.nv-switch-input:is(:disabled, [data-disabled]) { - cursor: not-allowed; - box-shadow: inset 0 0 0 2px var(--border-color-disabled); -} -.nv-switch-input:is(:disabled, [data-disabled]):before { - background-color: var(--text-color-disabled); -} -.nv-switch-input:is(:disabled, [data-disabled]):is(:checked, [data-state='checked']) { - box-shadow: none; -} -.nv-switch-input:is(:disabled, [data-disabled]):is(:checked, [data-state='checked']):after { - background-color: var(--background-color-interaction-disabled-checked); -} -.nv-switch-input:is(:disabled, [data-disabled]):is(:checked, [data-state='checked']):before { - background-color: var(--text-color-interaction-disabled-checked); -} -.nv-table-root { - --table-cell-inline-padding: var(--spacing-density-lg); - --table-cell-block-padding: var(--spacing-density-md); - --table-cell-content-height: 40px; - background-color: var(--background-color-surface-base); - font-size: var(--text-14); - line-height: var(--leading-lh-150); - color: var(--text-color-primary); -} -.nv-table-root, -.nv-table-root.nv-table-root--layout-fixed { - table-layout: fixed; -} -.nv-table-root.nv-table-root--layout-auto { - table-layout: auto; -} -@media (hover: hover) { - :where(.nv-table-root.nv-table-root--hoverable-rows .nv-table-body .nv-table-row):hover { - background-color: var(--background-color-interaction-hover); - } -} -.nv-table--align-left { - text-align: left; -} -.nv-table--align-center { - text-align: center; -} -.nv-table--align-right { - text-align: right; -} -.nv-table-row { - border-bottom: 1px solid; - border-color: var(--border-color-base); -} -.nv-table-row.nv-table-row--selected { - background-color: var(--background-color-interaction-pressed); -} -.nv-table-data-cell, -.nv-table-header-cell { - text-overflow: ellipsis; - white-space: nowrap; - padding-inline: var(--table-cell-inline-padding); - padding-block: var(--table-cell-block-padding); - vertical-align: middle; - box-sizing: border-box; - height: calc(var(--table-cell-content-height) + var(--table-cell-block-padding) * 2); - align-items: center; - overflow: hidden; -} -:is(.nv-table-data-cell, .nv-table-header-cell) [data-sorting-icon] { - color: var(--text-color-base); -} -:is(.nv-table-data-cell, .nv-table-header-cell) [data-sorting-icon][data-selected] { - color: var(--text-color-primary); -} -.nv-table-head { - border-bottom: 2px solid; - border-color: var(--border-color-base); - font-weight: var(--font-weight-semibold); -} -.nv-table-body { - font-weight: var(--font-weight-regular); -} -.nv-table-toolbar { - height: calc(var(--spacing) * 12); - position: relative; -} -.nv-table-toolbar [data-active='false'] { - opacity: 0; - translate: 0 130%; -} -.nv-table-toolbar [data-active='true'] { - opacity: 1; - translate: 0; -} -.nv-table-toolbar .nv-table-toolbar-content { - justify-content: space-between; - align-items: center; - gap: calc(var(--spacing) * 1); - width: 100%; - height: 100%; - padding-block: var(--spacing-density-xs); - display: flex; -} -.nv-table-toolbar .nv-table-toolbar-bulk-actions-section { - inset: calc(var(--spacing) * 0); - position: absolute; -} -@media (prefers-reduced-motion: no-preference) { - :is( - .nv-table-toolbar .nv-table-toolbar-content, - .nv-table-toolbar .nv-table-toolbar-bulk-actions-section - ) { - transition-property: translate, opacity; - transition-duration: 0.15s; - transition-timing-function: var(--ease-out); - } -} -.nv-table-bulk-action-toolbar { - justify-content: space-between; - align-items: center; - gap: calc(var(--spacing) * 1); - background-color: var(--background-color-component-track); - padding-inline: var(--spacing-density-xl); - padding-block: var(--spacing-density-xs); - display: flex; -} -.nv-tabs-root { - gap: inherit; - flex-direction: column; - width: 100%; - max-width: 100%; - display: flex; - overflow: hidden; -} -.nv-tabs-content { - align-items: flex-start; - gap: calc(var(--spacing) * 4); - padding: calc(var(--spacing) * 6); - flex-direction: column; - align-self: stretch; - display: flex; -} -.nv-tabs-content:not([data-active]) { - display: none; -} -.nv-tabs-list { - align-items: center; - gap: calc(var(--spacing) * 2); - flex-wrap: nowrap; - width: 100%; - display: flex; - position: relative; -} -.nv-tabs-list .nv-tabs-trigger { - cursor: pointer; - justify-content: center; - align-items: center; - gap: calc(var(--spacing) * 2); - min-width: fit-content; - font-size: var(--text-14); - line-height: var(--leading-lh-100); - color: var(--text-color-secondary); - font-weight: var(--font-weight-regular); - flex-shrink: 0; - display: inline-flex; -} -.nv-tabs-list .nv-tabs-trigger:hover, -.nv-tabs-list .nv-tabs-trigger:active, -.nv-tabs-list .nv-tabs-trigger:focus-visible { - color: var(--text-color-primary); -} -.nv-tabs-list .nv-tabs-trigger .nv-tabs-trigger-visible, -.nv-tabs-list .nv-tabs-trigger .nv-tabs-trigger-invisible { - justify-content: center; - align-items: center; - gap: inherit; - display: inline-flex; -} -.nv-tabs-list .nv-tabs-trigger .nv-tabs-trigger-visible { - position: absolute; -} -.nv-tabs-list .nv-tabs-trigger .nv-tabs-trigger-invisible { - visibility: hidden; - font-weight: var(--font-weight-bold); -} -@media (prefers-reduced-motion: no-preference) { - .nv-tabs-list .nv-tabs-trigger { - transition-property: - color, background-color, border-color, text-decoration-color, fill, stroke; - transition-duration: 0.2s; - transition-timing-function: var(--ease-out); - } -} -.nv-tabs-list .nv-tabs-trigger[data-active] { - color: var(--text-color-primary); - font-weight: var(--font-weight-bold); -} -.nv-tabs-list .nv-tabs-trigger[data-active] > svg, -.nv-tabs-list .nv-tabs-trigger[data-active] > .nv-icon { - color: var(--text-color-brand); -} -.nv-tabs-list .nv-tabs-trigger:disabled, -.nv-tabs-list .nv-tabs-trigger[data-disabled] { - cursor: not-allowed; - color: var(--text-color-disabled); - background-color: #0000; - border-color: #0000; -} -.nv-tabs-list .nv-tabs-scroll-button { - height: calc(var(--spacing) * 10); - width: calc(var(--spacing) * 10); -} -.nv-tabs-list.nv-tabs-list--kind-primary { - box-shadow: inset 0 -2px 0 0 var(--border-color-base); -} -.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-scroll-container { - white-space: nowrap; -} -.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-scroll-shadow { - height: 40px; -} -.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger { - height: calc(var(--spacing) * 10); - border-radius: var(--radius-none); - padding-inline: calc(var(--spacing) * 3); - padding-block: calc(var(--spacing) * 1); - border-bottom: 2px solid #0000; - position: relative; - overflow-x: clip; -} -.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger:disabled { - cursor: not-allowed; -} -.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger:before, -.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger:after { - content: ''; - pointer-events: none; - z-index: 50; - margin-top: calc(var(--spacing) * 1); - border-bottom: 4px solid #0000; - width: 100%; - height: 100%; - position: absolute; - left: -100%; -} -.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger:after { - left: 100%; -} -.nv-tabs-list.nv-tabs-list--kind-primary - .nv-tabs-trigger:not(:disabled):where(:hover, :active, :focus-visible) { - border-bottom-color: var(--border-color-interaction-hover); -} -.nv-tabs-list.nv-tabs-list--kind-primary - .nv-tabs-trigger:where(.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger[data-active]) { - border-bottom-color: var(--border-color-interaction-hover); -} -.nv-tabs-list.nv-tabs-list--kind-primary - .nv-tabs-trigger:where( - .nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger[data-active] - ):before, -.nv-tabs-list.nv-tabs-list--kind-primary - .nv-tabs-trigger:where( - .nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger[data-active] - ):after { - left: calc(var(--spacing) * 0); - border-bottom-color: var(--border-color-interaction-selected); - border-bottom-width: 4px; -} -@media (prefers-reduced-motion: no-preference) { - .nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger:before { - transition: - left 0s var(--ease-out), - border-color 0s var(--ease-out); - } - .nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger:after { - transition: - left 0.2s var(--ease-out), - border-color 0s var(--ease-out) 0.2s; - } - .nv-tabs-list.nv-tabs-list--kind-primary - .nv-tabs-trigger:where( - .nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger[data-active] - ):before { - transition: - left 0.2s var(--ease-out), - border-color 0s var(--ease-out); - } - .nv-tabs-list.nv-tabs-list--kind-primary - .nv-tabs-trigger:where( - .nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger[data-active] - ):after { - transition: - left 0s var(--ease-out) 0.2s, - border-color 0s var(--ease-out) 0.2s; - } -} -.nv-tabs-list.nv-tabs-list--kind-secondary .nv-tabs-scroll-container { - height: calc(var(--spacing) * 8); - gap: calc(var(--spacing) * 2); -} -.nv-tabs-list.nv-tabs-list--kind-secondary .nv-tabs-scroll-shadow { - height: calc(var(--spacing) * 8); -} -.nv-tabs-list.nv-tabs-list--kind-secondary .nv-tabs-trigger { - border-radius: var(--radius-3xl); - padding-inline: calc(var(--spacing) * 3); - padding-block: calc(var(--spacing) * 1); -} -.nv-tabs-list.nv-tabs-list--kind-secondary - .nv-tabs-trigger:not(:disabled):where(:hover, :focus-visible) { - background-color: var(--background-color-interaction-hover); -} -.nv-tabs-list.nv-tabs-list--kind-secondary .nv-tabs-trigger:active { - background-color: var(--background-color-interaction-pressed); -} -.nv-tabs-list.nv-tabs-list--kind-secondary .nv-tabs-trigger[data-active] { - background-color: var(--background-color-interaction-hover); -} -.nv-tabs-list.nv-tabs-list--kind-tertiary .nv-tabs-scroll-container { - gap: calc(var(--spacing) * 2); - height: 22px; -} -.nv-tabs-list.nv-tabs-list--kind-tertiary .nv-tabs-scroll-shadow { - height: 22px; -} -.nv-tabs-list.nv-tabs-list--kind-tertiary .nv-tabs-scroll-shadow:first-child { - left: calc(var(--spacing) * 8); -} -.nv-tabs-list.nv-tabs-list--kind-tertiary .nv-tabs-scroll-shadow:last-child { - right: calc(var(--spacing) * 8); -} -.nv-tabs-list.nv-tabs-list--kind-tertiary .nv-tabs-trigger { - padding-inline: calc(var(--spacing) * 3); - padding-block: calc(var(--spacing) * 1); -} -.nv-tabs-list.nv-tabs-list--kind-tertiary - .nv-tabs-trigger:has(> svg:only-child, > .nv-icon:only-child) { - padding: calc(var(--spacing) * 1); -} -.nv-tabs-scroll-container { - scrollbar-width: none; - -ms-overflow-style: none; - flex-wrap: nowrap; - align-items: center; - width: 100%; - display: flex; - position: relative; - overflow-x: auto; -} -.nv-tabs-scroll-container::-webkit-scrollbar { - display: none; -} -.nv-tabs-scroll-container.nv-tabs-scroll-container--fade-left { - -webkit-mask-image: linear-gradient(90deg, #0000 0%, #000 5%); - mask-image: linear-gradient(90deg, #0000 0%, #000 5%); -} -.nv-tabs-scroll-container.nv-tabs-scroll-container--fade-right { - -webkit-mask-image: linear-gradient(270deg, #0000 0%, #000 5%); - mask-image: linear-gradient(270deg, #0000 0%, #000 5%); -} -.nv-tabs-scroll-container.nv-tabs-scroll-container--fade-both { - -webkit-mask-image: linear-gradient(90deg, #0000 0%, #000 5% 95%, #0000 100%); - mask-image: linear-gradient(90deg, #0000 0%, #000 5% 95%, #0000 100%); -} -.nv-tabs-scroll-container-ellipses { - padding-inline: calc(var(--spacing) * 3); - font-size: var(--text-14); - color: var(--text-color-primary); - font-weight: var(--font-weight-semibold); -} -.nv-tag { - border-radius: var(--radius-3xl); - align-items: center; - gap: calc(var(--spacing) * 1); - width: fit-content; - max-width: 100%; - height: fit-content; - padding-inline: var(--spacing-density-lg); - padding-block: calc(var(--spacing-density-sm) - 2px); - font-family: var(--font-sans); - font-size: var(--text-12); - font-weight: var(--font-weight-semibold); - vertical-align: middle; - --bg-color: var(--background-color-accent-blue-subtle); - --border-color: var(--border-color-accent-blue); - --text-color: var(--text-color-accent-blue); - --hover-bg-color: var(--background-color-accent-blue-subtle-hover); - --hover-text-color: var(--text-color-accent-blue); - --active-bg-color: var(--background-color-accent-blue-subtle-selected); - --active-text-color: var(--text-color-accent-white); - border: 1px solid; - border-color: var(--border-color); - background-color: var(--bg-color); - color: var(--text-color); - flex-grow: 0; - flex-shrink: 0; - line-height: 1.33333; - display: inline-flex; -} -.nv-tag svg, -.nv-tag .nv-icon { - flex-shrink: 0; - width: 1em; - height: 1em; -} -.nv-tag:disabled { - cursor: not-allowed; - border-color: var(--border-color-disabled); - background-color: var(--background-color-interaction-disabled); - color: var(--text-color-disabled); -} -.nv-tag:where(:not([disabled], [data-readonly])) { - cursor: pointer; -} -@media (hover: hover) { - .nv-tag:where(:not([disabled], [data-readonly])):hover { - color: var(--hover-text-color); - } -} -@media (hover: hover) { - .nv-tag:where(:not([disabled], [data-readonly])):hover { - background: var(--hover-bg-color); - } -} -.nv-tag:where(:not([disabled], [data-readonly])):active { - background-color: var(--active-bg-color); - color: var(--active-text-color); -} -@media (prefers-reduced-motion: no-preference) { - .nv-tag:where(:not([disabled], [data-readonly])) { - transition-property: - color, background-color, border-color, text-decoration-color, fill, stroke; - transition-duration: 0.2s; - transition-timing-function: var(--ease-out); - } -} -.nv-tag:where([data-readonly]) { - cursor: default; -} -.nv-tag:where(.nv-tag--kind-outline) { - --bg-color: transparent; - --border-color: var(--border-color-accent-blue); - --text-color: var(--text-color-accent-blue); - --hover-bg-color: var(--background-color-accent-blue-hover); - --hover-text-color: var(--text-color-accent-blue); - --active-bg-color: var(--background-color-accent-blue-selected); - --active-text-color: var(--text-color-accent-blue); -} -.nv-tag:where(.nv-tag--kind-outline):disabled { - background-color: #0000; -} -.nv-tag:where(.nv-tag--color-green) { - --bg-color: var(--background-color-accent-green-subtle); - --border-color: var(--border-color-accent-green); - --text-color: var(--text-color-accent-green); - --hover-bg-color: var(--background-color-accent-green-subtle-hover); - --hover-text-color: var(--text-color-accent-green); - --active-bg-color: var(--background-color-accent-green-subtle-selected); -} -.nv-tag:where(.nv-tag--color-green):where(.nv-tag--kind-outline) { - --bg-color: inherit; - --border-color: var(--border-color-accent-green); - --text-color: var(--text-color-accent-green); - --hover-bg-color: var(--background-color-accent-green-hover); - --hover-text-color: var(--text-color-accent-green); - --active-bg-color: var(--background-color-accent-green-selected); - --active-text-color: var(--text-color-accent-green); -} -.nv-tag:where(.nv-tag--color-yellow) { - --bg-color: var(--background-color-accent-yellow-subtle); - --border-color: var(--border-color-accent-yellow); - --text-color: var(--text-color-accent-yellow); - --hover-bg-color: var(--background-color-accent-yellow-subtle-hover); - --hover-text-color: var(--text-color-accent-yellow); - --active-bg-color: var(--background-color-accent-yellow-subtle-selected); -} -.nv-tag:where(.nv-tag--color-yellow):where(.nv-tag--kind-outline) { - --bg-color: inherit; - --border-color: var(--border-color-accent-yellow); - --text-color: var(--text-color-accent-yellow); - --hover-bg-color: var(--background-color-accent-yellow-hover); - --hover-text-color: var(--text-color-accent-yellow); - --active-bg-color: var(--background-color-accent-yellow-selected); - --active-text-color: var(--text-color-accent-yellow); -} -.nv-tag:where(.nv-tag--color-purple) { - --bg-color: var(--background-color-accent-purple-subtle); - --border-color: var(--border-color-accent-purple); - --text-color: var(--text-color-accent-purple); - --hover-bg-color: var(--background-color-accent-purple-subtle-hover); - --hover-text-color: var(--text-color-accent-purple); - --active-bg-color: var(--background-color-accent-purple-subtle-selected); -} -.nv-tag:where(.nv-tag--color-purple):where(.nv-tag--kind-outline) { - --bg-color: inherit; - --border-color: var(--border-color-accent-purple); - --text-color: var(--text-color-accent-purple); - --hover-bg-color: var(--background-color-accent-purple-hover); - --hover-text-color: var(--text-color-accent-purple); - --active-bg-color: var(--background-color-accent-purple-selected); - --active-text-color: var(--text-color-accent-purple); -} -.nv-tag:where(.nv-tag--color-red) { - --bg-color: var(--background-color-accent-red-subtle); - --border-color: var(--border-color-accent-red); - --text-color: var(--text-color-accent-red); - --hover-bg-color: var(--background-color-accent-red-subtle-hover); - --hover-text-color: var(--text-color-accent-red); - --active-bg-color: var(--background-color-accent-red-subtle-selected); -} -.nv-tag:where(.nv-tag--color-red):where(.nv-tag--kind-outline) { - --bg-color: inherit; - --text-color: var(--text-color-accent-red); - --border-color: var(--border-color-accent-red); - --hover-bg-color: var(--background-color-accent-red-hover); - --hover-text-color: var(--text-color-accent-red); - --active-bg-color: var(--background-color-accent-red-selected); - --active-text-color: var(--text-color-accent-red); -} -.nv-tag:where(.nv-tag--color-teal) { - --bg-color: var(--background-color-accent-teal-subtle); - --border-color: var(--border-color-accent-teal); - --text-color: var(--text-color-accent-teal); - --hover-bg-color: var(--background-color-accent-teal-subtle-hover); - --hover-text-color: var(--text-color-accent-teal); - --active-bg-color: var(--background-color-accent-teal-subtle-selected); -} -.nv-tag:where(.nv-tag--color-teal):where(.nv-tag--kind-outline) { - --bg-color: inherit; - --border-color: var(--border-color-accent-teal); - --text-color: var(--text-color-accent-teal); - --hover-bg-color: var(--background-color-accent-teal-hover); - --hover-text-color: var(--text-color-accent-teal); - --active-bg-color: var(--background-color-accent-teal-selected); - --active-text-color: var(--text-color-accent-teal); -} -.nv-tag:where(.nv-tag--color-gray) { - --bg-color: var(--background-color-accent-gray-subtle); - --border-color: var(--border-color-accent-gray); - --text-color: var(--text-color-primary); - --hover-bg-color: - linear-gradient( - 0deg, - var(--background-color-interaction-hover) 0%, - var(--background-color-interaction-hover) 100% - ), - var(--background-color-accent-gray-subtle); - --hover-text-color: var(--text-color-primary); - --active-bg-color: var(--background-color-accent-gray-subtle-selected); - --active-text-color: var(--text-color-accent-white); -} -.nv-tag:where(.nv-tag--color-gray):where(.nv-tag--kind-outline) { - --bg-color: inherit; - --border-color: var(--border-color-accent-gray); - --text-color: var(--text-color-accent-gray); - --hover-bg-color: var(--background-color-interaction-hover); - --hover-text-color: var(--text-color-accent-gray); - --active-bg-color: var(--background-color-accent-gray-selected); - --active-text-color: var(--text-color-inverse); -} -.nv-tag:where(.nv-tag--selected, :has(:checked, [data-state='checked'])) { - --bg-color: var(--active-bg-color); - --text-color: var(--active-text-color); - --hover-bg-color: oklch(from var(--active-bg-color) calc(l + 0.1) c h); - --hover-text-color: var(--active-text-color); -} -.nv-text-area-root { - --max-auto-height: 400px; -} -.nv-text-area-root .nv-text-area-element { - resize: none; - width: 100%; - min-width: 100%; - height: 100%; - min-height: 3lh; - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); - text-overflow: ellipsis; - scrollbar-width: thin; - scrollbar-color: var(--nv-scrollbar-color); - flex: 1; -} -.nv-text-area-root .nv-text-area-element::placeholder { - color: var(--text-color-placeholder); -} -.nv-text-area-root .nv-text-area-element:focus-visible { - outline: none; -} -.nv-text-area-root .nv-text-area-element.nv-text-area-element--resizeable-manual { - resize: vertical; -} -.nv-text-area-root .nv-text-area-element.nv-text-area-element--resizeable-auto { - field-sizing: content; - max-height: var(--max-auto-height, 100%); -} -.nv-toast-root { - --toast-icon-color: var(--text-color-feedback-info); - height: calc(var(--spacing) * 10); - border-radius: var(--radius-md); - border: 1px solid; - border-color: var(--border-color-base); - background-color: var(--background-color-surface-overlay); - width: 100%; - padding: calc(var(--spacing) * 2); - font-family: var(--font-sans); - color: var(--text-color-primary); - font-style: normal; - font-weight: var(--font-weight-regular); - box-shadow: var(--shadow-lg); - justify-content: space-between; - align-items: center; - display: flex; -} -.nv-toast-root.nv-toast-root--status-success { - --toast-icon-color: var(--text-color-feedback-success); -} -.nv-toast-root.nv-toast-root--status-warning { - --toast-icon-color: var(--text-color-feedback-warning); -} -.nv-toast-root.nv-toast-root--status-error { - --toast-icon-color: var(--text-color-feedback-danger); -} -.nv-toast-root.nv-toast-root--status-info { - --toast-icon-color: var(--text-color-feedback-info); -} -.nv-toast-root.nv-toast-root--status-neutral, -.nv-toast-root.nv-toast-root--status-working { - --toast-icon-color: var(--text-color-base); -} -.nv-toast-icon { - color: var(--toast-icon-color); - flex-shrink: 0; - place-items: center; - display: grid; -} -.nv-toast-text { - text-overflow: ellipsis; - white-space: nowrap; - font-size: var(--text-14); - line-height: var(--leading-lh-150); - overflow: hidden; -} -.nv-toast-content { - align-items: center; - gap: calc(var(--spacing) * 2); - text-overflow: ellipsis; - white-space: nowrap; - display: flex; - overflow: hidden; -} -.nv-toast-actions { - align-items: center; - gap: calc(var(--spacing) * 2); - display: flex; -} -@keyframes nv-tooltip-in { - 0% { - opacity: 0; - translate: var(--nv-tooltip-translate-start); - } - to { - opacity: 1; - translate: 0; - } -} -.nv-tooltip-content { - --nv-tooltip-translate-start: 0 4px; - border-radius: var(--radius-md); - border: 1px solid; - border-color: var(--border-color-component-tooltip); - background-color: var(--background-color-component-tooltip); - padding-inline: calc(var(--spacing) * 4); - padding-block: calc(var(--spacing) * 2); - font-family: var(--font-sans); - font-size: var(--text-12); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-semibold); - color: var(--text-color-accent-white); - box-shadow: var(--shadow-md); - z-index: 1080; - text-wrap: wrap; - opacity: 0; - width: max-content; - max-width: min(100vw - 16px, 320px); - translate: var(--nv-tooltip-translate-start); - margin: auto; - font-style: normal; - position: fixed; - inset: auto; -} -@media (prefers-reduced-motion: no-preference) { - .nv-tooltip-content { - transition: - opacity 0.25s var(--ease-out), - translate 0.25s var(--ease-out); - } -} -@supports (position-anchor: --a) { - .nv-tooltip-content { - --nv-tooltip-offset: 4px; - margin: var(--nv-tooltip-offset); - position-try-fallbacks: - flip-block, - flip-inline, - flip-block flip-inline; - } - .nv-tooltip-content[data-side='top'] { - --nv-tooltip-translate-start: 0 4px; - position-area: top; - } - .nv-tooltip-content[data-side='top'][data-align='start'] { - position-area: top span-right; - } - .nv-tooltip-content[data-side='top'][data-align='end'] { - position-area: top span-left; - } - .nv-tooltip-content[data-side='bottom'] { - --nv-tooltip-translate-start: 0 -4px; - position-area: bottom; - } - .nv-tooltip-content[data-side='bottom'][data-align='start'] { - position-area: bottom span-right; - } - .nv-tooltip-content[data-side='bottom'][data-align='end'] { - position-area: bottom span-left; - } - .nv-tooltip-content[data-side='left'] { - --nv-tooltip-translate-start: 4px 0; - position-area: left; - } - .nv-tooltip-content[data-side='left'][data-align='start'] { - position-area: left span-bottom; - } - .nv-tooltip-content[data-side='left'][data-align='end'] { - position-area: left span-top; - } - .nv-tooltip-content[data-side='right'] { - --nv-tooltip-translate-start: -4px 0; - position-area: right; - } - .nv-tooltip-content[data-side='right'][data-align='start'] { - position-area: right span-bottom; - } - .nv-tooltip-content[data-side='right'][data-align='end'] { - position-area: right span-top; - } -} -:is( - .nv-tooltip-content[data-state='open'], - .nv-tooltip-content:popover-open, - .nv-tooltip-content.\:popover-open -) { - opacity: 1; - translate: 0; -} -@media (prefers-reduced-motion: no-preference) { - :is( - .nv-tooltip-content[data-state='open'], - .nv-tooltip-content:popover-open, - .nv-tooltip-content.\:popover-open - ) { - animation: nv-tooltip-in 0.25s var(--ease-out); - } -} -.nv-tooltip-content[data-state='closed']:not(:popover-open):not(.\:popover-open) { - opacity: 0; - translate: var(--nv-tooltip-translate-start); -} -.nv-tree-nav-branch > summary { - list-style: none; -} -.nv-tree-nav-branch > summary::-webkit-details-marker { - display: none; -} -.nv-tree-nav-branch > summary::marker { - content: ''; - display: none; -} -.nv-tree-nav-branch::details-content { - height: 0; - transition: - height 0.2s var(--ease-out), - content-visibility 0.2s allow-discrete; - display: block; - overflow: clip; -} -.nv-tree-nav-branch[open]::details-content { - height: auto; -} -@media (prefers-reduced-motion: reduce) { - .nv-tree-nav-branch::details-content { - transition-duration: 0.01ms; - } -} -.nv-tree-nav-root { - font-family: var(--font-sans); - color: var(--text-color-primary); - font-weight: var(--font-weight-regular); - font-style: normal; -} -.nv-tree-nav-root .nv-icon, -.nv-tree-nav-root svg { - width: calc(var(--spacing) * 4); - height: calc(var(--spacing) * 4); - color: var(--text-color-base); - flex-shrink: 0; -} -.nv-tree-nav-root [data-disabled], -.nv-tree-nav-root [aria-disabled='true'] { - color: var(--text-color-disabled); -} -:is(.nv-tree-nav-root [data-disabled], .nv-tree-nav-root [aria-disabled='true']) .nv-icon, -:is(.nv-tree-nav-root [data-disabled], .nv-tree-nav-root [aria-disabled='true']) svg { - color: var(--text-color-disabled); -} -.nv-tree-nav-list { - margin: calc(var(--spacing) * 0); - padding: calc(var(--spacing) * 0); - list-style-type: none; -} -.nv-tree-nav-list[aria-disabled='true'] { - pointer-events: none; -} -.nv-tree-nav-list-item { - content-visibility: auto; - contain-intrinsic-size: auto 1rem; -} -.nv-tree-nav-branch-trigger, -.nv-tree-nav-leaf { - cursor: pointer; - font-size: var(--text-14); - align-items: center; - gap: var(--spacing); - color: inherit; - background: 0 0; - border: 1px solid #0000; - padding-inline-start: calc(var(--nv-tree-nav-depth, 0) * 24px + var(--spacing)); - padding-inline-end: var(--spacing); - line-height: 1.57143; - text-decoration: none; - display: flex; -} -@media (prefers-reduced-motion: no-preference) { - :is(.nv-tree-nav-branch-trigger, .nv-tree-nav-leaf) { - transition-property: - color, background-color, border-color, outline-color, text-decoration-color, fill, - stroke; - transition-duration: 0.2s; - transition-timing-function: var(--ease-out); - } -} -:is(.nv-tree-nav-branch-trigger, .nv-tree-nav-leaf):hover:not([data-disabled]):not( - [aria-disabled='true'] - ) { - background: var(--background-color-interaction-hover); -} -:is(.nv-tree-nav-branch-trigger, .nv-tree-nav-leaf)[data-disabled], -:is(.nv-tree-nav-branch-trigger, .nv-tree-nav-leaf)[aria-disabled='true'] { - pointer-events: none; -} -.nv-tree-nav-branch-trigger[data-collapsible='false'] { - cursor: default; -} -.nv-tree-nav-branch-trigger--active, -.nv-tree-nav-leaf--active { - background: var(--background-color-interaction-selected); -} -:is(.nv-tree-nav-branch-trigger--active, .nv-tree-nav-leaf--active):hover:not([data-disabled]):not( - [aria-disabled='true'] - ) { - background: var(--background-color-interaction-selected); - border-color: var(--border-color-interaction-hover); -} -details:not([open]) > summary .nv-tree-nav-icon { - rotate: -90deg; -} -details[open] > summary .nv-tree-nav-icon { - rotate: none; -} -.nv-tree-nav-label { - min-width: calc(var(--spacing) * 0); - text-overflow: ellipsis; - white-space: nowrap; - flex: 1; - overflow: hidden; -} -.nv-upload-trigger.nv-input-shell { - text-align: center; - line-height: 1.14286; - display: inline-block; -} -.nv-upload-trigger.nv-input-shell.nv-upload-trigger--dragged-over:not(:has(input:disabled)) { - border-color: var(--border-color-interaction-hover); - background-color: var(--background-color-interaction-hover); -} -.nv-upload-trigger.nv-input-shell:has(input:disabled) - .nv-upload-trigger-anchor.nv-upload-trigger-anchor { - color: inherit; - cursor: inherit; - background: 0 0; - text-decoration: none; -} -.nv-upload-trigger.nv-input-shell ::file-selector-button { - display: none; -} -@media (scripting: enabled) { - .nv-upload-trigger.nv-input-shell .nv-upload-input-element { - pointer-events: none; - opacity: 0; - width: 1px; - height: 1px; - position: absolute; - } -} -.nv-upload-content { - gap: calc(var(--spacing) * 2); - padding-top: calc(var(--spacing) * 2); - flex-direction: column; - display: flex; -} -.nv-upload-content.nv-upload-content--kind-media { - flex-flow: wrap; -} -.nv-upload-item-actions-group { - justify-content: flex-end; - gap: calc(var(--spacing) * 1); - flex-wrap: wrap; - width: fit-content; - display: flex; -} -.nv-upload-description { - justify-content: center; - gap: calc(var(--spacing) * 1); - width: 100%; - padding-top: calc(var(--spacing) * 1.5); - font-family: var(--font-sans); - font-size: var(--text-10); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); - flex-wrap: wrap; - display: inline-flex; -} -.nv-upload-item { - --nv-input-height: auto; - border-radius: var(--radius-md); - border: 1px solid; - border-color: var(--border-color-base); - background-color: var(--background-color-surface-raised); - padding: calc(var(--spacing) * 2); - font-family: var(--font-sans); - color: var(--text-color-primary); - font-style: normal; - font-weight: var(--font-weight-regular); - display: flex; - overflow: hidden; -} -@media (hover: hover) { - .nv-upload-item:hover { - border-color: var(--border-color-interaction-hover); - } -} -@media (prefers-reduced-motion: no-preference) { - .nv-upload-item { - transition-property: - color, background-color, border-color, text-decoration-color, fill, stroke; - transition-duration: 0.25s; - transition-timing-function: var(--ease-out); - } -} -.nv-upload-item.nv-upload-item--status-error { - --status-icon-color: var(--text-color-feedback-danger); - border: 1px solid; - border-color: var(--border-color-feedback-danger); -} -@media (hover: hover) { - .nv-upload-item.nv-upload-item--status-error:hover { - border-color: var(--border-color-feedback-danger-hover); - } -} -.nv-upload-item.nv-upload-item--status-error .nv-upload-item-content-info { - color: var(--text-color-feedback-danger-subtle); -} -.nv-upload-item svg:not(.nv-button svg), -.nv-upload-item .nv-icon:not(.nv-button .nv-icon) { - color: var(--status-icon-color, var(--text-color-primary)); -} -.nv-upload-item .nv-upload-item-hover-section { - opacity: 0; -} -@media (prefers-reduced-motion: no-preference) { - .nv-upload-item .nv-upload-item-hover-section { - transition: opacity 0.25s var(--ease-out); - } -} -:is(.nv-upload-item:hover, .nv-upload-item:focus, .nv-upload-item:focus-within) - .nv-upload-item-hover-section { - opacity: 1; -} -.nv-upload-item .nv-upload-item-thumbnail { - object-fit: cover; -} -.nv-upload-item.nv-upload-item--kind-card { - gap: calc(var(--spacing) * 4); - padding: calc(var(--spacing) * 3); -} -.nv-upload-item.nv-upload-item--kind-card .nv-upload-item-thumbnail { - width: calc(var(--spacing) * 8); - height: calc(var(--spacing) * 8); - min-width: calc(var(--spacing) * 8); - place-items: center; - display: grid; -} -.nv-upload-item.nv-upload-item--kind-card .nv-upload-item-thumbnail.nv-upload-item-thumbnail-icon { - border: 1px solid; - border-color: var(--border-color-base); - color: var(--text-color-base); -} -.nv-upload-item.nv-upload-item--kind-card - .nv-upload-item-thumbnail.nv-upload-item-thumbnail-icon:before { - width: calc(var(--spacing) * 4); - height: calc(var(--spacing) * 4); -} -.nv-upload-item.nv-upload-item--kind-media { - justify-content: center; - align-items: center; - width: 100px; - height: 100px; - display: flex; - position: relative; -} -.nv-upload-item.nv-upload-item--kind-media .nv-upload-item-thumbnail { - inset: calc(var(--spacing) * 0); - transition: opacity 0.25s var(--ease-out); - position: absolute; -} -:is( - .nv-upload-item.nv-upload-item--kind-media:hover, - .nv-upload-item.nv-upload-item--kind-media:focus, - .nv-upload-item.nv-upload-item--kind-media:focus-within - ) - .nv-upload-item-thumbnail { - opacity: 0.1; -} -.nv-upload-item-content { - justify-content: space-between; - gap: calc(var(--spacing) * 2); - flex-direction: column; - flex: 1; - display: flex; -} -.nv-upload-item-content-heading { - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: 1.21429; - font-weight: var(--font-weight-regular); - line-height: 1.28571; -} -.nv-upload-item-content-info { - align-items: center; - gap: calc(var(--spacing) * 1); - font-family: var(--font-sans); - font-size: var(--text-10); - line-height: 1.2; - font-weight: var(--font-weight-regular); - display: inline-flex; -} -.nv-upload-item-content-info * { - text-wrap: nowrap; -} -.nv-upload-item-upload-spinner { - animation: var(--animate-spin); - font-size: var(--text-12); -} -.nv-upload-item-top-left { - top: calc(var(--spacing) * 2); - left: calc(var(--spacing) * 2); - position: absolute; -} -.nv-upload-item-top-right { - top: calc(var(--spacing) * 2); - right: calc(var(--spacing) * 2); - position: absolute; -} -.nv-vertical-nav-root { - border-right: 1px solid; - border-color: var(--border-color-base); - background-color: var(--background-color-surface-navigation); - width: 240px; - height: 100%; - font-family: var(--font-sans); - color: var(--text-color-primary); -} -.nv-vertical-nav-root .nv-vertical-nav-item-label { - min-width: calc(var(--spacing) * 0); - text-overflow: ellipsis; - white-space: nowrap; - line-height: var(--leading-lh-125); - flex: 1; - display: block; - overflow: hidden; -} -.nv-vertical-nav-root .nv-vertical-nav-list { - margin: calc(var(--spacing) * 0); - width: 100%; - height: 100%; - padding: calc(var(--spacing) * 1); - scrollbar-width: thin; - list-style-type: none; - position: relative; - overflow-y: auto; -} -.nv-vertical-nav-root .nv-vertical-nav-sub-list { - padding-right: calc(var(--spacing) * 6); - padding-left: calc(var(--spacing) * 8); - list-style-type: none; -} -.nv-vertical-nav-root .nv-vertical-nav-item--active:not(.nv-vertical-nav-item--disabled):before { - content: ''; - left: var(--spacing); - top: var(--spacing); - bottom: var(--spacing); - width: calc(var(--spacing) / 2); - background-color: var(--color-brand); - border-radius: var(--radius-sm); - position: absolute; -} -.nv-vertical-nav-root .nv-vertical-nav-item { - cursor: pointer; - align-items: center; - gap: calc(var(--spacing) * 2); - border-radius: var(--radius-sm); - width: 100%; - max-width: 100%; - padding-inline: calc(var(--spacing) * 6); - padding-block: calc(var(--spacing) * 4); - text-align: start; - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: 1.21429; - font-weight: var(--font-weight-bold); - line-height: var(--leading-lh-100); - display: flex; - position: relative; - overflow: hidden; -} -.nv-vertical-nav-root - .nv-vertical-nav-item.nv-vertical-nav-item--active:not(.nv-vertical-nav-item--disabled) { - background-color: var(--background-color-interaction-pressed); -} -.nv-vertical-nav-root - .nv-vertical-nav-item:hover:not(.nv-vertical-nav-item--disabled):not(:disabled) { - background-color: var(--background-color-interaction-hover); -} -.nv-vertical-nav-root - .nv-vertical-nav-item:active:not(.nv-vertical-nav-item--disabled):not(:disabled) { - background-color: var(--background-color-interaction-pressed); -} -.nv-vertical-nav-root .nv-vertical-nav-item--kind-secondary { - height: calc(var(--spacing) * 10); - align-items: center; - gap: calc(var(--spacing) * 2); - border-radius: var(--radius-sm); - width: 100%; - max-width: 100%; - padding-inline: calc(var(--spacing) * 4); - padding-block: calc(var(--spacing) * 2); - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: 1.21429; - font-weight: var(--font-weight-regular); - color: var(--text-color-secondary); - line-height: var(--leading-lh-100); - display: flex; - position: relative; -} -.nv-vertical-nav-root - .nv-vertical-nav-item--kind-secondary:hover:not(.nv-vertical-nav-item--disabled) { - background-color: var(--background-color-interaction-hover); -} -.nv-vertical-nav-root - .nv-vertical-nav-item--kind-secondary:active:not(.nv-vertical-nav-item--disabled) { - background-color: var(--background-color-interaction-pressed); -} -.nv-vertical-nav-root - .nv-vertical-nav-item--kind-secondary.nv-vertical-nav-item--active:not( - .nv-vertical-nav-item--disabled - ) { - background-color: var(--background-color-interaction-pressed); - color: var(--text-color-primary); - font-weight: var(--font-weight-bold); -} -.nv-vertical-nav-root .nv-vertical-nav-item--disabled { - cursor: not-allowed; - color: var(--text-color-disabled); -} -.nv-vertical-nav-root .nv-vertical-nav-item svg, -.nv-vertical-nav-root .nv-vertical-nav-item .nv-icon { - height: calc(var(--spacing) * 4); - width: calc(var(--spacing) * 4); - flex-shrink: 0; -} -.nv-vertical-nav-root .nv-vertical-nav-collapsible-trigger .nv-animated-chevron { - margin-left: auto; -} -.nv-vertical-nav-root .nv-vertical-nav-collapsible-trigger { - border-radius: var(--radius-sm); - max-width: 100%; - display: flex; - overflow: hidden; -} -.nv-vertical-nav-root .nv-collapsible-trigger[data-disabled] { - opacity: 1; -} -.nv-vertical-nav-root - .nv-vertical-nav-collapsible-section:not([open]):has( - .nv-vertical-nav-item--kind-secondary.nv-vertical-nav-item--active:not( - .nv-vertical-nav-item--disabled - ) - ):not([data-disabled]) - .nv-vertical-nav-collapsible-trigger { - position: relative; -} -.nv-vertical-nav-root - .nv-vertical-nav-collapsible-section:not([open]):has( - .nv-vertical-nav-item--kind-secondary.nv-vertical-nav-item--active:not( - .nv-vertical-nav-item--disabled - ) - ):not([data-disabled]) - .nv-vertical-nav-collapsible-trigger:before { - content: ''; - left: var(--spacing); - top: var(--spacing); - bottom: var(--spacing); - width: calc(var(--spacing) / 2); - background-color: var(--color-brand); - border-radius: var(--radius-sm); - position: absolute; -} -.nv-vertical-nav-root - .nv-vertical-nav-collapsible-section:not([open]):not([data-disabled]):has( - .nv-vertical-nav-item--kind-secondary.nv-vertical-nav-item--active:not( - .nv-vertical-nav-item--disabled - ) - ) - .nv-vertical-nav-collapsible-trigger { - background-color: var(--background-color-interaction-pressed); -} -.nv-vertical-nav-root - .nv-vertical-nav-collapsible-section[data-disabled] - .nv-vertical-nav-item--kind-secondary { - cursor: not-allowed; - color: var(--text-color-disabled); -} -@media (prefers-reduced-motion: no-preference) { - :is( - .nv-vertical-nav-root .nv-vertical-nav-item, - .nv-vertical-nav-root .nv-vertical-nav-collapsible-section:not([data-disabled]) - ) { - transition: - background-color 0.2s var(--ease-out), - color 0.2s var(--ease-out); - } -} -.nv-text--body-bold-2xl { - font-family: var(--font-sans); - font-size: var(--text-24); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-bold); -} -.nv-text--body-bold-3xl { - font-family: var(--font-sans); - font-size: var(--text-32); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-bold); -} -.nv-text--body-bold-lg { - font-family: var(--font-sans); - font-size: var(--text-16); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-bold); -} -.nv-text--body-bold-md { - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-bold); -} -.nv-text--body-bold-sm { - font-family: var(--font-sans); - font-size: var(--text-12); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-bold); -} -.nv-text--body-bold-xl { - font-family: var(--font-sans); - font-size: var(--text-18); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-bold); -} -.nv-text--body-bold-xs { - font-family: var(--font-sans); - font-size: var(--text-10); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-bold); -} -.nv-text--body-regular-2xl { - font-family: var(--font-sans); - font-size: var(--text-24); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); -} -.nv-text--body-regular-3xl { - font-family: var(--font-sans); - font-size: var(--text-32); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); -} -.nv-text--body-regular-lg { - font-family: var(--font-sans); - font-size: var(--text-16); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); -} -.nv-text--body-regular-md { - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); -} -.nv-text--body-regular-sm { - font-family: var(--font-sans); - font-size: var(--text-12); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); -} -.nv-text--body-regular-xl { - font-family: var(--font-sans); - font-size: var(--text-18); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); -} -.nv-text--body-regular-xs { - font-family: var(--font-sans); - font-size: var(--text-10); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); -} -.nv-text--body-semibold-2xl { - font-family: var(--font-sans); - font-size: var(--text-24); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-semibold); -} -.nv-text--body-semibold-3xl { - font-family: var(--font-sans); - font-size: var(--text-32); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-semibold); -} -.nv-text--body-semibold-lg { - font-family: var(--font-sans); - font-size: var(--text-16); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-semibold); -} -.nv-text--body-semibold-md { - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-semibold); -} -.nv-text--body-semibold-sm { - font-family: var(--font-sans); - font-size: var(--text-12); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-semibold); -} -.nv-text--body-semibold-xl { - font-family: var(--font-sans); - font-size: var(--text-18); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-semibold); -} -.nv-text--body-semibold-xs { - font-family: var(--font-sans); - font-size: var(--text-10); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-semibold); -} -.nv-text--display-2xl { - font-family: var(--font-sans); - font-size: var(--text-64); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-bold); -} -.nv-text--display-lg { - font-family: var(--font-sans); - font-size: var(--text-50); - line-height: 1.24; - font-weight: var(--font-weight-bold); -} -.nv-text--display-md { - font-family: var(--font-sans); - font-size: var(--text-44); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-bold); -} -.nv-text--display-sm { - font-family: var(--font-sans); - font-size: var(--text-40); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-bold); -} -.nv-text--display-xl { - font-family: var(--font-sans); - font-size: var(--text-56); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-bold); -} -.nv-text--display-xs { - font-family: var(--font-sans); - font-size: var(--text-36); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-bold); -} -.nv-text--label-bold-2xl { - font-family: var(--font-sans); - font-size: var(--text-24); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-bold); -} -.nv-text--label-bold-3xl { - font-family: var(--font-sans); - font-size: var(--text-32); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-bold); -} -.nv-text--label-bold-lg { - font-family: var(--font-sans); - font-size: var(--text-16); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-bold); -} -.nv-text--label-bold-md { - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: 1.21429; - font-weight: var(--font-weight-bold); -} -.nv-text--label-bold-sm { - font-family: var(--font-sans); - font-size: var(--text-12); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-bold); -} -.nv-text--label-bold-xl { - font-family: var(--font-sans); - font-size: var(--text-18); - line-height: 1.22222; - font-weight: var(--font-weight-bold); -} -.nv-text--label-bold-xs { - font-family: var(--font-sans); - font-size: var(--text-10); - line-height: 1.2; - font-weight: var(--font-weight-bold); -} -.nv-text--label-light-2xl { - font-family: var(--font-sans); - font-size: var(--text-24); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-light); -} -.nv-text--label-light-3xl { - font-family: var(--font-sans); - font-size: var(--text-32); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-light); -} -.nv-text--label-light-lg { - font-family: var(--font-sans); - font-size: var(--text-16); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-light); -} -.nv-text--label-light-md { - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: 1.21429; - font-weight: var(--font-weight-light); -} -.nv-text--label-light-sm { - font-family: var(--font-sans); - font-size: var(--text-12); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-light); -} -.nv-text--label-light-xl { - font-family: var(--font-sans); - font-size: var(--text-18); - line-height: 1.22222; - font-weight: var(--font-weight-light); -} -.nv-text--label-light-xs { - font-family: var(--font-sans); - font-size: var(--text-10); - line-height: 1.2; - font-weight: var(--font-weight-light); -} -.nv-text--label-regular-2xl { - font-family: var(--font-sans); - font-size: var(--text-24); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-regular); -} -.nv-text--label-regular-3xl { - font-family: var(--font-sans); - font-size: var(--text-32); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-regular); -} -.nv-text--label-regular-lg { - font-family: var(--font-sans); - font-size: var(--text-16); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-regular); -} -.nv-text--label-regular-md { - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: 1.21429; - font-weight: var(--font-weight-regular); -} -.nv-text--label-regular-sm { - font-family: var(--font-sans); - font-size: var(--text-12); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-regular); -} -.nv-text--label-regular-xl { - font-family: var(--font-sans); - font-size: var(--text-18); - line-height: 1.22222; - font-weight: var(--font-weight-regular); -} -.nv-text--label-regular-xs { - font-family: var(--font-sans); - font-size: var(--text-10); - line-height: 1.2; - font-weight: var(--font-weight-regular); -} -.nv-text--label-semibold-2xl { - font-family: var(--font-sans); - font-size: var(--text-24); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-semibold); -} -.nv-text--label-semibold-3xl { - font-family: var(--font-sans); - font-size: var(--text-32); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-semibold); -} -.nv-text--label-semibold-lg { - font-family: var(--font-sans); - font-size: var(--text-16); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-semibold); -} -.nv-text--label-semibold-md { - font-family: var(--font-sans); - font-size: var(--text-14); - line-height: 1.21429; - font-weight: var(--font-weight-semibold); -} -.nv-text--label-semibold-sm { - font-family: var(--font-sans); - font-size: var(--text-12); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-semibold); -} -.nv-text--label-semibold-xl { - font-family: var(--font-sans); - font-size: var(--text-18); - line-height: 1.22222; - font-weight: var(--font-weight-semibold); -} -.nv-text--label-semibold-xs { - font-family: var(--font-sans); - font-size: var(--text-10); - line-height: 1.2; - font-weight: var(--font-weight-semibold); -} -.nv-text--mono-2xl { - font-family: var(--font-mono); - font-size: var(--text-24); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); -} -.nv-text--mono-lg { - font-family: var(--font-mono); - font-size: var(--text-16); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); -} -.nv-text--mono-md { - font-family: var(--font-mono); - font-size: var(--text-14); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); -} -.nv-text--mono-sm { - font-family: var(--font-mono); - font-size: var(--text-12); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); -} -.nv-text--mono-xl { - font-family: var(--font-mono); - font-size: var(--text-20); - line-height: var(--leading-lh-150); - font-weight: var(--font-weight-regular); -} -.nv-text--title-2xl { - font-family: var(--font-sans); - font-size: var(--text-36); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-bold); -} -.nv-text--title-lg { - font-family: var(--font-sans); - font-size: var(--text-28); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-bold); -} -.nv-text--title-md { - font-family: var(--font-sans); - font-size: var(--text-24); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-bold); -} -.nv-text--title-sm { - font-family: var(--font-sans); - font-size: var(--text-20); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-bold); -} -.nv-text--title-xl { - font-family: var(--font-sans); - font-size: var(--text-32); - line-height: var(--leading-lh-125); - font-weight: var(--font-weight-bold); -} -.nv-text--title-xs { - font-family: var(--font-sans); - font-size: var(--text-18); - line-height: 1.22222; - font-weight: var(--font-weight-bold); -} -.nv-text--font-mono { - font-family: var(--font-mono); -} -.nv-text--font-sans { - font-family: var(--font-sans); -} -.nv-text--weight-light { - font-weight: var(--font-weight-light); -} -.nv-text--weight-regular { - font-weight: var(--font-weight-regular); -} -.nv-text--weight-semibold { - font-weight: var(--font-weight-semibold); -} -.nv-text--weight-bold { - font-weight: var(--font-weight-bold); -} -.nv-text--style-italic { - font-style: italic; -} -.nv-text--style-normal { - font-style: normal; -} -.nv-text--size-10 { - font-size: var(--text-10); -} -.nv-text--size-12 { - font-size: var(--text-12); -} -.nv-text--size-14 { - font-size: var(--text-14); -} -.nv-text--size-16 { - font-size: var(--text-16); -} -.nv-text--size-18 { - font-size: var(--text-18); -} -.nv-text--size-20 { - font-size: var(--text-20); -} -.nv-text--size-22 { - font-size: var(--text-22); -} -.nv-text--size-24 { - font-size: var(--text-24); -} -.nv-text--size-28 { - font-size: var(--text-28); -} -.nv-text--size-32 { - font-size: var(--text-32); -} -.nv-text--size-36 { - font-size: var(--text-36); -} -.nv-text--size-40 { - font-size: var(--text-40); -} -.nv-text--size-44 { - font-size: var(--text-44); -} -.nv-text--size-48 { - font-size: var(--text-48); -} -.nv-text--size-50 { - font-size: var(--text-50); -} -.nv-text--size-56 { - font-size: var(--text-56); -} -.nv-text--size-60 { - font-size: var(--text-60); -} -.nv-text--size-64 { - font-size: var(--text-64); -} -.nv-text--size-72 { - font-size: var(--text-72); -} -.nv-text--size-80 { - font-size: var(--text-80); -} -.nv-text--underline { - text-decoration-line: underline; -} -.nv-text--line-height-100 { - line-height: var(--leading-lh-100); -} -.nv-text--line-height-125 { - line-height: var(--leading-lh-125); -} -.nv-text--line-height-150 { - line-height: var(--leading-lh-150); -} -.nv-text--line-height-175 { - line-height: var(--leading-lh-175); -} -.nv-primitive--spacing-0 { - padding: calc(var(--spacing) * 0); -} -.nv-primitive--spacing-0_25 { - padding: calc(var(--spacing) * 0.25); -} -.nv-primitive--spacing-0_5 { - padding: calc(var(--spacing) * 0.5); -} -.nv-primitive--spacing-0_75 { - padding: calc(var(--spacing) * 0.75); -} -.nv-primitive--spacing-1 { - padding: calc(var(--spacing) * 1); -} -.nv-primitive--spacing-1_5 { - padding: calc(var(--spacing) * 1.5); -} -.nv-primitive--spacing-2 { - padding: calc(var(--spacing) * 2); -} -.nv-primitive--spacing-2_5 { - padding: calc(var(--spacing) * 2.5); -} -.nv-primitive--spacing-3 { - padding: calc(var(--spacing) * 3); -} -.nv-primitive--spacing-3_5 { - padding: calc(var(--spacing) * 3.5); -} -.nv-primitive--spacing-4 { - padding: calc(var(--spacing) * 4); -} -.nv-primitive--spacing-5 { - padding: calc(var(--spacing) * 5); -} -.nv-primitive--spacing-6 { - padding: calc(var(--spacing) * 6); -} -.nv-primitive--spacing-7 { - padding: calc(var(--spacing) * 7); -} -.nv-primitive--spacing-8 { - padding: calc(var(--spacing) * 8); -} -.nv-primitive--spacing-9 { - padding: calc(var(--spacing) * 9); -} -.nv-primitive--spacing-10 { - padding: calc(var(--spacing) * 10); -} -.nv-primitive--spacing-11 { - padding: calc(var(--spacing) * 11); -} -.nv-primitive--spacing-12 { - padding: calc(var(--spacing) * 12); -} -.nv-primitive--spacing-14 { - padding: calc(var(--spacing) * 14); -} -.nv-primitive--spacing-16 { - padding: calc(var(--spacing) * 16); -} -.nv-primitive--spacing-18 { - padding: calc(var(--spacing) * 18); -} -.nv-primitive--spacing-20 { - padding: calc(var(--spacing) * 20); -} -.nv-primitive--spacing-24 { - padding: calc(var(--spacing) * 24); -} -.nv-primitive--spacing-28 { - padding: calc(var(--spacing) * 28); -} -.nv-primitive--spacing-32 { - padding: calc(var(--spacing) * 32); -} -.nv-primitive--spacing-36 { - padding: calc(var(--spacing) * 36); -} -.nv-primitive--spacing-40 { - padding: calc(var(--spacing) * 40); -} -.nv-primitive--spacing-44 { - padding: calc(var(--spacing) * 44); -} -.nv-primitive--spacing-48 { - padding: calc(var(--spacing) * 48); -} -.nv-primitive--spacing-52 { - padding: calc(var(--spacing) * 52); -} -.nv-primitive--spacing-56 { - padding: calc(var(--spacing) * 56); -} -.nv-primitive--spacing-60 { - padding: calc(var(--spacing) * 60); -} -.nv-primitive--spacing-64 { - padding: calc(var(--spacing) * 64); -} -.nv-primitive--spacing-72 { - padding: calc(var(--spacing) * 72); -} -.nv-primitive--spacing-80 { - padding: calc(var(--spacing) * 80); -} -.nv-primitive--spacing-96 { - padding: calc(var(--spacing) * 96); -} -.nv-primitive--spacing-250 { - padding: calc(var(--spacing) * 250); -} -.nv-primitive--spacing-px { - padding: 1px; -} -.nv-primitive--spacing-density-xxs { - padding: var(--spacing-density-xxs); -} -.nv-primitive--spacing-density-xs { - padding: var(--spacing-density-xs); -} -.nv-primitive--spacing-density-sm { - padding: var(--spacing-density-sm); -} -.nv-primitive--spacing-density-md { - padding: var(--spacing-density-md); -} -.nv-primitive--spacing-density-lg { - padding: var(--spacing-density-lg); -} -.nv-primitive--spacing-density-xl { - padding: var(--spacing-density-xl); -} -.nv-primitive--spacing-density-2xl { - padding: var(--spacing-density-2xl); -} -.nv-primitive--spacing-density-3xl { - padding: var(--spacing-density-3xl); -} -.nv-primitive--spacing-density-4xl { - padding: var(--spacing-density-4xl); -} -.nv-primitive--spacing-density-5xl { - padding: var(--spacing-density-5xl); -} -.nv-primitive--spacing-inherit { - padding: inherit; -} -.nv-primitive--spacing-x-0 { - padding-inline: calc(var(--spacing) * 0); -} -.nv-primitive--spacing-x-0_25 { - padding-inline: calc(var(--spacing) * 0.25); -} -.nv-primitive--spacing-x-0_5 { - padding-inline: calc(var(--spacing) * 0.5); -} -.nv-primitive--spacing-x-0_75 { - padding-inline: calc(var(--spacing) * 0.75); -} -.nv-primitive--spacing-x-1 { - padding-inline: calc(var(--spacing) * 1); -} -.nv-primitive--spacing-x-1_5 { - padding-inline: calc(var(--spacing) * 1.5); -} -.nv-primitive--spacing-x-2 { - padding-inline: calc(var(--spacing) * 2); -} -.nv-primitive--spacing-x-2_5 { - padding-inline: calc(var(--spacing) * 2.5); -} -.nv-primitive--spacing-x-3 { - padding-inline: calc(var(--spacing) * 3); -} -.nv-primitive--spacing-x-3_5 { - padding-inline: calc(var(--spacing) * 3.5); -} -.nv-primitive--spacing-x-4 { - padding-inline: calc(var(--spacing) * 4); -} -.nv-primitive--spacing-x-5 { - padding-inline: calc(var(--spacing) * 5); -} -.nv-primitive--spacing-x-6 { - padding-inline: calc(var(--spacing) * 6); -} -.nv-primitive--spacing-x-7 { - padding-inline: calc(var(--spacing) * 7); -} -.nv-primitive--spacing-x-8 { - padding-inline: calc(var(--spacing) * 8); -} -.nv-primitive--spacing-x-9 { - padding-inline: calc(var(--spacing) * 9); -} -.nv-primitive--spacing-x-10 { - padding-inline: calc(var(--spacing) * 10); -} -.nv-primitive--spacing-x-11 { - padding-inline: calc(var(--spacing) * 11); -} -.nv-primitive--spacing-x-12 { - padding-inline: calc(var(--spacing) * 12); -} -.nv-primitive--spacing-x-14 { - padding-inline: calc(var(--spacing) * 14); -} -.nv-primitive--spacing-x-16 { - padding-inline: calc(var(--spacing) * 16); -} -.nv-primitive--spacing-x-18 { - padding-inline: calc(var(--spacing) * 18); -} -.nv-primitive--spacing-x-20 { - padding-inline: calc(var(--spacing) * 20); -} -.nv-primitive--spacing-x-24 { - padding-inline: calc(var(--spacing) * 24); -} -.nv-primitive--spacing-x-28 { - padding-inline: calc(var(--spacing) * 28); -} -.nv-primitive--spacing-x-32 { - padding-inline: calc(var(--spacing) * 32); -} -.nv-primitive--spacing-x-36 { - padding-inline: calc(var(--spacing) * 36); -} -.nv-primitive--spacing-x-40 { - padding-inline: calc(var(--spacing) * 40); -} -.nv-primitive--spacing-x-44 { - padding-inline: calc(var(--spacing) * 44); -} -.nv-primitive--spacing-x-48 { - padding-inline: calc(var(--spacing) * 48); -} -.nv-primitive--spacing-x-52 { - padding-inline: calc(var(--spacing) * 52); -} -.nv-primitive--spacing-x-56 { - padding-inline: calc(var(--spacing) * 56); -} -.nv-primitive--spacing-x-60 { - padding-inline: calc(var(--spacing) * 60); -} -.nv-primitive--spacing-x-64 { - padding-inline: calc(var(--spacing) * 64); -} -.nv-primitive--spacing-x-72 { - padding-inline: calc(var(--spacing) * 72); -} -.nv-primitive--spacing-x-80 { - padding-inline: calc(var(--spacing) * 80); -} -.nv-primitive--spacing-x-96 { - padding-inline: calc(var(--spacing) * 96); -} -.nv-primitive--spacing-x-250 { - padding-inline: calc(var(--spacing) * 250); -} -.nv-primitive--spacing-x-px { - padding-inline: 1px; -} -.nv-primitive--spacing-x-density-xxs { - padding-inline: var(--spacing-density-xxs); -} -.nv-primitive--spacing-x-density-xs { - padding-inline: var(--spacing-density-xs); -} -.nv-primitive--spacing-x-density-sm { - padding-inline: var(--spacing-density-sm); -} -.nv-primitive--spacing-x-density-md { - padding-inline: var(--spacing-density-md); -} -.nv-primitive--spacing-x-density-lg { - padding-inline: var(--spacing-density-lg); -} -.nv-primitive--spacing-x-density-xl { - padding-inline: var(--spacing-density-xl); -} -.nv-primitive--spacing-x-density-2xl { - padding-inline: var(--spacing-density-2xl); -} -.nv-primitive--spacing-x-density-3xl { - padding-inline: var(--spacing-density-3xl); -} -.nv-primitive--spacing-x-density-4xl { - padding-inline: var(--spacing-density-4xl); -} -.nv-primitive--spacing-x-density-5xl { - padding-inline: var(--spacing-density-5xl); -} -.nv-primitive--spacing-x-inherit { - padding-inline: inherit; -} -.nv-primitive--spacing-y-0 { - padding-block: calc(var(--spacing) * 0); -} -.nv-primitive--spacing-y-0_25 { - padding-block: calc(var(--spacing) * 0.25); -} -.nv-primitive--spacing-y-0_5 { - padding-block: calc(var(--spacing) * 0.5); -} -.nv-primitive--spacing-y-0_75 { - padding-block: calc(var(--spacing) * 0.75); -} -.nv-primitive--spacing-y-1 { - padding-block: calc(var(--spacing) * 1); -} -.nv-primitive--spacing-y-1_5 { - padding-block: calc(var(--spacing) * 1.5); -} -.nv-primitive--spacing-y-2 { - padding-block: calc(var(--spacing) * 2); -} -.nv-primitive--spacing-y-2_5 { - padding-block: calc(var(--spacing) * 2.5); -} -.nv-primitive--spacing-y-3 { - padding-block: calc(var(--spacing) * 3); -} -.nv-primitive--spacing-y-3_5 { - padding-block: calc(var(--spacing) * 3.5); -} -.nv-primitive--spacing-y-4 { - padding-block: calc(var(--spacing) * 4); -} -.nv-primitive--spacing-y-5 { - padding-block: calc(var(--spacing) * 5); -} -.nv-primitive--spacing-y-6 { - padding-block: calc(var(--spacing) * 6); -} -.nv-primitive--spacing-y-7 { - padding-block: calc(var(--spacing) * 7); -} -.nv-primitive--spacing-y-8 { - padding-block: calc(var(--spacing) * 8); -} -.nv-primitive--spacing-y-9 { - padding-block: calc(var(--spacing) * 9); -} -.nv-primitive--spacing-y-10 { - padding-block: calc(var(--spacing) * 10); -} -.nv-primitive--spacing-y-11 { - padding-block: calc(var(--spacing) * 11); -} -.nv-primitive--spacing-y-12 { - padding-block: calc(var(--spacing) * 12); -} -.nv-primitive--spacing-y-14 { - padding-block: calc(var(--spacing) * 14); -} -.nv-primitive--spacing-y-16 { - padding-block: calc(var(--spacing) * 16); -} -.nv-primitive--spacing-y-18 { - padding-block: calc(var(--spacing) * 18); -} -.nv-primitive--spacing-y-20 { - padding-block: calc(var(--spacing) * 20); -} -.nv-primitive--spacing-y-24 { - padding-block: calc(var(--spacing) * 24); -} -.nv-primitive--spacing-y-28 { - padding-block: calc(var(--spacing) * 28); -} -.nv-primitive--spacing-y-32 { - padding-block: calc(var(--spacing) * 32); -} -.nv-primitive--spacing-y-36 { - padding-block: calc(var(--spacing) * 36); -} -.nv-primitive--spacing-y-40 { - padding-block: calc(var(--spacing) * 40); -} -.nv-primitive--spacing-y-44 { - padding-block: calc(var(--spacing) * 44); -} -.nv-primitive--spacing-y-48 { - padding-block: calc(var(--spacing) * 48); -} -.nv-primitive--spacing-y-52 { - padding-block: calc(var(--spacing) * 52); -} -.nv-primitive--spacing-y-56 { - padding-block: calc(var(--spacing) * 56); -} -.nv-primitive--spacing-y-60 { - padding-block: calc(var(--spacing) * 60); -} -.nv-primitive--spacing-y-64 { - padding-block: calc(var(--spacing) * 64); -} -.nv-primitive--spacing-y-72 { - padding-block: calc(var(--spacing) * 72); -} -.nv-primitive--spacing-y-80 { - padding-block: calc(var(--spacing) * 80); -} -.nv-primitive--spacing-y-96 { - padding-block: calc(var(--spacing) * 96); -} -.nv-primitive--spacing-y-250 { - padding-block: calc(var(--spacing) * 250); -} -.nv-primitive--spacing-y-px { - padding-block: 1px; -} -.nv-primitive--spacing-y-density-xxs { - padding-block: var(--spacing-density-xxs); -} -.nv-primitive--spacing-y-density-xs { - padding-block: var(--spacing-density-xs); -} -.nv-primitive--spacing-y-density-sm { - padding-block: var(--spacing-density-sm); -} -.nv-primitive--spacing-y-density-md { - padding-block: var(--spacing-density-md); -} -.nv-primitive--spacing-y-density-lg { - padding-block: var(--spacing-density-lg); -} -.nv-primitive--spacing-y-density-xl { - padding-block: var(--spacing-density-xl); -} -.nv-primitive--spacing-y-density-2xl { - padding-block: var(--spacing-density-2xl); -} -.nv-primitive--spacing-y-density-3xl { - padding-block: var(--spacing-density-3xl); -} -.nv-primitive--spacing-y-density-4xl { - padding-block: var(--spacing-density-4xl); -} -.nv-primitive--spacing-y-density-5xl { - padding-block: var(--spacing-density-5xl); -} -.nv-primitive--spacing-y-inherit { - padding-block: inherit; -} -.nv-primitive--spacing-t-0 { - padding-top: calc(var(--spacing) * 0); -} -.nv-primitive--spacing-t-0_25 { - padding-top: calc(var(--spacing) * 0.25); -} -.nv-primitive--spacing-t-0_5 { - padding-top: calc(var(--spacing) * 0.5); -} -.nv-primitive--spacing-t-0_75 { - padding-top: calc(var(--spacing) * 0.75); -} -.nv-primitive--spacing-t-1 { - padding-top: calc(var(--spacing) * 1); -} -.nv-primitive--spacing-t-1_5 { - padding-top: calc(var(--spacing) * 1.5); -} -.nv-primitive--spacing-t-2 { - padding-top: calc(var(--spacing) * 2); -} -.nv-primitive--spacing-t-2_5 { - padding-top: calc(var(--spacing) * 2.5); -} -.nv-primitive--spacing-t-3 { - padding-top: calc(var(--spacing) * 3); -} -.nv-primitive--spacing-t-3_5 { - padding-top: calc(var(--spacing) * 3.5); -} -.nv-primitive--spacing-t-4 { - padding-top: calc(var(--spacing) * 4); -} -.nv-primitive--spacing-t-5 { - padding-top: calc(var(--spacing) * 5); -} -.nv-primitive--spacing-t-6 { - padding-top: calc(var(--spacing) * 6); -} -.nv-primitive--spacing-t-7 { - padding-top: calc(var(--spacing) * 7); -} -.nv-primitive--spacing-t-8 { - padding-top: calc(var(--spacing) * 8); -} -.nv-primitive--spacing-t-9 { - padding-top: calc(var(--spacing) * 9); -} -.nv-primitive--spacing-t-10 { - padding-top: calc(var(--spacing) * 10); -} -.nv-primitive--spacing-t-11 { - padding-top: calc(var(--spacing) * 11); -} -.nv-primitive--spacing-t-12 { - padding-top: calc(var(--spacing) * 12); -} -.nv-primitive--spacing-t-14 { - padding-top: calc(var(--spacing) * 14); -} -.nv-primitive--spacing-t-16 { - padding-top: calc(var(--spacing) * 16); -} -.nv-primitive--spacing-t-18 { - padding-top: calc(var(--spacing) * 18); -} -.nv-primitive--spacing-t-20 { - padding-top: calc(var(--spacing) * 20); -} -.nv-primitive--spacing-t-24 { - padding-top: calc(var(--spacing) * 24); -} -.nv-primitive--spacing-t-28 { - padding-top: calc(var(--spacing) * 28); -} -.nv-primitive--spacing-t-32 { - padding-top: calc(var(--spacing) * 32); -} -.nv-primitive--spacing-t-36 { - padding-top: calc(var(--spacing) * 36); -} -.nv-primitive--spacing-t-40 { - padding-top: calc(var(--spacing) * 40); -} -.nv-primitive--spacing-t-44 { - padding-top: calc(var(--spacing) * 44); -} -.nv-primitive--spacing-t-48 { - padding-top: calc(var(--spacing) * 48); -} -.nv-primitive--spacing-t-52 { - padding-top: calc(var(--spacing) * 52); -} -.nv-primitive--spacing-t-56 { - padding-top: calc(var(--spacing) * 56); -} -.nv-primitive--spacing-t-60 { - padding-top: calc(var(--spacing) * 60); -} -.nv-primitive--spacing-t-64 { - padding-top: calc(var(--spacing) * 64); -} -.nv-primitive--spacing-t-72 { - padding-top: calc(var(--spacing) * 72); -} -.nv-primitive--spacing-t-80 { - padding-top: calc(var(--spacing) * 80); -} -.nv-primitive--spacing-t-96 { - padding-top: calc(var(--spacing) * 96); -} -.nv-primitive--spacing-t-250 { - padding-top: calc(var(--spacing) * 250); -} -.nv-primitive--spacing-t-px { - padding-top: 1px; -} -.nv-primitive--spacing-t-density-xxs { - padding-top: var(--spacing-density-xxs); -} -.nv-primitive--spacing-t-density-xs { - padding-top: var(--spacing-density-xs); -} -.nv-primitive--spacing-t-density-sm { - padding-top: var(--spacing-density-sm); -} -.nv-primitive--spacing-t-density-md { - padding-top: var(--spacing-density-md); -} -.nv-primitive--spacing-t-density-lg { - padding-top: var(--spacing-density-lg); -} -.nv-primitive--spacing-t-density-xl { - padding-top: var(--spacing-density-xl); -} -.nv-primitive--spacing-t-density-2xl { - padding-top: var(--spacing-density-2xl); -} -.nv-primitive--spacing-t-density-3xl { - padding-top: var(--spacing-density-3xl); -} -.nv-primitive--spacing-t-density-4xl { - padding-top: var(--spacing-density-4xl); -} -.nv-primitive--spacing-t-density-5xl { - padding-top: var(--spacing-density-5xl); -} -.nv-primitive--spacing-t-inherit { - padding-top: inherit; -} -.nv-primitive--spacing-r-0 { - padding-right: calc(var(--spacing) * 0); -} -.nv-primitive--spacing-r-0_25 { - padding-right: calc(var(--spacing) * 0.25); -} -.nv-primitive--spacing-r-0_5 { - padding-right: calc(var(--spacing) * 0.5); -} -.nv-primitive--spacing-r-0_75 { - padding-right: calc(var(--spacing) * 0.75); -} -.nv-primitive--spacing-r-1 { - padding-right: calc(var(--spacing) * 1); -} -.nv-primitive--spacing-r-1_5 { - padding-right: calc(var(--spacing) * 1.5); -} -.nv-primitive--spacing-r-2 { - padding-right: calc(var(--spacing) * 2); -} -.nv-primitive--spacing-r-2_5 { - padding-right: calc(var(--spacing) * 2.5); -} -.nv-primitive--spacing-r-3 { - padding-right: calc(var(--spacing) * 3); -} -.nv-primitive--spacing-r-3_5 { - padding-right: calc(var(--spacing) * 3.5); -} -.nv-primitive--spacing-r-4 { - padding-right: calc(var(--spacing) * 4); -} -.nv-primitive--spacing-r-5 { - padding-right: calc(var(--spacing) * 5); -} -.nv-primitive--spacing-r-6 { - padding-right: calc(var(--spacing) * 6); -} -.nv-primitive--spacing-r-7 { - padding-right: calc(var(--spacing) * 7); -} -.nv-primitive--spacing-r-8 { - padding-right: calc(var(--spacing) * 8); -} -.nv-primitive--spacing-r-9 { - padding-right: calc(var(--spacing) * 9); -} -.nv-primitive--spacing-r-10 { - padding-right: calc(var(--spacing) * 10); -} -.nv-primitive--spacing-r-11 { - padding-right: calc(var(--spacing) * 11); -} -.nv-primitive--spacing-r-12 { - padding-right: calc(var(--spacing) * 12); -} -.nv-primitive--spacing-r-14 { - padding-right: calc(var(--spacing) * 14); -} -.nv-primitive--spacing-r-16 { - padding-right: calc(var(--spacing) * 16); -} -.nv-primitive--spacing-r-18 { - padding-right: calc(var(--spacing) * 18); -} -.nv-primitive--spacing-r-20 { - padding-right: calc(var(--spacing) * 20); -} -.nv-primitive--spacing-r-24 { - padding-right: calc(var(--spacing) * 24); -} -.nv-primitive--spacing-r-28 { - padding-right: calc(var(--spacing) * 28); -} -.nv-primitive--spacing-r-32 { - padding-right: calc(var(--spacing) * 32); -} -.nv-primitive--spacing-r-36 { - padding-right: calc(var(--spacing) * 36); -} -.nv-primitive--spacing-r-40 { - padding-right: calc(var(--spacing) * 40); -} -.nv-primitive--spacing-r-44 { - padding-right: calc(var(--spacing) * 44); -} -.nv-primitive--spacing-r-48 { - padding-right: calc(var(--spacing) * 48); -} -.nv-primitive--spacing-r-52 { - padding-right: calc(var(--spacing) * 52); -} -.nv-primitive--spacing-r-56 { - padding-right: calc(var(--spacing) * 56); -} -.nv-primitive--spacing-r-60 { - padding-right: calc(var(--spacing) * 60); -} -.nv-primitive--spacing-r-64 { - padding-right: calc(var(--spacing) * 64); -} -.nv-primitive--spacing-r-72 { - padding-right: calc(var(--spacing) * 72); -} -.nv-primitive--spacing-r-80 { - padding-right: calc(var(--spacing) * 80); -} -.nv-primitive--spacing-r-96 { - padding-right: calc(var(--spacing) * 96); -} -.nv-primitive--spacing-r-250 { - padding-right: calc(var(--spacing) * 250); -} -.nv-primitive--spacing-r-px { - padding-right: 1px; -} -.nv-primitive--spacing-r-density-xxs { - padding-right: var(--spacing-density-xxs); -} -.nv-primitive--spacing-r-density-xs { - padding-right: var(--spacing-density-xs); -} -.nv-primitive--spacing-r-density-sm { - padding-right: var(--spacing-density-sm); -} -.nv-primitive--spacing-r-density-md { - padding-right: var(--spacing-density-md); -} -.nv-primitive--spacing-r-density-lg { - padding-right: var(--spacing-density-lg); -} -.nv-primitive--spacing-r-density-xl { - padding-right: var(--spacing-density-xl); -} -.nv-primitive--spacing-r-density-2xl { - padding-right: var(--spacing-density-2xl); -} -.nv-primitive--spacing-r-density-3xl { - padding-right: var(--spacing-density-3xl); -} -.nv-primitive--spacing-r-density-4xl { - padding-right: var(--spacing-density-4xl); -} -.nv-primitive--spacing-r-density-5xl { - padding-right: var(--spacing-density-5xl); -} -.nv-primitive--spacing-r-inherit { - padding-right: inherit; -} -.nv-primitive--spacing-b-0 { - padding-bottom: calc(var(--spacing) * 0); -} -.nv-primitive--spacing-b-0_25 { - padding-bottom: calc(var(--spacing) * 0.25); -} -.nv-primitive--spacing-b-0_5 { - padding-bottom: calc(var(--spacing) * 0.5); -} -.nv-primitive--spacing-b-0_75 { - padding-bottom: calc(var(--spacing) * 0.75); -} -.nv-primitive--spacing-b-1 { - padding-bottom: calc(var(--spacing) * 1); -} -.nv-primitive--spacing-b-1_5 { - padding-bottom: calc(var(--spacing) * 1.5); -} -.nv-primitive--spacing-b-2 { - padding-bottom: calc(var(--spacing) * 2); -} -.nv-primitive--spacing-b-2_5 { - padding-bottom: calc(var(--spacing) * 2.5); -} -.nv-primitive--spacing-b-3 { - padding-bottom: calc(var(--spacing) * 3); -} -.nv-primitive--spacing-b-3_5 { - padding-bottom: calc(var(--spacing) * 3.5); -} -.nv-primitive--spacing-b-4 { - padding-bottom: calc(var(--spacing) * 4); -} -.nv-primitive--spacing-b-5 { - padding-bottom: calc(var(--spacing) * 5); -} -.nv-primitive--spacing-b-6 { - padding-bottom: calc(var(--spacing) * 6); -} -.nv-primitive--spacing-b-7 { - padding-bottom: calc(var(--spacing) * 7); -} -.nv-primitive--spacing-b-8 { - padding-bottom: calc(var(--spacing) * 8); -} -.nv-primitive--spacing-b-9 { - padding-bottom: calc(var(--spacing) * 9); -} -.nv-primitive--spacing-b-10 { - padding-bottom: calc(var(--spacing) * 10); -} -.nv-primitive--spacing-b-11 { - padding-bottom: calc(var(--spacing) * 11); -} -.nv-primitive--spacing-b-12 { - padding-bottom: calc(var(--spacing) * 12); -} -.nv-primitive--spacing-b-14 { - padding-bottom: calc(var(--spacing) * 14); -} -.nv-primitive--spacing-b-16 { - padding-bottom: calc(var(--spacing) * 16); -} -.nv-primitive--spacing-b-18 { - padding-bottom: calc(var(--spacing) * 18); -} -.nv-primitive--spacing-b-20 { - padding-bottom: calc(var(--spacing) * 20); -} -.nv-primitive--spacing-b-24 { - padding-bottom: calc(var(--spacing) * 24); -} -.nv-primitive--spacing-b-28 { - padding-bottom: calc(var(--spacing) * 28); -} -.nv-primitive--spacing-b-32 { - padding-bottom: calc(var(--spacing) * 32); -} -.nv-primitive--spacing-b-36 { - padding-bottom: calc(var(--spacing) * 36); -} -.nv-primitive--spacing-b-40 { - padding-bottom: calc(var(--spacing) * 40); -} -.nv-primitive--spacing-b-44 { - padding-bottom: calc(var(--spacing) * 44); -} -.nv-primitive--spacing-b-48 { - padding-bottom: calc(var(--spacing) * 48); -} -.nv-primitive--spacing-b-52 { - padding-bottom: calc(var(--spacing) * 52); -} -.nv-primitive--spacing-b-56 { - padding-bottom: calc(var(--spacing) * 56); -} -.nv-primitive--spacing-b-60 { - padding-bottom: calc(var(--spacing) * 60); -} -.nv-primitive--spacing-b-64 { - padding-bottom: calc(var(--spacing) * 64); -} -.nv-primitive--spacing-b-72 { - padding-bottom: calc(var(--spacing) * 72); -} -.nv-primitive--spacing-b-80 { - padding-bottom: calc(var(--spacing) * 80); -} -.nv-primitive--spacing-b-96 { - padding-bottom: calc(var(--spacing) * 96); -} -.nv-primitive--spacing-b-250 { - padding-bottom: calc(var(--spacing) * 250); -} -.nv-primitive--spacing-b-px { - padding-bottom: 1px; -} -.nv-primitive--spacing-b-density-xxs { - padding-bottom: var(--spacing-density-xxs); -} -.nv-primitive--spacing-b-density-xs { - padding-bottom: var(--spacing-density-xs); -} -.nv-primitive--spacing-b-density-sm { - padding-bottom: var(--spacing-density-sm); -} -.nv-primitive--spacing-b-density-md { - padding-bottom: var(--spacing-density-md); -} -.nv-primitive--spacing-b-density-lg { - padding-bottom: var(--spacing-density-lg); -} -.nv-primitive--spacing-b-density-xl { - padding-bottom: var(--spacing-density-xl); -} -.nv-primitive--spacing-b-density-2xl { - padding-bottom: var(--spacing-density-2xl); -} -.nv-primitive--spacing-b-density-3xl { - padding-bottom: var(--spacing-density-3xl); -} -.nv-primitive--spacing-b-density-4xl { - padding-bottom: var(--spacing-density-4xl); -} -.nv-primitive--spacing-b-density-5xl { - padding-bottom: var(--spacing-density-5xl); -} -.nv-primitive--spacing-b-inherit { - padding-bottom: inherit; -} -.nv-primitive--spacing-l-0 { - padding-left: calc(var(--spacing) * 0); -} -.nv-primitive--spacing-l-0_25 { - padding-left: calc(var(--spacing) * 0.25); -} -.nv-primitive--spacing-l-0_5 { - padding-left: calc(var(--spacing) * 0.5); -} -.nv-primitive--spacing-l-0_75 { - padding-left: calc(var(--spacing) * 0.75); -} -.nv-primitive--spacing-l-1 { - padding-left: calc(var(--spacing) * 1); -} -.nv-primitive--spacing-l-1_5 { - padding-left: calc(var(--spacing) * 1.5); -} -.nv-primitive--spacing-l-2 { - padding-left: calc(var(--spacing) * 2); -} -.nv-primitive--spacing-l-2_5 { - padding-left: calc(var(--spacing) * 2.5); -} -.nv-primitive--spacing-l-3 { - padding-left: calc(var(--spacing) * 3); -} -.nv-primitive--spacing-l-3_5 { - padding-left: calc(var(--spacing) * 3.5); -} -.nv-primitive--spacing-l-4 { - padding-left: calc(var(--spacing) * 4); -} -.nv-primitive--spacing-l-5 { - padding-left: calc(var(--spacing) * 5); -} -.nv-primitive--spacing-l-6 { - padding-left: calc(var(--spacing) * 6); -} -.nv-primitive--spacing-l-7 { - padding-left: calc(var(--spacing) * 7); -} -.nv-primitive--spacing-l-8 { - padding-left: calc(var(--spacing) * 8); -} -.nv-primitive--spacing-l-9 { - padding-left: calc(var(--spacing) * 9); -} -.nv-primitive--spacing-l-10 { - padding-left: calc(var(--spacing) * 10); -} -.nv-primitive--spacing-l-11 { - padding-left: calc(var(--spacing) * 11); -} -.nv-primitive--spacing-l-12 { - padding-left: calc(var(--spacing) * 12); -} -.nv-primitive--spacing-l-14 { - padding-left: calc(var(--spacing) * 14); -} -.nv-primitive--spacing-l-16 { - padding-left: calc(var(--spacing) * 16); -} -.nv-primitive--spacing-l-18 { - padding-left: calc(var(--spacing) * 18); -} -.nv-primitive--spacing-l-20 { - padding-left: calc(var(--spacing) * 20); -} -.nv-primitive--spacing-l-24 { - padding-left: calc(var(--spacing) * 24); -} -.nv-primitive--spacing-l-28 { - padding-left: calc(var(--spacing) * 28); -} -.nv-primitive--spacing-l-32 { - padding-left: calc(var(--spacing) * 32); -} -.nv-primitive--spacing-l-36 { - padding-left: calc(var(--spacing) * 36); -} -.nv-primitive--spacing-l-40 { - padding-left: calc(var(--spacing) * 40); -} -.nv-primitive--spacing-l-44 { - padding-left: calc(var(--spacing) * 44); -} -.nv-primitive--spacing-l-48 { - padding-left: calc(var(--spacing) * 48); -} -.nv-primitive--spacing-l-52 { - padding-left: calc(var(--spacing) * 52); -} -.nv-primitive--spacing-l-56 { - padding-left: calc(var(--spacing) * 56); -} -.nv-primitive--spacing-l-60 { - padding-left: calc(var(--spacing) * 60); -} -.nv-primitive--spacing-l-64 { - padding-left: calc(var(--spacing) * 64); -} -.nv-primitive--spacing-l-72 { - padding-left: calc(var(--spacing) * 72); -} -.nv-primitive--spacing-l-80 { - padding-left: calc(var(--spacing) * 80); -} -.nv-primitive--spacing-l-96 { - padding-left: calc(var(--spacing) * 96); -} -.nv-primitive--spacing-l-250 { - padding-left: calc(var(--spacing) * 250); -} -.nv-primitive--spacing-l-px { - padding-left: 1px; -} -.nv-primitive--spacing-l-density-xxs { - padding-left: var(--spacing-density-xxs); -} -.nv-primitive--spacing-l-density-xs { - padding-left: var(--spacing-density-xs); -} -.nv-primitive--spacing-l-density-sm { - padding-left: var(--spacing-density-sm); -} -.nv-primitive--spacing-l-density-md { - padding-left: var(--spacing-density-md); -} -.nv-primitive--spacing-l-density-lg { - padding-left: var(--spacing-density-lg); -} -.nv-primitive--spacing-l-density-xl { - padding-left: var(--spacing-density-xl); -} -.nv-primitive--spacing-l-density-2xl { - padding-left: var(--spacing-density-2xl); -} -.nv-primitive--spacing-l-density-3xl { - padding-left: var(--spacing-density-3xl); -} -.nv-primitive--spacing-l-density-4xl { - padding-left: var(--spacing-density-4xl); -} -.nv-primitive--spacing-l-density-5xl { - padding-left: var(--spacing-density-5xl); -} -.nv-primitive--spacing-l-inherit { - padding-left: inherit; -} -.nv-primitive--gap-0 { - gap: calc(var(--spacing) * 0); -} -.nv-primitive--gap-0_25 { - gap: calc(var(--spacing) * 0.25); -} -.nv-primitive--gap-0_5 { - gap: calc(var(--spacing) * 0.5); -} -.nv-primitive--gap-0_75 { - gap: calc(var(--spacing) * 0.75); -} -.nv-primitive--gap-1 { - gap: calc(var(--spacing) * 1); -} -.nv-primitive--gap-1_5 { - gap: calc(var(--spacing) * 1.5); -} -.nv-primitive--gap-2 { - gap: calc(var(--spacing) * 2); -} -.nv-primitive--gap-2_5 { - gap: calc(var(--spacing) * 2.5); -} -.nv-primitive--gap-3 { - gap: calc(var(--spacing) * 3); -} -.nv-primitive--gap-3_5 { - gap: calc(var(--spacing) * 3.5); -} -.nv-primitive--gap-4 { - gap: calc(var(--spacing) * 4); -} -.nv-primitive--gap-5 { - gap: calc(var(--spacing) * 5); -} -.nv-primitive--gap-6 { - gap: calc(var(--spacing) * 6); -} -.nv-primitive--gap-7 { - gap: calc(var(--spacing) * 7); -} -.nv-primitive--gap-8 { - gap: calc(var(--spacing) * 8); -} -.nv-primitive--gap-9 { - gap: calc(var(--spacing) * 9); -} -.nv-primitive--gap-10 { - gap: calc(var(--spacing) * 10); -} -.nv-primitive--gap-11 { - gap: calc(var(--spacing) * 11); -} -.nv-primitive--gap-12 { - gap: calc(var(--spacing) * 12); -} -.nv-primitive--gap-14 { - gap: calc(var(--spacing) * 14); -} -.nv-primitive--gap-16 { - gap: calc(var(--spacing) * 16); -} -.nv-primitive--gap-18 { - gap: calc(var(--spacing) * 18); -} -.nv-primitive--gap-20 { - gap: calc(var(--spacing) * 20); -} -.nv-primitive--gap-24 { - gap: calc(var(--spacing) * 24); -} -.nv-primitive--gap-28 { - gap: calc(var(--spacing) * 28); -} -.nv-primitive--gap-32 { - gap: calc(var(--spacing) * 32); -} -.nv-primitive--gap-36 { - gap: calc(var(--spacing) * 36); -} -.nv-primitive--gap-40 { - gap: calc(var(--spacing) * 40); -} -.nv-primitive--gap-44 { - gap: calc(var(--spacing) * 44); -} -.nv-primitive--gap-48 { - gap: calc(var(--spacing) * 48); -} -.nv-primitive--gap-52 { - gap: calc(var(--spacing) * 52); -} -.nv-primitive--gap-56 { - gap: calc(var(--spacing) * 56); -} -.nv-primitive--gap-60 { - gap: calc(var(--spacing) * 60); -} -.nv-primitive--gap-64 { - gap: calc(var(--spacing) * 64); -} -.nv-primitive--gap-72 { - gap: calc(var(--spacing) * 72); -} -.nv-primitive--gap-80 { - gap: calc(var(--spacing) * 80); -} -.nv-primitive--gap-96 { - gap: calc(var(--spacing) * 96); -} -.nv-primitive--gap-250 { - gap: calc(var(--spacing) * 250); -} -.nv-primitive--gap-px { - gap: 1px; -} -.nv-primitive--gap-density-xxs { - gap: var(--spacing-density-xxs); -} -.nv-primitive--gap-density-xs { - gap: var(--spacing-density-xs); -} -.nv-primitive--gap-density-sm { - gap: var(--spacing-density-sm); -} -.nv-primitive--gap-density-md { - gap: var(--spacing-density-md); -} -.nv-primitive--gap-density-lg { - gap: var(--spacing-density-lg); -} -.nv-primitive--gap-density-xl { - gap: var(--spacing-density-xl); -} -.nv-primitive--gap-density-2xl { - gap: var(--spacing-density-2xl); -} -.nv-primitive--gap-density-3xl { - gap: var(--spacing-density-3xl); -} -.nv-primitive--gap-density-4xl { - gap: var(--spacing-density-4xl); -} -.nv-primitive--gap-density-5xl { - gap: var(--spacing-density-5xl); -} -.nv-primitive--gap-inherit { - gap: inherit; -} +.nv-accordion-item>summary{list-style:none}.nv-accordion-item>summary::-webkit-details-marker{display:none}.nv-accordion-item>summary::marker{content:"";display:none}.nv-accordion-root{font-family:var(--font-sans);background:var(--nv-accordion-root-bg,var(--background-color-surface-raised));border-radius:var(--nv-accordion-root-border-radius,0);color:var(--nv-accordion-root-color,var(--text-color-primary))}.nv-accordion-root svg,.nv-accordion-root .nv-icon{color:var(--nv-accordion-icon-color,var(--text-color-base));flex-shrink:0}.nv-accordion-root [data-disabled],.nv-accordion-root [aria-disabled=true]{color:var(--text-color-disabled)}.nv-accordion-item{border-bottom:var(--nv-accordion-item-border,1px solid var(--border-color-base));display:block}.nv-accordion-item::details-content{height:0;display:block;overflow:clip}@media (prefers-reduced-motion:no-preference){.nv-accordion-item::details-content{transition:height .2s var(--ease-out),content-visibility .2s allow-discrete}}.nv-accordion-item[open]::details-content{height:auto}@media (prefers-reduced-motion:reduce){.nv-accordion-item::details-content{transition-duration:.01ms}}.nv-accordion-trigger{cursor:pointer;background:var(--nv-accordion-trigger-bg,transparent);width:100%;padding:var(--nv-accordion-trigger-padding,calc(var(--spacing)*3));justify-content:space-between;align-items:center;gap:var(--nv-accordion-trigger-gap,calc(var(--spacing)*1.5));color:var(--nv-accordion-trigger-color,inherit);font-weight:var(--font-weight-bold);font-size:var(--nv-accordion-label-font-size,var(--text-14));border:none;line-height:1.14286;display:flex}@media (prefers-reduced-motion:no-preference){.nv-accordion-trigger{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke;transition-duration:.2s;transition-timing-function:var(--ease-out)}}.nv-accordion-trigger:hover{background:var(--nv-accordion-trigger-bg-hover,var(--background-color-interaction-hover))}.nv-accordion-trigger[data-disabled],.nv-accordion-trigger[aria-disabled=true]{cursor:not-allowed;color:var(--text-color-disabled);background:var(--nv-accordion-trigger-bg-disabled,transparent)}.nv-accordion-trigger.nv-accordion-trigger--chevron-end{flex-direction:row;justify-content:space-between}.nv-accordion-trigger.nv-accordion-trigger--chevron-start{flex-direction:row-reverse;justify-content:flex-end}.nv-accordion-trigger svg,.nv-accordion-trigger .nv-icon{width:calc(var(--spacing)*3);height:calc(var(--spacing)*3)}.nv-accordion-trigger .nv-animated-chevron{width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.nv-accordion-trigger .nv-accordion-label-text{text-overflow:ellipsis;white-space:nowrap;gap:inherit;overflow:hidden}.nv-accordion-content{background:var(--nv-accordion-content-bg,transparent);color:var(--nv-accordion-content-color,inherit);font-size:var(--nv-accordion-content-font-size,var(--text-14));line-height:var(--nv-accordion-content-line-height,var(--leading-lh-150));padding:var(--nv-accordion-content-padding,calc(var(--spacing)*6)calc(var(--spacing)*3))}.nv-accordion-root .nv-accordion-trigger--chevron-start+.nv-accordion-content .nv-accordion-root .nv-accordion-trigger{flex-direction:row-reverse;justify-content:flex-end}.nv-accordion-root .nv-accordion-root{background-color:var(--background-color-surface-overlay);--nv-accordion-label-font-size:var(--text-12);line-height:1.33333}.nv-anchor{cursor:pointer;width:fit-content;color:var(--text-color-primary);display:inline}.nv-anchor:not(.nv-anchor--kind-standalone){text-decoration:underline;-webkit-text-decoration-color:var(--border-color-brand);text-decoration-color:var(--border-color-brand);text-underline-offset:4px;-webkit-text-decoration-skip-ink:auto;text-decoration-skip-ink:auto;text-decoration-thickness:1px}:is(.nv-anchor:not(.nv-anchor--disabled):not(:disabled):hover,.nv-anchor:not(.nv-anchor--disabled):not(:disabled):focus-visible):not(:active){background-color:var(--background-color-interaction-hover)}.nv-anchor:not(.nv-anchor--disabled):not(:disabled):not(.nv-anchor--kind-standalone):hover,.nv-anchor:not(.nv-anchor--disabled):not(:disabled):not(.nv-anchor--kind-standalone):focus-visible,.nv-anchor:not(.nv-anchor--disabled):not(:disabled):not(.nv-anchor--kind-standalone):active{-webkit-text-decoration-color:var(--border-color-interaction-hover);text-decoration-color:var(--border-color-interaction-hover)}.nv-anchor:is(.nv-anchor--disabled,:disabled){cursor:not-allowed;color:var(--text-color-disabled)}.nv-anchor:is(.nv-anchor--disabled,:disabled):not(.nv-anchor--kind-standalone){-webkit-text-decoration-color:var(--text-color-disabled);text-decoration-color:var(--text-color-disabled)}.nv-anchor:is(.nv-anchor--disabled,:disabled):is(a){pointer-events:none}@media (prefers-reduced-motion:no-preference){.nv-anchor{transition-property:color,text-decoration-color;transition-duration:.2s;transition-timing-function:var(--ease-out)}}.nv-anchor [data-nv-gui-icon-before],.nv-anchor [data-nv-gui-icon-after],.nv-anchor svg{display:inline}:is(.nv-anchor [data-nv-gui-icon-before],.nv-anchor [data-nv-gui-icon-after],.nv-anchor svg):before{vertical-align:-.3em;display:inline;position:static}.nv-animated-chevron{pointer-events:none}@media (prefers-reduced-motion:no-preference){.nv-animated-chevron{transition:rotate .2s var(--ease-out)}}:where(select:open+.nv-animated-chevron),:where([data-state=open] .nv-animated-chevron),.nv-animated-chevron[data-state=open]{rotate:180deg}:where([data-state=closed] .nv-animated-chevron),.nv-animated-chevron[data-state=closed]{rotate:none}:where(select:open+.nv-animated-chevron),:where([data-state=open] .nv-animated-chevron),.nv-animated-chevron[data-state=open]{rotate:180deg}:where([data-state=closed] .nv-animated-chevron),.nv-animated-chevron[data-state=closed]{rotate:none}details[open] .nv-animated-chevron{rotate:180deg!important}details:not([open]) .nv-animated-chevron{rotate:none!important}:has(select:open) .nv-animated-chevron{rotate:180deg}.nv-app-bar-root{border-bottom:var(--border-width-1)solid var(--border-color-base);background-color:var(--background-color-surface-navigation);width:100%;padding-inline:calc(var(--spacing)*4);font-family:var(--font-sans);text-wrap:nowrap;color:var(--text-color-primary);align-items:center;gap:calc(var(--spacing)*4);height:var(--nv-app-bar-height);max-height:var(--nv-app-bar-height);display:flex;overflow:hidden}.nv-app-bar-slot-start{gap:inherit;font-size:var(--text-14);font-weight:var(--font-weight-bold);line-height:var(--leading-lh-150);flex-shrink:0;align-items:center;display:flex}.nv-app-bar-slot-center{flex-grow:1;align-items:center;height:100%;display:flex;overflow:hidden}.nv-app-bar-slot-end{gap:inherit;flex-shrink:0;align-items:center;display:flex}.nv-app-bar-expander-button.nv-button{--nv-button-icon-size:var(--text-24);--nv-button-icon-margin:-8px}.nv-avatar-root{width:calc(var(--spacing)*12);height:calc(var(--spacing)*12);background-color:var(--background-color-accent-teal-strong);font-size:var(--text-20);line-height:var(--leading-lh-150);color:var(--text-color-accent-white);font-weight:var(--font-weight-bold);border-radius:3.40282e38px;flex-shrink:0;place-items:center;display:grid;position:relative;overflow:hidden}@media (prefers-reduced-motion:no-preference){.nv-avatar-root{transition:background-color .2s var(--ease-out)}}.nv-avatar-root--size-small{width:calc(var(--spacing)*6);height:calc(var(--spacing)*6);font-size:var(--text-10)}.nv-avatar-root--size-medium{width:calc(var(--spacing)*8);height:calc(var(--spacing)*8);font-size:var(--text-14)}.nv-avatar-root--size-large{width:calc(var(--spacing)*12);height:calc(var(--spacing)*12)}.nv-avatar-root--size-xlarge{width:calc(var(--spacing)*16);height:calc(var(--spacing)*16);font-size:var(--text-28)}.nv-avatar-root--size-xxlarge{width:calc(var(--spacing)*32);height:calc(var(--spacing)*32);font-size:var(--text-60)}.nv-avatar-root--interactive{cursor:pointer}.nv-avatar-root--interactive:hover:before{border-color:var(--border-color-interaction-hover)}.nv-avatar-root--interactive:active:before{border-color:var(--border-color-interaction-pressed)}.nv-avatar-root--interactive:not(.nv-avatar-root--kind-outline):before{opacity:0}.nv-avatar-root--interactive:not(.nv-avatar-root--kind-outline):hover:before,.nv-avatar-root--interactive:not(.nv-avatar-root--kind-outline):active:before{opacity:1}:is(.nv-avatar-root--kind-outline,.nv-avatar-root--interactive):before{content:"";inset:calc(var(--spacing)*0);border:2px solid;border-color:var(--border-color-base);border-radius:3.40282e38px;position:absolute}@media (prefers-reduced-motion:no-preference){:is(.nv-avatar-root--kind-outline,.nv-avatar-root--interactive):before{transition:border-color .2s var(--ease-out)}}:is(.nv-avatar-root--kind-outline,.nv-avatar-root--interactive).nv-avatar-root--size-small:before,:is(.nv-avatar-root--kind-outline,.nv-avatar-root--interactive).nv-avatar-root--size-medium:before{border-width:1px}:is(.nv-avatar-root--kind-outline,.nv-avatar-root--interactive).nv-avatar-root--size-xxlarge:before{border-width:4px}.nv-avatar-image{object-fit:cover;width:100%;height:100%}.nv-avatar-image:not([data-loaded]),.nv-avatar-root:has(.nv-avatar-image[data-loaded]) .nv-avatar-fallback{display:none}.nv-badge{justify-content:center;align-items:center;gap:calc(var(--spacing)*1);border-radius:var(--radius-md);width:fit-content;max-width:100%;height:fit-content;padding-inline:calc(var(--spacing)*2);font-family:var(--font-sans);font-size:var(--text-12);font-weight:var(--font-weight-bold);vertical-align:middle;--_bg-color:transparent;--_border-color:var(--border-color-accent-blue);--_text-color:var(--text-color-accent-blue);background-color:var(--nv-badge-bg-color,var(--bg-color,var(--_bg-color)));border:1px solid var(--nv-badge-border-color,var(--border-color,var(--_border-color)));color:var(--nv-badge-text-color,var(--text-color,var(--_text-color)));flex-grow:0;flex-shrink:0;line-height:1.33333;display:inline-flex}.nv-badge svg,.nv-badge .nv-icon{flex-shrink:0;width:1em;height:1em}.nv-badge.nv-badge--kind-solid{--_bg-color:var(--background-color-accent-blue);--_text-color:var(--text-color-accent-blue-strong);--_border-color:var(--background-color-accent-blue)}.nv-badge.nv-badge--color-green{--_border-color:var(--border-color-accent-green);--_text-color:var(--text-color-accent-green)}.nv-badge.nv-badge--color-green.nv-badge--kind-solid{--_bg-color:var(--background-color-accent-green);--_text-color:var(--text-color-accent-green-strong);--_border-color:var(--background-color-accent-green)}.nv-badge.nv-badge--color-red{--_border-color:var(--border-color-accent-red);--_text-color:var(--text-color-accent-red)}.nv-badge.nv-badge--color-red.nv-badge--kind-solid{--_bg-color:var(--background-color-accent-red);--_text-color:var(--text-color-accent-red-strong);--_border-color:var(--background-color-accent-red)}.nv-badge.nv-badge--color-yellow{--_border-color:var(--border-color-accent-yellow);--_text-color:var(--text-color-accent-yellow)}.nv-badge.nv-badge--color-yellow.nv-badge--kind-solid{--_bg-color:var(--background-color-accent-yellow);--_text-color:var(--text-color-accent-yellow-strong);--_border-color:var(--background-color-accent-yellow)}.nv-badge.nv-badge--color-purple{--_border-color:var(--border-color-accent-purple);--_text-color:var(--text-color-accent-purple)}.nv-badge.nv-badge--color-purple.nv-badge--kind-solid{--_bg-color:var(--background-color-accent-purple);--_text-color:var(--text-color-accent-purple-strong);--_border-color:var(--background-color-accent-purple)}.nv-badge.nv-badge--color-teal{--_border-color:var(--border-color-accent-teal);--_text-color:var(--text-color-accent-teal)}.nv-badge.nv-badge--color-teal.nv-badge--kind-solid{--_bg-color:var(--background-color-accent-teal);--_text-color:var(--text-color-accent-teal-strong);--_border-color:var(--background-color-accent-teal)}.nv-badge.nv-badge--color-gray{--_border-color:var(--border-color-accent-gray);--_text-color:var(--text-color-primary)}.nv-badge.nv-badge--color-gray.nv-badge--kind-solid{--_bg-color:var(--background-color-accent-gray-subtle);--_border-color:var(--background-color-accent-gray-subtle)}.nv-banner-root{box-sizing:border-box;border-radius:var(--radius-md);background-color:var(--bg-color);width:100%;min-height:40px;color:var(--text-color);border:1px solid var(--border-color);padding:calc(var(--spacing)*2);font-family:var(--font-sans);font-size:var(--text-14);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular);align-items:center;display:flex;container-type:inline-size}.nv-banner-root,.nv-banner-root:where(.nv-banner-root--status-info){--bg-color:var(--background-color-feedback-info);--text-color:var(--text-color-feedback-info-inverse);--border-color:var(--border-color-feedback-info)}.nv-banner-root.nv-banner-root--status-error{--bg-color:var(--background-color-feedback-danger);--text-color:var(--text-color-feedback-danger-inverse);--border-color:var(--border-color-feedback-danger-subtle)}.nv-banner-root.nv-banner-root--status-warning{--bg-color:var(--background-color-feedback-warning);--text-color:var(--text-color-feedback-warning-inverse);--border-color:var(--border-color-feedback-warning)}.nv-banner-root.nv-banner-root--status-success{--bg-color:var(--background-color-feedback-success);--text-color:var(--text-color-feedback-success-inverse);--border-color:var(--border-color-feedback-success)}.nv-banner-root.nv-banner-root--kind-header{padding:calc(var(--spacing)*4)}.nv-banner-root.nv-banner-root--kind-header .nv-banner-icon{align-self:flex-start}.nv-banner-root.nv-banner-root--kind-header .nv-banner-heading{font-family:var(--font-sans);font-size:var(--text-16);line-height:var(--leading-lh-150);font-weight:var(--font-weight-bold)}.nv-banner-root.nv-banner-root--kind-global{border-radius:var(--radius-none);border:0}.nv-banner-root.nv-banner-root--kind-global .nv-banner-content{justify-content:center}.nv-banner-root .nv-banner-icon{padding-block:calc(var(--spacing)*1);font-size:var(--text-16);display:flex}.nv-banner-root .nv-banner-header{gap:calc(var(--spacing)*1);flex-direction:column;display:flex}.nv-banner-root .nv-banner-layout{grid-template-columns:1fr auto auto;grid-template-areas:"content actions close-button";width:100%;display:grid}.nv-banner-root .nv-banner-layout .nv-banner-content{align-items:center;gap:calc(var(--spacing)*2);grid-area:content;width:100%;display:flex}.nv-banner-root .nv-banner-layout .nv-banner-actions-section{justify-content:flex-end;place-items:start;gap:calc(var(--spacing)*2);padding-left:calc(var(--spacing)*2);flex-wrap:wrap;grid-area:actions;display:flex}.nv-banner-root .nv-banner-layout .nv-banner-close-button-section{padding-left:calc(var(--spacing)*2);grid-area:close-button}@container (width<400px){.nv-banner-root .nv-banner-layout{grid-template-columns:1fr auto;grid-template-areas:"content close-button""actions actions"}.nv-banner-root .nv-banner-layout .nv-banner-close-button-section{place-content:start}.nv-banner-root .nv-banner-layout .nv-banner-content{justify-content:flex-start!important}.nv-banner-root .nv-banner-layout .nv-banner-actions-section{padding-top:calc(var(--spacing)*2);padding-left:calc(var(--spacing)*0)}}.nv-banner-root.nv-banner-root--actionsPosition-bottom .nv-banner-layout{grid-template-columns:1fr auto;grid-template-areas:"content close-button""actions actions"}.nv-banner-root.nv-banner-root--actionsPosition-bottom .nv-banner-layout .nv-banner-close-button-section{place-content:start}.nv-banner-root.nv-banner-root--actionsPosition-bottom .nv-banner-layout .nv-banner-content{justify-content:flex-start!important}.nv-banner-root.nv-banner-root--actionsPosition-bottom .nv-banner-layout .nv-banner-actions-section{padding-top:calc(var(--spacing)*2);padding-left:calc(var(--spacing)*0)}.nv-block{max-width:100%;max-height:100%;font-family:var(--font-sans);display:block}.nv-block--overflow-auto{overflow:auto}.nv-block--overflow-clip{overflow:clip}.nv-block--overflow-hidden{overflow:hidden}.nv-block--overflow-scroll{overflow:scroll}.nv-block--overflow-visible{overflow:visible}.nv-block--overflow-x-auto{overflow-x:auto}.nv-block--overflow-x-clip{overflow-x:clip}.nv-block--overflow-x-hidden{overflow-x:hidden}.nv-block--overflow-x-scroll{overflow-x:scroll}.nv-block--overflow-x-visible{overflow-x:visible}.nv-block--overflow-y-auto{overflow-y:auto}.nv-block--overflow-y-clip{overflow-y:clip}.nv-block--overflow-y-hidden{overflow-y:hidden}.nv-block--overflow-y-scroll{overflow-y:scroll}.nv-block--overflow-y-visible{overflow-y:visible}.nv-block--text-ellipsis{text-overflow:ellipsis}.nv-block--text-clip{text-overflow:clip}.nv-block--text-wrap{text-wrap:wrap}.nv-block--text-nowrap{text-wrap:nowrap}.nv-block--text-balance{text-wrap:balance}.nv-block--text-pretty{text-wrap:pretty}.nv-breadcrumbs-root{align-items:center;gap:calc(var(--spacing)*1);font-family:var(--font-sans);font-size:var(--text-14);line-height:1.21429;font-weight:var(--font-weight-regular);color:var(--text-color-secondary);flex-wrap:wrap;display:flex}:is(.nv-breadcrumbs-root,.nv-breadcrumbs-root.nv-breadcrumbs-root--size-medium) .nv-breadcrumbs-separator{width:calc(var(--spacing)*4);height:calc(var(--spacing)*4);font-size:var(--text-16)}.nv-breadcrumbs-root.nv-breadcrumbs-root--size-small{font-family:var(--font-sans);font-size:var(--text-12);line-height:var(--leading-lh-125);font-weight:var(--font-weight-regular)}.nv-breadcrumbs-root.nv-breadcrumbs-root--size-large{font-family:var(--font-sans);font-size:var(--text-16);line-height:var(--leading-lh-125);font-weight:var(--font-weight-regular)}.nv-breadcrumbs-root.nv-breadcrumbs-root--size-large .nv-breadcrumbs-separator{width:calc(var(--spacing)*6);height:calc(var(--spacing)*6);font-size:var(--text-24)}.nv-breadcrumbs-item{align-items:center;gap:calc(var(--spacing)*1);text-underline-offset:25.0%;text-underline-position:from-font;text-decoration-thickness:10%;display:inline-flex}.nv-breadcrumbs-item.nv-breadcrumbs-item--active{color:var(--text-color-primary);font-weight:var(--font-weight-bold)}.nv-breadcrumbs-item:not(.nv-breadcrumbs-item--active){text-decoration-line:underline;text-decoration-color:#0000}@media (hover:hover){:is(.nv-breadcrumbs-item:not(.nv-breadcrumbs-item--active):hover,.nv-breadcrumbs-item:not(.nv-breadcrumbs-item--active):focus){-webkit-text-decoration-color:var(--text-color-base);text-decoration-color:var(--text-color-base)}}@media (prefers-reduced-motion:no-preference){.nv-breadcrumbs-item{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-duration:.15s;transition-timing-function:var(--ease-out)}}.nv-breadcrumbs-separator{color:var(--text-color-base);line-height:var(--leading-lh-100);place-items:center;display:grid}.nv-button{cursor:pointer;background:var(--nv-button-bg,var(--_bg,var(--background-color-interaction-inverse)));border:var(--nv-button-border,var(--_border,1px solid transparent));width:fit-content;min-width:fit-content;max-width:100%;height:fit-content;color:var(--nv-button-color,var(--_color,var(--text-color-inverse)));--_padding:var(--_padding,calc(var(--spacing)*3));padding-inline:var(--nv-button-padding,var(--_padding));padding-block:calc(var(--_padding) - 2px);justify-content:center;align-items:center;gap:var(--nv-button-gap,var(--spacing));border-radius:var(--nv-button-border-radius,var(--radius-md));font-size:var(--nv-button-font-size,var(--_font-size,var(--text-14)));font-weight:var(--font-weight-bold);font-family:var(--font-sans);flex-grow:0;flex-shrink:0;display:inline-flex}.nv-button:disabled{cursor:not-allowed}@media (prefers-reduced-motion:no-preference){.nv-button{transition-property:background-color,color,scale,border-color;transition-duration:.15s;transition-timing-function:var(--ease-out)}}.nv-button:focus-visible{outline:2px solid var(--text-color-inverse);outline-offset:-2px;box-shadow:0 0 0 2px var(--text-color-primary)}.nv-button:active:not(:disabled){scale:99%}.nv-button:hover:not(:disabled){background:var(--nv-button-bg-hover,var(--_bg,var(--background-color-interaction-inverse-hover)));color:var(--nv-button-color-hover,var(--_color,var(--text-color-inverse)));border:var(--nv-button-border-hover,var(--_border,1px solid transparent))}.nv-button:active:not(:disabled),.nv-button[data-state=open]:not([data-active-state=disabled]),.nv-button[aria-expanded=true]:not([data-active-state=disabled]){background:var(--nv-button-bg-active,var(--_bg,var(--background-color-interaction-inverse-pressed)));color:var(--nv-button-color-active,var(--_color,var(--text-color-inverse)));border:var(--nv-button-border-active,var(--_border,1px solid transparent))}.nv-button:disabled{background:var(--nv-button-bg-disabled,var(--_bg,var(--background-color-interaction-disabled)));color:var(--nv-button-color-disabled,var(--_color,var(--text-color-disabled)));border:var(--nv-button-border-disabled,var(--_border,1px solid transparent))}.nv-button svg,.nv-button .nv-icon{color:var(--nv-button-icon-color,var(--_icon-color,var(--_color,var(--text-color-inverse))));font-size:var(--nv-button-icon-size,var(--_icon-size,var(--text-16)));margin:var(--nv-button-icon-margin,var(--_icon-margin,0));flex-shrink:0}.nv-button:hover:not(:disabled) svg,.nv-button:hover:not(:disabled) .nv-icon{color:var(--nv-button-icon-color-hover,var(--_icon-color,var(--_color,var(--text-color-inverse))))}.nv-button:active:not(:disabled) svg,.nv-button:active:not(:disabled) .nv-icon,.nv-button[data-state=open]:not([data-active-state=disabled]) svg,.nv-button[data-state=open]:not([data-active-state=disabled]) .nv-icon,.nv-button[aria-expanded=true]:not([data-active-state=disabled]) svg,.nv-button[aria-expanded=true]:not([data-active-state=disabled]) .nv-icon{color:var(--nv-button-icon-color-active,var(--_icon-color,var(--_color,var(--text-color-inverse))))}.nv-button:disabled svg,.nv-button:disabled .nv-icon{color:var(--nv-button-icon-color-disabled,var(--_icon-color,var(--text-color-disabled)))}:is(.nv-button:not(.nv-button-group .nv-button),.nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-primary>.nv-button)),:is(.nv-button:not(.nv-button-group .nv-button),.nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-primary>.nv-button)):where(.nv-button--color-neutral){--_bg:var(--background-color-interaction-inverse);--_color:var(--text-color-inverse);--_border:1px solid transparent}:is(.nv-button:not(.nv-button-group .nv-button),.nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-primary>.nv-button)):where(.nv-button--color-brand){--_bg:var(--background-color-interaction-primary-base);--_color:var(--text-color-accent-black);--_border:1px solid transparent}:is(.nv-button:not(.nv-button-group .nv-button),.nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-primary>.nv-button)):where(.nv-button--color-danger){--_bg:var(--background-color-feedback-danger-strong);--_color:var(--text-color-accent-white);--_border:1px solid var(--border-color-feedback-danger)}:is(.nv-button:not(.nv-button-group .nv-button),.nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-primary>.nv-button)):hover:not(:disabled),:is(.nv-button:not(.nv-button-group .nv-button),.nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-primary>.nv-button)):hover:not(:disabled):where(.nv-button--color-neutral){--_bg:var(--background-color-interaction-inverse-hover)}:is(.nv-button:not(.nv-button-group .nv-button),.nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-primary>.nv-button)):hover:not(:disabled):where(.nv-button--color-brand){--_bg:var(--background-color-interaction-primary-hover)}:is(.nv-button:not(.nv-button-group .nv-button),.nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-primary>.nv-button)):hover:not(:disabled):where(.nv-button--color-danger){--_bg:var(--background-color-feedback-danger-hover);--_border:1px solid var(--border-color-feedback-danger-hover)}:is(:is(.nv-button:not(.nv-button-group .nv-button),.nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-primary>.nv-button)):active:not(:disabled),:is(.nv-button:not(.nv-button-group .nv-button),.nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-primary>.nv-button))[data-state=open]:not([data-active-state=disabled]):not(:disabled),:is(.nv-button:not(.nv-button-group .nv-button),.nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-primary>.nv-button))[aria-expanded=true]:not([data-active-state=disabled]):not(:disabled)),:is(:is(.nv-button:not(.nv-button-group .nv-button),.nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-primary>.nv-button)):active:not(:disabled),:is(.nv-button:not(.nv-button-group .nv-button),.nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-primary>.nv-button))[data-state=open]:not([data-active-state=disabled]):not(:disabled),:is(.nv-button:not(.nv-button-group .nv-button),.nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-primary>.nv-button))[aria-expanded=true]:not([data-active-state=disabled]):not(:disabled)):where(.nv-button--color-neutral){--_bg:var(--background-color-interaction-inverse-pressed)}:is(:is(.nv-button:not(.nv-button-group .nv-button),.nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-primary>.nv-button)):active:not(:disabled),:is(.nv-button:not(.nv-button-group .nv-button),.nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-primary>.nv-button))[data-state=open]:not([data-active-state=disabled]):not(:disabled),:is(.nv-button:not(.nv-button-group .nv-button),.nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-primary>.nv-button))[aria-expanded=true]:not([data-active-state=disabled]):not(:disabled)):where(.nv-button--color-brand){--_bg:var(--background-color-interaction-primary-selected)}:is(:is(.nv-button:not(.nv-button-group .nv-button),.nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-primary>.nv-button)):active:not(:disabled),:is(.nv-button:not(.nv-button-group .nv-button),.nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-primary>.nv-button))[data-state=open]:not([data-active-state=disabled]):not(:disabled),:is(.nv-button:not(.nv-button-group .nv-button),.nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-primary>.nv-button))[aria-expanded=true]:not([data-active-state=disabled]):not(:disabled)):where(.nv-button--color-danger){--_bg:var(--background-color-feedback-danger-pressed);--_border:1px solid var(--border-color-feedback-danger-strong)}:is(.nv-button:not(.nv-button-group .nv-button),.nv-button:where(.nv-button--kind-primary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-primary>.nv-button)):disabled{--_bg:var(--background-color-interaction-disabled);--_color:var(--text-color-disabled);--_border:1px solid transparent}:is(.nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-secondary>.nv-button)),:is(.nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-secondary>.nv-button)):where(.nv-button--color-neutral){--_bg:transparent;--_border:1px solid var(--border-color-interaction-strong);--_color:var(--text-color-primary)}:is(.nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-secondary>.nv-button)):where(.nv-button--color-brand){--_bg:transparent;--_border:1px solid var(--border-color-brand);--_color:var(--text-color-primary);--_icon-color:var(--text-color-brand)}:is(.nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-secondary>.nv-button)):where(.nv-button--color-danger){--_bg:transparent;--_border:1px solid var(--border-color-feedback-danger);--_color:var(--text-color-feedback-danger)}:is(.nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-secondary>.nv-button)):hover:not(:disabled),:is(.nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-secondary>.nv-button)):hover:not(:disabled):where(.nv-button--color-neutral),:is(.nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-secondary>.nv-button)):hover:not(:disabled):where(.nv-button--color-brand){--_bg:var(--background-color-interaction-hover)}:is(.nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-secondary>.nv-button)):hover:not(:disabled):where(.nv-button--color-danger){--_bg:var(--background-color-feedback-danger-subtle-hover);--_color:var(--text-color-feedback-danger-subtle)}:is(:is(.nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-secondary>.nv-button)):active:not(:disabled),:is(.nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-secondary>.nv-button))[data-state=open]:not([data-active-state=disabled]):not(:disabled),:is(.nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-secondary>.nv-button))[aria-expanded=true]:not([data-active-state=disabled]):not(:disabled)),:is(:is(.nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-secondary>.nv-button)):active:not(:disabled),:is(.nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-secondary>.nv-button))[data-state=open]:not([data-active-state=disabled]):not(:disabled),:is(.nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-secondary>.nv-button))[aria-expanded=true]:not([data-active-state=disabled]):not(:disabled)):where(.nv-button--color-neutral),:is(:is(.nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-secondary>.nv-button)):active:not(:disabled),:is(.nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-secondary>.nv-button))[data-state=open]:not([data-active-state=disabled]):not(:disabled),:is(.nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-secondary>.nv-button))[aria-expanded=true]:not([data-active-state=disabled]):not(:disabled)):where(.nv-button--color-brand){--_bg:var(--background-color-interaction-pressed)}:is(:is(.nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-secondary>.nv-button)):active:not(:disabled),:is(.nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-secondary>.nv-button))[data-state=open]:not([data-active-state=disabled]):not(:disabled),:is(.nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-secondary>.nv-button))[aria-expanded=true]:not([data-active-state=disabled]):not(:disabled)):where(.nv-button--color-danger){--_bg:var(--background-color-feedback-danger-subtle-pressed);--_color:var(--text-color-feedback-danger-strong);--_border:1px solid var(--border-color-feedback-danger)}:is(.nv-button:where(.nv-button--kind-secondary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-secondary>.nv-button)):disabled{--_bg:transparent;--_border:1px solid var(--border-color-interaction-disabled);--_color:var(--text-color-disabled);--_icon-color:var(--text-color-disabled)}.nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-tertiary>.nv-button){--_bg:transparent;--_border:1px solid transparent;--_color:var(--text-color-primary)}:is(.nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-tertiary>.nv-button)):where(.nv-button--color-brand){--_icon-color:var(--text-color-brand)}:is(.nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-tertiary>.nv-button)):where(.nv-button--color-danger){--_color:var(--text-color-feedback-danger)}:is(.nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-tertiary>.nv-button)):hover:not(:disabled){--_bg:var(--background-color-interaction-hover)}:is(.nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-tertiary>.nv-button)):hover:not(:disabled):where(.nv-button--color-danger){--_bg:var(--background-color-feedback-danger-subtle-hover);--_color:var(--text-color-feedback-danger-subtle);--_border:1px solid var(--background-color-feedback-danger-subtle-hover)}:is(:is(.nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-tertiary>.nv-button)):active:not(:disabled),:is(.nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-tertiary>.nv-button))[data-state=open]:not([data-active-state=disabled]):not(:disabled),:is(.nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-tertiary>.nv-button))[aria-expanded=true]:not([data-active-state=disabled]):not(:disabled)),:is(:is(.nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-tertiary>.nv-button)):active:not(:disabled),:is(.nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-tertiary>.nv-button))[data-state=open]:not([data-active-state=disabled]):not(:disabled),:is(.nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-tertiary>.nv-button))[aria-expanded=true]:not([data-active-state=disabled]):not(:disabled)):where(.nv-button--color-neutral),:is(:is(.nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-tertiary>.nv-button)):active:not(:disabled),:is(.nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-tertiary>.nv-button))[data-state=open]:not([data-active-state=disabled]):not(:disabled),:is(.nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-tertiary>.nv-button))[aria-expanded=true]:not([data-active-state=disabled]):not(:disabled)):where(.nv-button--color-brand){--_bg:var(--background-color-interaction-pressed)}:is(:is(.nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-tertiary>.nv-button)):active:not(:disabled),:is(.nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-tertiary>.nv-button))[data-state=open]:not([data-active-state=disabled]):not(:disabled),:is(.nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-tertiary>.nv-button))[aria-expanded=true]:not([data-active-state=disabled]):not(:disabled)):where(.nv-button--color-danger){--_bg:var(--background-color-feedback-danger-subtle-pressed);--_color:var(--text-color-feedback-danger-strong);--_border:1px solid transparent}:is(.nv-button:where(.nv-button--kind-tertiary):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--kind-tertiary>.nv-button)):disabled{--_bg:transparent;--_color:var(--text-color-disabled);--_icon-color:var(--text-color-disabled)}.nv-button,.nv-button:where(.nv-button--size-medium):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--size-medium>.nv-button){--_padding:calc(var(--spacing)*3);--_font-size:var(--text-14);--_icon-size:var(--text-16);--_icon-margin:-1px;line-height:var(--nv-button-line-height,var(--_line-height,calc(16/14)));min-height:calc(var(--spacing)*10);min-width:calc(var(--spacing)*10)}.nv-button:where(.nv-button--size-tiny):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--size-tiny>.nv-button){--_padding:var(--spacing);--_font-size:var(--text-12);--_icon-size:var(--text-12);--_line-height:1;min-height:calc(var(--spacing)*5);min-width:calc(var(--spacing)*5)}.nv-button:where(.nv-button--size-small):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--size-small>.nv-button){--_padding:calc(var(--spacing)*2);--_font-size:var(--text-12);--_icon-size:var(--text-12);--_line-height:1;min-height:calc(var(--spacing)*7);min-width:calc(var(--spacing)*7)}.nv-button:where(.nv-button--size-large):not(.nv-button-group .nv-button),.nv-button:where(.nv-button-group--size-large>.nv-button){--_padding:calc(var(--spacing)*4);--_font-size:var(--text-16);--_icon-size:var(--text-16);--_line-height:1;min-height:calc(var(--spacing)*12);min-width:calc(var(--spacing)*12)}.nv-card-root{border:1px solid;border-color:var(--border-color-base);text-align:left;height:100%;min-height:fit-content;font-family:var(--font-sans);color:var(--text-color-primary);border-radius:var(--radius-density-xl);background-color:var(--background-color-surface-raised);box-shadow:var(--shadow-md);background-clip:content-box;flex-direction:column;display:flex;position:relative;overflow:hidden}.nv-card-root.nv-card-root--layout-horizontal{flex-direction:row}.nv-card-root .nv-card-content{padding:var(--spacing-density-2xl)}.nv-card-root .nv-card-media{aspect-ratio:400/234}.nv-card-root.nv-card-root--kind-float .nv-card-media{border-radius:var(--radius-xl);background-color:var(--background-color-surface-raised);box-shadow:var(--shadow-md);background-clip:content-box;border:1px solid #0000;overflow:hidden}@media (prefers-reduced-motion:no-preference){.nv-card-root{transition-property:background-color,border-color,box-shadow;transition-duration:.2s;transition-timing-function:var(--ease-out)}}.nv-card-root.nv-card-root--interactive:not(.nv-card-root--selected):hover,.nv-card-root.nv-card-root--interactive:not(.nv-card-root--selected):focus-visible{cursor:pointer}:is(.nv-card-root.nv-card-root--interactive:not(.nv-card-root--selected):hover,.nv-card-root.nv-card-root--interactive:not(.nv-card-root--selected):focus-visible):not(.nv-card-root--kind-float){background-color:var(--background-color-surface-overlay)}:is(.nv-card-root.nv-card-root--interactive:not(.nv-card-root--selected):hover,.nv-card-root.nv-card-root--interactive:not(.nv-card-root--selected):focus-visible):not(.nv-card-root--kind-float),:is(.nv-card-root.nv-card-root--interactive:not(.nv-card-root--selected):hover,.nv-card-root.nv-card-root--interactive:not(.nv-card-root--selected):focus-visible).nv-card-root--kind-float .nv-card-media{border:1px solid;border-color:var(--border-color-interaction-hover);box-shadow:var(--shadow-lg)}.nv-card-root.nv-card-root--selected:not(.nv-card-root--kind-float),.nv-card-root.nv-card-root--selected.nv-card-root--kind-float .nv-card-media{border-color:var(--border-color-interaction-selected);box-shadow:var(--shadow-lg)}.nv-card-root.nv-card-root--kind-gradient .nv-card-content{padding:var(--spacing-density-2xl)}.nv-card-root.nv-card-root--kind-gradient .nv-card-media{-webkit-mask-image:linear-gradient(#000 65%,#0000 98%);mask-image:linear-gradient(#000 65%,#0000 98%)}.nv-card-root.nv-card-root--kind-gradient.nv-card-root--layout-horizontal .nv-card-media{aspect-ratio:400/186;-webkit-mask-image:linear-gradient(90deg,#000 58%,#0000 87%);mask-image:linear-gradient(90deg,#000 58%,#0000 87%)}.nv-card-root.nv-card-root--kind-float{gap:var(--spacing-density-xl);border-radius:var(--radius-none);box-shadow:none;background-color:#0000;border:0;overflow:visible}.nv-card-root.nv-card-root--kind-float .nv-card-content{padding:calc(var(--spacing)*0)}.nv-card-root.nv-card-root--kind-float.nv-card-root--layout-horizontal .nv-card-media{aspect-ratio:2}.nv-card-root.nv-card-root--kind-float .nv-card-media{aspect-ratio:400/234}.nv-card-root .nv-card-media{flex:1;transition:inherit;position:relative;overflow:hidden}.nv-card-root .nv-card-media>img,.nv-card-root .nv-card-media>video{object-fit:cover;width:100%;height:100%;position:absolute}.nv-card-root .nv-card-media-header{inset:calc(var(--spacing)*4);position:absolute}.nv-card-root .nv-card-content-header,.nv-card-root .nv-card-media-header{gap:calc(var(--spacing)*2);flex-wrap:wrap;display:flex}.nv-card-root .nv-card-content{align-items:flex-start;gap:var(--spacing-density-xl);flex:1;height:fit-content;display:grid}.nv-checkbox-root{align-items:flex-start;gap:calc(var(--spacing)*2);width:fit-content;display:flex}.nv-checkbox-root .nv-label,.nv-checkbox-root label{line-height:1.14286}.nv-checkbox-root.nv-checkbox-root--label-left{flex-direction:row-reverse}.nv-checkbox-root:has(:disabled){cursor:not-allowed}.nv-checkbox-input{border:2px solid var(--border-color-interaction-base);width:calc(var(--spacing)*4);height:calc(var(--spacing)*4);cursor:pointer;appearance:none;border-radius:var(--radius-md);background-color:var(--background-color-interaction-base);flex-shrink:0;position:relative}.nv-checkbox-input:before{content:"";inset:calc(var(--spacing)*0);color:var(--text-color-accent-black);opacity:0;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,70% 80%,70% 100%);background-color:currentColor;width:70%;height:70%;margin:auto;display:block;position:absolute;rotate:45deg}.nv-checkbox-input:hover{border-color:var(--border-color-interaction-hover);background-color:var(--background-color-interaction-hover)}.nv-checkbox-input:active{border-color:var(--border-color-interaction-pressed);background-color:var(--background-color-interaction-selected)}.nv-checkbox-input:is(:disabled,[aria-disabled=true]){border-color:var(--border-color-interaction-disabled);pointer-events:none;cursor:not-allowed;background-color:var(--background-color-interaction-disabled)}.nv-checkbox-input:is(:checked,[data-state=checked]){background-color:var(--background-color-interaction-primary-base);border:1px solid #0000}.nv-checkbox-input:is(:checked,[data-state=checked]):before{opacity:1;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 0%,70% 0%,70% 100%)}.nv-checkbox-input:is(:checked,[data-state=checked]):hover{background-color:var(--background-color-interaction-primary-hover)}.nv-checkbox-input:is(:checked,[data-state=checked]):active{background-color:var(--background-color-interaction-primary-selected)}.nv-checkbox-input:is(:checked,[data-state=checked]):disabled,.nv-checkbox-input:is(:checked,[data-state=checked])[aria-disabled=true]{background-color:var(--background-color-interaction-disabled-checked)}:is(.nv-checkbox-input:is(:checked,[data-state=checked]):disabled,.nv-checkbox-input:is(:checked,[data-state=checked])[aria-disabled=true]):before{color:var(--text-color-inverse)}.nv-checkbox-input:indeterminate{background-color:var(--background-color-interaction-primary-base);border:1px solid #0000}.nv-checkbox-input:indeterminate:before{opacity:1;clip-path:polygon(10% 60%,10% 40%,90% 40%,90% 60%);rotate:none}.nv-checkbox-input:indeterminate:hover{background-color:var(--background-color-interaction-primary-hover)}.nv-checkbox-input:indeterminate:active{background-color:var(--background-color-interaction-primary-selected)}.nv-checkbox-input:indeterminate:disabled,.nv-checkbox-input:indeterminate[aria-disabled=true]{background-color:var(--background-color-interaction-disabled-checked)}:is(.nv-checkbox-input:indeterminate:disabled,.nv-checkbox-input:indeterminate[aria-disabled=true]):before{color:var(--text-color-inverse)}@media (prefers-reduced-motion:no-preference){.nv-checkbox-input{transition-duration:.15s;transition-timing-function:var(--ease-out);transition-property:background-color,border-width,border-color}.nv-checkbox-input:before{transition:clip-path var(--ease-out),opacity var(--ease-out),transform var(--ease-out)}}@media (forced-colors:active){.nv-checkbox-input:is(:checked,:indeterminate):before{clip-path:none;background-color:#0000;justify-content:center;align-items:center;font-size:.75rem;line-height:1;display:flex;rotate:none}.nv-checkbox-input:is(:checked,[data-state=checked]):before{content:"\2713"}.nv-checkbox-input:indeterminate:before{content:"\2013"}}@media print{.nv-checkbox-input:is(:checked,[data-state=checked]):before{content:"\2713";clip-path:none;background-color:#0000;rotate:none}.nv-checkbox-input:indeterminate:before{content:"\2013";clip-path:none;background-color:#0000;rotate:none}}.nv-checkbox-input--error.nv-checkbox-input:not(:disabled):not([aria-disabled=true]){border-color:var(--border-color-feedback-danger)}.nv-checkbox-input--error.nv-checkbox-input:not(:disabled):not([aria-disabled=true]):hover{background-color:var(--background-color-feedback-danger-subtle-hover)}.nv-checkbox-input--error.nv-checkbox-input:not(:disabled):not([aria-disabled=true]):active{background-color:var(--background-color-feedback-danger-subtle-pressed)}.nv-checkbox-input--error.nv-checkbox-input:not(:disabled):not([aria-disabled=true]):is(:checked,[data-state=checked]){background-color:var(--background-color-feedback-danger-strong);border-color:#0000}.nv-checkbox-input--error.nv-checkbox-input:not(:disabled):not([aria-disabled=true]):is(:checked,[data-state=checked]):hover{background-color:var(--background-color-feedback-danger-hover)}.nv-checkbox-input--error.nv-checkbox-input:not(:disabled):not([aria-disabled=true]):is(:checked,[data-state=checked]):active{background-color:var(--background-color-feedback-danger-pressed)}.nv-checkbox-input--error.nv-checkbox-input:not(:disabled):not([aria-disabled=true]):indeterminate{background-color:var(--background-color-feedback-danger-strong);border-color:#0000}.nv-checkbox-input--error.nv-checkbox-input:not(:disabled):not([aria-disabled=true]):indeterminate:hover{background-color:var(--background-color-feedback-danger-hover)}.nv-checkbox-input--error.nv-checkbox-input:not(:disabled):not([aria-disabled=true]):indeterminate:active{background-color:var(--background-color-feedback-danger-pressed)}.nv-code-snippet-root{--nv-code-snippet-custom-background:var(--background-color-surface-base);gap:calc(var(--spacing)*1);border-radius:var(--radius-md);min-height:fit-content;font-family:var(--font-mono);font-size:var(--text-14);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular);flex-direction:column;display:flex;position:relative;overflow:hidden}.nv-code-snippet-root .nv-code-snippet-actions{justify-content:flex-end;align-items:center;display:flex}.nv-code-snippet-root.nv-code-snippet-root--collapsible:not(.nv-code-snippet-root--open):after{content:"";height:calc(var(--nv-code-snippet-rows,4)*1lh);pointer-events:none;background-color:var(--nv-code-snippet-custom-background);position:absolute;bottom:1px;left:1px;right:1px;-webkit-mask-image:linear-gradient(#0000 0% 0%,#0003 60%,#0009 80%,#000 100%);mask-image:linear-gradient(#0000 0% 0%,#0003 60%,#0009 80%,#000 100%)}.nv-code-snippet-root.nv-code-snippet-root--kind-inline{width:fit-content;padding:calc(var(--spacing)*0);align-items:center;display:inline-flex}.nv-code-snippet-root.nv-code-snippet-root--kind-inline .nv-code-snippet-code{padding:calc(var(--spacing)*0)}.nv-code-snippet-root .nv-code-snippet-code{border-radius:var(--radius-md);border:1px solid;border-color:var(--border-color-base);background-color:var(--nv-code-snippet-custom-background);width:100%;padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*2);opacity:1;transition:opacity var(--ease-out)ease-in-out;scrollbar-width:thin;scrollbar-color:var(--nv-scrollbar-color);align-content:center;align-self:stretch;overflow:auto}.nv-code-snippet-root .nv-code-snippet-code.nv-code-snippet-code--loading{opacity:0}.nv-code-snippet-root .nv-code-snippet-copy-button{justify-content:flex-end}.nv-code-snippet-root.nv-code-snippet-root--with-rows:not(.nv-code-snippet-root--open) .nv-code-snippet-code{max-height:calc(var(--nv-code-snippet-rows)*1lh);position:relative;overflow-y:auto}.nv-code-snippet-root.nv-code-snippet-root--collapsible{position:relative}.nv-code-snippet-root.nv-code-snippet-root--collapsible.nv-code-snippet-root--open .nv-code-snippet-code{max-height:none}.nv-code-snippet-root.nv-code-snippet-root--collapsible .nv-code-snippet-code.nv-code-snippet-code--collapsed{max-height:calc(var(--nv-code-snippet-rows,4)*1lh);position:relative;overflow-y:auto}.nv-collapsible-root>summary{list-style:none}.nv-collapsible-root>summary::-webkit-details-marker{display:none}.nv-collapsible-root>summary::marker{content:"";display:none}.nv-collapsible-root::details-content{height:0;transition:height .2s var(--ease-out),content-visibility .2s allow-discrete;display:block;overflow:clip}.nv-collapsible-root[open]::details-content{height:auto}@media (prefers-reduced-motion:reduce){.nv-collapsible-root::details-content{transition-duration:.01ms}}.nv-collapsible-trigger-wrapper{list-style:none}.nv-collapsible-trigger-wrapper::-webkit-details-marker{display:none}.nv-collapsible-trigger-wrapper::marker{content:"";display:none}.nv-collapsible-trigger[data-disabled],.nv-collapsible-trigger[aria-disabled=true]{pointer-events:none;cursor:not-allowed;opacity:.5}.nv-collapsible-trigger[data-state=open] .group-data-\[state\=open\]\:hidden{display:none}.nv-collapsible-trigger[data-state=closed] .group-data-\[state\=closed\]\:hidden{display:none}.nv-collapsible-content-inner{padding:var(--nv-collapsible-content-padding,0)}@media (scripting:enabled){.nv-combobox-native-fallback{display:none}}@media (scripting:none){[data-combobox-enhanced]{display:none!important}}.nv-combobox-content[popover]{color:inherit;background:0 0;border:0;margin:0;padding:0;display:none;position:fixed;inset:auto;overflow:visible}.nv-combobox-content[popover]:popover-open{display:block}.nv-combobox-content[popover].\:popover-open{display:block}.nv-combobox-content{--menu-translate-start:0 calc(var(--transition-offset)*-1);transform-origin:top}@supports (position-anchor:--a){.nv-combobox-content{--nv-combobox-offset:4px;width:anchor-size(width);position-try-fallbacks:flip-block,flip-inline,flip-block flip-inline;margin:0}.nv-combobox-content[data-side=bottom]{margin-top:var(--nv-combobox-offset);position-area:bottom}.nv-combobox-content[data-side=top]{margin-bottom:var(--nv-combobox-offset);position-area:top}.nv-combobox-content[data-side=left]{margin-right:var(--nv-combobox-offset);position-area:left}.nv-combobox-content[data-side=right]{margin-left:var(--nv-combobox-offset);position-area:right}}.nv-combobox-content[data-side=top]{--menu-translate-start:0 calc(var(--transition-offset));transform-origin:bottom}.nv-combobox-content[data-side=left]{--menu-translate-start:var(--transition-offset)0;transform-origin:100%}.nv-combobox-content[data-side=right]{--menu-translate-start:calc(var(--transition-offset)*-1)0;transform-origin:0}.nv-combobox-content .nv-menu-root{overscroll-behavior:contain;max-height:min(320px,100vh - 2rem)}.nv-combobox-content .nv-menu-item[data-active-item]{background-color:var(--background-color-interaction-hover)}@keyframes combobox-in{0%{translate:var(--menu-translate-start);opacity:0}to{opacity:1;translate:0}}@media (prefers-reduced-motion:no-preference){.nv-combobox-content:popover-open{animation:combobox-in .25s var(--ease-out)}}.nv-input-shell.nv-combobox-trigger--multiple{--nv-combobox-trigger-max-height:calc(var(--nv-input-height)*3.5);height:auto;min-height:var(--nv-input-height);padding-block:calc(var(--nv-input-padding)/2)}.nv-input-shell.nv-combobox-trigger--multiple input:disabled,.nv-input-shell.nv-combobox-trigger--multiple input[readonly]{flex:0 0 0;min-width:0}.nv-combobox-trigger-field{align-items:center;gap:inherit;min-width:0;max-height:var(--nv-combobox-trigger-max-height);scrollbar-width:thin;scrollbar-color:var(--nv-scrollbar-color);flex-wrap:wrap;flex-grow:1;display:flex;overflow:hidden auto}.nv-combobox-trigger-field input{flex:1 0 6rem;min-width:2.5rem}.nv-combobox-trigger-field .nv-combobox-selected-tag{max-width:100%}.nv-combobox-trigger-field .nv-combobox-selected-tag-label{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.nv-combobox-native-fallback{padding-inline:var(--nv-input-padding)}.nv-date-picker-content{margin-block:calc(var(--spacing)*1);overflow-y:auto;padding:calc(var(--spacing)*0)!important}.nv-date-picker-trigger{width:100%}.nv-date-picker-trigger.nv-date-picker-trigger--kind-range{width:100%}.nv-date-picker-trigger.nv-date-picker-trigger--kind-range.nv-date-picker-trigger.nv-date-picker-trigger--kind-range>*{height:var(--nv-input-height)!important}@media (scripting:enabled){.nv-date-picker-native-fallback{display:none}}@media (scripting:none){.nv-date-picker-trigger [data-date-picker-enhanced]{display:none!important}.nv-date-picker-native-fallback{appearance:auto;width:100%;height:100%;font:inherit;letter-spacing:inherit;word-spacing:inherit;background-color:#0000;border:0;outline:none}.nv-date-picker-content{display:none!important}}.nv-date-picker-calendar-dropdown-container{justify-content:space-between;align-items:center;width:100%;display:flex}.nv-date-picker-calendar-header-container{justify-content:space-between;align-items:center;display:inline-flex}.nv-date-picker-calendar-caption{height:calc(var(--spacing)*8);width:100%;padding-top:calc(var(--spacing)*4.5);font-family:var(--font-sans);font-size:var(--text-14);line-height:1.21429;font-weight:var(--font-weight-semibold);justify-content:center;align-items:center;display:flex;position:relative}.nv-date-picker-calendar-caption.nv-date-picker-calendar-caption--range{width:fit-content;margin-inline:auto}.nv-date-picker-calendar-weekday{height:var(--spacing-density-3xl);width:var(--spacing-density-3xl);vertical-align:bottom;font-family:var(--font-sans);font-size:var(--text-14);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular);color:var(--text-color-placeholder)}.nv-date-picker-calendar-prev-button{top:calc(var(--spacing)*4);left:calc(var(--spacing)*4);z-index:auto;position:absolute}.nv-date-picker-calendar-next-button{top:calc(var(--spacing)*4);right:calc(var(--spacing)*4);z-index:auto;position:absolute}.nv-date-picker-calendar-prev-button,.nv-date-picker-calendar-next-button>.nv-icon{--icon-font-size:var(--text-16);--icon-size:var(--text-16)}.nv-date-picker-calendar-table{border-collapse:collapse;gap:var(--spacing-density-xs);width:100%}.nv-date-picker-calendar-month{gap:var(--spacing-density-sm);padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*2);flex-direction:column;display:flex}.nv-date-picker-calendar-month:not(:first-child){padding:calc(var(--spacing)*0)}@media (width>=36rem){.nv-date-picker-calendar-month:not(:first-child){padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*2)}}.nv-date-picker-calendar-month:not(:first-child) .nv-date-picker-calendar-caption{display:none}@media (width>=36rem){.nv-date-picker-calendar-month:not(:first-child) .nv-date-picker-calendar-caption{display:flex}}.nv-date-picker-calendar-month:not(:first-child) table{display:none}@media (width>=36rem){.nv-date-picker-calendar-month:not(:first-child) table{display:table}}.nv-date-picker-calendar-months{flex-direction:column;display:flex}@media (width>=36rem){.nv-date-picker-calendar-months{gap:var(--spacing-density-sm);flex-direction:row}}.nv-date-picker-calendar-footer{padding-inline:calc(var(--spacing)*4);padding-bottom:calc(var(--spacing)*4)}.nv-date-picker-calendar-week:not(:first-child){border-top:1px solid #0000}.nv-date-picker-calendar-cell{height:var(--spacing-density-3xl);width:var(--spacing-density-3xl);border-radius:var(--radius-lg);padding:calc(var(--spacing)*0);text-align:center;position:relative;overflow:hidden}.nv-date-picker-calendar-cell:focus-within{position:relative}.nv-date-picker-calendar-cell[aria-selected]>.nv-date-picker-calendar-day--selected:not(.nv-date-picker-calendar-day--disabled):not(.nv-date-picker-calendar-day--outside){color:var(--text-color-primary)}.nv-date-picker-calendar-cell.nv-date-picker-calendar-cell--range.nv-date-picker-calendar-range-end{border-top-left-radius:var(--radius-none);border-bottom-left-radius:var(--radius-none)}.nv-date-picker-calendar-cell.nv-date-picker-calendar-cell--range.nv-date-picker-calendar-range-start{border-top-right-radius:var(--radius-none);border-bottom-right-radius:var(--radius-none)}.nv-date-picker-calendar-cell.nv-date-picker-calendar-cell--range.nv-date-picker-calendar-range-middle{border-radius:var(--radius-none)}.nv-date-picker-calendar-cell.nv-date-picker-calendar-cell--range.nv-date-picker-calendar-range-start.nv-date-picker-calendar-range-end{border-radius:var(--radius-lg)}.nv-date-picker-calendar-day{height:var(--spacing-density-3xl);width:var(--spacing-density-3xl);cursor:pointer;font-size:var(--text-14);color:var(--text-color-primary);justify-content:center;align-items:center;display:flex}.nv-date-picker-calendar-day.nv-date-picker-calendar-day--outside{pointer-events:none;color:var(--text-color-placeholder)}.nv-date-picker-calendar-day.nv-date-picker-calendar-day--selected:not(.nv-date-picker-calendar-day--disabled):not(.nv-date-picker-calendar-day--outside){background-color:var(--background-color-interaction-pressed);font-weight:var(--font-weight-bold)}.nv-date-picker-calendar-day.nv-date-picker-calendar-day--disabled{cursor:not-allowed;color:var(--text-color-placeholder)}.nv-date-picker-calendar-day:not(.nv-date-picker-calendar-day--outside):not(.nv-date-picker-calendar-day--disabled):not(.nv-date-picker-calendar-day--selected):hover{background-color:var(--background-color-interaction-hover)}.nv-date-picker-calendar-dropdown{visibility:hidden;height:calc(var(--spacing)*48);width:calc(var(--spacing)*46)}.nv-date-picker-calendar-dropdown[data-visible=true]{visibility:visible}.nv-date-picker-calendar-dropdown .nv-menu-checkbox-item .nv-checkbox-input{display:none}.nv-divider-root{flex-grow:1;flex-shrink:0;flex-basis:calc(var(--spacing)*0);align-items:stretch;width:100%;height:100%;display:flex}.nv-divider-root:has(.nv-divider-element--orientation-horizontal){align-items:center}.nv-divider-root:has(.nv-divider-element--orientation-vertical){justify-content:center}.nv-divider-element{margin:calc(var(--spacing)*0);border-style:solid;list-style-type:none}.nv-divider-element.nv-divider-element--orientation-horizontal{border-bottom:1px solid;border-bottom-color:var(--border-color-base);width:100%}.nv-divider-element.nv-divider-element--orientation-horizontal.nv-divider-element--width-medium{border-bottom-width:2px}.nv-divider-element.nv-divider-element--orientation-horizontal.nv-divider-element--width-large{border-bottom-width:4px}.nv-divider-element.nv-divider-element--orientation-vertical{border-left:1px solid;border-left-color:var(--border-color-base);min-height:1em}.nv-divider-element.nv-divider-element--orientation-vertical.nv-divider-element--width-medium{border-left-width:2px}.nv-divider-element.nv-divider-element--orientation-vertical.nv-divider-element--width-large{border-left-width:4px}.nv-dropdown-content[popover]{margin:0;display:none;position:fixed;inset:auto}.nv-dropdown-content[popover]:popover-open{flex-direction:column;display:flex}.nv-dropdown-content[popover].\:popover-open{flex-direction:column;display:flex}.nv-dropdown-trigger{align-items:center;gap:var(--spacing);display:inline-flex}@supports (position-anchor:--a){.nv-dropdown-content{--nv-dropdown-offset:4px;position-try-fallbacks:flip-block,flip-inline,flip-block flip-inline;margin:0}.nv-dropdown-content[data-side=bottom]{margin-top:var(--nv-dropdown-offset);position-area:bottom span-right}.nv-dropdown-content[data-side=bottom][data-align=center]{position-area:bottom}.nv-dropdown-content[data-side=bottom][data-align=end]{position-area:bottom span-left}.nv-dropdown-content[data-side=top]{margin-bottom:var(--nv-dropdown-offset);position-area:top span-right}.nv-dropdown-content[data-side=top][data-align=center]{position-area:top}.nv-dropdown-content[data-side=top][data-align=end]{position-area:top span-left}.nv-dropdown-content[data-side=left]{margin-right:var(--nv-dropdown-offset);position-area:left span-bottom}.nv-dropdown-content[data-side=left][data-align=center]{position-area:left}.nv-dropdown-content[data-side=left][data-align=end]{position-area:left span-top}.nv-dropdown-content[data-side=right]{margin-left:var(--nv-dropdown-offset);position-area:right span-bottom}.nv-dropdown-content[data-side=right][data-align=center]{position-area:right}.nv-dropdown-content[data-side=right][data-align=end]{position-area:right span-top}}.nv-dropdown-sub{width:100%;display:flex;position:relative}.nv-dropdown-sub-content[popover]{margin:0;display:none;position:fixed}.nv-dropdown-sub-content[popover]:popover-open{flex-direction:column;display:flex}.nv-dropdown-sub-content[popover].\:popover-open{flex-direction:column;display:flex}@supports (position-anchor:--a){.nv-dropdown-sub-content{position-area:right span-bottom;position-try-fallbacks:flip-inline,flip-block,flip-inline flip-block;margin:0}}@keyframes dropdown-in{0%{translate:var(--menu-translate-start);opacity:0}to{opacity:1;translate:0}}@keyframes dropdown-out{0%{opacity:1;translate:0}to{translate:var(--menu-translate-start);opacity:0}}.nv-dropdown-content{overscroll-behavior:contain;--menu-translate-start:0 calc(var(--transition-offset)*-1);transform-origin:top}@supports (position-anchor:--a){.nv-dropdown-content{max-height:calc(100.0% - var(--nv-dropdown-offset))}}.nv-dropdown-content[data-side=top]{--menu-translate-start:0 var(--transition-offset);transform-origin:bottom}.nv-dropdown-content[data-side=left]{--menu-translate-start:var(--transition-offset)0;transform-origin:100%}.nv-dropdown-content[data-side=right]{--menu-translate-start:calc(var(--transition-offset)*-1)0;transform-origin:0}@media (prefers-reduced-motion:no-preference){.nv-dropdown-content:popover-open{animation:dropdown-in .25s var(--ease-out)}}@media (prefers-reduced-motion:no-preference){.nv-dropdown-content.\:popover-open{animation:dropdown-in .25s var(--ease-out)}}.nv-dropdown-sub-content{--transition-offset:8px;--submenu-translate-start:calc(var(--transition-offset)*-1)0}.nv-dropdown-sub-content:popover-open{transform-origin:0}@media (prefers-reduced-motion:no-preference){.nv-dropdown-sub-content:popover-open{animation:submenu-in .25s var(--ease-out)}}.nv-dropdown-sub-content.\:popover-open{transform-origin:0}@media (prefers-reduced-motion:no-preference){.nv-dropdown-sub-content.\:popover-open{animation:submenu-in .25s var(--ease-out)}}@keyframes submenu-in{0%{translate:var(--submenu-translate-start);opacity:0}to{opacity:1;translate:0}}@keyframes submenu-out{0%{opacity:1;translate:0}to{translate:var(--submenu-translate-start);opacity:0}}.nv-flex{flex-direction:row;display:flex}.nv-flex--direction-row{flex-direction:row}.nv-flex--direction-row-reverse{flex-direction:row-reverse}.nv-flex--direction-col-reverse{flex-direction:column-reverse}.nv-flex--direction-col{flex-direction:column}.nv-flex--align-start{align-items:flex-start}.nv-flex--align-end{align-items:flex-end}.nv-flex--align-center{align-items:center}.nv-flex--align-baseline{align-items:baseline}.nv-flex--align-stretch{align-items:stretch}.nv-flex--justify-normal{justify-content:normal}.nv-flex--justify-start{justify-content:flex-start}.nv-flex--justify-end{justify-content:flex-end}.nv-flex--justify-center{justify-content:center}.nv-flex--justify-between{justify-content:space-between}.nv-flex--justify-around{justify-content:space-around}.nv-flex--justify-evenly{justify-content:space-evenly}.nv-flex--justify-stretch{justify-content:stretch}.nv-flex--justify-stretch>*{flex:1}.nv-flex--wrap-wrap{flex-wrap:wrap}.nv-flex--wrap-wrap-reverse{flex-wrap:wrap-reverse}.nv-flex--wrap-nowrap{flex-wrap:nowrap}.nv-form-field-root{gap:var(--spacing-density-xs);width:100%;font-family:var(--font-sans);line-height:var(--leading-lh-125);font-size:var(--text-12);--nv-form-field-label-group-width:160px;--nv-form-field-helper-margin-left:0px;flex-direction:column;display:flex;container-type:inline-size}@container (width>=320px){.nv-form-field-root.nv-form-field-root--label-position-left .nv-form-field-content-group{align-items:center;gap:calc(var(--spacing)*3);flex-direction:row}.nv-form-field-root.nv-form-field-root--label-position-left .nv-form-field-content-group .nv-form-field-label-group{width:var(--nv-form-field-label-group-width);justify-content:flex-start}.nv-form-field-root.nv-form-field-root--label-position-left .nv-form-field-helper{--nv-form-field-helper-margin-left:calc(var(--nv-form-field-label-group-width) + 12px)}}:is(.nv-form-field-root.nv-form-field-root--required,.nv-form-field-root:has(:required):not([data-required=false])) .nv-form-field-label-group .nv-label{padding-right:calc(var(--spacing)*3);position:relative}:is(.nv-form-field-root.nv-form-field-root--required,.nv-form-field-root:has(:required):not([data-required=false])) .nv-form-field-label-group .nv-label:after{font-weight:var(--font-weight-regular);right:calc(var(--spacing)*0);color:var(--text-color-feedback-danger-subtle);content:"*";position:absolute}.nv-form-field-label-group{align-items:center;gap:calc(var(--spacing)*2);padding-block:calc(var(--spacing)*.5);flex-direction:row;flex-shrink:0;display:flex}.nv-form-field-label-group .nv-label{font-weight:var(--font-weight-bold);text-overflow:ellipsis;white-space:nowrap;font-size:var(--text-12);overflow:hidden}.nv-form-field-label-group>svg,.nv-form-field-label-group>.nv-icon{height:calc(var(--spacing)*3);width:calc(var(--spacing)*3);--icon-font-size:var(--text-12);flex-shrink:0}.nv-form-field-label-group>.nv-popover-trigger{cursor:pointer;padding:calc(var(--spacing)*0);color:var(--text-color-secondary);background-color:#0000;border:0;flex-shrink:0;align-items:center;display:flex}.nv-form-field-label-group>.nv-popover-trigger>.nv-icon{height:calc(var(--spacing)*3);width:calc(var(--spacing)*3);--icon-font-size:var(--text-12)}.nv-form-field-content-group{gap:inherit;flex-direction:column;display:flex}.nv-form-field-content-group:has(.nv-input-shell)>.nv-form-field-label-group{justify-content:space-between}.nv-form-field-helper{padding-block:calc(var(--spacing)*.5);margin-left:var(--nv-form-field-helper-margin-left);color:var(--text-color-secondary);font-weight:var(--font-weight-regular)}.nv-form-field-helper.nv-form-field-helper--kind-error{color:var(--text-color-feedback-danger-subtle)}.nv-form-field-helper.nv-form-field-helper--kind-success{color:var(--text-color-feedback-success)}.nv-form-field-sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.nv-grid-item{grid-column-start:var(--nv-grid-item-col-start,auto);grid-column-end:var(--nv-grid-item-col-end,auto);grid-row-start:var(--nv-grid-item-row-start,auto);grid-row-end:var(--nv-grid-item-row-end,auto)}@media (width>=320px){.nv-grid-item{grid-column-start:var(--nv-grid-item-col-start-xs,var(--nv-grid-item-col-start,auto));grid-column-end:var(--nv-grid-item-col-end-xs,var(--nv-grid-item-col-end,auto));grid-row-start:var(--nv-grid-item-row-start-xs,var(--nv-grid-item-row-start,auto));grid-row-end:var(--nv-grid-item-row-end-xs,var(--nv-grid-item-row-end,auto))}}@media (width>=576px){.nv-grid-item{grid-column-start:var(--nv-grid-item-col-start-sm,var(--nv-grid-item-col-start-xs,var(--nv-grid-item-col-start,auto)));grid-column-end:var(--nv-grid-item-col-end-sm,var(--nv-grid-item-col-end-xs,var(--nv-grid-item-col-end,auto)));grid-row-start:var(--nv-grid-item-row-start-sm,var(--nv-grid-item-row-start-xs,var(--nv-grid-item-row-start,auto)));grid-row-end:var(--nv-grid-item-row-end-sm,var(--nv-grid-item-row-end-xs,var(--nv-grid-item-row-end,auto)))}}@media (width>=768px){.nv-grid-item{grid-column-start:var(--nv-grid-item-col-start-md,var(--nv-grid-item-col-start-sm,var(--nv-grid-item-col-start-xs,var(--nv-grid-item-col-start,auto))));grid-column-end:var(--nv-grid-item-col-end-md,var(--nv-grid-item-col-end-sm,var(--nv-grid-item-col-end-xs,var(--nv-grid-item-col-end,auto))));grid-row-start:var(--nv-grid-item-row-start-md,var(--nv-grid-item-row-start-sm,var(--nv-grid-item-row-start-xs,var(--nv-grid-item-row-start,auto))));grid-row-end:var(--nv-grid-item-row-end-md,var(--nv-grid-item-row-end-sm,var(--nv-grid-item-row-end-xs,var(--nv-grid-item-row-end,auto))))}}@media (width>=992px){.nv-grid-item{grid-column-start:var(--nv-grid-item-col-start-lg,var(--nv-grid-item-col-start-md,var(--nv-grid-item-col-start-sm,var(--nv-grid-item-col-start-xs,var(--nv-grid-item-col-start,auto)))));grid-column-end:var(--nv-grid-item-col-end-lg,var(--nv-grid-item-col-end-md,var(--nv-grid-item-col-end-sm,var(--nv-grid-item-col-end-xs,var(--nv-grid-item-col-end,auto)))));grid-row-start:var(--nv-grid-item-row-start-lg,var(--nv-grid-item-row-start-md,var(--nv-grid-item-row-start-sm,var(--nv-grid-item-row-start-xs,var(--nv-grid-item-row-start,auto)))));grid-row-end:var(--nv-grid-item-row-end-lg,var(--nv-grid-item-row-end-md,var(--nv-grid-item-row-end-sm,var(--nv-grid-item-row-end-xs,var(--nv-grid-item-row-end,auto)))))}}@media (width>=1200px){.nv-grid-item{grid-column-start:var(--nv-grid-item-col-start-xl,var(--nv-grid-item-col-start-lg,var(--nv-grid-item-col-start-md,var(--nv-grid-item-col-start-sm,var(--nv-grid-item-col-start-xs,var(--nv-grid-item-col-start,auto))))));grid-column-end:var(--nv-grid-item-col-end-xl,var(--nv-grid-item-col-end-lg,var(--nv-grid-item-col-end-md,var(--nv-grid-item-col-end-sm,var(--nv-grid-item-col-end-xs,var(--nv-grid-item-col-end,auto))))));grid-row-start:var(--nv-grid-item-row-start-xl,var(--nv-grid-item-row-start-lg,var(--nv-grid-item-row-start-md,var(--nv-grid-item-row-start-sm,var(--nv-grid-item-row-start-xs,var(--nv-grid-item-row-start,auto))))));grid-row-end:var(--nv-grid-item-row-end-xl,var(--nv-grid-item-row-end-lg,var(--nv-grid-item-row-end-md,var(--nv-grid-item-row-end-sm,var(--nv-grid-item-row-end-xs,var(--nv-grid-item-row-end,auto))))))}}@media (width>=1600px){.nv-grid-item{grid-column-start:var(--nv-grid-item-col-start-xxl,var(--nv-grid-item-col-start-xl,var(--nv-grid-item-col-start-lg,var(--nv-grid-item-col-start-md,var(--nv-grid-item-col-start-sm,var(--nv-grid-item-col-start-xs,var(--nv-grid-item-col-start,auto)))))));grid-column-end:var(--nv-grid-item-col-end-xxl,var(--nv-grid-item-col-end-xl,var(--nv-grid-item-col-end-lg,var(--nv-grid-item-col-end-md,var(--nv-grid-item-col-end-sm,var(--nv-grid-item-col-end-xs,var(--nv-grid-item-col-end,auto)))))));grid-row-start:var(--nv-grid-item-row-start-xxl,var(--nv-grid-item-row-start-xl,var(--nv-grid-item-row-start-lg,var(--nv-grid-item-row-start-md,var(--nv-grid-item-row-start-sm,var(--nv-grid-item-row-start-xs,var(--nv-grid-item-row-start,auto)))))));grid-row-end:var(--nv-grid-item-row-end-xxl,var(--nv-grid-item-row-end-xl,var(--nv-grid-item-row-end-lg,var(--nv-grid-item-row-end-md,var(--nv-grid-item-row-end-sm,var(--nv-grid-item-row-end-xs,var(--nv-grid-item-row-end,auto)))))))}}.nv-grid{width:100%;font-family:var(--font-sans);grid-template-columns:var(--nv-grid-template-columns,none);grid-template-rows:var(--nv-grid-template-rows,none);display:grid}.nv-grid--flow-row{grid-auto-flow:row}.nv-grid--flow-col{grid-auto-flow:column}.nv-grid--flow-dense{grid-auto-flow:dense}.nv-grid--flow-row-dense{grid-auto-flow:dense}.nv-grid--flow-col-dense{grid-auto-flow:column dense}@media (width>=320px){.nv-grid{grid-template-columns:var(--nv-grid-template-columns-xs,var(--nv-grid-template-columns,none));grid-template-rows:var(--nv-grid-template-rows-xs,var(--nv-grid-template-rows,none))}}@media (width>=576px){.nv-grid{grid-template-columns:var(--nv-grid-template-columns-sm,var(--nv-grid-template-columns-xs,var(--nv-grid-template-columns,none)));grid-template-rows:var(--nv-grid-template-rows-sm,var(--nv-grid-template-rows-xs,var(--nv-grid-template-rows,none)))}}@media (width>=768px){.nv-grid{grid-template-columns:var(--nv-grid-template-columns-md,var(--nv-grid-template-columns-sm,var(--nv-grid-template-columns-xs,var(--nv-grid-template-columns,none))));grid-template-rows:var(--nv-grid-template-rows-md,var(--nv-grid-template-rows-sm,var(--nv-grid-template-rows-xs,var(--nv-grid-template-rows,none))))}}@media (width>=992px){.nv-grid{grid-template-columns:var(--nv-grid-template-columns-lg,var(--nv-grid-template-columns-md,var(--nv-grid-template-columns-sm,var(--nv-grid-template-columns-xs,var(--nv-grid-template-columns,none)))));grid-template-rows:var(--nv-grid-template-rows-lg,var(--nv-grid-template-rows-md,var(--nv-grid-template-rows-sm,var(--nv-grid-template-rows-xs,var(--nv-grid-template-rows,none)))))}}@media (width>=1200px){.nv-grid{grid-template-columns:var(--nv-grid-template-columns-xl,var(--nv-grid-template-columns-lg,var(--nv-grid-template-columns-md,var(--nv-grid-template-columns-sm,var(--nv-grid-template-columns-xs,var(--nv-grid-template-columns,none))))));grid-template-rows:var(--nv-grid-template-rows-xl,var(--nv-grid-template-rows-lg,var(--nv-grid-template-rows-md,var(--nv-grid-template-rows-sm,var(--nv-grid-template-rows-xs,var(--nv-grid-template-rows,none))))))}}@media (width>=1600px){.nv-grid{grid-template-columns:var(--nv-grid-template-columns-xxl,var(--nv-grid-template-columns-xl,var(--nv-grid-template-columns-lg,var(--nv-grid-template-columns-md,var(--nv-grid-template-columns-sm,var(--nv-grid-template-columns-xs,var(--nv-grid-template-columns,none)))))));grid-template-rows:var(--nv-grid-template-rows-xxl,var(--nv-grid-template-rows-xl,var(--nv-grid-template-rows-lg,var(--nv-grid-template-rows-md,var(--nv-grid-template-rows-sm,var(--nv-grid-template-rows-xs,var(--nv-grid-template-rows,none)))))))}}.nv-group{align-items:stretch;width:fit-content;height:fit-content;display:inline-flex}.nv-group,.nv-group.nv-group--kind-flush{--group-item-overlap:1px}.nv-group.nv-group--kind-gap{--group-item-overlap:0;gap:1px}.nv-group.nv-group--kind-border>*:where(:not(:first-child)){border-block-width:0;border-right-width:0;border-left-width:var(--border-width-1);border-color:var(--border-color-base)}.nv-group.nv-group>*{max-height:none;height:auto!important}.nv-group.nv-group>*:where(:not(:first-child)){margin-block-start:0;margin-inline-start:calc(var(--group-item-overlap)*-1)}.nv-group.nv-group>*:focus-within{z-index:1}.nv-group.nv-group:has(>:nth-child(2))>*:where(:first-child){border-top-right-radius:var(--radius-none)!important;border-bottom-right-radius:var(--radius-none)!important}.nv-group.nv-group:has(>:nth-child(2))>*:where(:last-child){border-top-left-radius:var(--radius-none)!important;border-bottom-left-radius:var(--radius-none)!important}.nv-group.nv-group:has(>:nth-child(2))>*:where(:not(:first-child):not(:last-child)){border-radius:var(--radius-none)!important}.nv-hero-root{--padding:calc(var(--spacing)*8)calc(var(--spacing)*6);--max-width:940px;--text-color:var(--text-color-primary);isolation:isolate;width:100%;height:fit-content;font-family:var(--font-sans);padding:var(--padding);color:var(--text-color);font-style:normal;font-weight:var(--font-weight-regular);display:flex;position:relative;overflow:hidden;container-type:inline-size}.nv-hero-content{width:clamp(100.0%,var(--max-width),100.0%);max-width:var(--max-width);gap:calc(var(--spacing)*6);flex-direction:column;margin-inline:auto;display:flex;position:relative}@container (width>=48rem){.nv-hero-content{padding-block:calc(var(--spacing)*16)}}@container (width>=64rem){.nv-hero-content{padding-block:calc(var(--spacing)*31)}}.nv-hero-media{inset:calc(var(--spacing)*0);object-fit:cover;width:100%;height:100%;position:absolute}.nv-hero-subheading{-webkit-line-clamp:1;font-family:var(--font-sans);font-size:var(--text-16);line-height:var(--leading-lh-125);font-weight:var(--font-weight-bold);text-wrap:pretty;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}@container (width>=48rem){.nv-hero-subheading{font-family:var(--font-sans);font-size:var(--text-24);line-height:var(--leading-lh-125);font-weight:var(--font-weight-bold)}}@container (width>=64rem){.nv-hero-subheading{font-family:var(--font-sans);font-size:var(--text-32);line-height:var(--leading-lh-125);font-weight:var(--font-weight-bold)}}.nv-hero-heading{-webkit-line-clamp:2;font-family:var(--font-sans);font-size:var(--text-44);line-height:var(--leading-lh-125);font-weight:var(--font-weight-bold);text-wrap:pretty;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}@container (width>=48rem){.nv-hero-heading{font-family:var(--font-sans);font-size:var(--text-44);line-height:var(--leading-lh-125);font-weight:var(--font-weight-bold)}}@container (width>=64rem){.nv-hero-heading{font-family:var(--font-sans);font-size:var(--text-50);line-height:1.24;font-weight:var(--font-weight-bold)}}.nv-hero-body{-webkit-line-clamp:3;font-size:var(--text-24);line-height:var(--leading-lh-175);text-wrap:pretty;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.nv-hero-footer{gap:calc(var(--spacing)*2);padding-top:calc(var(--spacing)*2);display:inline-flex}.nv-horizontal-nav-list{isolation:isolate;display:flex}.nv-horizontal-nav-item{--nav-padding-x:1rem;--nav-transition-duration:.2s;--nav-edge-offset:calc(100.0% - var(--nav-padding-x));cursor:pointer;align-items:center;gap:calc(var(--spacing)*2);height:32px;padding-inline:calc(var(--spacing)*4);font-family:var(--font-sans);font-size:var(--text-14);line-height:1.21429;font-weight:var(--font-weight-regular);white-space:nowrap;color:var(--text-color-secondary);display:flex;position:relative}.nv-horizontal-nav-item:focus-visible{outline-offset:-2px;border-radius:4px;outline:2px solid}@media (prefers-reduced-motion:no-preference){.nv-horizontal-nav-item{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-duration:.2s;transition-timing-function:var(--ease-out)}}.nv-horizontal-nav-item:before,.nv-horizontal-nav-item:after{content:"";pointer-events:none;bottom:calc(var(--spacing)*0);z-index:10;border-bottom:2px solid #0000;position:absolute}.nv-horizontal-nav-item:before{left:var(--nav-padding-x);right:var(--nav-edge-offset)}.nv-horizontal-nav-item:after{left:var(--nav-edge-offset);right:var(--nav-padding-x)}@media (hover:hover){.nv-horizontal-nav-item:not(:disabled,.nv-horizontal-nav-item.nv-horizontal-nav-item--disabled,.nv-horizontal-nav-item.nv-horizontal-nav-item--selected,[data-active]):where(:hover,[data-hover]){color:var(--text-color-secondary)}.nv-horizontal-nav-item:not(:disabled,.nv-horizontal-nav-item.nv-horizontal-nav-item--disabled,.nv-horizontal-nav-item.nv-horizontal-nav-item--selected,[data-active]):where(:hover,[data-hover]):before,.nv-horizontal-nav-item:not(:disabled,.nv-horizontal-nav-item.nv-horizontal-nav-item--disabled,.nv-horizontal-nav-item.nv-horizontal-nav-item--selected,[data-active]):where(:hover,[data-hover]):after{border-bottom-color:var(--border-color-interaction-hover);left:var(--nav-padding-x);right:var(--nav-padding-x)}}.nv-horizontal-nav-item:disabled,.nv-horizontal-nav-item.nv-horizontal-nav-item--disabled{cursor:not-allowed;color:var(--text-color-disabled)}.nv-horizontal-nav-item.nv-horizontal-nav-item--selected,.nv-horizontal-nav-item[data-active]{color:var(--text-color-primary);font-weight:var(--font-weight-bold)}:is(.nv-horizontal-nav-item.nv-horizontal-nav-item--selected,.nv-horizontal-nav-item[data-active]):before,:is(.nv-horizontal-nav-item.nv-horizontal-nav-item--selected,.nv-horizontal-nav-item[data-active]):after{border-bottom-color:var(--border-color-interaction-selected);left:var(--nav-padding-x);right:var(--nav-padding-x)}@media (prefers-reduced-motion:no-preference){.nv-horizontal-nav-item:before,.nv-horizontal-nav-item:after{transition:left var(--nav-transition-duration)var(--ease-out),right var(--nav-transition-duration)var(--ease-out),border-color var(--nav-transition-duration)var(--ease-out)}.nv-horizontal-nav-item:before{transition-duration:0s,0s,var(--nav-transition-duration)}.nv-horizontal-nav-item:not(:disabled,.nv-horizontal-nav-item.nv-horizontal-nav-item--disabled,.nv-horizontal-nav-item.nv-horizontal-nav-item--selected,[data-active]):where(:hover,[data-hover]):before{transition-duration:var(--nav-transition-duration),var(--nav-transition-duration),var(--nav-transition-duration)}.nv-horizontal-nav-item:not(:disabled,.nv-horizontal-nav-item.nv-horizontal-nav-item--disabled,.nv-horizontal-nav-item.nv-horizontal-nav-item--selected,[data-active]):where(:hover,[data-hover]):after{transition-delay:var(--nav-transition-duration),var(--nav-transition-duration),0s;transition-duration:0s,0s,var(--nav-transition-duration)}:is(.nv-horizontal-nav-item.nv-horizontal-nav-item--selected,.nv-horizontal-nav-item[data-active]):before,:is(.nv-horizontal-nav-item.nv-horizontal-nav-item--selected,.nv-horizontal-nav-item[data-active]):after{transition-duration:0s,0s,var(--nav-transition-duration)}}.nv-input-shell textarea,.nv-input-shell input{font:inherit;letter-spacing:inherit;word-spacing:inherit}.nv-input-shell{--nv-input-height:40px;--nv-input-padding:12px;height:var(--nv-input-height);--input-gap:6px;align-items:center;gap:var(--input-gap);border-radius:var(--radius-md);width:100%;padding-inline:var(--nv-input-padding);font:var(--font-sans);font-size:var(--text-14);font-style:normal;font-weight:var(--font-weight-regular);color:var(--text-color-primary);display:flex}.nv-input-shell select,.nv-input-shell [data-input-slot]{cursor:pointer}.nv-input-shell:where(:not(.nv-input-shell--kind-floating)){border:1px solid var(--border-color-interaction-base);background:var(--background-color-interaction-base)}:where(.nv-input-shell svg,.nv-input-shell .nv-icon){--icon-font-size:var(--text-16);width:calc(var(--spacing)*4);height:calc(var(--spacing)*4);color:var(--text-color-base);flex-shrink:0}@media (hover:hover){.nv-input-shell:hover{border-color:var(--border-color-interaction-hover)}.nv-input-shell:hover:has(input[type=file]){background-color:var(--background-color-interaction-hover)}}.nv-input-shell:where(:has([data-status=error])){--border-color:var(--border-color-feedback-danger);border-color:var(--border-color-feedback-danger)}.nv-input-shell.nv-input-shell--validated:has(:user-invalid){--border-color:var(--border-color-feedback-danger);border-color:var(--border-color-feedback-danger)}.nv-input-shell:where(:has([data-status=success])){--border-color:var(--border-color-feedback-success);border-color:var(--border-color-feedback-success)}.nv-input-shell.nv-input-shell--validated:has(:user-valid){--border-color:var(--border-color-feedback-success);border-color:var(--border-color-feedback-success)}.nv-input-shell[data-state=open],.nv-input-shell:has([data-state=open]){border-color:var(--border-color-interaction-selected)}.nv-input-shell:has(select:open){border-color:var(--border-color-interaction-selected)}.nv-input-shell:has(:focus-visible),.nv-input-shell[data-force-focus=true]{outline:2px solid var(--border-color,currentColor);outline-offset:var(--outline-offset,-2px)}.nv-input-shell:has([readonly]){background-color:#0000;border-color:#0000}.nv-input-shell:has([readonly])>.nv-dismiss-button{display:none}.nv-input-shell:where(:has([data-disabled=true]),[data-disabled=true]),.nv-input-shell:has(input:disabled,textarea:disabled,select:disabled,[data-input-slot]:disabled:not([readonly]),[data-input-slot][aria-disabled=true]:not([readonly])){cursor:not-allowed;border-color:var(--border-color-interaction-disabled);background-color:var(--background-color-interaction-disabled);color:var(--text-color-disabled)}:is(.nv-input-shell:where(:has([data-disabled=true]),[data-disabled=true]),.nv-input-shell:has(input:disabled,textarea:disabled,select:disabled,[data-input-slot]:disabled:not([readonly]),[data-input-slot][aria-disabled=true]:not([readonly]))) ::file-selector-button{-webkit-text-decoration-color:var(--border-color-interaction-disabled)!important;text-decoration-color:var(--border-color-interaction-disabled)!important}:is(.nv-input-shell:where(:has([data-disabled=true]),[data-disabled=true]),.nv-input-shell:has(input:disabled,textarea:disabled,select:disabled,[data-input-slot]:disabled:not([readonly]),[data-input-slot][aria-disabled=true]:not([readonly])))>.nv-dismiss-button{display:none}.nv-input-shell input,.nv-input-shell textarea,.nv-input-shell select,.nv-input-shell>[data-input-slot]{appearance:none;text-align:left;background-color:#0000;border:none;outline:none;width:100%;height:100%}:is(.nv-input-shell input,.nv-input-shell textarea,.nv-input-shell select,.nv-input-shell>[data-input-slot])::placeholder,:is(.nv-input-shell input,.nv-input-shell textarea,.nv-input-shell select,.nv-input-shell>[data-input-slot])[data-has-selected-value=false]{color:var(--text-color-placeholder)}.nv-input-shell>select:invalid{color:var(--text-color-placeholder)}:is(.nv-input-shell:has(:placeholder-shown),.nv-input-shell:has(input[type=search]),.nv-input-shell:has([data-has-selected-value=false]))>.nv-dismiss-button{display:none}.nv-input-shell:has(textarea){height:auto;padding-block:8px}.nv-input-shell:has(textarea) textarea{scrollbar-width:thin;scrollbar-color:var(--nv-scrollbar-color);min-height:3lh}.nv-input-shell:has(input[type=file]){cursor:pointer;height:auto;padding-block:24px}.nv-input-shell:has(input[type=file]) input[type=file]{width:auto;margin:0 auto}.nv-input-shell:has(input[type=file]) ::file-selector-button{text-decoration:underline;-webkit-text-decoration-color:var(--border-color-brand);text-decoration-color:var(--border-color-brand);text-underline-offset:4px;background:0 0;border:0}.nv-input-shell:has(button[data-input-slot]){gap:0;padding-inline:0}.nv-input-shell:has(button[data-input-slot]) button[data-input-slot]{padding-inline:var(--input-gap);line-height:1}.nv-input-shell:has(button[data-input-slot]) button[data-input-slot]:first-child{padding-inline-start:var(--nv-input-padding)}.nv-input-shell:has(button[data-input-slot]) button[data-input-slot]:last-child{padding-inline-end:var(--nv-input-padding)}.nv-input-shell:has(button[data-input-slot]):has(button[data-input-slot]:not(:first-child)){padding-inline-start:var(--nv-input-padding)}.nv-input-shell:has(button[data-input-slot])>:last-child:not(button[data-input-slot]){padding-inline-end:var(--nv-input-padding)}.nv-input-shell>.nv-dismiss-button{--nv-button-icon-margin:-4px;--nv-button-icon-color:var(--text-color-base)}.nv-input-shell>button[data-input-slot],.nv-input-shell:is(button){text-overflow:ellipsis;white-space:nowrap;overflow:hidden}@media (prefers-reduced-motion:no-preference){.nv-input-shell{transition-property:border-color,color,background-color;transition-duration:.25s;transition-timing-function:var(--ease-out)}}@media (scripting:none){.nv-input-shell .nv-dismiss-button{display:none}}.nv-input-shell.nv-input-shell--size-small{--nv-input-padding:8px;--nv-input-height:28px;font-size:var(--text-12);padding-block:6px}:where(.nv-input-shell.nv-input-shell--size-small svg,.nv-input-shell.nv-input-shell--size-small .nv-icon){--icon-font-size:var(--text-12);width:calc(var(--spacing)*3);height:calc(var(--spacing)*3)}.nv-input-shell.nv-input-shell--size-large{--nv-input-padding:16px;--nv-input-height:48px;font-size:var(--text-16);padding-block:12px}.nv-input-shell.nv-input-shell--layout-vertical{--nv-input-height:auto;padding-block:var(--nv-input-padding,12px);flex-direction:column}.nv-input-shell input:is([type=time],[type=date],[type=datetime-local]){font-variant-numeric:tabular-nums}.nv-input-shell input:is([type=time],[type=date],[type=datetime-local]):not(.nv-date-picker-native-fallback)::-webkit-calendar-picker-indicator{display:none}.nv-input-shell ::-webkit-datetime-edit-hour-field:focus{background:var(--background-color-interaction-hover);border-radius:var(--spacing)}.nv-input-shell ::-webkit-datetime-edit-minute-field:focus{background:var(--background-color-interaction-hover);border-radius:var(--spacing)}.nv-input-shell ::-webkit-datetime-edit-second-field:focus{background:var(--background-color-interaction-hover);border-radius:var(--spacing)}.nv-input-shell ::-webkit-datetime-edit-ampm-field:focus{background:var(--background-color-interaction-hover);border-radius:var(--spacing)}.nv-input-shell ::-webkit-datetime-edit-year-field:focus{background:var(--background-color-interaction-hover);border-radius:var(--spacing)}.nv-input-shell ::-webkit-datetime-edit-month-field:focus{background:var(--background-color-interaction-hover);border-radius:var(--spacing)}.nv-input-shell ::-webkit-datetime-edit-day-field:focus{background:var(--background-color-interaction-hover);border-radius:var(--spacing)}.nv-input-shell-control{flex-shrink:0;align-items:center;height:100%;display:flex}.nv-label{width:fit-content;font-family:var(--font-sans);color:var(--text-color-primary);font-weight:var(--nv-label-font-weight,var(--font-weight-regular));font-size:var(--nv-label-font-size,var(--text-14));line-height:var(--nv-label-line-height,var(--leading-lh-125));text-overflow:var(--nv-label-text-overflow,clip);overflow:var(--nv-label-overflow,visible);vertical-align:middle}.nv-label--disabled{cursor:not-allowed;color:var(--text-color-disabled)}.nv-label--size-small{font-size:var(--nv-label-font-size,var(--text-12))}.nv-label--size-medium{font-size:var(--nv-label-font-size,var(--text-14))}.nv-label--size-large{font-size:var(--nv-label-font-size,var(--text-16))}.nv-label--required{padding-right:calc(var(--spacing)*3);position:relative}.nv-label--required:after{font-weight:var(--font-weight-regular);right:calc(var(--spacing)*0);color:var(--text-color-feedback-danger-subtle);content:"*";position:absolute}.nv-list-root{margin:calc(var(--spacing)*0);gap:calc(var(--spacing)*1);width:100%;padding:calc(var(--spacing)*0);font-family:var(--font-sans);font-size:var(--text-16);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular);color:var(--text-color-primary);flex-direction:column;list-style-type:none;display:flex}.nv-list-item{align-items:flex-start;gap:calc(var(--spacing)*1);width:100%;display:flex}.nv-list-item-marker{font-weight:var(--font-weight-bold)}.nv-list-root--kind-ordered .nv-list-item-marker{justify-content:flex-end}.nv-list-item-marker,.nv-list-item-marker svg,.nv-list-item-marker .nv-icon{width:calc(var(--spacing)*6);height:calc(var(--spacing)*6);flex-shrink:0;justify-content:center;align-items:center;display:flex}.nv-menu-root{font-family:var(--font-sans);font-size:var(--text-14);color:var(--text-color-primary);font-weight:var(--font-weight-regular);z-index:1060;align-items:flex-start;gap:calc(var(--spacing)*0);border-radius:var(--radius-md);border:1px solid;border-color:var(--border-color-base);background-color:var(--background-color-surface-raised);box-shadow:var(--shadow-md);scrollbar-width:thin;scrollbar-color:var(--nv-scrollbar-color);--transition-offset:12px;--menu-translate-start:0 calc(var(--transition-offset)*-1);flex-direction:column;font-style:normal;line-height:1.14286;display:flex;overflow-y:auto}.nv-menu-root.nv-menu--filterable:not(:has(.nv-menu-item)):after{padding:var(--spacing-density-lg);color:var(--text-color-placeholder);content:attr(data-empty-message,"No results found")}.nv-menu-root.nv-menu--filterable:not(:has(.nv-menu-item)) .nv-divider-root{display:none}.nv-menu-list{margin:calc(var(--spacing)*0);align-items:flex-start;gap:calc(var(--spacing)*0);width:100%;padding:calc(var(--spacing)*0);flex-direction:column;list-style-type:none;display:flex}.nv-menu-section{align-items:center;gap:calc(var(--spacing)*0);border-bottom:1px solid;border-bottom-color:var(--border-color-base);background-color:var(--background-color-surface-raised);flex-direction:column;width:100%;display:flex}.nv-menu-section:last-child{border:none}.nv-menu-section:not(:has(.nv-menu-item)){display:none}.nv-menu-heading{font-size:var(--text-14);font-weight:var(--font-weight-bold);align-items:flex-start;gap:calc(var(--spacing)*1.5);text-overflow:ellipsis;white-space:nowrap;width:100%;padding:var(--spacing-density-lg);line-height:1.14286;display:flex;overflow:hidden}.nv-menu-search.nv-input-shell{margin:calc(var(--spacing)*2);width:calc(100.0% - var(--spacing)*4)}.nv-menu-search.nv-input-shell svg,.nv-menu-search.nv-input-shell .nv-icon{color:var(--text-color-base)}.nv-menu-search.nv-input-shell input[type=search]::-webkit-search-decoration{-webkit-appearance:none;display:none}.nv-menu-search.nv-input-shell input[type=search]::-webkit-search-cancel-button{-webkit-appearance:none;display:none}.nv-menu-root>li[role=none],.nv-menu-list>li[role=none],.nv-menu-section>li[role=none]{width:100%;list-style-type:none}.nv-menu-item{cursor:pointer;align-items:center;gap:calc(var(--spacing)*1.5);width:100%;padding:var(--spacing-density-lg);text-align:left;outline-offset:-2px;background-color:#0000;border:none;flex-shrink:0;list-style-type:none;display:flex}@media (prefers-reduced-motion:no-preference){.nv-menu-item{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke;transition-duration:.25s;transition-timing-function:var(--ease-out)}}.nv-menu-item.nv-menu-checkbox-item,.nv-menu-item.nv-menu-radio-group-item{gap:calc(var(--spacing)*2)}.nv-menu-item:hover{background-color:var(--background-color-interaction-hover)}.nv-menu-item:active,.nv-menu-item[data-active-item]{background-color:var(--background-color-interaction-pressed)}.nv-menu-item.nv-menu-item--danger{color:var(--text-color-feedback-danger)}.nv-menu-item.nv-menu-item--danger:hover{background-color:var(--background-color-feedback-danger-subtle-hover);color:var(--text-color-feedback-danger-subtle)}.nv-menu-item.nv-menu-item--danger:active{background-color:var(--background-color-feedback-danger-subtle-pressed);color:var(--text-color-feedback-danger-strong)}.nv-menu-item .nv-menu-item-slot{justify-content:center;align-items:center;gap:calc(var(--spacing)*1.5);flex-shrink:0;min-width:1em;display:flex}.nv-menu-item svg,.nv-menu-item .nv-icon{flex-shrink:0}.nv-menu-item.nv-menu-item--disabled,.nv-menu-item[data-disabled]{cursor:not-allowed;background-color:var(--background-color-interaction-disabled);color:var(--text-color-disabled)}.nv-menu-item[data-state=unchecked] [data-state-indicator]{display:none}.nv-menu-radio-group{margin:calc(var(--spacing)*0);min-width:calc(var(--spacing)*0);padding:calc(var(--spacing)*0);border-top:0;border-left:0;border-right:0}.nv-menu-radio-group.nv-radio-group-root{align-items:stretch;gap:calc(var(--spacing)*0);width:100%}.nv-menu-item.nv-radio-group-item{width:100%}.nv-menu-radio-group>li[role=none]{width:100%;list-style-type:none;display:flex}.nv-menu-item-label{min-height:calc(var(--spacing)*4);text-overflow:ellipsis;white-space:nowrap;text-align:left;align-content:center;width:100%;display:inline-block;overflow:hidden}dialog.nv-modal-overlay,dialog.nv-modal-dialog{margin:calc(var(--spacing)*0);max-width:none;max-height:none;padding:calc(var(--spacing)*0);background-color:#0000;border:none}:is(dialog.nv-modal-overlay,dialog.nv-modal-dialog)[open]{inset:calc(var(--spacing)*0);z-index:1000;position:fixed}:is(dialog.nv-modal-overlay,dialog.nv-modal-dialog):not([open]){display:none}:is(dialog.nv-modal-overlay,dialog.nv-modal-dialog) .nv-modal-content{position:relative;inset:auto;translate:0}dialog.nv-modal-overlay[open]{justify-content:center;align-items:center;width:100vw;height:100vh;display:flex}dialog.nv-modal-overlay::backdrop{background-color:var(--background-color-surface-blanket)}@media (prefers-reduced-motion:no-preference){dialog.nv-modal-overlay{animation:modal-in .3s var(--ease-out)}dialog.nv-modal-overlay[data-state=closed]{animation:modal-out .2s var(--ease-out)forwards}dialog.nv-modal-overlay::backdrop{animation:modal-in .3s var(--ease-out)}dialog.nv-modal-overlay[data-state=closed]::backdrop{animation:modal-out .2s var(--ease-out)forwards}}dialog.nv-modal-dialog[open]{justify-content:center;align-items:center;width:100vw;height:100vh;display:flex}dialog.nv-modal-dialog::backdrop{background-color:#0000}.nv-modal-overlay[popover]{margin:calc(var(--spacing)*0);max-width:none;max-height:none;padding:calc(var(--spacing)*0);background-color:#0000;border:none}.nv-modal-overlay[popover]:popover-open{inset:calc(var(--spacing)*0);z-index:1000;justify-content:center;align-items:center;width:100vw;height:100vh;display:flex;position:fixed}.nv-modal-overlay[popover]::backdrop{background-color:var(--background-color-surface-blanket)}.nv-modal-overlay[popover] .nv-modal-content{position:relative;inset:auto;translate:0}.nv-modal-content{border:1px solid var(--border-color-base);z-index:1050;border-radius:var(--radius-xl);background-color:var(--background-color-surface-overlay);width:420px;max-width:95%;max-height:90dvh;font-family:var(--font-sans);font-size:var(--text-14);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular);color:var(--text-color-primary);gap:var(--spacing-density-2xl);padding:var(--spacing-density-2xl);flex-direction:column;font-style:normal;display:flex;position:relative}.nv-modal-heading{align-items:center;gap:calc(var(--spacing)*2);width:100%;padding-right:calc(var(--spacing)*8);font-family:var(--font-sans);font-size:var(--text-18);line-height:1.22222;font-weight:var(--font-weight-bold);line-height:var(--leading-lh-100);display:flex;position:relative}.nv-modal-heading>svg,.nv-modal-heading>.nv-icon{width:calc(var(--spacing)*6);height:calc(var(--spacing)*6);color:var(--text-color-base);flex-shrink:0}.nv-modal-heading.nv-modal-heading--hidden{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.nv-modal-heading.nv-modal-heading--invisible{visibility:hidden}.nv-modal-main{min-height:calc(var(--spacing)*0);gap:calc(var(--spacing)*4);scrollbar-width:thin;scrollbar-color:var(--nv-scrollbar-color);flex-direction:column;margin:-4px;padding:4px;display:flex;overflow-y:auto}.nv-modal-footer{justify-content:flex-end;align-items:center;gap:calc(var(--spacing)*2);margin-top:auto;display:flex}.nv-modal-close{top:var(--spacing-density-2xl);right:var(--spacing-density-lg);position:absolute;translate:0 -25%}.nv-modal-close>:not(svg):not(.nv-icon){clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.nv-modal-portal{pointer-events:none;inset:calc(var(--spacing)*0);z-index:1060;position:fixed}.nv-modal-portal>*{pointer-events:auto}.nv-notification-root{border-radius:var(--radius-lg);border-left:4px solid;border-left-color:var(--border-color-feedback-info);background-color:var(--background-color-surface-overlay);width:100%;padding:calc(var(--spacing)*4);font-family:var(--font-sans);color:var(--text-color-primary);box-shadow:var(--shadow-lg);position:relative}.nv-notification-root.nv-notification-root--status-error{border-left-color:var(--border-color-feedback-danger)}.nv-notification-root.nv-notification-root--status-error .nv-notification-icon{color:var(--text-color-feedback-danger)}.nv-notification-root.nv-notification-root--status-success{border-left-color:var(--border-color-feedback-success)}.nv-notification-root.nv-notification-root--status-success .nv-notification-icon{color:var(--text-color-feedback-success)}.nv-notification-root.nv-notification-root--status-warning{border-left-color:var(--border-color-feedback-warning)}.nv-notification-root.nv-notification-root--status-warning .nv-notification-icon{color:var(--text-color-feedback-warning)}.nv-notification-root.nv-notification-root--kind-inline .nv-notification-close-button-section{align-content:center;position:static}.nv-notification-root.nv-notification-root--kind-inline .nv-notification-close-button-section button{margin-right:calc(var(--spacing)*-3)}.nv-notification-root.nv-notification-root--kind-inline .nv-notification-content{gap:calc(var(--spacing)*3);grid-template-rows:repeat(1,minmax(0,1fr));grid-template-columns:auto 1fr auto auto;grid-template-areas:"icon header footer close-button"}.nv-notification-root.nv-notification-root--kind-inline .nv-notification-content:not(:has(.nv-notification-footer)){grid-template-columns:auto 1fr auto;grid-template-areas:"icon header close-button"}.nv-notification-root.nv-notification-root--kind-inline .nv-notification-content:not(:has(.nv-notification-close-button-section)){grid-template-columns:auto 1fr auto;grid-template-areas:"icon header footer"}.nv-notification-root.nv-notification-root--kind-inline .nv-notification-footer{align-items:center}.nv-notification-root .nv-notification-content{gap:calc(var(--spacing)*3);row-gap:calc(var(--spacing)*0);grid-template:"icon header""footer footer"/auto 1fr;display:grid}.nv-notification-root .nv-notification-content:has(.nv-notification-footer){row-gap:calc(var(--spacing)*3)}.nv-notification-root .nv-notification-close-button-section{top:calc(var(--spacing)*1);right:calc(var(--spacing)*1);grid-area:close-button;position:absolute}.nv-notification-root .nv-notification-icon{color:var(--text-color-feedback-info);grid-area:icon}.nv-notification-root .nv-notification-header{gap:calc(var(--spacing)*2);flex-direction:column;grid-area:header;display:flex}.nv-notification-root .nv-notification-heading{font-family:var(--font-sans);font-size:var(--text-16);line-height:var(--leading-lh-150);font-weight:var(--font-weight-bold)}.nv-notification-root .nv-notification-subheading{font-family:var(--font-sans);font-size:var(--text-14);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular)}.nv-notification-root .nv-notification-footer{justify-content:flex-end;gap:calc(var(--spacing)*2);grid-area:footer;display:flex}.nv-notification-root .nv-notification-icon{padding-block:calc(var(--spacing)*1)}.nv-page-header-root{width:100%;container-type:inline-size}.nv-page-header-container{font-family:var(--font-sans);color:var(--text-color-primary);font-style:normal;font-weight:var(--font-weight-regular);gap:calc(var(--spacing)*4);flex-direction:column;width:100%;display:flex}@container (width>=42rem){.nv-page-header-container{flex-direction:row}}.nv-page-header-container .nv-page-header-content{gap:var(--spacing-density-xl);flex-direction:column;flex:1;display:flex}.nv-page-header-container .nv-page-header-header{gap:calc(var(--spacing)*2);flex-direction:column;display:flex}.nv-page-header-container .nv-page-header-subheading{font-family:var(--font-sans);font-size:var(--text-18);line-height:1.22222;font-weight:var(--font-weight-light);margin-block:-2px}.nv-page-header-container .nv-page-header-heading{font-family:var(--font-sans);font-size:var(--text-24);line-height:var(--leading-lh-150);font-weight:var(--font-weight-bold)}.nv-page-header-container .nv-page-header-description{font-family:var(--font-sans);font-size:var(--text-14);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular)}.nv-page-header-container .nv-page-header-footer{justify-content:flex-end;align-items:flex-end;gap:calc(var(--spacing)*2);flex-wrap:wrap;display:flex}@container (width>=42rem){.nv-page-header-container .nv-page-header-footer{flex-wrap:nowrap}}@container (width<42rem){.nv-page-header-container .nv-page-header-footer>*{flex:1}}.nv-page-header-container.nv-page-header-container--kind-floating{border-radius:var(--radius-xl);border:1px solid;border-color:var(--border-color-base);background-color:var(--background-color-surface-base);padding:var(--spacing-density-2xl);box-shadow:var(--shadow-md)}.nv-pagination-root{align-items:center;gap:calc(var(--spacing)*2);width:100%;font-size:var(--text-14);flex-wrap:wrap;display:flex;overflow:hidden;container-type:inline-size}.nv-pagination-page-size-select{margin-inline:calc(var(--spacing)*1);color:var(--text-color-primary);width:72px!important}.nv-pagination-arrow-button{flex-shrink:0}.nv-pagination-page-input{flex-shrink:0;width:calc(var(--spacing)*16)!important}.nv-pagination-page-list{width:fit-content!important}.nv-pagination-page-list .nv-tabs-list{border-bottom:none}.nv-pagination-page-count-text{white-space:nowrap;color:var(--text-color-secondary);font-weight:var(--font-weight-regular);flex-shrink:0}.nv-pagination-divider{align-self:stretch;height:auto}.nv-pagination-divider .nv-divider-element{height:100%;min-height:calc(var(--spacing)*0)}.nv-pagination-item-range-text{text-overflow:ellipsis;white-space:nowrap;color:var(--text-color-secondary);font-weight:var(--font-weight-regular);overflow:hidden}.nv-pagination-controls-group{white-space:nowrap;color:var(--text-color-secondary);font-weight:var(--font-weight-regular);flex-grow:1;flex-shrink:1;justify-content:flex-start;align-items:center;display:flex;overflow:hidden}.nv-pagination-controls-group .nv-divider-root{margin-right:calc(var(--spacing)*3.5);flex-grow:0}.nv-pagination-navigation-group{flex-shrink:0;align-items:center;display:flex}.nv-pagination--kind-input{justify-content:flex-start;gap:calc(var(--spacing)*2)}.nv-pagination--kind-input:has(.nv-pagination-page-size-select) .nv-pagination-navigation-group{margin-inline:auto}.nv-pagination--kind-input .nv-pagination-navigation-group{gap:calc(var(--spacing)*2)}@container (width<=720px){.nv-pagination--kind-input .nv-pagination-controls-group{justify-content:center;width:100%}}.nv-pagination--kind-input:not(:has(.nv-pagination-page-size-select)){justify-content:center}.nv-pagination--kind-tabs{align-items:center;gap:calc(var(--spacing)*2);grid-template-columns:1fr auto 1fr;width:100%;display:grid}.nv-pagination--kind-tabs .nv-pagination-controls-group--start{grid-column-start:1;justify-content:center;justify-self:flex-start;align-items:center;display:flex}.nv-pagination--kind-tabs .nv-pagination-controls-group--start .nv-divider-root{margin-right:calc(var(--spacing)*0)!important}@container (width<=720px){.nv-pagination--kind-tabs .nv-pagination-controls-group--start{grid-column:1/-1;justify-self:center;width:100%}.nv-pagination--kind-tabs .nv-pagination-controls-group--start .nv-divider-root{display:none}}.nv-pagination--kind-tabs .nv-pagination-navigation-group--tabs{grid-column-start:2;justify-self:center;align-items:center;display:flex}@container (width<=720px){.nv-pagination--kind-tabs .nv-pagination-navigation-group--tabs{grid-column:1/-1}}.nv-pagination--kind-tabs .nv-pagination-navigation-group{gap:calc(var(--spacing)*0)}.nv-pagination--kind-tabs .nv-pagination-controls-group--end{grid-column-start:3;justify-self:flex-end;align-items:center;display:flex}.nv-pagination--kind-tabs .nv-pagination-controls-group--end .nv-pagination-page-input{margin-right:calc(var(--spacing)*2)}@container (width<=720px){.nv-pagination--kind-tabs .nv-pagination-controls-group--end{grid-column:1/-1;justify-content:center;width:100%}.nv-pagination--kind-tabs .nv-pagination-controls-group--end .nv-divider-root{display:none}}.nv-pagination--kind-tabs:not(:has(.nv-pagination-page-size-select)) .nv-pagination-navigation-group--tabs{grid-column:1/-1}.nv-pagination--kind-simple{justify-content:flex-start;gap:calc(var(--spacing)*2)}.nv-pagination--kind-simple:has(.nv-pagination-page-size-select) .nv-pagination-navigation-group{margin-inline:auto}.nv-pagination--kind-simple .nv-pagination-navigation-group{gap:calc(var(--spacing)*2)}@container (width<=720px){.nv-pagination--kind-simple .nv-pagination-controls-group{justify-content:center;width:100%}}.nv-pagination--kind-simple:not(:has(.nv-pagination-page-size-select)){justify-content:center}.nv-panel-root{border:1px solid;border-color:var(--border-color-base);background-color:var(--background-color-surface-base);width:100%;font-family:var(--font-sans);color:var(--text-color-primary);gap:calc(var(--spacing)*6);border-radius:var(--radius-density-xl);padding:var(--spacing-density-2xl);flex-direction:column;display:flex}.nv-panel-root--elevation-low{background-color:var(--background-color-surface-sunken)}.nv-panel-root--elevation-high{background-color:var(--background-color-surface-raised)}.nv-panel-root--elevation-higher{background-color:var(--background-color-surface-overlay)}.nv-panel-header{justify-content:flex-start;align-items:center;gap:calc(var(--spacing)*4);display:flex}.nv-panel-icon{font-size:var(--text-24);color:var(--text-color-base)}.nv-panel-header-heading{width:100%;font-family:var(--font-sans);font-size:var(--text-18);line-height:1.22222;font-weight:var(--font-weight-bold);margin-block:-2px}.nv-panel-footer{justify-content:flex-end;align-items:center;gap:calc(var(--spacing)*2);display:flex}@keyframes nv-popover-in{0%{opacity:0;translate:var(--nv-popover-translate-start)}to{opacity:1;translate:0}}.nv-popover-content{--nv-popover-translate-start:0 -4px;isolation:isolate;gap:calc(var(--spacing)*4);border-radius:var(--radius-md);border:1px solid;border-color:var(--border-color-base);background-color:var(--background-color-surface-overlay);padding:calc(var(--spacing)*4);color:var(--text-color-primary);box-shadow:var(--shadow-md);font-family:var(--font-sans);font-size:var(--text-14);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular);text-wrap:wrap;z-index:1070;opacity:0;width:max-content;max-width:min(100vw - 16px,560px);max-height:min(100vh - 16px,560px);translate:var(--nv-popover-translate-start);margin:auto;font-style:normal;position:fixed;inset:auto}@media (prefers-reduced-motion:no-preference){.nv-popover-content{transition:opacity .25s var(--ease-out),translate .25s var(--ease-out)}}@supports (position-anchor:--a){.nv-popover-content{--nv-popover-offset:4px;position-try-fallbacks:flip-block,flip-inline,flip-block flip-inline;margin:0}.nv-popover-content[data-side=top]{margin-bottom:var(--nv-popover-offset);position-area:top}.nv-popover-content[data-side=top][data-align=start]{position-area:top span-right}.nv-popover-content[data-side=top][data-align=end]{position-area:top span-left}.nv-popover-content[data-side=bottom]{margin-top:var(--nv-popover-offset);position-area:bottom}.nv-popover-content[data-side=bottom][data-align=start]{position-area:bottom span-right}.nv-popover-content[data-side=bottom][data-align=end]{position-area:bottom span-left}.nv-popover-content[data-side=left]{--nv-popover-translate-start:4px 0;margin-right:var(--nv-popover-offset);position-area:left}.nv-popover-content[data-side=left][data-align=start]{position-area:left span-bottom}.nv-popover-content[data-side=left][data-align=end]{position-area:left span-top}.nv-popover-content[data-side=right]{--nv-popover-translate-start:-4px 0;margin-left:var(--nv-popover-offset);position-area:right}.nv-popover-content[data-side=right][data-align=start]{position-area:right span-bottom}.nv-popover-content[data-side=right][data-align=end]{position-area:right span-top}}:is(.nv-popover-content[data-state=open],.nv-popover-content:popover-open,.nv-popover-content.\:popover-open){opacity:1;translate:0}@media (prefers-reduced-motion:no-preference){:is(.nv-popover-content[data-state=open],.nv-popover-content:popover-open,.nv-popover-content.\:popover-open){animation:nv-popover-in .25s var(--ease-out)}}.nv-popover-content[data-state=closed]:not(:popover-open):not(.\:popover-open){opacity:0;translate:var(--nv-popover-translate-start)}.nv-progress-bar-root{height:calc(var(--spacing)*2.5);border-radius:var(--radius-xl);background-color:var(--background-color-component-track);width:100%;position:relative;overflow:hidden;transform:translateZ(0);container-type:size}.nv-progress-bar-root--size-small{height:calc(var(--spacing)*1)}.nv-progress-bar-root--size-large{height:calc(var(--spacing)*3.5)}.nv-progress-bar-indicator{inset:calc(var(--spacing)*0);border-radius:inherit;background-color:var(--background-color-interaction-primary-base);height:100%;transition:width .5s cubic-bezier(.65,0,.35,1);position:absolute}.nv-progress-bar-root--indeterminate{--progress-bar-indicator-width:50.0%}.nv-progress-bar-root--indeterminate .nv-progress-bar-indicator{width:var(--progress-bar-indicator-width);animation:1.5s linear infinite progressIndeterminatePosition}@keyframes progressIndeterminatePosition{0%{transform:translate(-100%)}80%,to{transform:translate(100cqw)}}.nv-radio-group-root{gap:calc(var(--spacing)*3);flex-direction:column;width:fit-content;display:flex}.nv-radio-group-root.nv-radio-group-root--orientation-horizontal{flex-direction:row}.nv-radio-group-input:not(.nv-radio-group-input--hidden){height:calc(var(--spacing)*4);width:calc(var(--spacing)*4);border:2px solid;border-color:var(--border-color-interaction-base);background-color:var(--background-color-interaction-base);color:var(--text-color-primary);border-radius:3.40282e38px;flex-shrink:0;place-items:center;display:grid}input.nv-radio-group-input:not(.nv-radio-group-input--hidden){margin:calc(var(--spacing)*0);cursor:pointer;appearance:none}.nv-radio-group-item{cursor:pointer;align-items:center;gap:calc(var(--spacing)*2);width:fit-content;font-family:var(--font-sans);color:var(--text-color-primary);font-weight:var(--nv-label-font-weight,var(--font-weight-regular));font-size:var(--nv-label-font-size,var(--text-14));line-height:var(--nv-label-line-height,calc(16/14));display:flex}@media (prefers-reduced-motion:no-preference){.nv-radio-group-item:not(:has(.nv-radio-group-input:disabled)) .nv-radio-group-input:not(.nv-radio-group-input--hidden){transition-duration:.2s;transition-timing-function:var(--ease-out);transition-property:background-color,border-color,border-width}}.nv-radio-group-item:not(:has(.nv-radio-group-input:disabled)) .nv-radio-group-input:not(.nv-radio-group-input--hidden):hover{border-color:var(--border-color-interaction-hover);background-color:var(--background-color-interaction-hover)}.nv-radio-group-item:not(:has(.nv-radio-group-input:disabled)) .nv-radio-group-input:not(.nv-radio-group-input--hidden):active{border-color:var(--border-color-interaction-pressed);background-color:var(--background-color-interaction-selected)}.nv-radio-group-item:not(:has(.nv-radio-group-input:disabled)) .nv-radio-group-indicator{opacity:0;background-color:#0000;scale:2}@media (prefers-reduced-motion:no-preference){.nv-radio-group-item:not(:has(.nv-radio-group-input:disabled)) .nv-radio-group-indicator{transition-duration:.25s;transition-timing-function:var(--ease-out);transition-property:background-color,opacity,scale}}.nv-radio-group-item:not(:has(.nv-radio-group-input:disabled)) input.nv-radio-group-input:not(.nv-radio-group-input--hidden):before{content:"";opacity:0;background-color:#0000;border-radius:3.40282e38px;scale:2}@media (prefers-reduced-motion:no-preference){.nv-radio-group-item:not(:has(.nv-radio-group-input:disabled)) input.nv-radio-group-input:not(.nv-radio-group-input--hidden):before{transition-duration:.25s;transition-timing-function:var(--ease-out);transition-property:background-color,opacity,scale}}.nv-radio-group-item:has(input.nv-radio-group-input:checked):not(:has(.nv-radio-group-input:disabled)) .nv-radio-group-input:not(.nv-radio-group-input--hidden){border-color:var(--background-color-interaction-primary-base);background-color:var(--text-color-accent-black);border-width:5px;position:relative}.nv-radio-group-item:has(input.nv-radio-group-input:checked):not(:has(.nv-radio-group-input:disabled)) .nv-radio-group-input:not(.nv-radio-group-input--hidden):hover{border-color:var(--background-color-interaction-primary-hover)}.nv-radio-group-item:has(input.nv-radio-group-input:checked):not(:has(.nv-radio-group-input:disabled)) .nv-radio-group-input:not(.nv-radio-group-input--hidden):active{border-color:var(--background-color-interaction-primary-selected)}.nv-radio-group-item:has(input.nv-radio-group-input:checked):has(.nv-radio-group-input:disabled) .nv-radio-group-input:not(.nv-radio-group-input--hidden){background-color:var(--border-color-interaction-disabled);border-color:#0000}.nv-radio-group-item:has(input.nv-radio-group-input:checked) .nv-radio-group-indicator{width:calc(var(--spacing)*1.5);height:calc(var(--spacing)*1.5);background-color:var(--text-color-accent-black);opacity:1;border-radius:3.40282e38px;scale:1}input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked{border-color:var(--background-color-interaction-primary-base);background-color:var(--text-color-accent-black);border-width:5px;position:relative}input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked:hover{border-color:var(--background-color-interaction-primary-hover)}input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked:active{border-color:var(--background-color-interaction-primary-selected)}input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked:before{content:"";width:calc(var(--spacing)*1.5);height:calc(var(--spacing)*1.5);background-color:var(--text-color-accent-black);opacity:1;border-radius:3.40282e38px;scale:1}input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked:disabled{background-color:var(--border-color-interaction-disabled);border-color:#0000}input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked:disabled:before{background-color:var(--border-color-interaction-disabled)}.nv-radio-group-item:has(.nv-radio-group-input:disabled){cursor:not-allowed;color:var(--text-color-disabled)}input.nv-radio-group-input:not(.nv-radio-group-input--hidden):disabled{cursor:not-allowed;border-color:var(--border-color-disabled);background-color:var(--background-color-interaction-disabled);color:var(--text-color-disabled)}.nv-radio-group-item:has(.nv-radio-group-input[data-danger]):not(:has(.nv-radio-group-input:disabled)) .nv-radio-group-input:not(.nv-radio-group-input--hidden),.nv-radio-group-root--error .nv-radio-group-item:not(:has(.nv-radio-group-input:disabled)) .nv-radio-group-input:not(.nv-radio-group-input--hidden){border-color:var(--border-color-feedback-danger);background-color:var(--background-color-interaction-base)}:is(.nv-radio-group-item:has(.nv-radio-group-input[data-danger]):not(:has(.nv-radio-group-input:disabled)) .nv-radio-group-input:not(.nv-radio-group-input--hidden),.nv-radio-group-root--error .nv-radio-group-item:not(:has(.nv-radio-group-input:disabled)) .nv-radio-group-input:not(.nv-radio-group-input--hidden)):hover{background-color:var(--background-color-feedback-danger-subtle-hover)}:is(.nv-radio-group-item:has(.nv-radio-group-input[data-danger]):not(:has(.nv-radio-group-input:disabled)) .nv-radio-group-input:not(.nv-radio-group-input--hidden),.nv-radio-group-root--error .nv-radio-group-item:not(:has(.nv-radio-group-input:disabled)) .nv-radio-group-input:not(.nv-radio-group-input--hidden)):active{background-color:var(--background-color-feedback-danger-subtle-pressed)}:is(.nv-radio-group-item:has(.nv-radio-group-input[data-danger]):has(input.nv-radio-group-input:checked),.nv-radio-group-root--error .nv-radio-group-item:has(input.nv-radio-group-input:checked)):not(:has(.nv-radio-group-input:disabled)) .nv-radio-group-input:not(.nv-radio-group-input--hidden){border-color:var(--border-color-feedback-danger);background-color:var(--text-color-inverse);border-width:5px}:is(.nv-radio-group-item:has(.nv-radio-group-input[data-danger]):has(input.nv-radio-group-input:checked),.nv-radio-group-root--error .nv-radio-group-item:has(input.nv-radio-group-input:checked)):not(:has(.nv-radio-group-input:disabled)) .nv-radio-group-input:not(.nv-radio-group-input--hidden):hover{border-color:var(--background-color-feedback-danger-hover)}:is(.nv-radio-group-item:has(.nv-radio-group-input[data-danger]):has(input.nv-radio-group-input:checked),.nv-radio-group-root--error .nv-radio-group-item:has(input.nv-radio-group-input:checked)):not(:has(.nv-radio-group-input:disabled)) .nv-radio-group-input:not(.nv-radio-group-input--hidden):active{border-color:var(--background-color-feedback-danger-pressed)}:is(.nv-radio-group-item:has(.nv-radio-group-input[data-danger]):has(input.nv-radio-group-input:checked),.nv-radio-group-root--error .nv-radio-group-item:has(input.nv-radio-group-input:checked)):has(.nv-radio-group-input:disabled) .nv-radio-group-input:not(.nv-radio-group-input--hidden){background-color:var(--border-color-interaction-disabled);color:var(--text-color-disabled);border-color:#0000}:is(.nv-radio-group-item:has(.nv-radio-group-input[data-danger]):has(input.nv-radio-group-input:checked),.nv-radio-group-root--error .nv-radio-group-item:has(input.nv-radio-group-input:checked)):not(:has(.nv-radio-group-input:disabled)) .nv-radio-group-indicator{width:calc(var(--spacing)*1.5);height:calc(var(--spacing)*1.5);background-color:var(--text-color-inverse);border-radius:3.40282e38px}.nv-radio-group-item:has(.nv-radio-group-input[data-danger]) input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked,.nv-radio-group-root--error input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked{border-color:var(--border-color-feedback-danger);background-color:var(--text-color-inverse);border-width:5px}:is(.nv-radio-group-item:has(.nv-radio-group-input[data-danger]) input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked,.nv-radio-group-root--error input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked):hover{border-color:var(--background-color-feedback-danger-hover)}:is(.nv-radio-group-item:has(.nv-radio-group-input[data-danger]) input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked,.nv-radio-group-root--error input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked):active{border-color:var(--background-color-feedback-danger-pressed)}:is(.nv-radio-group-item:has(.nv-radio-group-input[data-danger]) input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked,.nv-radio-group-root--error input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked):before{content:"";width:calc(var(--spacing)*1.5);height:calc(var(--spacing)*1.5);background-color:var(--text-color-inverse);border-radius:3.40282e38px}:is(.nv-radio-group-item:has(.nv-radio-group-input[data-danger]) input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked,.nv-radio-group-root--error input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked):disabled{background-color:var(--border-color-interaction-disabled);color:var(--text-color-disabled);border-color:#0000}:is(.nv-radio-group-item:has(.nv-radio-group-input[data-danger]) input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked,.nv-radio-group-root--error input.nv-radio-group-input:not(.nv-radio-group-input--hidden):checked):disabled:before{background-color:var(--border-color-interaction-disabled)}.nv-radio-group-item:has(.nv-radio-group-input[data-danger]):has(.nv-radio-group-input:disabled) .nv-radio-group-input:not(.nv-radio-group-input--hidden),.nv-radio-group-root--error .nv-radio-group-item:has(.nv-radio-group-input:disabled) .nv-radio-group-input:not(.nv-radio-group-input--hidden),.nv-radio-group-item:has(.nv-radio-group-input[data-danger]) input.nv-radio-group-input:not(.nv-radio-group-input--hidden):disabled,.nv-radio-group-root--error input.nv-radio-group-input:not(.nv-radio-group-input--hidden):disabled{border-color:var(--border-color-disabled);background-color:var(--background-color-interaction-disabled);color:var(--text-color-disabled)}.nv-radio-input{width:calc(var(--spacing)*4);height:calc(var(--spacing)*4);cursor:pointer;appearance:none;border-color:var(--border-color-interaction-base);background-color:var(--background-color-interaction-base);border-style:solid;border-width:2px;border-radius:3.40282e38px;flex-shrink:0;position:relative}.nv-radio-input:before{content:"";inset:calc(var(--spacing)*0);width:calc(var(--spacing)*1.5);height:calc(var(--spacing)*1.5);background-color:var(--text-color-accent-black);opacity:0;border-radius:3.40282e38px;margin:auto;display:block;position:absolute;scale:0}.nv-radio-input:hover{border-color:var(--border-color-interaction-hover);background-color:var(--background-color-interaction-hover)}.nv-radio-input:active{border-color:var(--border-color-interaction-pressed);background-color:var(--background-color-interaction-selected)}.nv-radio-input:disabled{pointer-events:none;cursor:not-allowed;border-color:var(--border-color-interaction-disabled);background-color:var(--background-color-interaction-disabled)}.nv-radio-input:checked{border-color:var(--background-color-interaction-primary-base);background-color:var(--text-color-accent-black);border-style:solid;border-width:5px}.nv-radio-input:checked:before{opacity:1;scale:1}.nv-radio-input:checked:hover{border-color:var(--background-color-interaction-primary-hover)}.nv-radio-input:checked:active{border-color:var(--background-color-interaction-primary-selected)}.nv-radio-input:checked:disabled{background-color:var(--border-color-interaction-disabled);border-color:#0000}.nv-radio-input:checked:disabled:before{background-color:var(--text-color-disabled)}@media (prefers-reduced-motion:no-preference){.nv-radio-input{transition-property:background-color,border-width,border-color;transition-duration:.2s;transition-timing-function:var(--ease-out)}.nv-radio-input:before{transition-property:opacity,scale;transition-duration:.15s;transition-timing-function:var(--ease-out)}}.nv-radio-group-item:has([data-danger]) .nv-radio-input:not(:disabled),.nv-menu-item--danger .nv-radio-input:not(:disabled),.nv-radio-group-root--error .nv-radio-input:not(:disabled){border-color:var(--border-color-feedback-danger)}:is(.nv-radio-group-item:has([data-danger]) .nv-radio-input:not(:disabled),.nv-menu-item--danger .nv-radio-input:not(:disabled),.nv-radio-group-root--error .nv-radio-input:not(:disabled)):hover{background-color:var(--background-color-feedback-danger-subtle-hover)}:is(.nv-radio-group-item:has([data-danger]) .nv-radio-input:not(:disabled),.nv-menu-item--danger .nv-radio-input:not(:disabled),.nv-radio-group-root--error .nv-radio-input:not(:disabled)):active{background-color:var(--background-color-feedback-danger-subtle-pressed)}:is(.nv-radio-group-item:has([data-danger]) .nv-radio-input:not(:disabled),.nv-menu-item--danger .nv-radio-input:not(:disabled),.nv-radio-group-root--error .nv-radio-input:not(:disabled)):checked{border-color:var(--border-color-feedback-danger);background-color:var(--text-color-inverse)}:is(.nv-radio-group-item:has([data-danger]) .nv-radio-input:not(:disabled),.nv-menu-item--danger .nv-radio-input:not(:disabled),.nv-radio-group-root--error .nv-radio-input:not(:disabled)):checked:hover{border-color:var(--background-color-feedback-danger-hover)}:is(.nv-radio-group-item:has([data-danger]) .nv-radio-input:not(:disabled),.nv-menu-item--danger .nv-radio-input:not(:disabled),.nv-radio-group-root--error .nv-radio-input:not(:disabled)):checked:active{border-color:var(--background-color-feedback-danger-pressed)}.nv-radio-group-item:has(.nv-radio-group-input--hidden){position:relative}input.nv-radio-group-input.nv-radio-group-input--hidden{pointer-events:none;inset:calc(var(--spacing)*0);margin:calc(var(--spacing)*0);appearance:none;border-radius:inherit;opacity:0;width:100%;height:100%;display:block;position:absolute}.nv-radio-group-item:has(input.nv-radio-group-input--hidden:focus-visible){outline:2px solid var(--border-color-interaction-pressed);outline-offset:2px}.nv-segmented-control-root{gap:calc(var(--spacing)*1);border-radius:var(--radius-lg);background-color:var(--background-color-component-track);width:fit-content;padding:calc(var(--spacing)*1);color:var(--text-color-primary);font-family:var(--font-sans);font-size:var(--text-14);font-weight:var(--font-weight-bold);display:flex;position:relative}.nv-segmented-control-root:has(:focus-visible){outline-offset:-2px;outline:2px solid}.nv-segmented-control-root .nv-segmented-control-item{padding-inline:calc(var(--spacing)*3);padding-block:7px;line-height:1.28571}.nv-segmented-control-root.nv-segmented-control-root--size-tiny .nv-segmented-control-item{padding-inline:calc(var(--spacing)*2);font-size:var(--text-12);padding-block:2px;line-height:1.33333}.nv-segmented-control-root.nv-segmented-control-root--size-small .nv-segmented-control-item{padding-inline:calc(var(--spacing)*2);font-size:var(--text-12);padding-block:4px;line-height:1.33333}.nv-segmented-control-root.nv-segmented-control-root--size-large .nv-segmented-control-item{padding-inline:calc(var(--spacing)*4);font-size:var(--text-16);padding-block:11px;line-height:1.125}.nv-segmented-control-item{cursor:pointer;justify-content:center;align-items:center;gap:calc(var(--spacing)*1);border-radius:var(--radius-md);text-align:center;flex-grow:1;display:flex;position:relative}.nv-segmented-control-item .nv-segmented-control-input{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}@media (hover:hover){.nv-segmented-control-item:hover{background-color:var(--background-color-interaction-hover)}}.nv-segmented-control-item:has(.nv-segmented-control-input:checked){background-color:var(--background-color-interaction-selected);box-shadow:var(--shadow-sm)}@media (prefers-reduced-motion:no-preference){.nv-segmented-control-item{transition-property:color,background-color;transition-duration:.25s;transition-timing-function:var(--ease-out)}}@supports (anchor-name:--nv-segmented-control-selected) and (position-anchor:--nv-segmented-control-selected){.nv-segmented-control-root{isolation:isolate}.nv-segmented-control-root:before{content:"";border-radius:var(--radius-md);background-color:var(--background-color-interaction-selected);position-anchor:--nv-segmented-control-selected;top:anchor(top);left:anchor(left);width:anchor-size(width);height:anchor-size(height);box-shadow:var(--shadow-sm);pointer-events:none;z-index:0;position:absolute}.nv-segmented-control-item{z-index:1}.nv-segmented-control-item:not(:has(.nv-segmented-control-input:checked)):hover{background-color:var(--background-color-interaction-hover)}.nv-segmented-control-item:has(.nv-segmented-control-input:checked){anchor-name:--nv-segmented-control-selected;box-shadow:none;background-color:#0000}@media (prefers-reduced-motion:no-preference){.nv-segmented-control-root:before{transition-property:left,top,width,height;transition-duration:.25s;transition-timing-function:var(--ease-out)}}}.nv-select-toggle{flex-shrink:0;align-items:center;height:100%;display:flex}@media (scripting:enabled){.nv-select-native-fallback{display:none}}@media (scripting:none){[data-select-enhanced]{display:none!important}}.nv-select-content[popover]{color:inherit;background:0 0;border:0;margin:0;padding:0;display:none;position:fixed;inset:auto;overflow:visible}.nv-select-content[popover]:popover-open{display:block}.nv-select-content[popover].\:popover-open{display:block}.nv-select-content{--menu-translate-start:0 calc(var(--transition-offset)*-1);transform-origin:top}@supports (position-anchor:--a){.nv-select-content{--nv-select-offset:4px;width:anchor-size(width);position-try-fallbacks:flip-block,flip-inline,flip-block flip-inline;margin:0}.nv-select-content[data-side=bottom]{margin-top:var(--nv-select-offset);position-area:bottom span-right}.nv-select-content[data-side=top]{margin-bottom:var(--nv-select-offset);position-area:top span-right}.nv-select-content[data-side=left]{margin-right:var(--nv-select-offset);position-area:left span-bottom}.nv-select-content[data-side=right]{margin-left:var(--nv-select-offset);position-area:right span-bottom}}.nv-select-content[data-side=top]{--menu-translate-start:0 var(--transition-offset);transform-origin:bottom}.nv-select-content[data-side=left]{--menu-translate-start:var(--transition-offset)0;transform-origin:100%}.nv-select-content[data-side=right]{--menu-translate-start:calc(var(--transition-offset)*-1)0;transform-origin:0}@keyframes select-in{0%{translate:var(--menu-translate-start);opacity:0}to{opacity:1;translate:0}}@media (prefers-reduced-motion:no-preference){.nv-select-content:popover-open{animation:select-in .25s var(--ease-out)}}.nv-select-content .nv-menu-root{overscroll-behavior:contain;max-height:min(320px,100vh - 2rem)}.nv-select-native-fallback{padding-inline:var(--nv-input-padding)}.nv-select-native-fallback[multiple]{min-height:calc(var(--nv-input-height)*4);padding-block:calc(var(--nv-input-padding)/2)}@media (scripting:none){.nv-select-trigger:has(.nv-select-native-fallback[multiple]){height:auto}.nv-select-trigger:has(.nv-select-native-fallback[multiple]) .nv-input-shell-control:has(.nv-animated-chevron){display:none}}@keyframes left-sidepanel-in{0%{transform:translate(-100%)}to{transform:translate(0)}}@keyframes left-sidepanel-out{0%{transform:translate(0)}to{transform:translate(-100%)}}@keyframes right-sidepanel-in{0%{transform:translate(100%)}to{transform:translate(0)}}@keyframes right-sidepanel-out{0%{transform:translate(0)}to{transform:translate(100%)}}dialog.nv-side-panel-overlay,dialog.nv-side-panel-dialog{margin:calc(var(--spacing)*0);max-width:none;max-height:none;padding:calc(var(--spacing)*0);background-color:#0000;border:none}:is(dialog.nv-side-panel-overlay,dialog.nv-side-panel-dialog):not([open]){display:none}dialog.nv-side-panel-overlay{width:100vw;height:100vh}dialog.nv-side-panel-overlay[open]{inset:calc(var(--spacing)*0);z-index:1000;position:fixed}dialog.nv-side-panel-overlay::backdrop{background-color:var(--background-color-surface-blanket)}@media (prefers-reduced-motion:no-preference){dialog.nv-side-panel-overlay::backdrop{animation:modal-in .3s var(--ease-out)}dialog.nv-side-panel-overlay[data-state=closed]::backdrop{animation:modal-out .2s var(--ease-out)forwards}}.nv-side-panel-overlay[popover]{margin:calc(var(--spacing)*0);width:100vw;max-width:none;height:100vh;max-height:none;padding:calc(var(--spacing)*0);background-color:#0000;border:none}.nv-side-panel-overlay[popover]:popover-open{inset:calc(var(--spacing)*0);z-index:1000;position:fixed}.nv-side-panel-overlay[popover]::backdrop{background-color:var(--background-color-surface-blanket)}dialog.nv-side-panel-dialog{pointer-events:none;width:100vw;height:100vh}dialog.nv-side-panel-dialog[open]{inset:calc(var(--spacing)*0);z-index:1000;position:fixed}dialog.nv-side-panel-dialog::backdrop{background-color:#0000}dialog.nv-side-panel-dialog:has(>.nv-side-panel-content--relative){width:100%;height:100%}dialog.nv-side-panel-dialog:has(>.nv-side-panel-content--relative)[open]{inset:calc(var(--spacing)*0);z-index:1000;position:absolute}.nv-side-panel-content{pointer-events:auto;top:calc(var(--spacing)*0);bottom:calc(var(--spacing)*0);z-index:1030;width:var(--side-panel-width,320px);background-color:var(--background-color-surface-raised);max-width:100%;box-shadow:var(--shadow-lg);font-family:var(--font-sans);font-size:var(--text-14);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular);color:var(--text-color-primary);--heading-padding-block:calc(var(--spacing-density-xl) + calc(var(--spacing)*3));flex-direction:column;display:flex;position:fixed}.nv-side-panel-content.nv-side-panel-content--relative{top:calc(var(--spacing)*0);bottom:calc(var(--spacing)*0);position:absolute}.nv-side-panel-content.nv-side-panel-content--side-left{left:calc(var(--spacing)*0)}@media (prefers-reduced-motion:no-preference){.nv-side-panel-content.nv-side-panel-content--side-left[data-state=open]{animation:left-sidepanel-in .3s var(--ease-out);animation-fill-mode:both}.nv-side-panel-content.nv-side-panel-content--side-left[data-state=closed]{animation:left-sidepanel-out .2s var(--ease-out)forwards}}.nv-side-panel-content.nv-side-panel-content--side-right{right:calc(var(--spacing)*0)}@media (prefers-reduced-motion:no-preference){.nv-side-panel-content.nv-side-panel-content--side-right[data-state=open]{animation:right-sidepanel-in .3s var(--ease-out);animation-fill-mode:both}.nv-side-panel-content.nv-side-panel-content--side-right[data-state=closed]{animation:right-sidepanel-out .2s var(--ease-out)forwards}}.nv-side-panel-content.nv-side-panel-content--bordered{border:1px solid var(--border-color-base)}.nv-side-panel-content.nv-side-panel-content--bordered .nv-side-panel-heading{border-bottom:1px solid var(--border-color-base)}.nv-side-panel-content.nv-side-panel-content--bordered .nv-side-panel-footer{border-top:1px solid var(--border-color-base)}.nv-side-panel-heading{align-items:center;gap:calc(var(--spacing)*2);width:100%;padding-block:var(--heading-padding-block);padding-right:calc(var(--spacing)*14);padding-left:calc(var(--spacing)*4);font-family:var(--font-sans);font-size:var(--text-16);line-height:var(--leading-lh-125);font-weight:var(--font-weight-bold);line-height:var(--leading-lh-100);flex-shrink:0;display:flex;position:relative}.nv-side-panel-heading>svg,.nv-side-panel-heading>.nv-icon{width:calc(var(--spacing)*6);height:calc(var(--spacing)*6);color:var(--text-color-base);flex-shrink:0}.nv-side-panel-heading.nv-side-panel-heading--hidden{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.nv-side-panel-heading.nv-side-panel-heading--invisible{visibility:hidden}.nv-side-panel-navigation{padding-inline:calc(var(--spacing)*4)}.nv-side-panel-main{min-height:calc(var(--spacing)*0);gap:var(--spacing-density-md);padding:calc(var(--spacing)*4);scrollbar-width:thin;scrollbar-color:var(--nv-scrollbar-color);flex-direction:column;flex:1;margin-block-end:-1px;padding-block-end:calc(var(--spacing)*4 + 1px);display:flex;overflow-y:auto}.nv-side-panel-footer{justify-content:flex-end;align-items:center;gap:calc(var(--spacing)*2);padding-inline:calc(var(--spacing)*4);padding-block:var(--spacing-density-xl);flex-shrink:0;display:flex}.nv-side-panel-close{--close-button-top:calc(var(--heading-padding-block) - 1px);top:var(--close-button-top);right:calc(var(--spacing)*4);position:absolute;translate:0 -25%}.nv-side-panel-close>:not(svg):not(.nv-icon){clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.nv-side-panel-portal{pointer-events:none;inset:calc(var(--spacing)*0);z-index:1060;position:fixed}.nv-side-panel-portal>*{pointer-events:auto}.nv-skeleton{background-color:var(--background-color-component-skeleton);width:100%;height:1.3em}@media (prefers-reduced-motion:no-preference){.nv-skeleton.nv-skeleton--animated{animation:var(--animate-pulse)}}.nv-skeleton.nv-skeleton--kind-pill{width:calc(var(--spacing)*16);border-radius:var(--radius-xl)}.nv-skeleton.nv-skeleton--kind-circle{width:calc(var(--spacing)*8);height:calc(var(--spacing)*8);border-radius:3.40282e38px}.nv-slider-root{touch-action:none;-webkit-user-select:none;user-select:none;grid-template-rows:calc(var(--nv-slider-thumb-size)*2);--nv-slider-thumb-size:calc(var(--spacing)*3);--slider-fill-color:var(--color-brand);--slider-track-color:var(--background-color-component-track);grid-template-columns:1fr;align-items:center;width:100%;display:grid;position:relative}.nv-slider-root:where(.nv-slider-root--orientation-vertical){grid-template-rows:1fr;grid-template-columns:calc(var(--nv-slider-thumb-size)*2);width:fit-content;height:100%}.nv-slider-root:has(.nv-slider-input:disabled){--slider-fill-color:var(--text-color-disabled);--slider-track-color:var(--background-color-interaction-disabled)}.nv-slider-root:has(.nv-slider-steps--position-end){row-gap:var(--nv-slider-steps-gap,calc(var(--spacing)*.5));overflow:visible}.nv-slider-root:has(.nv-slider-steps--position-start):not(.nv-slider-root--orientation-vertical){row-gap:var(--nv-slider-steps-gap,calc(var(--spacing)*.5));grid-template-rows:auto calc(var(--nv-slider-thumb-size)*2);overflow:visible}.nv-slider-root--orientation-vertical:has(.nv-slider-steps--position-start){grid-template-columns:auto calc(var(--nv-slider-thumb-size)*2)}.nv-slider-root--orientation-vertical:has(.nv-slider-steps--position-start) .nv-slider-input{left:auto;right:0}.nv-slider-root--orientation-vertical:has(.nv-slider-steps--position-end){grid-template-columns:calc(var(--nv-slider-thumb-size)*2)auto}.nv-slider-root .nv-slider-input{appearance:none;cursor:pointer;width:100%;height:calc(var(--nv-slider-thumb-size)*2);z-index:1;background:0 0;margin:0;position:absolute;top:0;left:0;right:0}.nv-slider-root .nv-slider-input::-webkit-slider-runnable-track{height:calc(var(--spacing)*1);border-radius:var(--radius-xl);background-color:var(--slider-track-color)}.nv-slider-root .nv-slider-input::-moz-range-track{height:calc(var(--spacing)*1);border-radius:var(--radius-xl);background-color:var(--slider-track-color)}.nv-slider-root .nv-slider-input::-webkit-slider-thumb{border:1px solid;border-color:var(--border-color-interaction-strong);background-color:var(--text-color-accent-black);width:var(--nv-slider-thumb-size);height:var(--nv-slider-thumb-size);margin-top:calc((var(--spacing) - var(--nv-slider-thumb-size))/2);border-radius:3.40282e38px}.nv-slider-root .nv-slider-input::-moz-range-thumb{border:1px solid;border-color:var(--border-color-interaction-strong);background-color:var(--text-color-accent-black);width:var(--nv-slider-thumb-size);height:var(--nv-slider-thumb-size);margin-top:calc((var(--spacing) - var(--nv-slider-thumb-size))/2);border-radius:3.40282e38px}.nv-slider-root .nv-slider-input::-webkit-slider-thumb{-webkit-appearance:none;margin-top:calc((var(--spacing) - var(--nv-slider-thumb-size))/2)}.nv-slider-root .nv-slider-input::-moz-range-thumb{box-sizing:border-box}@media (scripting:enabled){.nv-slider-root .nv-slider-input::-webkit-slider-runnable-track{background:linear-gradient(to right,var(--slider-fill-color)var(--slider-percent,0.0%),var(--slider-track-color)var(--slider-percent,0.0%))}.nv-slider-root .nv-slider-input::-moz-range-progress{background-color:var(--slider-fill-color);border-radius:var(--radius-xl);height:calc(var(--spacing)*1)}.nv-slider-root--orientation-vertical .nv-slider-input::-webkit-slider-runnable-track{background:linear-gradient(to top,var(--slider-fill-color)var(--slider-percent,0.0%),var(--slider-track-color)var(--slider-percent,0.0%))}}.nv-slider-root .nv-slider-input:disabled{cursor:default}.nv-slider-root .nv-slider-input:disabled::-webkit-slider-thumb{border-color:var(--border-color-interaction-disabled);background-color:var(--background-color-accent-gray)}.nv-slider-root .nv-slider-input:disabled::-moz-range-thumb{border-color:var(--border-color-interaction-disabled);background-color:var(--background-color-accent-gray)}.nv-slider-root--orientation-vertical .nv-slider-input{writing-mode:vertical-lr;height:100%;width:calc(var(--nv-slider-thumb-size)*2);right:unset;direction:rtl}.nv-slider-root--orientation-vertical .nv-slider-input::-webkit-slider-thumb{margin-left:calc((calc(var(--spacing)*1) - var(--nv-slider-thumb-size))/2)}.nv-slider-root--orientation-vertical .nv-slider-input::-moz-range-thumb{margin-left:calc((calc(var(--spacing)*1) - var(--nv-slider-thumb-size))/2)}.nv-slider-root--orientation-vertical .nv-slider-input::-webkit-slider-runnable-track{border-radius:var(--radius-xl);width:calc(var(--spacing)*1);height:100%}.nv-slider-root--orientation-vertical .nv-slider-input::-moz-range-track{border-radius:var(--radius-xl);background-color:var(--slider-track-color);width:calc(var(--spacing)*1);height:100%}.nv-slider-root--orientation-vertical .nv-slider-input::-moz-range-progress{background-color:var(--slider-fill-color);border-radius:var(--radius-xl);width:calc(var(--spacing)*1)}.nv-slider-steps.nv-slider-steps--position-end{padding-inline:calc(var(--nv-slider-thumb-size)/2);grid-area:2/1;justify-content:space-between;align-self:flex-start;display:flex}.nv-slider-steps.nv-slider-steps--position-start{padding-inline:calc(var(--nv-slider-thumb-size)/2);grid-area:1/1;justify-content:space-between;align-self:flex-end;display:flex}.nv-slider-root--orientation-vertical .nv-slider-steps.nv-slider-steps--position-start{padding-inline:0;padding-block:calc(var(--nv-slider-thumb-size)/2);flex-direction:column-reverse;grid-area:1/1;justify-content:space-between;align-self:stretch;align-items:flex-end;display:flex}.nv-slider-root--orientation-vertical .nv-slider-steps.nv-slider-steps--position-start .nv-slider-step{height:calc(var(--spacing)*0);flex-direction:row-reverse;width:fit-content}.nv-slider-root--orientation-vertical .nv-slider-steps.nv-slider-steps--position-end{padding-inline:0;padding-block:calc(var(--nv-slider-thumb-size)/2);flex-direction:column-reverse;grid-area:1/2;justify-content:space-between;align-self:stretch;align-items:flex-start;display:flex}.nv-slider-root--orientation-vertical .nv-slider-steps.nv-slider-steps--position-end .nv-slider-step{height:calc(var(--spacing)*0);flex-direction:row;width:fit-content}.nv-slider-step{width:calc(var(--spacing)*0);align-items:center;gap:var(--spacing-density-xs);flex-direction:column;display:flex;overflow:visible}.nv-slider-step-dot{height:calc(var(--spacing)*1);width:calc(var(--spacing)*1);background-color:var(--text-color-secondary);border-radius:3.40282e38px;display:block}.nv-slider-step-label{font-family:var(--font-sans);font-size:var(--text-12);line-height:var(--leading-lh-125);font-weight:var(--font-weight-regular);white-space:nowrap;color:var(--text-color-secondary)}.nv-range-slider-root{touch-action:none;-webkit-user-select:none;user-select:none;grid-template-rows:calc(var(--nv-slider-thumb-size)*2);--nv-slider-thumb-size:calc(var(--spacing)*3);width:100%;height:calc(var(--nv-slider-thumb-size)*2);grid-template-columns:1fr;align-items:center;display:grid;position:relative}.nv-range-slider-root:where(.nv-range-slider-root--orientation-vertical){grid-template-rows:1fr;grid-template-columns:calc(var(--nv-slider-thumb-size)*2);justify-items:center;width:fit-content;height:100%}.nv-range-slider-root--orientation-vertical:has(.nv-range-slider-steps--position-start){grid-template-columns:auto calc(var(--nv-slider-thumb-size)*2);column-gap:var(--nv-slider-steps-gap,calc(var(--spacing)*.5))}.nv-range-slider-root--orientation-vertical:has(.nv-range-slider-steps--position-end){grid-template-columns:calc(var(--nv-slider-thumb-size)*2)auto;column-gap:var(--nv-slider-steps-gap,calc(var(--spacing)*.5))}.nv-range-slider-track{height:calc(var(--spacing)*1);border-radius:var(--radius-xl);background-color:var(--background-color-component-track);flex-grow:1;width:100%;position:relative;overflow:hidden}.nv-range-slider-range{border-radius:var(--radius-xl);background-color:var(--color-brand);width:auto;height:100%;position:absolute}.nv-range-slider-range--orientation-vertical{bottom:calc(var(--spacing)*0);background-color:var(--color-brand);width:100%;height:auto}.nv-range-slider-thumb{box-shadow:var(--shadow-sm);cursor:pointer;border:1px solid;border-color:var(--border-color-interaction-strong);background-color:var(--text-color-accent-black);border-radius:3.40282e38px;display:block;position:relative}.nv-range-slider-thumb:focus{outline-style:none}.nv-range-slider-thumb:after{content:"";pointer-events:none;inset:calc(var(--spacing)*0);border-radius:inherit;position:absolute}.nv-range-slider-thumb:hover:after{background-color:var(--background-color-interaction-hover)}.nv-range-slider-thumb:active:after{background-color:var(--background-color-interaction-selected)}.nv-range-slider-root .nv-range-slider-thumb{width:var(--nv-slider-thumb-size);height:var(--nv-slider-thumb-size)}.nv-range-slider-root>.nv-range-slider-track,.nv-range-slider-root>:not(.nv-range-slider-track):not(.nv-range-slider-steps):not(.nv-range-slider-native-fallback):not(.nv-range-slider-native-fallback-fields){grid-area:1/1}.nv-range-slider-root--orientation-vertical:has(.nv-range-slider-steps--position-start)>.nv-range-slider-track,.nv-range-slider-root--orientation-vertical:has(.nv-range-slider-steps--position-start)>:not(.nv-range-slider-track):not(.nv-range-slider-steps):not(.nv-range-slider-native-fallback):not(.nv-range-slider-native-fallback-fields){grid-column:2}.nv-range-slider-root--orientation-vertical .nv-range-slider-track{height:100%;width:calc(var(--spacing)*1)}.nv-range-slider-root--orientation-vertical>:not(.nv-range-slider-track):not(.nv-range-slider-steps):not(.nv-range-slider-native-fallback):not(.nv-range-slider-native-fallback-fields){left:calc(var(--nv-slider-thumb-size)/2)}.nv-range-slider-root[data-disabled] .nv-range-slider-track{background-color:var(--background-color-interaction-disabled)}.nv-range-slider-root[data-disabled] .nv-range-slider-range{background-color:var(--text-color-disabled);background-image:none}.nv-range-slider-root[data-disabled] .nv-range-slider-thumb{border-color:var(--border-color-interaction-disabled);background-color:var(--background-color-accent-gray)}.nv-range-slider-native-fallback-fields{gap:var(--spacing-density-sm);min-width:0}.nv-range-slider-native-fallback-field{gap:var(--spacing-density-xs);min-width:0;display:grid}.nv-range-slider-native-fallback{width:100%;min-width:0}.nv-range-slider-native-fallback-label{font-family:var(--font-sans);font-size:var(--text-12);line-height:var(--leading-lh-125);font-weight:var(--font-weight-regular);color:var(--text-color-secondary)}@media (scripting:enabled){.nv-range-slider-native-fallback,.nv-range-slider-native-fallback-fields,.nv-range-slider-native-fallback-field,.nv-range-slider-native-fallback-label,.nv-range-slider-native-fallback-steps{display:none}}@media (scripting:none){.nv-range-slider-root{touch-action:auto;-webkit-user-select:auto;user-select:auto;height:auto;display:block}.nv-range-slider-track,.nv-range-slider-thumb,.nv-range-slider-range,.nv-range-slider-steps,.nv-range-slider-root>:not(.nv-range-slider-track):not(.nv-range-slider-steps):not(.nv-range-slider-native-fallback-fields){display:none!important}.nv-range-slider-native-fallback-fields{display:flex}.nv-range-slider-native-fallback-field{flex:1 1 0}}.nv-range-slider-root:has(.nv-range-slider-steps--position-end):not(.nv-range-slider-root--orientation-vertical){row-gap:var(--nv-slider-steps-gap,calc(var(--spacing)*.5));height:auto}.nv-range-slider-root:has(.nv-range-slider-steps--position-end):not(.nv-range-slider-root--orientation-vertical)>:not(.nv-range-slider-track):not(.nv-range-slider-steps):not(.nv-range-slider-native-fallback):not(.nv-range-slider-native-fallback-fields){top:calc(var(--nv-slider-thumb-size)/2)}.nv-range-slider-root:has(.nv-range-slider-steps--position-start):not(.nv-range-slider-root--orientation-vertical){row-gap:var(--nv-slider-steps-gap,calc(var(--spacing)*.5));grid-template-rows:auto calc(var(--nv-slider-thumb-size)*2);height:auto}.nv-range-slider-root:has(.nv-range-slider-steps--position-start):not(.nv-range-slider-root--orientation-vertical)>:not(.nv-range-slider-track):not(.nv-range-slider-steps):not(.nv-range-slider-native-fallback):not(.nv-range-slider-native-fallback-fields){top:calc(var(--nv-slider-thumb-size)/2)}.nv-range-slider-steps{position:relative}.nv-range-slider-steps.nv-range-slider-steps--position-end{padding-inline:calc(var(--nv-slider-thumb-size)/2);grid-area:2/1;justify-content:space-between;display:flex}.nv-range-slider-steps.nv-range-slider-steps--position-start{padding-inline:calc(var(--nv-slider-thumb-size)/2);grid-area:1/1;justify-content:space-between;display:flex}.nv-range-slider-root--orientation-vertical .nv-range-slider-steps.nv-range-slider-steps--position-start{padding-inline:0;padding-block:calc(var(--nv-slider-thumb-size)/2);flex-direction:column-reverse;grid-area:1/1;justify-content:space-between;align-self:stretch;align-items:flex-end;display:flex}.nv-range-slider-root--orientation-vertical .nv-range-slider-steps.nv-range-slider-steps--position-start .nv-range-slider-step{height:calc(var(--spacing)*0);flex-direction:row-reverse;width:fit-content}.nv-range-slider-root--orientation-vertical .nv-range-slider-steps.nv-range-slider-steps--position-end{padding-inline:0;padding-block:calc(var(--nv-slider-thumb-size)/2);flex-direction:column-reverse;grid-area:1/2;justify-content:space-between;align-self:stretch;align-items:flex-start;display:flex}.nv-range-slider-root--orientation-vertical .nv-range-slider-steps.nv-range-slider-steps--position-end .nv-range-slider-step{height:calc(var(--spacing)*0);flex-direction:row;width:fit-content}.nv-range-slider-step{width:calc(var(--spacing)*0);align-items:center;gap:var(--spacing-density-xs);flex-direction:column;display:flex;overflow:visible}.nv-range-slider-step-dot{height:calc(var(--spacing)*1);width:calc(var(--spacing)*1);background-color:var(--text-color-secondary);border-radius:3.40282e38px;display:block}.nv-range-slider-step-label{font-family:var(--font-sans);font-size:var(--text-12);line-height:var(--leading-lh-125);font-weight:var(--font-weight-regular);white-space:nowrap;color:var(--text-color-secondary)}.nv-spinner-root{filter:drop-shadow(0 0 calc(var(--spinner-shadow-size)*2)#76b90080);flex-direction:column;align-items:center;display:flex}@supports (color:color-mix(in lab, red, red)){.nv-spinner-root{filter:drop-shadow(0 0 calc(var(--spinner-shadow-size)*2)color-mix(in srgb,var(--background-color-interaction-primary-base)50.0%,transparent))}}.nv-spinner-root svg{width:auto}.nv-spinner-root--size-small{gap:calc(var(--spacing)*2);--spinner-shadow-size:calc(var(--spacing)*1.5)}.nv-spinner-root--size-small>div:first-child{height:calc(var(--spacing)*8)}.nv-spinner-root--size-medium{gap:calc(var(--spacing)*3);--spinner-shadow-size:calc(var(--spacing)*2)}.nv-spinner-root--size-medium>div:first-child{height:calc(var(--spacing)*16)}.nv-spinner-root--size-large{gap:calc(var(--spacing)*6);--spinner-shadow-size:calc(var(--spacing)*3)}.nv-spinner-root--size-large>div:first-child{height:calc(var(--spacing)*32)}.nv-spinner-arrow{fill:var(--background-color-interaction-primary-base);width:0;height:0;animation:1s infinite spinnerArrowOpacity}.nv-spinner-description{font-family:var(--font-sans);font-size:var(--text-14);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular);color:var(--text-color-primary)}@keyframes spinnerArrowOpacity{0%{opacity:.1}30%{opacity:1}to{opacity:.1}}.nv-stepper-root{gap:calc(var(--spacing)*3);display:flex}.nv-stepper-root:not(.nv-stepper-root--layout-vertical){width:100%;overflow:auto}.nv-stepper-root.nv-stepper-root--layout-vertical{flex-direction:column;height:100%}.nv-stepper-root.nv-stepper-root--kind-compact{gap:calc(var(--spacing)*1)}.nv-stepper-root.nv-stepper-root--kind-compact.nv-stepper-root--layout-horizontal{flex-direction:column}.nv-stepper-root.nv-stepper-root--kind-compact.nv-stepper-root--layout-vertical{flex-direction:column;align-items:flex-start;height:auto}.nv-stepper-item{flex-direction:column;display:flex}.nv-stepper-root--layout-horizontal>.nv-stepper-item{flex:1}.nv-stepper-root--kind-compact .nv-stepper-item{min-width:calc(var(--spacing)*0);flex:none}.nv-stepper-node-row{align-items:center;gap:calc(var(--spacing)*3);flex-shrink:0;width:100%;display:flex}.nv-stepper-node{width:calc(var(--spacing)*6);height:calc(var(--spacing)*6);border:2px solid;border-color:var(--border-color-base);border-radius:3.40282e38px;flex-shrink:0;justify-content:center;align-items:center;display:flex;position:relative;overflow:clip}.nv-stepper-item[data-state=completed] .nv-stepper-node{background-color:var(--background-color-interaction-primary-base);border-color:#0000}.nv-stepper-item[data-state=active] .nv-stepper-node{border-color:var(--border-color-interaction-selected)}.nv-stepper-item[data-status=error] .nv-stepper-node{border-color:var(--border-color-feedback-danger);background-color:#0000}.nv-stepper-root--kind-compact .nv-stepper-node{width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.nv-stepper-node:has(a,button){cursor:pointer;position:relative}.nv-stepper-node:has(a,button) a:after,.nv-stepper-node:has(a,button) button:after{content:"";position:absolute;inset:0}.nv-stepper-node-icon{width:calc(var(--spacing)*3);height:calc(var(--spacing)*3);justify-content:center;align-items:center;display:flex}.nv-stepper-node-icon svg,.nv-stepper-node-icon .nv-icon{width:calc(var(--spacing)*3);height:calc(var(--spacing)*3);color:var(--text-color-accent-black)}.nv-stepper-item[data-status=error] .nv-stepper-node-icon.nv-stepper-node-icon--error svg,.nv-stepper-item[data-status=error] .nv-stepper-node-icon.nv-stepper-node-icon--error .nv-icon{color:var(--text-color-feedback-danger)}.nv-stepper-node-number{font-family:var(--font-sans);font-size:var(--text-14);line-height:1.21429;font-weight:var(--font-weight-regular);color:var(--text-color-secondary)}.nv-stepper-item[data-state=active] .nv-stepper-node-number{color:var(--text-color-strong)}.nv-stepper-item[data-state=completed] .nv-stepper-node-number{inset:calc(var(--spacing)*0);color:#0000;position:absolute}.nv-stepper-item-heading{font-family:var(--font-sans);font-size:var(--text-12);line-height:var(--leading-lh-125);font-weight:var(--font-weight-bold);white-space:nowrap;color:var(--text-color-secondary)}.nv-stepper-item[data-state=active] .nv-stepper-item-heading,.nv-stepper-item[data-state=completed] .nv-stepper-item-heading{color:var(--text-color-primary)}.nv-stepper-item-body{gap:calc(var(--spacing)*3);flex-direction:column;display:flex}.nv-stepper-root--layout-horizontal:not(.nv-stepper-root--kind-compact)>.nv-stepper-item>.nv-stepper-item-body{padding-left:calc(var(--spacing)*9)}.nv-stepper-item-description{font-family:var(--font-sans);font-size:var(--text-12);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular);color:var(--text-color-secondary)}.nv-stepper-root--kind-compact .nv-stepper-item-description{font-family:var(--font-sans);font-size:var(--text-12);line-height:var(--leading-lh-125);font-weight:var(--font-weight-regular)}.nv-stepper-item[data-state=default] .nv-stepper-item-description{color:var(--text-color-placeholder)}.nv-stepper-item-content{display:flex}.nv-stepper-root--layout-horizontal:not(.nv-stepper-root--kind-compact)>.nv-stepper-item:not(:last-child)>.nv-stepper-node-row:after{content:"";height:calc(var(--spacing)*.5);background-image:repeating-linear-gradient(to right,var(--border-color-base)0,var(--border-color-base)var(--spacing),transparent var(--spacing),transparent calc(var(--spacing)*2));border-radius:3.40282e38px;flex:1;display:block}.nv-stepper-root--layout-horizontal:not(.nv-stepper-root--kind-compact)>.nv-stepper-item[data-state=completed]:not(:last-child)>.nv-stepper-node-row:after{background:var(--border-color-interaction-selected)}.nv-stepper-root--layout-vertical:not(.nv-stepper-root--kind-compact)>.nv-stepper-item:not(:last-child)>.nv-stepper-node-row:after{content:"";min-height:calc(var(--spacing)*6);width:calc(var(--spacing)*.5);background-image:repeating-linear-gradient(to bottom,var(--border-color-base)0,var(--border-color-base)var(--spacing),transparent var(--spacing),transparent calc(var(--spacing)*2));border-radius:3.40282e38px;flex:1;display:block}.nv-stepper-root--layout-vertical:not(.nv-stepper-root--kind-compact)>.nv-stepper-item[data-state=completed]:not(:last-child)>.nv-stepper-node-row:after{background:var(--border-color-interaction-selected)}.nv-stepper-compact-dots{align-items:center;display:flex}.nv-stepper-root--layout-vertical>.nv-stepper-compact-dots{flex-direction:column}.nv-stepper-compact-dots>.nv-stepper-item{flex-direction:row;align-items:center;display:flex}.nv-stepper-compact-dots>.nv-stepper-item>.nv-stepper-node-row{width:auto}.nv-stepper-compact-dots>.nv-stepper-item:not(:last-child):after{content:"";height:calc(var(--spacing)*.5);width:calc(var(--spacing)*4);background-image:repeating-linear-gradient(to right,var(--border-color-base)0,var(--border-color-base)var(--spacing),transparent var(--spacing),transparent calc(var(--spacing)*2));border-radius:3.40282e38px;margin-inline:4px;display:block}.nv-stepper-compact-dots>.nv-stepper-item[data-state=completed]:not(:last-child):after{background:var(--border-color-interaction-selected)}.nv-stepper-root--layout-vertical .nv-stepper-compact-dots>.nv-stepper-item{flex-direction:column;align-items:center;display:flex}.nv-stepper-root--layout-vertical .nv-stepper-compact-dots>.nv-stepper-item:not(:last-child):after{width:calc(var(--spacing)*.5);background-image:repeating-linear-gradient(to bottom,var(--border-color-base)0,var(--border-color-base)var(--spacing),transparent var(--spacing),transparent calc(var(--spacing)*2));height:16px;margin-block:4px}.nv-stepper-active-info{flex-direction:column;justify-content:center;align-items:flex-start;gap:1px;display:flex}.nv-stepper-active-info>.nv-stepper-item-heading{color:var(--text-color-primary)}.nv-stepper-item-text{min-height:calc(var(--spacing)*6);flex-direction:column;justify-content:center;align-items:flex-start;gap:1px;display:flex}.nv-stepper-root--layout-vertical:not(.nv-stepper-root--kind-compact)>.nv-stepper-item{gap:calc(var(--spacing)*3);flex-direction:row;flex:1;width:100%;min-height:60px}.nv-stepper-root--layout-vertical:not(.nv-stepper-root--kind-compact)>.nv-stepper-item>.nv-stepper-node-row{width:calc(var(--spacing)*6);align-items:center;gap:calc(var(--spacing)*3);flex-direction:column;align-self:stretch}.nv-stepper-root--layout-vertical:not(.nv-stepper-root--kind-compact)>.nv-stepper-item>.nv-stepper-item-body{gap:calc(var(--spacing)*3);padding-top:calc(var(--spacing)*0);padding-left:calc(var(--spacing)*0);flex:1}.nv-stepper-root--layout-vertical:not(.nv-stepper-root--kind-compact)>.nv-stepper-item>.nv-stepper-item-body>.nv-stepper-item-heading{white-space:normal}.nv-status-indicator{width:var(--size);height:var(--size);background-color:var(--color);border-radius:3.40282e38px;display:inline-block}.nv-status-indicator,.nv-status-indicator.nv-status-indicator--size-medium{--size:8px}.nv-status-indicator.nv-status-indicator--size-small{--size:6px}.nv-status-indicator.nv-status-indicator--size-large{--size:12px}.nv-status-indicator.nv-status-indicator--size-xlarge{--size:16px}.nv-status-indicator.nv-status-indicator--size-xxlarge{--size:20px}.nv-status-indicator,.nv-status-indicator.nv-status-indicator--color-red{--color:var(--text-color-feedback-danger)}.nv-status-indicator.nv-status-indicator--color-blue{--color:var(--text-color-feedback-info)}.nv-status-indicator.nv-status-indicator--color-yellow{--color:var(--text-color-feedback-warning)}.nv-status-indicator.nv-status-indicator--color-green{--color:var(--text-color-feedback-success)}.nv-status-message-root{justify-content:center;align-items:center;gap:calc(var(--spacing)*4);width:fit-content;min-width:200px;font-family:var(--font-sans);color:var(--text-color-primary);flex-direction:column;margin-inline:auto;display:flex}.nv-status-message-root .nv-status-message-heading{font-family:var(--font-sans);font-size:var(--text-24);line-height:var(--leading-lh-150);font-weight:var(--font-weight-bold)}.nv-status-message-root .nv-status-message-actions{justify-content:center;align-items:center;gap:calc(var(--spacing)*2);width:100%;display:flex}.nv-status-message-root .nv-status-message-header{justify-content:center;align-items:center;gap:calc(var(--spacing)*2);text-align:center;flex-direction:column;width:100%;display:flex}.nv-status-message-root .nv-status-message-footer{justify-content:center;align-items:center;gap:calc(var(--spacing)*2);width:100%;display:flex}.nv-status-message-root .nv-status-message-subheading{font-family:var(--font-sans);font-size:var(--text-14);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular);color:var(--text-color-secondary)}.nv-status-message-root .nv-status-message-media{font-size:var(--text-64);color:var(--text-color-base);justify-content:center;align-items:center;display:flex}.nv-status-message-root.nv-status-message-root--size-small .nv-status-message-heading{font-family:var(--font-sans);font-size:var(--text-18);line-height:var(--leading-lh-150);font-weight:var(--font-weight-bold)}.nv-status-message-root.nv-status-message-root--size-small .nv-status-message-media{font-size:var(--text-32)}.nv-status-message-root.nv-status-message-root--size-small .nv-status-message-actions{gap:calc(var(--spacing)*1.5)}.nv-status-message-root.nv-status-message-root--size-small .nv-status-message-header{gap:calc(var(--spacing)*1)}.nv-switch-root{align-items:center;gap:calc(var(--spacing)*2);--nv-switch-track-height:calc(var(--spacing)*6);--nv-switch-track-width:calc(var(--spacing)*11);--nv-switch-thumb-size:calc(var(--spacing)*4);--nv-switch-thumb-margin:calc(var(--spacing)*1);--nv-switch-thumb-margin-checked:calc(var(--spacing)*6);width:fit-content;display:inline-flex}.nv-switch-root.nv-switch-root--size-small{gap:calc(var(--spacing)*1);--nv-switch-track-height:calc(var(--spacing)*4);--nv-switch-track-width:calc(var(--spacing)*7);--nv-switch-thumb-size:calc(var(--spacing)*2);--nv-switch-thumb-margin-checked:calc(var(--spacing)*4)}.nv-switch-root.nv-switch-root--size-large{--nv-switch-track-height:calc(var(--spacing)*8);--nv-switch-track-width:calc(var(--spacing)*15);--nv-switch-thumb-size:calc(var(--spacing)*6);--nv-switch-thumb-margin-checked:calc(var(--spacing)*8)}.nv-switch-root.nv-switch-root--side-start{flex-direction:row-reverse}.nv-switch-input{appearance:none;cursor:pointer;border-radius:var(--radius-xl);height:var(--nv-switch-track-height);width:var(--nv-switch-track-width);box-shadow:inset 0 0 0 2px var(--border-color-interaction-strong);background-color:#0000;position:relative;overflow:hidden;scale:1}.nv-switch-input:focus-visible{outline:2px solid -webkit-focus-ring-color;outline-offset:2px}.nv-switch-input:before{content:"";box-shadow:var(--shadow-sm);z-index:1;border-radius:var(--radius-xl);background-color:var(--background-color-interaction-inverse);top:50%;left:var(--nv-switch-thumb-margin);width:var(--nv-switch-thumb-size);height:var(--nv-switch-thumb-size);transition:left .3s var(--ease-out),background-color .3s var(--ease-out);display:block;position:absolute;transform:translateY(-50%)}@media (prefers-reduced-motion:reduce){.nv-switch-input:before{transition:none}}.nv-switch-input:after{content:"";inset:calc(var(--spacing)*0);z-index:calc(1*-1);border-radius:var(--radius-xl);background-color:var(--background-color-interaction-primary-base);transition:transform .25s var(--ease-out);position:absolute;transform:translate(-100%)}@media (prefers-reduced-motion:reduce){.nv-switch-input:after{transition:none}}.nv-switch-input:is(:checked,[data-state=checked]){box-shadow:none}.nv-switch-input:is(:checked,[data-state=checked]):before{left:var(--nv-switch-thumb-margin-checked);background-color:var(--text-color-accent-black)}.nv-switch-input:is(:checked,[data-state=checked]):after{transform:translate(0)}.nv-switch-input:is(:disabled,[data-disabled]){cursor:not-allowed;box-shadow:inset 0 0 0 2px var(--border-color-disabled)}.nv-switch-input:is(:disabled,[data-disabled]):before{background-color:var(--text-color-disabled)}.nv-switch-input:is(:disabled,[data-disabled]):is(:checked,[data-state=checked]){box-shadow:none}.nv-switch-input:is(:disabled,[data-disabled]):is(:checked,[data-state=checked]):after{background-color:var(--background-color-interaction-disabled-checked)}.nv-switch-input:is(:disabled,[data-disabled]):is(:checked,[data-state=checked]):before{background-color:var(--text-color-interaction-disabled-checked)}.nv-table-root{--table-cell-inline-padding:var(--spacing-density-lg);--table-cell-block-padding:var(--spacing-density-md);--table-cell-content-height:40px;background-color:var(--background-color-surface-base);font-size:var(--text-14);line-height:var(--leading-lh-150);color:var(--text-color-primary)}.nv-table-root,.nv-table-root.nv-table-root--layout-fixed{table-layout:fixed}.nv-table-root.nv-table-root--layout-auto{table-layout:auto}@media (hover:hover){:where(.nv-table-root.nv-table-root--hoverable-rows .nv-table-body .nv-table-row):hover{background-color:var(--background-color-interaction-hover)}}.nv-table--align-left{text-align:left}.nv-table--align-center{text-align:center}.nv-table--align-right{text-align:right}.nv-table-row{border-bottom:1px solid;border-color:var(--border-color-base)}.nv-table-row.nv-table-row--selected{background-color:var(--background-color-interaction-pressed)}.nv-table-data-cell,.nv-table-header-cell{text-overflow:ellipsis;white-space:nowrap;padding-inline:var(--table-cell-inline-padding);padding-block:var(--table-cell-block-padding);vertical-align:middle;box-sizing:border-box;height:calc(var(--table-cell-content-height) + var(--table-cell-block-padding)*2);align-items:center;overflow:hidden}:is(.nv-table-data-cell,.nv-table-header-cell) [data-sorting-icon]{color:var(--text-color-base)}:is(.nv-table-data-cell,.nv-table-header-cell) [data-sorting-icon][data-selected]{color:var(--text-color-primary)}.nv-table-head{border-bottom:2px solid;border-color:var(--border-color-base);font-weight:var(--font-weight-semibold)}.nv-table-body{font-weight:var(--font-weight-regular)}.nv-table-toolbar{height:calc(var(--spacing)*12);position:relative}.nv-table-toolbar [data-active=false]{opacity:0;translate:0 130%}.nv-table-toolbar [data-active=true]{opacity:1;translate:0}.nv-table-toolbar .nv-table-toolbar-content{justify-content:space-between;align-items:center;gap:calc(var(--spacing)*1);width:100%;height:100%;padding-block:var(--spacing-density-xs);display:flex}.nv-table-toolbar .nv-table-toolbar-bulk-actions-section{inset:calc(var(--spacing)*0);position:absolute}@media (prefers-reduced-motion:no-preference){:is(.nv-table-toolbar .nv-table-toolbar-content,.nv-table-toolbar .nv-table-toolbar-bulk-actions-section){transition-property:translate,opacity;transition-duration:.15s;transition-timing-function:var(--ease-out)}}.nv-table-bulk-action-toolbar{justify-content:space-between;align-items:center;gap:calc(var(--spacing)*1);background-color:var(--background-color-component-track);padding-inline:var(--spacing-density-xl);padding-block:var(--spacing-density-xs);display:flex}.nv-tabs-root{gap:inherit;flex-direction:column;width:100%;max-width:100%;display:flex;overflow:hidden}.nv-tabs-content{align-items:flex-start;gap:calc(var(--spacing)*4);padding:calc(var(--spacing)*6);flex-direction:column;align-self:stretch;display:flex}.nv-tabs-content:not([data-active]){display:none}.nv-tabs-list{align-items:center;gap:calc(var(--spacing)*2);flex-wrap:nowrap;width:100%;display:flex;position:relative}.nv-tabs-list .nv-tabs-trigger{cursor:pointer;justify-content:center;align-items:center;gap:calc(var(--spacing)*2);min-width:fit-content;font-size:var(--text-14);line-height:var(--leading-lh-100);color:var(--text-color-secondary);font-weight:var(--font-weight-regular);flex-shrink:0;display:inline-flex}.nv-tabs-list .nv-tabs-trigger:hover,.nv-tabs-list .nv-tabs-trigger:active,.nv-tabs-list .nv-tabs-trigger:focus-visible{color:var(--text-color-primary)}.nv-tabs-list .nv-tabs-trigger .nv-tabs-trigger-visible,.nv-tabs-list .nv-tabs-trigger .nv-tabs-trigger-invisible{justify-content:center;align-items:center;gap:inherit;display:inline-flex}.nv-tabs-list .nv-tabs-trigger .nv-tabs-trigger-visible{position:absolute}.nv-tabs-list .nv-tabs-trigger .nv-tabs-trigger-invisible{visibility:hidden;font-weight:var(--font-weight-bold)}@media (prefers-reduced-motion:no-preference){.nv-tabs-list .nv-tabs-trigger{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-duration:.2s;transition-timing-function:var(--ease-out)}}.nv-tabs-list .nv-tabs-trigger[data-active]{color:var(--text-color-primary);font-weight:var(--font-weight-bold)}.nv-tabs-list .nv-tabs-trigger[data-active]>svg,.nv-tabs-list .nv-tabs-trigger[data-active]>.nv-icon{color:var(--text-color-brand)}.nv-tabs-list .nv-tabs-trigger:disabled,.nv-tabs-list .nv-tabs-trigger[data-disabled]{cursor:not-allowed;color:var(--text-color-disabled);background-color:#0000;border-color:#0000}.nv-tabs-list .nv-tabs-scroll-button{height:calc(var(--spacing)*10);width:calc(var(--spacing)*10)}.nv-tabs-list.nv-tabs-list--kind-primary{box-shadow:inset 0 -2px 0 0 var(--border-color-base)}.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-scroll-container{white-space:nowrap}.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-scroll-shadow{height:40px}.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger{height:calc(var(--spacing)*10);border-radius:var(--radius-none);padding-inline:calc(var(--spacing)*3);padding-block:calc(var(--spacing)*1);border-bottom:2px solid #0000;position:relative;overflow-x:clip}.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger:disabled{cursor:not-allowed}.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger:before,.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger:after{content:"";pointer-events:none;z-index:50;margin-top:calc(var(--spacing)*1);border-bottom:4px solid #0000;width:100%;height:100%;position:absolute;left:-100%}.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger:after{left:100%}.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger:not(:disabled):where(:hover,:active,:focus-visible){border-bottom-color:var(--border-color-interaction-hover)}.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger:where(.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger[data-active]){border-bottom-color:var(--border-color-interaction-hover)}.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger:where(.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger[data-active]):before,.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger:where(.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger[data-active]):after{left:calc(var(--spacing)*0);border-bottom-color:var(--border-color-interaction-selected);border-bottom-width:4px}@media (prefers-reduced-motion:no-preference){.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger:before{transition:left 0s var(--ease-out),border-color 0s var(--ease-out)}.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger:after{transition:left .2s var(--ease-out),border-color 0s var(--ease-out).2s}.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger:where(.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger[data-active]):before{transition:left .2s var(--ease-out),border-color 0s var(--ease-out)}.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger:where(.nv-tabs-list.nv-tabs-list--kind-primary .nv-tabs-trigger[data-active]):after{transition:left 0s var(--ease-out).2s,border-color 0s var(--ease-out).2s}}.nv-tabs-list.nv-tabs-list--kind-secondary .nv-tabs-scroll-container{height:calc(var(--spacing)*8);gap:calc(var(--spacing)*2)}.nv-tabs-list.nv-tabs-list--kind-secondary .nv-tabs-scroll-shadow{height:calc(var(--spacing)*8)}.nv-tabs-list.nv-tabs-list--kind-secondary .nv-tabs-trigger{border-radius:var(--radius-3xl);padding-inline:calc(var(--spacing)*3);padding-block:calc(var(--spacing)*1)}.nv-tabs-list.nv-tabs-list--kind-secondary .nv-tabs-trigger:not(:disabled):where(:hover,:focus-visible){background-color:var(--background-color-interaction-hover)}.nv-tabs-list.nv-tabs-list--kind-secondary .nv-tabs-trigger:active{background-color:var(--background-color-interaction-pressed)}.nv-tabs-list.nv-tabs-list--kind-secondary .nv-tabs-trigger[data-active]{background-color:var(--background-color-interaction-hover)}.nv-tabs-list.nv-tabs-list--kind-tertiary .nv-tabs-scroll-container{gap:calc(var(--spacing)*2);height:22px}.nv-tabs-list.nv-tabs-list--kind-tertiary .nv-tabs-scroll-shadow{height:22px}.nv-tabs-list.nv-tabs-list--kind-tertiary .nv-tabs-scroll-shadow:first-child{left:calc(var(--spacing)*8)}.nv-tabs-list.nv-tabs-list--kind-tertiary .nv-tabs-scroll-shadow:last-child{right:calc(var(--spacing)*8)}.nv-tabs-list.nv-tabs-list--kind-tertiary .nv-tabs-trigger{padding-inline:calc(var(--spacing)*3);padding-block:calc(var(--spacing)*1)}.nv-tabs-list.nv-tabs-list--kind-tertiary .nv-tabs-trigger:has(>svg:only-child,>.nv-icon:only-child){padding:calc(var(--spacing)*1)}.nv-tabs-scroll-container{scrollbar-width:none;-ms-overflow-style:none;flex-wrap:nowrap;align-items:center;width:100%;display:flex;position:relative;overflow-x:auto}.nv-tabs-scroll-container::-webkit-scrollbar{display:none}.nv-tabs-scroll-container.nv-tabs-scroll-container--fade-left{-webkit-mask-image:linear-gradient(90deg,#0000 0%,#000 5%);mask-image:linear-gradient(90deg,#0000 0%,#000 5%)}.nv-tabs-scroll-container.nv-tabs-scroll-container--fade-right{-webkit-mask-image:linear-gradient(270deg,#0000 0%,#000 5%);mask-image:linear-gradient(270deg,#0000 0%,#000 5%)}.nv-tabs-scroll-container.nv-tabs-scroll-container--fade-both{-webkit-mask-image:linear-gradient(90deg,#0000 0%,#000 5% 95%,#0000 100%);mask-image:linear-gradient(90deg,#0000 0%,#000 5% 95%,#0000 100%)}.nv-tabs-scroll-container-ellipses{padding-inline:calc(var(--spacing)*3);font-size:var(--text-14);color:var(--text-color-primary);font-weight:var(--font-weight-semibold)}.nv-tag{border-radius:var(--radius-3xl);align-items:center;gap:calc(var(--spacing)*1);width:fit-content;max-width:100%;height:fit-content;padding-inline:var(--spacing-density-lg);padding-block:calc(var(--spacing-density-sm) - 2px);font-family:var(--font-sans);font-size:var(--text-12);font-weight:var(--font-weight-semibold);vertical-align:middle;--bg-color:var(--background-color-accent-blue-subtle);--border-color:var(--border-color-accent-blue);--text-color:var(--text-color-accent-blue);--hover-bg-color:var(--background-color-accent-blue-subtle-hover);--hover-text-color:var(--text-color-accent-blue);--active-bg-color:var(--background-color-accent-blue-subtle-selected);--active-text-color:var(--text-color-accent-white);border:1px solid;border-color:var(--border-color);background-color:var(--bg-color);color:var(--text-color);flex-grow:0;flex-shrink:0;line-height:1.33333;display:inline-flex}.nv-tag svg,.nv-tag .nv-icon{flex-shrink:0;width:1em;height:1em}.nv-tag:disabled{cursor:not-allowed;border-color:var(--border-color-disabled);background-color:var(--background-color-interaction-disabled);color:var(--text-color-disabled)}.nv-tag:where(:not([disabled],[data-readonly])){cursor:pointer}@media (hover:hover){.nv-tag:where(:not([disabled],[data-readonly])):hover{color:var(--hover-text-color)}}@media (hover:hover){.nv-tag:where(:not([disabled],[data-readonly])):hover{background:var(--hover-bg-color)}}.nv-tag:where(:not([disabled],[data-readonly])):active{background-color:var(--active-bg-color);color:var(--active-text-color)}@media (prefers-reduced-motion:no-preference){.nv-tag:where(:not([disabled],[data-readonly])){transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-duration:.2s;transition-timing-function:var(--ease-out)}}.nv-tag:where([data-readonly]){cursor:default}.nv-tag:where(.nv-tag--kind-outline){--bg-color:transparent;--border-color:var(--border-color-accent-blue);--text-color:var(--text-color-accent-blue);--hover-bg-color:var(--background-color-accent-blue-hover);--hover-text-color:var(--text-color-accent-blue);--active-bg-color:var(--background-color-accent-blue-selected);--active-text-color:var(--text-color-accent-blue)}.nv-tag:where(.nv-tag--kind-outline):disabled{background-color:#0000}.nv-tag:where(.nv-tag--color-green){--bg-color:var(--background-color-accent-green-subtle);--border-color:var(--border-color-accent-green);--text-color:var(--text-color-accent-green);--hover-bg-color:var(--background-color-accent-green-subtle-hover);--hover-text-color:var(--text-color-accent-green);--active-bg-color:var(--background-color-accent-green-subtle-selected)}.nv-tag:where(.nv-tag--color-green):where(.nv-tag--kind-outline){--bg-color:inherit;--border-color:var(--border-color-accent-green);--text-color:var(--text-color-accent-green);--hover-bg-color:var(--background-color-accent-green-hover);--hover-text-color:var(--text-color-accent-green);--active-bg-color:var(--background-color-accent-green-selected);--active-text-color:var(--text-color-accent-green)}.nv-tag:where(.nv-tag--color-yellow){--bg-color:var(--background-color-accent-yellow-subtle);--border-color:var(--border-color-accent-yellow);--text-color:var(--text-color-accent-yellow);--hover-bg-color:var(--background-color-accent-yellow-subtle-hover);--hover-text-color:var(--text-color-accent-yellow);--active-bg-color:var(--background-color-accent-yellow-subtle-selected)}.nv-tag:where(.nv-tag--color-yellow):where(.nv-tag--kind-outline){--bg-color:inherit;--border-color:var(--border-color-accent-yellow);--text-color:var(--text-color-accent-yellow);--hover-bg-color:var(--background-color-accent-yellow-hover);--hover-text-color:var(--text-color-accent-yellow);--active-bg-color:var(--background-color-accent-yellow-selected);--active-text-color:var(--text-color-accent-yellow)}.nv-tag:where(.nv-tag--color-purple){--bg-color:var(--background-color-accent-purple-subtle);--border-color:var(--border-color-accent-purple);--text-color:var(--text-color-accent-purple);--hover-bg-color:var(--background-color-accent-purple-subtle-hover);--hover-text-color:var(--text-color-accent-purple);--active-bg-color:var(--background-color-accent-purple-subtle-selected)}.nv-tag:where(.nv-tag--color-purple):where(.nv-tag--kind-outline){--bg-color:inherit;--border-color:var(--border-color-accent-purple);--text-color:var(--text-color-accent-purple);--hover-bg-color:var(--background-color-accent-purple-hover);--hover-text-color:var(--text-color-accent-purple);--active-bg-color:var(--background-color-accent-purple-selected);--active-text-color:var(--text-color-accent-purple)}.nv-tag:where(.nv-tag--color-red){--bg-color:var(--background-color-accent-red-subtle);--border-color:var(--border-color-accent-red);--text-color:var(--text-color-accent-red);--hover-bg-color:var(--background-color-accent-red-subtle-hover);--hover-text-color:var(--text-color-accent-red);--active-bg-color:var(--background-color-accent-red-subtle-selected)}.nv-tag:where(.nv-tag--color-red):where(.nv-tag--kind-outline){--bg-color:inherit;--text-color:var(--text-color-accent-red);--border-color:var(--border-color-accent-red);--hover-bg-color:var(--background-color-accent-red-hover);--hover-text-color:var(--text-color-accent-red);--active-bg-color:var(--background-color-accent-red-selected);--active-text-color:var(--text-color-accent-red)}.nv-tag:where(.nv-tag--color-teal){--bg-color:var(--background-color-accent-teal-subtle);--border-color:var(--border-color-accent-teal);--text-color:var(--text-color-accent-teal);--hover-bg-color:var(--background-color-accent-teal-subtle-hover);--hover-text-color:var(--text-color-accent-teal);--active-bg-color:var(--background-color-accent-teal-subtle-selected)}.nv-tag:where(.nv-tag--color-teal):where(.nv-tag--kind-outline){--bg-color:inherit;--border-color:var(--border-color-accent-teal);--text-color:var(--text-color-accent-teal);--hover-bg-color:var(--background-color-accent-teal-hover);--hover-text-color:var(--text-color-accent-teal);--active-bg-color:var(--background-color-accent-teal-selected);--active-text-color:var(--text-color-accent-teal)}.nv-tag:where(.nv-tag--color-gray){--bg-color:var(--background-color-accent-gray-subtle);--border-color:var(--border-color-accent-gray);--text-color:var(--text-color-primary);--hover-bg-color:linear-gradient(0deg,var(--background-color-interaction-hover)0.0%,var(--background-color-interaction-hover)100.0%),var(--background-color-accent-gray-subtle);--hover-text-color:var(--text-color-primary);--active-bg-color:var(--background-color-accent-gray-subtle-selected);--active-text-color:var(--text-color-accent-white)}.nv-tag:where(.nv-tag--color-gray):where(.nv-tag--kind-outline){--bg-color:inherit;--border-color:var(--border-color-accent-gray);--text-color:var(--text-color-accent-gray);--hover-bg-color:var(--background-color-interaction-hover);--hover-text-color:var(--text-color-accent-gray);--active-bg-color:var(--background-color-accent-gray-selected);--active-text-color:var(--text-color-inverse)}.nv-tag:where(.nv-tag--selected,:has(:checked,[data-state=checked])){--bg-color:var(--active-bg-color);--text-color:var(--active-text-color);--hover-bg-color:oklch(from var(--active-bg-color)calc(l + .1)c h);--hover-text-color:var(--active-text-color)}.nv-text-area-root{--max-auto-height:400px}.nv-text-area-root .nv-text-area-element{resize:none;width:100%;min-width:100%;height:100%;min-height:3lh;font-family:var(--font-sans);font-size:var(--text-14);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular);text-overflow:ellipsis;scrollbar-width:thin;scrollbar-color:var(--nv-scrollbar-color);flex:1}.nv-text-area-root .nv-text-area-element::placeholder{color:var(--text-color-placeholder)}.nv-text-area-root .nv-text-area-element:focus-visible{outline:none}.nv-text-area-root .nv-text-area-element.nv-text-area-element--resizeable-manual{resize:vertical}.nv-text-area-root .nv-text-area-element.nv-text-area-element--resizeable-auto{field-sizing:content;max-height:var(--max-auto-height,100.0%)}.nv-toast-root{--toast-icon-color:var(--text-color-feedback-info);height:calc(var(--spacing)*10);border-radius:var(--radius-md);border:1px solid;border-color:var(--border-color-base);background-color:var(--background-color-surface-overlay);width:100%;padding:calc(var(--spacing)*2);font-family:var(--font-sans);color:var(--text-color-primary);font-style:normal;font-weight:var(--font-weight-regular);box-shadow:var(--shadow-lg);justify-content:space-between;align-items:center;display:flex}.nv-toast-root.nv-toast-root--status-success{--toast-icon-color:var(--text-color-feedback-success)}.nv-toast-root.nv-toast-root--status-warning{--toast-icon-color:var(--text-color-feedback-warning)}.nv-toast-root.nv-toast-root--status-error{--toast-icon-color:var(--text-color-feedback-danger)}.nv-toast-root.nv-toast-root--status-info{--toast-icon-color:var(--text-color-feedback-info)}.nv-toast-root.nv-toast-root--status-neutral,.nv-toast-root.nv-toast-root--status-working{--toast-icon-color:var(--text-color-base)}.nv-toast-icon{color:var(--toast-icon-color);flex-shrink:0;place-items:center;display:grid}.nv-toast-text{text-overflow:ellipsis;white-space:nowrap;font-size:var(--text-14);line-height:var(--leading-lh-150);overflow:hidden}.nv-toast-content{align-items:center;gap:calc(var(--spacing)*2);text-overflow:ellipsis;white-space:nowrap;display:flex;overflow:hidden}.nv-toast-actions{align-items:center;gap:calc(var(--spacing)*2);display:flex}@keyframes nv-tooltip-in{0%{opacity:0;translate:var(--nv-tooltip-translate-start)}to{opacity:1;translate:0}}.nv-tooltip-content{--nv-tooltip-translate-start:0 4px;border-radius:var(--radius-md);border:1px solid;border-color:var(--border-color-component-tooltip);background-color:var(--background-color-component-tooltip);padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*2);font-family:var(--font-sans);font-size:var(--text-12);line-height:var(--leading-lh-125);font-weight:var(--font-weight-semibold);color:var(--text-color-accent-white);box-shadow:var(--shadow-md);z-index:1080;text-wrap:wrap;opacity:0;width:max-content;max-width:min(100vw - 16px,320px);translate:var(--nv-tooltip-translate-start);margin:auto;font-style:normal;position:fixed;inset:auto}@media (prefers-reduced-motion:no-preference){.nv-tooltip-content{transition:opacity .25s var(--ease-out),translate .25s var(--ease-out)}}@supports (position-anchor:--a){.nv-tooltip-content{--nv-tooltip-offset:4px;margin:var(--nv-tooltip-offset);position-try-fallbacks:flip-block,flip-inline,flip-block flip-inline}.nv-tooltip-content[data-side=top]{--nv-tooltip-translate-start:0 4px;position-area:top}.nv-tooltip-content[data-side=top][data-align=start]{position-area:top span-right}.nv-tooltip-content[data-side=top][data-align=end]{position-area:top span-left}.nv-tooltip-content[data-side=bottom]{--nv-tooltip-translate-start:0 -4px;position-area:bottom}.nv-tooltip-content[data-side=bottom][data-align=start]{position-area:bottom span-right}.nv-tooltip-content[data-side=bottom][data-align=end]{position-area:bottom span-left}.nv-tooltip-content[data-side=left]{--nv-tooltip-translate-start:4px 0;position-area:left}.nv-tooltip-content[data-side=left][data-align=start]{position-area:left span-bottom}.nv-tooltip-content[data-side=left][data-align=end]{position-area:left span-top}.nv-tooltip-content[data-side=right]{--nv-tooltip-translate-start:-4px 0;position-area:right}.nv-tooltip-content[data-side=right][data-align=start]{position-area:right span-bottom}.nv-tooltip-content[data-side=right][data-align=end]{position-area:right span-top}}:is(.nv-tooltip-content[data-state=open],.nv-tooltip-content:popover-open,.nv-tooltip-content.\:popover-open){opacity:1;translate:0}@media (prefers-reduced-motion:no-preference){:is(.nv-tooltip-content[data-state=open],.nv-tooltip-content:popover-open,.nv-tooltip-content.\:popover-open){animation:nv-tooltip-in .25s var(--ease-out)}}.nv-tooltip-content[data-state=closed]:not(:popover-open):not(.\:popover-open){opacity:0;translate:var(--nv-tooltip-translate-start)}.nv-tree-nav-branch>summary{list-style:none}.nv-tree-nav-branch>summary::-webkit-details-marker{display:none}.nv-tree-nav-branch>summary::marker{content:"";display:none}.nv-tree-nav-branch::details-content{height:0;transition:height .2s var(--ease-out),content-visibility .2s allow-discrete;display:block;overflow:clip}.nv-tree-nav-branch[open]::details-content{height:auto}@media (prefers-reduced-motion:reduce){.nv-tree-nav-branch::details-content{transition-duration:.01ms}}.nv-tree-nav-root{font-family:var(--font-sans);color:var(--text-color-primary);font-weight:var(--font-weight-regular);font-style:normal}.nv-tree-nav-root .nv-icon,.nv-tree-nav-root svg{width:calc(var(--spacing)*4);height:calc(var(--spacing)*4);color:var(--text-color-base);flex-shrink:0}.nv-tree-nav-root [data-disabled],.nv-tree-nav-root [aria-disabled=true]{color:var(--text-color-disabled)}:is(.nv-tree-nav-root [data-disabled],.nv-tree-nav-root [aria-disabled=true]) .nv-icon,:is(.nv-tree-nav-root [data-disabled],.nv-tree-nav-root [aria-disabled=true]) svg{color:var(--text-color-disabled)}.nv-tree-nav-list{margin:calc(var(--spacing)*0);padding:calc(var(--spacing)*0);list-style-type:none}.nv-tree-nav-list[aria-disabled=true]{pointer-events:none}.nv-tree-nav-list-item{content-visibility:auto;contain-intrinsic-size:auto 1rem}.nv-tree-nav-branch-trigger,.nv-tree-nav-leaf{cursor:pointer;font-size:var(--text-14);align-items:center;gap:var(--spacing);color:inherit;background:0 0;border:1px solid #0000;padding-inline-start:calc(var(--nv-tree-nav-depth,0)*24px + var(--spacing));padding-inline-end:var(--spacing);line-height:1.57143;text-decoration:none;display:flex}@media (prefers-reduced-motion:no-preference){:is(.nv-tree-nav-branch-trigger,.nv-tree-nav-leaf){transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke;transition-duration:.2s;transition-timing-function:var(--ease-out)}}:is(.nv-tree-nav-branch-trigger,.nv-tree-nav-leaf):hover:not([data-disabled]):not([aria-disabled=true]){background:var(--background-color-interaction-hover)}:is(.nv-tree-nav-branch-trigger,.nv-tree-nav-leaf)[data-disabled],:is(.nv-tree-nav-branch-trigger,.nv-tree-nav-leaf)[aria-disabled=true]{pointer-events:none}.nv-tree-nav-branch-trigger[data-collapsible=false]{cursor:default}.nv-tree-nav-branch-trigger--active,.nv-tree-nav-leaf--active{background:var(--background-color-interaction-selected)}:is(.nv-tree-nav-branch-trigger--active,.nv-tree-nav-leaf--active):hover:not([data-disabled]):not([aria-disabled=true]){background:var(--background-color-interaction-selected);border-color:var(--border-color-interaction-hover)}details:not([open])>summary .nv-tree-nav-icon{rotate:-90deg}details[open]>summary .nv-tree-nav-icon{rotate:none}.nv-tree-nav-label{min-width:calc(var(--spacing)*0);text-overflow:ellipsis;white-space:nowrap;flex:1;overflow:hidden}.nv-upload-trigger.nv-input-shell{text-align:center;line-height:1.14286;display:inline-block}.nv-upload-trigger.nv-input-shell.nv-upload-trigger--dragged-over:not(:has(input:disabled)){border-color:var(--border-color-interaction-hover);background-color:var(--background-color-interaction-hover)}.nv-upload-trigger.nv-input-shell:has(input:disabled) .nv-upload-trigger-anchor.nv-upload-trigger-anchor{color:inherit;cursor:inherit;background:0 0;text-decoration:none}.nv-upload-trigger.nv-input-shell ::file-selector-button{display:none}@media (scripting:enabled){.nv-upload-trigger.nv-input-shell .nv-upload-input-element{pointer-events:none;opacity:0;width:1px;height:1px;position:absolute}}.nv-upload-content{gap:calc(var(--spacing)*2);padding-top:calc(var(--spacing)*2);flex-direction:column;display:flex}.nv-upload-content.nv-upload-content--kind-media{flex-flow:wrap}.nv-upload-item-actions-group{justify-content:flex-end;gap:calc(var(--spacing)*1);flex-wrap:wrap;width:fit-content;display:flex}.nv-upload-description{justify-content:center;gap:calc(var(--spacing)*1);width:100%;padding-top:calc(var(--spacing)*1.5);font-family:var(--font-sans);font-size:var(--text-10);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular);flex-wrap:wrap;display:inline-flex}.nv-upload-item{--nv-input-height:auto;border-radius:var(--radius-md);border:1px solid;border-color:var(--border-color-base);background-color:var(--background-color-surface-raised);padding:calc(var(--spacing)*2);font-family:var(--font-sans);color:var(--text-color-primary);font-style:normal;font-weight:var(--font-weight-regular);display:flex;overflow:hidden}@media (hover:hover){.nv-upload-item:hover{border-color:var(--border-color-interaction-hover)}}@media (prefers-reduced-motion:no-preference){.nv-upload-item{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-duration:.25s;transition-timing-function:var(--ease-out)}}.nv-upload-item.nv-upload-item--status-error{--status-icon-color:var(--text-color-feedback-danger);border:1px solid;border-color:var(--border-color-feedback-danger)}@media (hover:hover){.nv-upload-item.nv-upload-item--status-error:hover{border-color:var(--border-color-feedback-danger-hover)}}.nv-upload-item.nv-upload-item--status-error .nv-upload-item-content-info{color:var(--text-color-feedback-danger-subtle)}.nv-upload-item svg:not(.nv-button svg),.nv-upload-item .nv-icon:not(.nv-button .nv-icon){color:var(--status-icon-color,var(--text-color-primary))}.nv-upload-item .nv-upload-item-hover-section{opacity:0}@media (prefers-reduced-motion:no-preference){.nv-upload-item .nv-upload-item-hover-section{transition:opacity .25s var(--ease-out)}}:is(.nv-upload-item:hover,.nv-upload-item:focus,.nv-upload-item:focus-within) .nv-upload-item-hover-section{opacity:1}.nv-upload-item .nv-upload-item-thumbnail{object-fit:cover}.nv-upload-item.nv-upload-item--kind-card{gap:calc(var(--spacing)*4);padding:calc(var(--spacing)*3)}.nv-upload-item.nv-upload-item--kind-card .nv-upload-item-thumbnail{width:calc(var(--spacing)*8);height:calc(var(--spacing)*8);min-width:calc(var(--spacing)*8);place-items:center;display:grid}.nv-upload-item.nv-upload-item--kind-card .nv-upload-item-thumbnail.nv-upload-item-thumbnail-icon{border:1px solid;border-color:var(--border-color-base);color:var(--text-color-base)}.nv-upload-item.nv-upload-item--kind-card .nv-upload-item-thumbnail.nv-upload-item-thumbnail-icon:before{width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.nv-upload-item.nv-upload-item--kind-media{justify-content:center;align-items:center;width:100px;height:100px;display:flex;position:relative}.nv-upload-item.nv-upload-item--kind-media .nv-upload-item-thumbnail{inset:calc(var(--spacing)*0);transition:opacity .25s var(--ease-out);position:absolute}:is(.nv-upload-item.nv-upload-item--kind-media:hover,.nv-upload-item.nv-upload-item--kind-media:focus,.nv-upload-item.nv-upload-item--kind-media:focus-within) .nv-upload-item-thumbnail{opacity:.1}.nv-upload-item-content{justify-content:space-between;gap:calc(var(--spacing)*2);flex-direction:column;flex:1;display:flex}.nv-upload-item-content-heading{font-family:var(--font-sans);font-size:var(--text-14);line-height:1.21429;font-weight:var(--font-weight-regular);line-height:1.28571}.nv-upload-item-content-info{align-items:center;gap:calc(var(--spacing)*1);font-family:var(--font-sans);font-size:var(--text-10);line-height:1.2;font-weight:var(--font-weight-regular);display:inline-flex}.nv-upload-item-content-info *{text-wrap:nowrap}.nv-upload-item-upload-spinner{animation:var(--animate-spin);font-size:var(--text-12)}.nv-upload-item-top-left{top:calc(var(--spacing)*2);left:calc(var(--spacing)*2);position:absolute}.nv-upload-item-top-right{top:calc(var(--spacing)*2);right:calc(var(--spacing)*2);position:absolute}.nv-vertical-nav-root{border-right:1px solid;border-color:var(--border-color-base);background-color:var(--background-color-surface-navigation);width:240px;height:100%;font-family:var(--font-sans);color:var(--text-color-primary)}.nv-vertical-nav-root .nv-vertical-nav-item-label{min-width:calc(var(--spacing)*0);text-overflow:ellipsis;white-space:nowrap;line-height:var(--leading-lh-125);flex:1;display:block;overflow:hidden}.nv-vertical-nav-root .nv-vertical-nav-list{margin:calc(var(--spacing)*0);width:100%;height:100%;padding:calc(var(--spacing)*1);scrollbar-width:thin;list-style-type:none;position:relative;overflow-y:auto}.nv-vertical-nav-root .nv-vertical-nav-sub-list{padding-right:calc(var(--spacing)*6);padding-left:calc(var(--spacing)*8);list-style-type:none}.nv-vertical-nav-root .nv-vertical-nav-item--active:not(.nv-vertical-nav-item--disabled):before{content:"";left:var(--spacing);top:var(--spacing);bottom:var(--spacing);width:calc(var(--spacing)/2);background-color:var(--color-brand);border-radius:var(--radius-sm);position:absolute}.nv-vertical-nav-root .nv-vertical-nav-item{cursor:pointer;align-items:center;gap:calc(var(--spacing)*2);border-radius:var(--radius-sm);width:100%;max-width:100%;padding-inline:calc(var(--spacing)*6);padding-block:calc(var(--spacing)*4);text-align:start;font-family:var(--font-sans);font-size:var(--text-14);line-height:1.21429;font-weight:var(--font-weight-bold);line-height:var(--leading-lh-100);display:flex;position:relative;overflow:hidden}.nv-vertical-nav-root .nv-vertical-nav-item.nv-vertical-nav-item--active:not(.nv-vertical-nav-item--disabled){background-color:var(--background-color-interaction-pressed)}.nv-vertical-nav-root .nv-vertical-nav-item:hover:not(.nv-vertical-nav-item--disabled):not(:disabled){background-color:var(--background-color-interaction-hover)}.nv-vertical-nav-root .nv-vertical-nav-item:active:not(.nv-vertical-nav-item--disabled):not(:disabled){background-color:var(--background-color-interaction-pressed)}.nv-vertical-nav-root .nv-vertical-nav-item--kind-secondary{height:calc(var(--spacing)*10);align-items:center;gap:calc(var(--spacing)*2);border-radius:var(--radius-sm);width:100%;max-width:100%;padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*2);font-family:var(--font-sans);font-size:var(--text-14);line-height:1.21429;font-weight:var(--font-weight-regular);color:var(--text-color-secondary);line-height:var(--leading-lh-100);display:flex;position:relative}.nv-vertical-nav-root .nv-vertical-nav-item--kind-secondary:hover:not(.nv-vertical-nav-item--disabled){background-color:var(--background-color-interaction-hover)}.nv-vertical-nav-root .nv-vertical-nav-item--kind-secondary:active:not(.nv-vertical-nav-item--disabled){background-color:var(--background-color-interaction-pressed)}.nv-vertical-nav-root .nv-vertical-nav-item--kind-secondary.nv-vertical-nav-item--active:not(.nv-vertical-nav-item--disabled){background-color:var(--background-color-interaction-pressed);color:var(--text-color-primary);font-weight:var(--font-weight-bold)}.nv-vertical-nav-root .nv-vertical-nav-item--disabled{cursor:not-allowed;color:var(--text-color-disabled)}.nv-vertical-nav-root .nv-vertical-nav-item svg,.nv-vertical-nav-root .nv-vertical-nav-item .nv-icon{height:calc(var(--spacing)*4);width:calc(var(--spacing)*4);flex-shrink:0}.nv-vertical-nav-root .nv-vertical-nav-collapsible-trigger .nv-animated-chevron{margin-left:auto}.nv-vertical-nav-root .nv-vertical-nav-collapsible-trigger{border-radius:var(--radius-sm);max-width:100%;display:flex;overflow:hidden}.nv-vertical-nav-root .nv-collapsible-trigger[data-disabled]{opacity:1}.nv-vertical-nav-root .nv-vertical-nav-collapsible-section:not([open]):has(.nv-vertical-nav-item--kind-secondary.nv-vertical-nav-item--active:not(.nv-vertical-nav-item--disabled)):not([data-disabled]) .nv-vertical-nav-collapsible-trigger{position:relative}.nv-vertical-nav-root .nv-vertical-nav-collapsible-section:not([open]):has(.nv-vertical-nav-item--kind-secondary.nv-vertical-nav-item--active:not(.nv-vertical-nav-item--disabled)):not([data-disabled]) .nv-vertical-nav-collapsible-trigger:before{content:"";left:var(--spacing);top:var(--spacing);bottom:var(--spacing);width:calc(var(--spacing)/2);background-color:var(--color-brand);border-radius:var(--radius-sm);position:absolute}.nv-vertical-nav-root .nv-vertical-nav-collapsible-section:not([open]):not([data-disabled]):has(.nv-vertical-nav-item--kind-secondary.nv-vertical-nav-item--active:not(.nv-vertical-nav-item--disabled)) .nv-vertical-nav-collapsible-trigger{background-color:var(--background-color-interaction-pressed)}.nv-vertical-nav-root .nv-vertical-nav-collapsible-section[data-disabled] .nv-vertical-nav-item--kind-secondary{cursor:not-allowed;color:var(--text-color-disabled)}@media (prefers-reduced-motion:no-preference){:is(.nv-vertical-nav-root .nv-vertical-nav-item,.nv-vertical-nav-root .nv-vertical-nav-collapsible-section:not([data-disabled])){transition:background-color .2s var(--ease-out),color .2s var(--ease-out)}}.nv-text--body-bold-2xl{font-family:var(--font-sans);font-size:var(--text-24);line-height:var(--leading-lh-150);font-weight:var(--font-weight-bold)}.nv-text--body-bold-3xl{font-family:var(--font-sans);font-size:var(--text-32);line-height:var(--leading-lh-150);font-weight:var(--font-weight-bold)}.nv-text--body-bold-lg{font-family:var(--font-sans);font-size:var(--text-16);line-height:var(--leading-lh-150);font-weight:var(--font-weight-bold)}.nv-text--body-bold-md{font-family:var(--font-sans);font-size:var(--text-14);line-height:var(--leading-lh-150);font-weight:var(--font-weight-bold)}.nv-text--body-bold-sm{font-family:var(--font-sans);font-size:var(--text-12);line-height:var(--leading-lh-150);font-weight:var(--font-weight-bold)}.nv-text--body-bold-xl{font-family:var(--font-sans);font-size:var(--text-18);line-height:var(--leading-lh-150);font-weight:var(--font-weight-bold)}.nv-text--body-bold-xs{font-family:var(--font-sans);font-size:var(--text-10);line-height:var(--leading-lh-150);font-weight:var(--font-weight-bold)}.nv-text--body-regular-2xl{font-family:var(--font-sans);font-size:var(--text-24);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular)}.nv-text--body-regular-3xl{font-family:var(--font-sans);font-size:var(--text-32);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular)}.nv-text--body-regular-lg{font-family:var(--font-sans);font-size:var(--text-16);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular)}.nv-text--body-regular-md{font-family:var(--font-sans);font-size:var(--text-14);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular)}.nv-text--body-regular-sm{font-family:var(--font-sans);font-size:var(--text-12);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular)}.nv-text--body-regular-xl{font-family:var(--font-sans);font-size:var(--text-18);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular)}.nv-text--body-regular-xs{font-family:var(--font-sans);font-size:var(--text-10);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular)}.nv-text--body-semibold-2xl{font-family:var(--font-sans);font-size:var(--text-24);line-height:var(--leading-lh-150);font-weight:var(--font-weight-semibold)}.nv-text--body-semibold-3xl{font-family:var(--font-sans);font-size:var(--text-32);line-height:var(--leading-lh-150);font-weight:var(--font-weight-semibold)}.nv-text--body-semibold-lg{font-family:var(--font-sans);font-size:var(--text-16);line-height:var(--leading-lh-150);font-weight:var(--font-weight-semibold)}.nv-text--body-semibold-md{font-family:var(--font-sans);font-size:var(--text-14);line-height:var(--leading-lh-150);font-weight:var(--font-weight-semibold)}.nv-text--body-semibold-sm{font-family:var(--font-sans);font-size:var(--text-12);line-height:var(--leading-lh-150);font-weight:var(--font-weight-semibold)}.nv-text--body-semibold-xl{font-family:var(--font-sans);font-size:var(--text-18);line-height:var(--leading-lh-150);font-weight:var(--font-weight-semibold)}.nv-text--body-semibold-xs{font-family:var(--font-sans);font-size:var(--text-10);line-height:var(--leading-lh-150);font-weight:var(--font-weight-semibold)}.nv-text--display-2xl{font-family:var(--font-sans);font-size:var(--text-64);line-height:var(--leading-lh-125);font-weight:var(--font-weight-bold)}.nv-text--display-lg{font-family:var(--font-sans);font-size:var(--text-50);line-height:1.24;font-weight:var(--font-weight-bold)}.nv-text--display-md{font-family:var(--font-sans);font-size:var(--text-44);line-height:var(--leading-lh-125);font-weight:var(--font-weight-bold)}.nv-text--display-sm{font-family:var(--font-sans);font-size:var(--text-40);line-height:var(--leading-lh-125);font-weight:var(--font-weight-bold)}.nv-text--display-xl{font-family:var(--font-sans);font-size:var(--text-56);line-height:var(--leading-lh-125);font-weight:var(--font-weight-bold)}.nv-text--display-xs{font-family:var(--font-sans);font-size:var(--text-36);line-height:var(--leading-lh-125);font-weight:var(--font-weight-bold)}.nv-text--label-bold-2xl{font-family:var(--font-sans);font-size:var(--text-24);line-height:var(--leading-lh-125);font-weight:var(--font-weight-bold)}.nv-text--label-bold-3xl{font-family:var(--font-sans);font-size:var(--text-32);line-height:var(--leading-lh-125);font-weight:var(--font-weight-bold)}.nv-text--label-bold-lg{font-family:var(--font-sans);font-size:var(--text-16);line-height:var(--leading-lh-125);font-weight:var(--font-weight-bold)}.nv-text--label-bold-md{font-family:var(--font-sans);font-size:var(--text-14);line-height:1.21429;font-weight:var(--font-weight-bold)}.nv-text--label-bold-sm{font-family:var(--font-sans);font-size:var(--text-12);line-height:var(--leading-lh-125);font-weight:var(--font-weight-bold)}.nv-text--label-bold-xl{font-family:var(--font-sans);font-size:var(--text-18);line-height:1.22222;font-weight:var(--font-weight-bold)}.nv-text--label-bold-xs{font-family:var(--font-sans);font-size:var(--text-10);line-height:1.2;font-weight:var(--font-weight-bold)}.nv-text--label-light-2xl{font-family:var(--font-sans);font-size:var(--text-24);line-height:var(--leading-lh-125);font-weight:var(--font-weight-light)}.nv-text--label-light-3xl{font-family:var(--font-sans);font-size:var(--text-32);line-height:var(--leading-lh-125);font-weight:var(--font-weight-light)}.nv-text--label-light-lg{font-family:var(--font-sans);font-size:var(--text-16);line-height:var(--leading-lh-125);font-weight:var(--font-weight-light)}.nv-text--label-light-md{font-family:var(--font-sans);font-size:var(--text-14);line-height:1.21429;font-weight:var(--font-weight-light)}.nv-text--label-light-sm{font-family:var(--font-sans);font-size:var(--text-12);line-height:var(--leading-lh-125);font-weight:var(--font-weight-light)}.nv-text--label-light-xl{font-family:var(--font-sans);font-size:var(--text-18);line-height:1.22222;font-weight:var(--font-weight-light)}.nv-text--label-light-xs{font-family:var(--font-sans);font-size:var(--text-10);line-height:1.2;font-weight:var(--font-weight-light)}.nv-text--label-regular-2xl{font-family:var(--font-sans);font-size:var(--text-24);line-height:var(--leading-lh-125);font-weight:var(--font-weight-regular)}.nv-text--label-regular-3xl{font-family:var(--font-sans);font-size:var(--text-32);line-height:var(--leading-lh-125);font-weight:var(--font-weight-regular)}.nv-text--label-regular-lg{font-family:var(--font-sans);font-size:var(--text-16);line-height:var(--leading-lh-125);font-weight:var(--font-weight-regular)}.nv-text--label-regular-md{font-family:var(--font-sans);font-size:var(--text-14);line-height:1.21429;font-weight:var(--font-weight-regular)}.nv-text--label-regular-sm{font-family:var(--font-sans);font-size:var(--text-12);line-height:var(--leading-lh-125);font-weight:var(--font-weight-regular)}.nv-text--label-regular-xl{font-family:var(--font-sans);font-size:var(--text-18);line-height:1.22222;font-weight:var(--font-weight-regular)}.nv-text--label-regular-xs{font-family:var(--font-sans);font-size:var(--text-10);line-height:1.2;font-weight:var(--font-weight-regular)}.nv-text--label-semibold-2xl{font-family:var(--font-sans);font-size:var(--text-24);line-height:var(--leading-lh-125);font-weight:var(--font-weight-semibold)}.nv-text--label-semibold-3xl{font-family:var(--font-sans);font-size:var(--text-32);line-height:var(--leading-lh-125);font-weight:var(--font-weight-semibold)}.nv-text--label-semibold-lg{font-family:var(--font-sans);font-size:var(--text-16);line-height:var(--leading-lh-125);font-weight:var(--font-weight-semibold)}.nv-text--label-semibold-md{font-family:var(--font-sans);font-size:var(--text-14);line-height:1.21429;font-weight:var(--font-weight-semibold)}.nv-text--label-semibold-sm{font-family:var(--font-sans);font-size:var(--text-12);line-height:var(--leading-lh-125);font-weight:var(--font-weight-semibold)}.nv-text--label-semibold-xl{font-family:var(--font-sans);font-size:var(--text-18);line-height:1.22222;font-weight:var(--font-weight-semibold)}.nv-text--label-semibold-xs{font-family:var(--font-sans);font-size:var(--text-10);line-height:1.2;font-weight:var(--font-weight-semibold)}.nv-text--mono-2xl{font-family:var(--font-mono);font-size:var(--text-24);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular)}.nv-text--mono-lg{font-family:var(--font-mono);font-size:var(--text-16);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular)}.nv-text--mono-md{font-family:var(--font-mono);font-size:var(--text-14);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular)}.nv-text--mono-sm{font-family:var(--font-mono);font-size:var(--text-12);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular)}.nv-text--mono-xl{font-family:var(--font-mono);font-size:var(--text-20);line-height:var(--leading-lh-150);font-weight:var(--font-weight-regular)}.nv-text--title-2xl{font-family:var(--font-sans);font-size:var(--text-36);line-height:var(--leading-lh-125);font-weight:var(--font-weight-bold)}.nv-text--title-lg{font-family:var(--font-sans);font-size:var(--text-28);line-height:var(--leading-lh-125);font-weight:var(--font-weight-bold)}.nv-text--title-md{font-family:var(--font-sans);font-size:var(--text-24);line-height:var(--leading-lh-125);font-weight:var(--font-weight-bold)}.nv-text--title-sm{font-family:var(--font-sans);font-size:var(--text-20);line-height:var(--leading-lh-125);font-weight:var(--font-weight-bold)}.nv-text--title-xl{font-family:var(--font-sans);font-size:var(--text-32);line-height:var(--leading-lh-125);font-weight:var(--font-weight-bold)}.nv-text--title-xs{font-family:var(--font-sans);font-size:var(--text-18);line-height:1.22222;font-weight:var(--font-weight-bold)}.nv-text--font-mono{font-family:var(--font-mono)}.nv-text--font-sans{font-family:var(--font-sans)}.nv-text--weight-light{font-weight:var(--font-weight-light)}.nv-text--weight-regular{font-weight:var(--font-weight-regular)}.nv-text--weight-semibold{font-weight:var(--font-weight-semibold)}.nv-text--weight-bold{font-weight:var(--font-weight-bold)}.nv-text--style-italic{font-style:italic}.nv-text--style-normal{font-style:normal}.nv-text--size-10{font-size:var(--text-10)}.nv-text--size-12{font-size:var(--text-12)}.nv-text--size-14{font-size:var(--text-14)}.nv-text--size-16{font-size:var(--text-16)}.nv-text--size-18{font-size:var(--text-18)}.nv-text--size-20{font-size:var(--text-20)}.nv-text--size-22{font-size:var(--text-22)}.nv-text--size-24{font-size:var(--text-24)}.nv-text--size-28{font-size:var(--text-28)}.nv-text--size-32{font-size:var(--text-32)}.nv-text--size-36{font-size:var(--text-36)}.nv-text--size-40{font-size:var(--text-40)}.nv-text--size-44{font-size:var(--text-44)}.nv-text--size-48{font-size:var(--text-48)}.nv-text--size-50{font-size:var(--text-50)}.nv-text--size-56{font-size:var(--text-56)}.nv-text--size-60{font-size:var(--text-60)}.nv-text--size-64{font-size:var(--text-64)}.nv-text--size-72{font-size:var(--text-72)}.nv-text--size-80{font-size:var(--text-80)}.nv-text--underline{text-decoration-line:underline}.nv-text--line-height-100{line-height:var(--leading-lh-100)}.nv-text--line-height-125{line-height:var(--leading-lh-125)}.nv-text--line-height-150{line-height:var(--leading-lh-150)}.nv-text--line-height-175{line-height:var(--leading-lh-175)}.nv-primitive--spacing-0{padding:calc(var(--spacing)*0)}.nv-primitive--spacing-0_25{padding:calc(var(--spacing)*.25)}.nv-primitive--spacing-0_5{padding:calc(var(--spacing)*.5)}.nv-primitive--spacing-0_75{padding:calc(var(--spacing)*.75)}.nv-primitive--spacing-1{padding:calc(var(--spacing)*1)}.nv-primitive--spacing-1_5{padding:calc(var(--spacing)*1.5)}.nv-primitive--spacing-2{padding:calc(var(--spacing)*2)}.nv-primitive--spacing-2_5{padding:calc(var(--spacing)*2.5)}.nv-primitive--spacing-3{padding:calc(var(--spacing)*3)}.nv-primitive--spacing-3_5{padding:calc(var(--spacing)*3.5)}.nv-primitive--spacing-4{padding:calc(var(--spacing)*4)}.nv-primitive--spacing-5{padding:calc(var(--spacing)*5)}.nv-primitive--spacing-6{padding:calc(var(--spacing)*6)}.nv-primitive--spacing-7{padding:calc(var(--spacing)*7)}.nv-primitive--spacing-8{padding:calc(var(--spacing)*8)}.nv-primitive--spacing-9{padding:calc(var(--spacing)*9)}.nv-primitive--spacing-10{padding:calc(var(--spacing)*10)}.nv-primitive--spacing-11{padding:calc(var(--spacing)*11)}.nv-primitive--spacing-12{padding:calc(var(--spacing)*12)}.nv-primitive--spacing-14{padding:calc(var(--spacing)*14)}.nv-primitive--spacing-16{padding:calc(var(--spacing)*16)}.nv-primitive--spacing-18{padding:calc(var(--spacing)*18)}.nv-primitive--spacing-20{padding:calc(var(--spacing)*20)}.nv-primitive--spacing-24{padding:calc(var(--spacing)*24)}.nv-primitive--spacing-28{padding:calc(var(--spacing)*28)}.nv-primitive--spacing-32{padding:calc(var(--spacing)*32)}.nv-primitive--spacing-36{padding:calc(var(--spacing)*36)}.nv-primitive--spacing-40{padding:calc(var(--spacing)*40)}.nv-primitive--spacing-44{padding:calc(var(--spacing)*44)}.nv-primitive--spacing-48{padding:calc(var(--spacing)*48)}.nv-primitive--spacing-52{padding:calc(var(--spacing)*52)}.nv-primitive--spacing-56{padding:calc(var(--spacing)*56)}.nv-primitive--spacing-60{padding:calc(var(--spacing)*60)}.nv-primitive--spacing-64{padding:calc(var(--spacing)*64)}.nv-primitive--spacing-72{padding:calc(var(--spacing)*72)}.nv-primitive--spacing-80{padding:calc(var(--spacing)*80)}.nv-primitive--spacing-96{padding:calc(var(--spacing)*96)}.nv-primitive--spacing-250{padding:calc(var(--spacing)*250)}.nv-primitive--spacing-px{padding:1px}.nv-primitive--spacing-density-xxs{padding:var(--spacing-density-xxs)}.nv-primitive--spacing-density-xs{padding:var(--spacing-density-xs)}.nv-primitive--spacing-density-sm{padding:var(--spacing-density-sm)}.nv-primitive--spacing-density-md{padding:var(--spacing-density-md)}.nv-primitive--spacing-density-lg{padding:var(--spacing-density-lg)}.nv-primitive--spacing-density-xl{padding:var(--spacing-density-xl)}.nv-primitive--spacing-density-2xl{padding:var(--spacing-density-2xl)}.nv-primitive--spacing-density-3xl{padding:var(--spacing-density-3xl)}.nv-primitive--spacing-density-4xl{padding:var(--spacing-density-4xl)}.nv-primitive--spacing-density-5xl{padding:var(--spacing-density-5xl)}.nv-primitive--spacing-inherit{padding:inherit}.nv-primitive--spacing-x-0{padding-inline:calc(var(--spacing)*0)}.nv-primitive--spacing-x-0_25{padding-inline:calc(var(--spacing)*.25)}.nv-primitive--spacing-x-0_5{padding-inline:calc(var(--spacing)*.5)}.nv-primitive--spacing-x-0_75{padding-inline:calc(var(--spacing)*.75)}.nv-primitive--spacing-x-1{padding-inline:calc(var(--spacing)*1)}.nv-primitive--spacing-x-1_5{padding-inline:calc(var(--spacing)*1.5)}.nv-primitive--spacing-x-2{padding-inline:calc(var(--spacing)*2)}.nv-primitive--spacing-x-2_5{padding-inline:calc(var(--spacing)*2.5)}.nv-primitive--spacing-x-3{padding-inline:calc(var(--spacing)*3)}.nv-primitive--spacing-x-3_5{padding-inline:calc(var(--spacing)*3.5)}.nv-primitive--spacing-x-4{padding-inline:calc(var(--spacing)*4)}.nv-primitive--spacing-x-5{padding-inline:calc(var(--spacing)*5)}.nv-primitive--spacing-x-6{padding-inline:calc(var(--spacing)*6)}.nv-primitive--spacing-x-7{padding-inline:calc(var(--spacing)*7)}.nv-primitive--spacing-x-8{padding-inline:calc(var(--spacing)*8)}.nv-primitive--spacing-x-9{padding-inline:calc(var(--spacing)*9)}.nv-primitive--spacing-x-10{padding-inline:calc(var(--spacing)*10)}.nv-primitive--spacing-x-11{padding-inline:calc(var(--spacing)*11)}.nv-primitive--spacing-x-12{padding-inline:calc(var(--spacing)*12)}.nv-primitive--spacing-x-14{padding-inline:calc(var(--spacing)*14)}.nv-primitive--spacing-x-16{padding-inline:calc(var(--spacing)*16)}.nv-primitive--spacing-x-18{padding-inline:calc(var(--spacing)*18)}.nv-primitive--spacing-x-20{padding-inline:calc(var(--spacing)*20)}.nv-primitive--spacing-x-24{padding-inline:calc(var(--spacing)*24)}.nv-primitive--spacing-x-28{padding-inline:calc(var(--spacing)*28)}.nv-primitive--spacing-x-32{padding-inline:calc(var(--spacing)*32)}.nv-primitive--spacing-x-36{padding-inline:calc(var(--spacing)*36)}.nv-primitive--spacing-x-40{padding-inline:calc(var(--spacing)*40)}.nv-primitive--spacing-x-44{padding-inline:calc(var(--spacing)*44)}.nv-primitive--spacing-x-48{padding-inline:calc(var(--spacing)*48)}.nv-primitive--spacing-x-52{padding-inline:calc(var(--spacing)*52)}.nv-primitive--spacing-x-56{padding-inline:calc(var(--spacing)*56)}.nv-primitive--spacing-x-60{padding-inline:calc(var(--spacing)*60)}.nv-primitive--spacing-x-64{padding-inline:calc(var(--spacing)*64)}.nv-primitive--spacing-x-72{padding-inline:calc(var(--spacing)*72)}.nv-primitive--spacing-x-80{padding-inline:calc(var(--spacing)*80)}.nv-primitive--spacing-x-96{padding-inline:calc(var(--spacing)*96)}.nv-primitive--spacing-x-250{padding-inline:calc(var(--spacing)*250)}.nv-primitive--spacing-x-px{padding-inline:1px}.nv-primitive--spacing-x-density-xxs{padding-inline:var(--spacing-density-xxs)}.nv-primitive--spacing-x-density-xs{padding-inline:var(--spacing-density-xs)}.nv-primitive--spacing-x-density-sm{padding-inline:var(--spacing-density-sm)}.nv-primitive--spacing-x-density-md{padding-inline:var(--spacing-density-md)}.nv-primitive--spacing-x-density-lg{padding-inline:var(--spacing-density-lg)}.nv-primitive--spacing-x-density-xl{padding-inline:var(--spacing-density-xl)}.nv-primitive--spacing-x-density-2xl{padding-inline:var(--spacing-density-2xl)}.nv-primitive--spacing-x-density-3xl{padding-inline:var(--spacing-density-3xl)}.nv-primitive--spacing-x-density-4xl{padding-inline:var(--spacing-density-4xl)}.nv-primitive--spacing-x-density-5xl{padding-inline:var(--spacing-density-5xl)}.nv-primitive--spacing-x-inherit{padding-inline:inherit}.nv-primitive--spacing-y-0{padding-block:calc(var(--spacing)*0)}.nv-primitive--spacing-y-0_25{padding-block:calc(var(--spacing)*.25)}.nv-primitive--spacing-y-0_5{padding-block:calc(var(--spacing)*.5)}.nv-primitive--spacing-y-0_75{padding-block:calc(var(--spacing)*.75)}.nv-primitive--spacing-y-1{padding-block:calc(var(--spacing)*1)}.nv-primitive--spacing-y-1_5{padding-block:calc(var(--spacing)*1.5)}.nv-primitive--spacing-y-2{padding-block:calc(var(--spacing)*2)}.nv-primitive--spacing-y-2_5{padding-block:calc(var(--spacing)*2.5)}.nv-primitive--spacing-y-3{padding-block:calc(var(--spacing)*3)}.nv-primitive--spacing-y-3_5{padding-block:calc(var(--spacing)*3.5)}.nv-primitive--spacing-y-4{padding-block:calc(var(--spacing)*4)}.nv-primitive--spacing-y-5{padding-block:calc(var(--spacing)*5)}.nv-primitive--spacing-y-6{padding-block:calc(var(--spacing)*6)}.nv-primitive--spacing-y-7{padding-block:calc(var(--spacing)*7)}.nv-primitive--spacing-y-8{padding-block:calc(var(--spacing)*8)}.nv-primitive--spacing-y-9{padding-block:calc(var(--spacing)*9)}.nv-primitive--spacing-y-10{padding-block:calc(var(--spacing)*10)}.nv-primitive--spacing-y-11{padding-block:calc(var(--spacing)*11)}.nv-primitive--spacing-y-12{padding-block:calc(var(--spacing)*12)}.nv-primitive--spacing-y-14{padding-block:calc(var(--spacing)*14)}.nv-primitive--spacing-y-16{padding-block:calc(var(--spacing)*16)}.nv-primitive--spacing-y-18{padding-block:calc(var(--spacing)*18)}.nv-primitive--spacing-y-20{padding-block:calc(var(--spacing)*20)}.nv-primitive--spacing-y-24{padding-block:calc(var(--spacing)*24)}.nv-primitive--spacing-y-28{padding-block:calc(var(--spacing)*28)}.nv-primitive--spacing-y-32{padding-block:calc(var(--spacing)*32)}.nv-primitive--spacing-y-36{padding-block:calc(var(--spacing)*36)}.nv-primitive--spacing-y-40{padding-block:calc(var(--spacing)*40)}.nv-primitive--spacing-y-44{padding-block:calc(var(--spacing)*44)}.nv-primitive--spacing-y-48{padding-block:calc(var(--spacing)*48)}.nv-primitive--spacing-y-52{padding-block:calc(var(--spacing)*52)}.nv-primitive--spacing-y-56{padding-block:calc(var(--spacing)*56)}.nv-primitive--spacing-y-60{padding-block:calc(var(--spacing)*60)}.nv-primitive--spacing-y-64{padding-block:calc(var(--spacing)*64)}.nv-primitive--spacing-y-72{padding-block:calc(var(--spacing)*72)}.nv-primitive--spacing-y-80{padding-block:calc(var(--spacing)*80)}.nv-primitive--spacing-y-96{padding-block:calc(var(--spacing)*96)}.nv-primitive--spacing-y-250{padding-block:calc(var(--spacing)*250)}.nv-primitive--spacing-y-px{padding-block:1px}.nv-primitive--spacing-y-density-xxs{padding-block:var(--spacing-density-xxs)}.nv-primitive--spacing-y-density-xs{padding-block:var(--spacing-density-xs)}.nv-primitive--spacing-y-density-sm{padding-block:var(--spacing-density-sm)}.nv-primitive--spacing-y-density-md{padding-block:var(--spacing-density-md)}.nv-primitive--spacing-y-density-lg{padding-block:var(--spacing-density-lg)}.nv-primitive--spacing-y-density-xl{padding-block:var(--spacing-density-xl)}.nv-primitive--spacing-y-density-2xl{padding-block:var(--spacing-density-2xl)}.nv-primitive--spacing-y-density-3xl{padding-block:var(--spacing-density-3xl)}.nv-primitive--spacing-y-density-4xl{padding-block:var(--spacing-density-4xl)}.nv-primitive--spacing-y-density-5xl{padding-block:var(--spacing-density-5xl)}.nv-primitive--spacing-y-inherit{padding-block:inherit}.nv-primitive--spacing-t-0{padding-top:calc(var(--spacing)*0)}.nv-primitive--spacing-t-0_25{padding-top:calc(var(--spacing)*.25)}.nv-primitive--spacing-t-0_5{padding-top:calc(var(--spacing)*.5)}.nv-primitive--spacing-t-0_75{padding-top:calc(var(--spacing)*.75)}.nv-primitive--spacing-t-1{padding-top:calc(var(--spacing)*1)}.nv-primitive--spacing-t-1_5{padding-top:calc(var(--spacing)*1.5)}.nv-primitive--spacing-t-2{padding-top:calc(var(--spacing)*2)}.nv-primitive--spacing-t-2_5{padding-top:calc(var(--spacing)*2.5)}.nv-primitive--spacing-t-3{padding-top:calc(var(--spacing)*3)}.nv-primitive--spacing-t-3_5{padding-top:calc(var(--spacing)*3.5)}.nv-primitive--spacing-t-4{padding-top:calc(var(--spacing)*4)}.nv-primitive--spacing-t-5{padding-top:calc(var(--spacing)*5)}.nv-primitive--spacing-t-6{padding-top:calc(var(--spacing)*6)}.nv-primitive--spacing-t-7{padding-top:calc(var(--spacing)*7)}.nv-primitive--spacing-t-8{padding-top:calc(var(--spacing)*8)}.nv-primitive--spacing-t-9{padding-top:calc(var(--spacing)*9)}.nv-primitive--spacing-t-10{padding-top:calc(var(--spacing)*10)}.nv-primitive--spacing-t-11{padding-top:calc(var(--spacing)*11)}.nv-primitive--spacing-t-12{padding-top:calc(var(--spacing)*12)}.nv-primitive--spacing-t-14{padding-top:calc(var(--spacing)*14)}.nv-primitive--spacing-t-16{padding-top:calc(var(--spacing)*16)}.nv-primitive--spacing-t-18{padding-top:calc(var(--spacing)*18)}.nv-primitive--spacing-t-20{padding-top:calc(var(--spacing)*20)}.nv-primitive--spacing-t-24{padding-top:calc(var(--spacing)*24)}.nv-primitive--spacing-t-28{padding-top:calc(var(--spacing)*28)}.nv-primitive--spacing-t-32{padding-top:calc(var(--spacing)*32)}.nv-primitive--spacing-t-36{padding-top:calc(var(--spacing)*36)}.nv-primitive--spacing-t-40{padding-top:calc(var(--spacing)*40)}.nv-primitive--spacing-t-44{padding-top:calc(var(--spacing)*44)}.nv-primitive--spacing-t-48{padding-top:calc(var(--spacing)*48)}.nv-primitive--spacing-t-52{padding-top:calc(var(--spacing)*52)}.nv-primitive--spacing-t-56{padding-top:calc(var(--spacing)*56)}.nv-primitive--spacing-t-60{padding-top:calc(var(--spacing)*60)}.nv-primitive--spacing-t-64{padding-top:calc(var(--spacing)*64)}.nv-primitive--spacing-t-72{padding-top:calc(var(--spacing)*72)}.nv-primitive--spacing-t-80{padding-top:calc(var(--spacing)*80)}.nv-primitive--spacing-t-96{padding-top:calc(var(--spacing)*96)}.nv-primitive--spacing-t-250{padding-top:calc(var(--spacing)*250)}.nv-primitive--spacing-t-px{padding-top:1px}.nv-primitive--spacing-t-density-xxs{padding-top:var(--spacing-density-xxs)}.nv-primitive--spacing-t-density-xs{padding-top:var(--spacing-density-xs)}.nv-primitive--spacing-t-density-sm{padding-top:var(--spacing-density-sm)}.nv-primitive--spacing-t-density-md{padding-top:var(--spacing-density-md)}.nv-primitive--spacing-t-density-lg{padding-top:var(--spacing-density-lg)}.nv-primitive--spacing-t-density-xl{padding-top:var(--spacing-density-xl)}.nv-primitive--spacing-t-density-2xl{padding-top:var(--spacing-density-2xl)}.nv-primitive--spacing-t-density-3xl{padding-top:var(--spacing-density-3xl)}.nv-primitive--spacing-t-density-4xl{padding-top:var(--spacing-density-4xl)}.nv-primitive--spacing-t-density-5xl{padding-top:var(--spacing-density-5xl)}.nv-primitive--spacing-t-inherit{padding-top:inherit}.nv-primitive--spacing-r-0{padding-right:calc(var(--spacing)*0)}.nv-primitive--spacing-r-0_25{padding-right:calc(var(--spacing)*.25)}.nv-primitive--spacing-r-0_5{padding-right:calc(var(--spacing)*.5)}.nv-primitive--spacing-r-0_75{padding-right:calc(var(--spacing)*.75)}.nv-primitive--spacing-r-1{padding-right:calc(var(--spacing)*1)}.nv-primitive--spacing-r-1_5{padding-right:calc(var(--spacing)*1.5)}.nv-primitive--spacing-r-2{padding-right:calc(var(--spacing)*2)}.nv-primitive--spacing-r-2_5{padding-right:calc(var(--spacing)*2.5)}.nv-primitive--spacing-r-3{padding-right:calc(var(--spacing)*3)}.nv-primitive--spacing-r-3_5{padding-right:calc(var(--spacing)*3.5)}.nv-primitive--spacing-r-4{padding-right:calc(var(--spacing)*4)}.nv-primitive--spacing-r-5{padding-right:calc(var(--spacing)*5)}.nv-primitive--spacing-r-6{padding-right:calc(var(--spacing)*6)}.nv-primitive--spacing-r-7{padding-right:calc(var(--spacing)*7)}.nv-primitive--spacing-r-8{padding-right:calc(var(--spacing)*8)}.nv-primitive--spacing-r-9{padding-right:calc(var(--spacing)*9)}.nv-primitive--spacing-r-10{padding-right:calc(var(--spacing)*10)}.nv-primitive--spacing-r-11{padding-right:calc(var(--spacing)*11)}.nv-primitive--spacing-r-12{padding-right:calc(var(--spacing)*12)}.nv-primitive--spacing-r-14{padding-right:calc(var(--spacing)*14)}.nv-primitive--spacing-r-16{padding-right:calc(var(--spacing)*16)}.nv-primitive--spacing-r-18{padding-right:calc(var(--spacing)*18)}.nv-primitive--spacing-r-20{padding-right:calc(var(--spacing)*20)}.nv-primitive--spacing-r-24{padding-right:calc(var(--spacing)*24)}.nv-primitive--spacing-r-28{padding-right:calc(var(--spacing)*28)}.nv-primitive--spacing-r-32{padding-right:calc(var(--spacing)*32)}.nv-primitive--spacing-r-36{padding-right:calc(var(--spacing)*36)}.nv-primitive--spacing-r-40{padding-right:calc(var(--spacing)*40)}.nv-primitive--spacing-r-44{padding-right:calc(var(--spacing)*44)}.nv-primitive--spacing-r-48{padding-right:calc(var(--spacing)*48)}.nv-primitive--spacing-r-52{padding-right:calc(var(--spacing)*52)}.nv-primitive--spacing-r-56{padding-right:calc(var(--spacing)*56)}.nv-primitive--spacing-r-60{padding-right:calc(var(--spacing)*60)}.nv-primitive--spacing-r-64{padding-right:calc(var(--spacing)*64)}.nv-primitive--spacing-r-72{padding-right:calc(var(--spacing)*72)}.nv-primitive--spacing-r-80{padding-right:calc(var(--spacing)*80)}.nv-primitive--spacing-r-96{padding-right:calc(var(--spacing)*96)}.nv-primitive--spacing-r-250{padding-right:calc(var(--spacing)*250)}.nv-primitive--spacing-r-px{padding-right:1px}.nv-primitive--spacing-r-density-xxs{padding-right:var(--spacing-density-xxs)}.nv-primitive--spacing-r-density-xs{padding-right:var(--spacing-density-xs)}.nv-primitive--spacing-r-density-sm{padding-right:var(--spacing-density-sm)}.nv-primitive--spacing-r-density-md{padding-right:var(--spacing-density-md)}.nv-primitive--spacing-r-density-lg{padding-right:var(--spacing-density-lg)}.nv-primitive--spacing-r-density-xl{padding-right:var(--spacing-density-xl)}.nv-primitive--spacing-r-density-2xl{padding-right:var(--spacing-density-2xl)}.nv-primitive--spacing-r-density-3xl{padding-right:var(--spacing-density-3xl)}.nv-primitive--spacing-r-density-4xl{padding-right:var(--spacing-density-4xl)}.nv-primitive--spacing-r-density-5xl{padding-right:var(--spacing-density-5xl)}.nv-primitive--spacing-r-inherit{padding-right:inherit}.nv-primitive--spacing-b-0{padding-bottom:calc(var(--spacing)*0)}.nv-primitive--spacing-b-0_25{padding-bottom:calc(var(--spacing)*.25)}.nv-primitive--spacing-b-0_5{padding-bottom:calc(var(--spacing)*.5)}.nv-primitive--spacing-b-0_75{padding-bottom:calc(var(--spacing)*.75)}.nv-primitive--spacing-b-1{padding-bottom:calc(var(--spacing)*1)}.nv-primitive--spacing-b-1_5{padding-bottom:calc(var(--spacing)*1.5)}.nv-primitive--spacing-b-2{padding-bottom:calc(var(--spacing)*2)}.nv-primitive--spacing-b-2_5{padding-bottom:calc(var(--spacing)*2.5)}.nv-primitive--spacing-b-3{padding-bottom:calc(var(--spacing)*3)}.nv-primitive--spacing-b-3_5{padding-bottom:calc(var(--spacing)*3.5)}.nv-primitive--spacing-b-4{padding-bottom:calc(var(--spacing)*4)}.nv-primitive--spacing-b-5{padding-bottom:calc(var(--spacing)*5)}.nv-primitive--spacing-b-6{padding-bottom:calc(var(--spacing)*6)}.nv-primitive--spacing-b-7{padding-bottom:calc(var(--spacing)*7)}.nv-primitive--spacing-b-8{padding-bottom:calc(var(--spacing)*8)}.nv-primitive--spacing-b-9{padding-bottom:calc(var(--spacing)*9)}.nv-primitive--spacing-b-10{padding-bottom:calc(var(--spacing)*10)}.nv-primitive--spacing-b-11{padding-bottom:calc(var(--spacing)*11)}.nv-primitive--spacing-b-12{padding-bottom:calc(var(--spacing)*12)}.nv-primitive--spacing-b-14{padding-bottom:calc(var(--spacing)*14)}.nv-primitive--spacing-b-16{padding-bottom:calc(var(--spacing)*16)}.nv-primitive--spacing-b-18{padding-bottom:calc(var(--spacing)*18)}.nv-primitive--spacing-b-20{padding-bottom:calc(var(--spacing)*20)}.nv-primitive--spacing-b-24{padding-bottom:calc(var(--spacing)*24)}.nv-primitive--spacing-b-28{padding-bottom:calc(var(--spacing)*28)}.nv-primitive--spacing-b-32{padding-bottom:calc(var(--spacing)*32)}.nv-primitive--spacing-b-36{padding-bottom:calc(var(--spacing)*36)}.nv-primitive--spacing-b-40{padding-bottom:calc(var(--spacing)*40)}.nv-primitive--spacing-b-44{padding-bottom:calc(var(--spacing)*44)}.nv-primitive--spacing-b-48{padding-bottom:calc(var(--spacing)*48)}.nv-primitive--spacing-b-52{padding-bottom:calc(var(--spacing)*52)}.nv-primitive--spacing-b-56{padding-bottom:calc(var(--spacing)*56)}.nv-primitive--spacing-b-60{padding-bottom:calc(var(--spacing)*60)}.nv-primitive--spacing-b-64{padding-bottom:calc(var(--spacing)*64)}.nv-primitive--spacing-b-72{padding-bottom:calc(var(--spacing)*72)}.nv-primitive--spacing-b-80{padding-bottom:calc(var(--spacing)*80)}.nv-primitive--spacing-b-96{padding-bottom:calc(var(--spacing)*96)}.nv-primitive--spacing-b-250{padding-bottom:calc(var(--spacing)*250)}.nv-primitive--spacing-b-px{padding-bottom:1px}.nv-primitive--spacing-b-density-xxs{padding-bottom:var(--spacing-density-xxs)}.nv-primitive--spacing-b-density-xs{padding-bottom:var(--spacing-density-xs)}.nv-primitive--spacing-b-density-sm{padding-bottom:var(--spacing-density-sm)}.nv-primitive--spacing-b-density-md{padding-bottom:var(--spacing-density-md)}.nv-primitive--spacing-b-density-lg{padding-bottom:var(--spacing-density-lg)}.nv-primitive--spacing-b-density-xl{padding-bottom:var(--spacing-density-xl)}.nv-primitive--spacing-b-density-2xl{padding-bottom:var(--spacing-density-2xl)}.nv-primitive--spacing-b-density-3xl{padding-bottom:var(--spacing-density-3xl)}.nv-primitive--spacing-b-density-4xl{padding-bottom:var(--spacing-density-4xl)}.nv-primitive--spacing-b-density-5xl{padding-bottom:var(--spacing-density-5xl)}.nv-primitive--spacing-b-inherit{padding-bottom:inherit}.nv-primitive--spacing-l-0{padding-left:calc(var(--spacing)*0)}.nv-primitive--spacing-l-0_25{padding-left:calc(var(--spacing)*.25)}.nv-primitive--spacing-l-0_5{padding-left:calc(var(--spacing)*.5)}.nv-primitive--spacing-l-0_75{padding-left:calc(var(--spacing)*.75)}.nv-primitive--spacing-l-1{padding-left:calc(var(--spacing)*1)}.nv-primitive--spacing-l-1_5{padding-left:calc(var(--spacing)*1.5)}.nv-primitive--spacing-l-2{padding-left:calc(var(--spacing)*2)}.nv-primitive--spacing-l-2_5{padding-left:calc(var(--spacing)*2.5)}.nv-primitive--spacing-l-3{padding-left:calc(var(--spacing)*3)}.nv-primitive--spacing-l-3_5{padding-left:calc(var(--spacing)*3.5)}.nv-primitive--spacing-l-4{padding-left:calc(var(--spacing)*4)}.nv-primitive--spacing-l-5{padding-left:calc(var(--spacing)*5)}.nv-primitive--spacing-l-6{padding-left:calc(var(--spacing)*6)}.nv-primitive--spacing-l-7{padding-left:calc(var(--spacing)*7)}.nv-primitive--spacing-l-8{padding-left:calc(var(--spacing)*8)}.nv-primitive--spacing-l-9{padding-left:calc(var(--spacing)*9)}.nv-primitive--spacing-l-10{padding-left:calc(var(--spacing)*10)}.nv-primitive--spacing-l-11{padding-left:calc(var(--spacing)*11)}.nv-primitive--spacing-l-12{padding-left:calc(var(--spacing)*12)}.nv-primitive--spacing-l-14{padding-left:calc(var(--spacing)*14)}.nv-primitive--spacing-l-16{padding-left:calc(var(--spacing)*16)}.nv-primitive--spacing-l-18{padding-left:calc(var(--spacing)*18)}.nv-primitive--spacing-l-20{padding-left:calc(var(--spacing)*20)}.nv-primitive--spacing-l-24{padding-left:calc(var(--spacing)*24)}.nv-primitive--spacing-l-28{padding-left:calc(var(--spacing)*28)}.nv-primitive--spacing-l-32{padding-left:calc(var(--spacing)*32)}.nv-primitive--spacing-l-36{padding-left:calc(var(--spacing)*36)}.nv-primitive--spacing-l-40{padding-left:calc(var(--spacing)*40)}.nv-primitive--spacing-l-44{padding-left:calc(var(--spacing)*44)}.nv-primitive--spacing-l-48{padding-left:calc(var(--spacing)*48)}.nv-primitive--spacing-l-52{padding-left:calc(var(--spacing)*52)}.nv-primitive--spacing-l-56{padding-left:calc(var(--spacing)*56)}.nv-primitive--spacing-l-60{padding-left:calc(var(--spacing)*60)}.nv-primitive--spacing-l-64{padding-left:calc(var(--spacing)*64)}.nv-primitive--spacing-l-72{padding-left:calc(var(--spacing)*72)}.nv-primitive--spacing-l-80{padding-left:calc(var(--spacing)*80)}.nv-primitive--spacing-l-96{padding-left:calc(var(--spacing)*96)}.nv-primitive--spacing-l-250{padding-left:calc(var(--spacing)*250)}.nv-primitive--spacing-l-px{padding-left:1px}.nv-primitive--spacing-l-density-xxs{padding-left:var(--spacing-density-xxs)}.nv-primitive--spacing-l-density-xs{padding-left:var(--spacing-density-xs)}.nv-primitive--spacing-l-density-sm{padding-left:var(--spacing-density-sm)}.nv-primitive--spacing-l-density-md{padding-left:var(--spacing-density-md)}.nv-primitive--spacing-l-density-lg{padding-left:var(--spacing-density-lg)}.nv-primitive--spacing-l-density-xl{padding-left:var(--spacing-density-xl)}.nv-primitive--spacing-l-density-2xl{padding-left:var(--spacing-density-2xl)}.nv-primitive--spacing-l-density-3xl{padding-left:var(--spacing-density-3xl)}.nv-primitive--spacing-l-density-4xl{padding-left:var(--spacing-density-4xl)}.nv-primitive--spacing-l-density-5xl{padding-left:var(--spacing-density-5xl)}.nv-primitive--spacing-l-inherit{padding-left:inherit}.nv-primitive--gap-0{gap:calc(var(--spacing)*0)}.nv-primitive--gap-0_25{gap:calc(var(--spacing)*.25)}.nv-primitive--gap-0_5{gap:calc(var(--spacing)*.5)}.nv-primitive--gap-0_75{gap:calc(var(--spacing)*.75)}.nv-primitive--gap-1{gap:calc(var(--spacing)*1)}.nv-primitive--gap-1_5{gap:calc(var(--spacing)*1.5)}.nv-primitive--gap-2{gap:calc(var(--spacing)*2)}.nv-primitive--gap-2_5{gap:calc(var(--spacing)*2.5)}.nv-primitive--gap-3{gap:calc(var(--spacing)*3)}.nv-primitive--gap-3_5{gap:calc(var(--spacing)*3.5)}.nv-primitive--gap-4{gap:calc(var(--spacing)*4)}.nv-primitive--gap-5{gap:calc(var(--spacing)*5)}.nv-primitive--gap-6{gap:calc(var(--spacing)*6)}.nv-primitive--gap-7{gap:calc(var(--spacing)*7)}.nv-primitive--gap-8{gap:calc(var(--spacing)*8)}.nv-primitive--gap-9{gap:calc(var(--spacing)*9)}.nv-primitive--gap-10{gap:calc(var(--spacing)*10)}.nv-primitive--gap-11{gap:calc(var(--spacing)*11)}.nv-primitive--gap-12{gap:calc(var(--spacing)*12)}.nv-primitive--gap-14{gap:calc(var(--spacing)*14)}.nv-primitive--gap-16{gap:calc(var(--spacing)*16)}.nv-primitive--gap-18{gap:calc(var(--spacing)*18)}.nv-primitive--gap-20{gap:calc(var(--spacing)*20)}.nv-primitive--gap-24{gap:calc(var(--spacing)*24)}.nv-primitive--gap-28{gap:calc(var(--spacing)*28)}.nv-primitive--gap-32{gap:calc(var(--spacing)*32)}.nv-primitive--gap-36{gap:calc(var(--spacing)*36)}.nv-primitive--gap-40{gap:calc(var(--spacing)*40)}.nv-primitive--gap-44{gap:calc(var(--spacing)*44)}.nv-primitive--gap-48{gap:calc(var(--spacing)*48)}.nv-primitive--gap-52{gap:calc(var(--spacing)*52)}.nv-primitive--gap-56{gap:calc(var(--spacing)*56)}.nv-primitive--gap-60{gap:calc(var(--spacing)*60)}.nv-primitive--gap-64{gap:calc(var(--spacing)*64)}.nv-primitive--gap-72{gap:calc(var(--spacing)*72)}.nv-primitive--gap-80{gap:calc(var(--spacing)*80)}.nv-primitive--gap-96{gap:calc(var(--spacing)*96)}.nv-primitive--gap-250{gap:calc(var(--spacing)*250)}.nv-primitive--gap-px{gap:1px}.nv-primitive--gap-density-xxs{gap:var(--spacing-density-xxs)}.nv-primitive--gap-density-xs{gap:var(--spacing-density-xs)}.nv-primitive--gap-density-sm{gap:var(--spacing-density-sm)}.nv-primitive--gap-density-md{gap:var(--spacing-density-md)}.nv-primitive--gap-density-lg{gap:var(--spacing-density-lg)}.nv-primitive--gap-density-xl{gap:var(--spacing-density-xl)}.nv-primitive--gap-density-2xl{gap:var(--spacing-density-2xl)}.nv-primitive--gap-density-3xl{gap:var(--spacing-density-3xl)}.nv-primitive--gap-density-4xl{gap:var(--spacing-density-4xl)}.nv-primitive--gap-density-5xl{gap:var(--spacing-density-5xl)}.nv-primitive--gap-inherit{gap:inherit} diff --git a/desktop/src/ui/types/engine-manifest.ts b/desktop/src/ui/types/engine-manifest.ts index 1cd998bf..5bd6fe89 100644 --- a/desktop/src/ui/types/engine-manifest.ts +++ b/desktop/src/ui/types/engine-manifest.ts @@ -36,6 +36,12 @@ export interface EngineCaps { modelOpsWhenStopped: boolean /** When true, show the Delete action in the model action menu. */ hasDeleteModel: boolean + /** + * Whether the hub accepts an identifier the user typed that is not in the + * catalogue. MLX has no catalogue to browse, and a model built locally has + * no repo id at all, so naming it is the only way to add it. + */ + acceptsTypedModelId?: boolean /** * When true, the engine restarts as part of deleting a model, so Delete asks * for confirmation first. The engine manager owns the restart; this flag only diff --git a/desktop/tests/modular/mlx-delete-action.test.ts b/desktop/tests/modular/mlx-delete-action.test.ts new file mode 100644 index 00000000..32ec6e02 --- /dev/null +++ b/desktop/tests/modular/mlx-delete-action.test.ts @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest' +import { deleteModelAction } from '@/electron/service-bridge/model-delete-action' + +/** + * The delete that could not delete. A locally built MLX model is advertised by + * absolute path and is not in the Hugging Face cache, so the cache delete fails + * with `Cache directory not found` and the model stays in the list forever -- + * the models-directory scan keeps finding it on disk. + */ +describe('deleteModelAction', () => { + it('sends an MLX path model to the delete that removes files', () => { + expect(deleteModelAction('mlx', '/Users/me/models/Qwen3.8-27B-3bit')).toBe( + 'delete_model_path' + ) + }) + + it('sends an MLX repo id to the cache delete', () => { + expect(deleteModelAction('mlx', 'mlx-community/Llama-3.2-1B-Instruct-4bit')).toBe( + 'delete_model' + ) + }) + + it('leaves other engines on the cache delete, even for a path', () => { + expect(deleteModelAction('lmstudio', '/Users/me/models/thing')).toBe('delete_model') + expect(deleteModelAction('ollama', 'llama3')).toBe('delete_model') + }) +}) diff --git a/desktop/tests/modular/model-hub-warm-gate.test.ts b/desktop/tests/modular/model-hub-warm-gate.test.ts new file mode 100644 index 00000000..af5d9dc7 --- /dev/null +++ b/desktop/tests/modular/model-hub-warm-gate.test.ts @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi, beforeEach } from 'vitest' + +// vi.mock is hoisted above module-level consts, so the spy has to be too. +const { refresh } = vi.hoisted(() => ({ refresh: vi.fn() })) + +vi.mock('electron', () => ({ + app: { isPackaged: false, getAppPath: () => process.cwd() }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('@/electron/model-hub/lmstudio-catalog', () => ({ + lmStudioCatalogCache: { refresh, ensureLoaded: vi.fn(), list: () => [] } +})) + +import { warmEngineHubs } from '@/electron/model-hub' +import type { EngineType } from '@/shared/types/engines' + +const installed = + (...engines: EngineType[]) => + (engine: EngineType) => + engines.includes(engine) + +describe('engine hub warming', () => { + beforeEach(() => refresh.mockClear()) + + // The LM Studio catalogue fetch is the only unprompted outbound request PAIR + // makes. A node running Ollama or MLX has no reason to announce itself to + // huggingface.co at every launch, so the warm is gated on the engine that + // needs it actually being present. + it('does not fetch the LM Studio catalogue when LM Studio is not installed', () => { + warmEngineHubs(installed('ollama', 'mlx')) + expect(refresh).not.toHaveBeenCalled() + }) + + it('fetches it when LM Studio is installed', () => { + warmEngineHubs(installed('lm-studio')) + expect(refresh).toHaveBeenCalledTimes(1) + }) + + // An engine-manager that has not reported yet reads as "not installed". The + // cost is a slower first modal open, because getEngineHubModels awaits + // ensureLoaded() regardless -- never a missing catalogue. + it('stays silent when no engine facts have arrived', () => { + warmEngineHubs(() => false) + expect(refresh).not.toHaveBeenCalled() + }) +}) diff --git a/desktop/tests/modular/proxy-engine-membership.test.ts b/desktop/tests/modular/proxy-engine-membership.test.ts new file mode 100644 index 00000000..fed8d24f --- /dev/null +++ b/desktop/tests/modular/proxy-engine-membership.test.ts @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest' +import { isProxyEngine, PROXY_ENGINES } from '@/electron/service-bridge/modular-state' +import { EngineTypes } from '@/shared/constants/engines' +import type { EngineType } from '@/shared/types/engines' + +/** + * `isProxyEngine` and `PROXY_ENGINES` are the same fact, and when they were + * written out twice they drifted: MLX was added to the type and the list but not + * to the predicate. TypeScript cannot catch that — a type guard's body is + * unchecked, and the signature asserts the very thing being got wrong. + * + * The cost was invisible and specific: `emitRemoteEngineStatus` returns early + * for a non-proxy engine, so a peer's MLX status was never pushed and its card + * sat on the `initializing` placeholder forever, while Ollama and LM Studio — + * which the predicate did name — rendered correctly on the very same card. + */ +describe('isProxyEngine agrees with PROXY_ENGINES', () => { + it('accepts every engine in the list', () => { + for (const engine of PROXY_ENGINES) { + expect(isProxyEngine(engine), `${engine} is in PROXY_ENGINES`).toBe(true) + } + }) + + it('accepts mlx, whose omission stranded the card on "Initializing…"', () => { + expect(isProxyEngine('mlx')).toBe(true) + }) + + it('rejects every engine not in the list', () => { + const proxies = new Set(PROXY_ENGINES) + for (const engine of EngineTypes as readonly EngineType[]) { + if (proxies.has(engine)) continue + expect(isProxyEngine(engine), `${engine} is not a proxy engine`).toBe(false) + } + }) + + it('rejects a value that is not an engine at all', () => { + expect(isProxyEngine('nonsense' as EngineType)).toBe(false) + }) +}) From 30e0ad926e041966aae24df760e955653efa5726 Mon Sep 17 00:00:00 2001 From: Denis Akimov Date: Mon, 14 Sep 2026 11:47:39 -0700 Subject: [PATCH 10/12] fix(desktop): declare local-network usage for mDNS discovery on macOS 15 macOS 15 gates unicast to your own subnet and all multicast behind a per-app grant, and only offers that grant to a bundle carrying a usage string. Without one there is no prompt to show, so discovery fails as EHOSTUNREACH with nothing in the UI to explain it. The grant also follows the responsible app, which is why a dev run launched from an editor is credited to the editor. Signed-off-by: Denis Akimov --- desktop/electron-builder.config.ts | 18 ++++- docs/macos-local-network.md | 120 +++++++++++++++++++++++++++++ scripts/macos-dev-local-network.sh | 71 +++++++++++++++++ 3 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 docs/macos-local-network.md create mode 100755 scripts/macos-dev-local-network.sh diff --git a/desktop/electron-builder.config.ts b/desktop/electron-builder.config.ts index 1f713d47..e34e4856 100644 --- a/desktop/electron-builder.config.ts +++ b/desktop/electron-builder.config.ts @@ -395,7 +395,23 @@ const config: Configuration = { mac: { executableName: APP_EXECUTABLE_NAME, extendInfo: { - CFBundleDisplayName: APP_DISPLAY_NAME + CFBundleDisplayName: APP_DISPLAY_NAME, + // macOS 15+ gates every local-network operation -- unicast to the + // subnet, and all multicast including mDNS -- behind an explicit + // per-app grant. Without a usage string macOS has nothing to put in + // the prompt, so it never asks and never lists the app in System + // Settings > Privacy & Security > Local Network; the sends just fail + // with EHOSTUNREACH. That is fatal here: node discovery IS mDNS. + NSLocalNetworkUsageDescription: `${APP_DISPLAY_NAME} finds other ${APP_DISPLAY_NAME} nodes on your local network so they can share models and run inference together.`, + // The Bonjour types the node scanner registers and browses. Required + // for the DNS-SD APIs; declared for the raw mDNS path too so the + // grant covers every service this app actually speaks. + NSBonjourServices: [ + '_nvpair-node._tcp', + '_nvpair-node-info._tcp', + '_nvpair-ollama._tcp', + '_nvpair-workload-manager._tcp' + ] }, icon: './resources/icons/logo.icns', // SMAppService (the privileged firewall helper) requires macOS 13+. diff --git a/docs/macos-local-network.md b/docs/macos-local-network.md new file mode 100644 index 00000000..dfa2132c --- /dev/null +++ b/docs/macos-local-network.md @@ -0,0 +1,120 @@ + + +# Local network access on macOS 15+ + +Node discovery is mDNS. macOS 15 (Sequoia) put every local-network operation +behind a per-app grant — unicast to your own subnet, and *all* multicast, which +is what mDNS is. Without that grant the sends do not merely go unanswered, they +fail outright: + +``` +mdns send: query did not leave any interface service=_nvpair-node._tcp + errors="en0 write: write udp4 192.168.1.36:56003->224.0.0.251:5353: sendto: no route to host" +``` + +`sendto: no route to host` is `EHOSTUNREACH`, which reads like a routing fault +and is not one. The tell is that traffic *through* the router keeps working +while anything *on* the subnet fails, and that the same send succeeds from a +shell on the same machine. + +## Two things have to be true + +**1. The app must have a usage string.** macOS only offers the grant to a bundle +whose `Info.plist` carries `NSLocalNetworkUsageDescription`. With no string +there is nothing to put in the prompt, so no prompt appears, the app never +enters *System Settings → Privacy & Security → Local Network*, and every send +fails silently. The packaged app declares it via `mac.extendInfo` in +`desktop/electron-builder.config.ts`, alongside `NSBonjourServices` listing the +service types the scanner registers and browses. + +**2. The grant follows the *responsible* app, not the process holding the +socket.** macOS walks up to the application that is responsible for the process +tree. A helper's traffic is credited to the app that launched it. This is the +part that surprises people. + +## Why a dev run looks broken + +`npm start` from an editor's integrated terminal produces this tree: + +``` +Visual Studio Code → Code Helper → bash → make → npm → node → Electron → nvpair-node-scanner +``` + +The responsible app is **Visual Studio Code**. Every discovery attempt is +attributed to it — the unified log says so directly: + +```bash +log show --last 1h --style compact \ + --predicate 'eventMessage CONTAINS[c] "LocalNetwork: found bundle"' \ + | grep -oE 'bundle id [a-zA-Z0-9.-]+' | sort | uniq -c | sort -rn +``` + +On an affected machine that prints thousands of `com.microsoft.VSCode` and zero +Electron. Patching Electron's `Info.plist` changes nothing while this is true, +because Electron is not the bundle being judged. + +## Fixing it + +Pick one: + +**Grant the responsible app.** Enable **Visual Studio Code** (or whichever app +launched the tree) in *System Settings → Privacy & Security → Local Network*, +quit it completely, and relaunch. Fastest, and correct as far as it goes — but +the grant is the editor's, so it covers anything else that editor spawns too. + +**Launch from Terminal.** Apple exempts command-line tools run from Terminal or +over SSH, including the processes they spawn, so `npm start` from Terminal.app +sidesteps the question entirely. Good for a quick discovery test. + +**Give Electron its own identity.** To have *Electron* prompt and appear in the +list under its own name it needs both the usage string and a launch through +Launch Services, so that it — not the editor — is the responsible app: + +```bash +make macos-dev-local-network # adds the keys, re-signs the ad-hoc bundle +open -n desktop/node_modules/electron/dist/Electron.app --args . +``` + +`npm ci` replaces `node_modules`, so re-run the make target after installing. + +Confirm which way it went by re-running the log query above and checking whether +the bundle id is now Electron's. + +## Verifying + +The cheapest check that separates authorization from a real network fault — +this send succeeds from a shell even when the app cannot make it: + +```bash +python3 - <<'EOF' +import socket +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton('')) +print(s.sendto(b'probe', ('224.0.0.251', 5353)), 'bytes') +EOF +``` + +Succeeds from a shell, fails from the app ⇒ authorization, not networking. + +## Notes + +- There is no API to raise the prompt on demand. It appears when the app + performs a local-network operation while frontmost and the grant is still + undetermined — so trigger discovery from a user action, and if it is denied, + say so and point at the Settings pane rather than failing silently. +- There is no supported way to reset a Local Network decision; `tccutil` does + not cover it. Testing a first-run prompt needs a fresh user account or a VM + snapshot. +- `AllowedWiFiLocalNetworkAddresses` / `AllowedEthernetLocalNetworkAddresses` + under the `com.apple.network.local-network` domain exempt whole CIDR ranges, + but they arrived in **macOS 15.5** and are unavailable on earlier 15.x. They + are also machine-wide and apply to every process, so they are a lab + convenience, not a fix to ship behind. +- An ad-hoc, linker-signed bundle with no Team ID has no stable identity for + macOS to hang a grant on, so a dev grant can be forgotten across reinstalls. + The shipped app avoids this by being Developer ID signed and notarized. +- macOS needs no multicast entitlement (that requirement is iOS-only), and the + `com.apple.security.network.*` entitlements matter only under App Sandbox. diff --git a/scripts/macos-dev-local-network.sh b/scripts/macos-dev-local-network.sh new file mode 100755 index 00000000..0ca3fe76 --- /dev/null +++ b/scripts/macos-dev-local-network.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +# SPDX-License-Identifier: Apache-2.0 +# +# Make the DEVELOPMENT Electron promptable for macOS Local Network access. +# +# macOS 15 gates every local-network operation -- unicast to the subnet and all +# multicast, which includes the mDNS that node discovery is built on -- behind a +# per-app grant. macOS only offers that grant to an app whose Info.plist carries +# a usage string. The Electron that npm installs has none, so on macOS 15+ a dev +# run cannot discover nodes: every mDNS send returns EHOSTUNREACH and no prompt +# is ever shown, because there is nothing to show. +# +# The packaged app declares these keys through electron-builder (see +# desktop/electron-builder.config.ts). This script does the same to the throwaway +# Electron under node_modules so `npm start` behaves like the shipped app. +# +# IMPORTANT -- this is not sufficient on its own. macOS attributes a local +# network operation to the RESPONSIBLE process, which is the app that launched +# the tree, not necessarily the process holding the socket. Launch `npm start` +# from VS Code's integrated terminal and the responsible app is Visual Studio +# Code, so the grant that governs discovery is VS Code's and Electron never +# appears in the list no matter what this script writes. To be judged on its own +# identity the app has to be launched through Launch Services: +# +# open -n +# +# See docs/macos-local-network.md for the full picture and the alternatives. +# +# npm rewrites node_modules, so re-run this after `npm ci` / `npm install`. +set -euo pipefail + +APP="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/desktop/node_modules/electron/dist/Electron.app}" +PLIST="$APP/Contents/Info.plist" + +if [ "$(uname -s)" != "Darwin" ]; then + echo "macos-dev-local-network: not macOS, nothing to do" >&2 + exit 0 +fi +if [ ! -f "$PLIST" ]; then + echo "no Electron bundle at $APP (run npm ci in desktop/ first)" >&2 + exit 1 +fi + +usage='Personal AI Router finds other Personal AI Router nodes on your local network so they can share models and run inference together.' + +# PlistBuddy has no upsert, so delete-then-add. The deletes are allowed to fail: +# on a fresh Electron neither key exists yet. +/usr/libexec/PlistBuddy -c "Delete :NSLocalNetworkUsageDescription" "$PLIST" >/dev/null 2>&1 || true +/usr/libexec/PlistBuddy -c "Add :NSLocalNetworkUsageDescription string $usage" "$PLIST" >/dev/null + +/usr/libexec/PlistBuddy -c "Delete :NSBonjourServices" "$PLIST" >/dev/null 2>&1 || true +/usr/libexec/PlistBuddy -c "Add :NSBonjourServices array" "$PLIST" >/dev/null +for svc in _nvpair-node._tcp _nvpair-node-info._tcp _nvpair-ollama._tcp _nvpair-workload-manager._tcp; do + /usr/libexec/PlistBuddy -c "Add :NSBonjourServices: string $svc" "$PLIST" >/dev/null +done + +# Info.plist is sealed by the code signature, so editing it invalidates the +# ad-hoc signature Electron ships with. An app whose signature does not verify +# is not one macOS will hand a privacy grant to, so re-sign in place. +codesign --force --sign - --deep "$APP" >/dev/null 2>&1 +codesign --verify --deep --strict "$APP" 2>/dev/null \ + && echo "signature verifies" \ + || echo "WARNING: signature did not verify; the grant may not stick" >&2 + +echo "patched $APP" +echo " NSLocalNetworkUsageDescription: set" +echo " NSBonjourServices: $(/usr/libexec/PlistBuddy -c 'Print :NSBonjourServices' "$PLIST" | grep -c '_nvpair') services" +echo +echo "The grant follows the RESPONSIBLE app. Launched from a VS Code terminal that" +echo "is VS Code, not Electron -- see docs/macos-local-network.md." From f3fe36abc182fd43fd279f8965f33f9b0fb0da1f Mon Sep 17 00:00:00 2001 From: Denis Akimov Date: Mon, 14 Sep 2026 11:47:39 -0700 Subject: [PATCH 11/12] chore: fork hygiene -- version 0.1.1+mlx, SPDX headers, licences Build metadata rather than a version bump, so the fork stays comparable to the upstream release it tracks. Files inherited from upstream keep NVIDIA's copyright notice as Apache-2.0 section 4 requires; files added by the fork carry their own, and the header checker accepts both. Signed-off-by: Denis Akimov --- .gitignore | 6 + AGENTS.md | 2 +- THIRD_PARTY_NOTICES.md | 5757 ++++++-------------------- desktop/package-lock.json | 111 - desktop/package.json | 2 +- desktop/scripts/generate-licenses.ts | 1 + scripts/spdx-headers.mjs | 20 +- 7 files changed, 1325 insertions(+), 4574 deletions(-) diff --git a/.gitignore b/.gitignore index 0e9ff4ff..a8c7a880 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,9 @@ __pycache__/ # Per-machine bookkeeping written by the Cursor agent hooks, not configuration. /.cursor/hooks/state/ + +# Benchmark scratch +tests/__pycache__/ + +# Local benchmark output (see tests/README.md for how to regenerate) +numbers.md diff --git a/AGENTS.md b/AGENTS.md index e1d73809..2f4872b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ Never edit `desktop/docs/services-api.md` by hand. It is generated by ## Services (`services/`) -Thirteen Go binaries. Each component is its own module, with its tests beside its +Fourteen Go binaries. Each component is its own module, with its tests beside its source and a `README.md` describing its JSON-RPC surface. Shared packages live in `shared/`, and `tests/` holds cross-process tests that drive real binaries. Prefer the Go source when a README disagrees with it. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 6aca5569..02df572b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -43,8 +43,6 @@ binaries across the Windows, Linux, and macOS targets. First-party modules | `@floating-ui/dom` | 1.7.6 | MIT | | `@floating-ui/react-dom` | 2.1.8 | MIT | | `@floating-ui/utils` | 0.2.11 | MIT | -| `@napi-rs/canvas` | 0.1.99 | MIT | -| `@napi-rs/canvas-darwin-arm64` | 0.1.99 | MIT | | `@noble/hashes` | 2.2.0 | MIT | | `@nvidia/foundations-react-core` | 1.0.0 | Apache-2.0 | | `@radix-ui/number` | 1.1.1 | MIT | @@ -115,41 +113,29 @@ binaries across the Windows, Linux, and macOS targets. First-party modules | `@shikijs/types` | 4.0.2 | MIT | | `@shikijs/vscode-textmate` | 10.0.2 | MIT | | `@tabby_ai/hijri-converter` | 1.0.5 | MIT | -| `@types/debug` | 4.1.13 | MIT | -| `@types/estree` | 1.0.8 | MIT | -| `@types/estree-jsx` | 1.0.5 | MIT | | `@types/hast` | 3.0.4 | MIT | | `@types/mdast` | 4.0.4 | MIT | -| `@types/ms` | 2.1.0 | MIT | | `@types/node` | 24.13.2 | MIT | | `@types/react` | 19.2.14 | MIT | | `@types/react-dom` | 19.2.3 | MIT | | `@types/trusted-types` | 2.0.7 | MIT | -| `@types/unist` | 2.0.11 | MIT | | `@types/unist` | 3.0.3 | MIT | | `@ungap/structured-clone` | 1.3.0 | ISC | -| `@xmldom/xmldom` | 0.8.13 | MIT | -| `argparse` | 1.0.10 | MIT | +| `agent-base` | 6.0.2 | MIT | | `argparse` | 2.0.1 | Python-2.0 | | `aria-hidden` | 1.2.6 | MIT | | `asynckit` | 0.4.0 | MIT | -| `axios` | 1.16.0 | MIT | -| `bail` | 2.0.2 | MIT | -| `base64-js` | 1.5.1 | MIT | +| `axios` | 1.18.0 | MIT | | `bidi-js` | 1.0.3 | MIT | -| `bluebird` | 3.4.7 | MIT | | `builder-util-runtime` | 9.7.0 | MIT | | `call-bind-apply-helpers` | 1.0.2 | MIT | | `ccount` | 2.0.1 | MIT | -| `character-entities` | 2.0.2 | MIT | | `character-entities-html4` | 2.1.0 | MIT | | `character-entities-legacy` | 3.0.0 | MIT | -| `character-reference-invalid` | 2.0.1 | MIT | | `class-variance-authority` | 0.7.1 | Apache-2.0 | | `clsx` | 2.1.1 | MIT | | `combined-stream` | 1.0.8 | MIT | | `comma-separated-tokens` | 2.0.3 | MIT | -| `core-util-is` | 1.0.2 | MIT | | `css-tree` | 3.2.1 | MIT | | `csstype` | 3.2.3 | MIT | | `data-urls` | 7.0.0 | MIT | @@ -157,27 +143,21 @@ binaries across the Windows, Linux, and macOS targets. First-party modules | `date-fns-jalali` | 4.1.0-0 | MIT | | `debug` | 4.4.3 | MIT | | `decimal.js` | 10.6.0 | MIT | -| `decode-named-character-reference` | 1.3.0 | MIT | | `delayed-stream` | 1.0.0 | MIT | | `dequal` | 2.0.3 | MIT | | `detect-node-es` | 1.1.0 | MIT | | `devlop` | 1.1.0 | MIT | -| `dingbat-to-unicode` | 1.0.1 | BSD-2-Clause | -| `dompurify` | 3.4.11 | (MPL-2.0 OR Apache-2.0) | -| `duck` | 0.1.12 | BSD* | +| `dompurify` | 3.4.13 | (MPL-2.0 OR Apache-2.0) | | `dunder-proto` | 1.0.1 | MIT | -| `electron` | 42.5.0 | MIT | +| `electron` | 42.10.0 | MIT | | `electron-log` | 5.4.4 | MIT | | `electron-updater` | 6.8.9 | MIT | -| `entities` | 6.0.1 | BSD-2-Clause | | `entities` | 8.0.0 | BSD-2-Clause | | `env-paths` | 3.0.0 | MIT | | `es-define-property` | 1.0.1 | MIT | | `es-errors` | 1.3.0 | MIT | | `es-object-atoms` | 1.1.1 | MIT | | `es-set-tostringtag` | 2.1.0 | MIT | -| `estree-util-is-identifier-name` | 3.0.0 | MIT | -| `extend` | 3.0.2 | MIT | | `flip-toolkit` | 7.2.4 | MIT | | `follow-redirects` | 1.16.0 | MIT | | `form-data` | 4.0.6 | MIT | @@ -199,6 +179,7 @@ binaries across the Windows, Linux, and macOS targets. First-party modules | `github.com/clipperhouse/displaywidth` | v0.9.0 | MIT | | `github.com/clipperhouse/stringish` | v0.1.1 | MIT | | `github.com/clipperhouse/uax29/v2` | v2.5.0 | MIT | +| `github.com/ebitengine/purego` | v0.10.2 | Apache-2.0 | | `github.com/erikgeiser/coninput` | v0.0.0-20211004153227-1c3628e74d0f | MIT | | `github.com/go-ole/go-ole` | v1.2.6 | MIT | | `github.com/grandcat/zeroconf` | v1.0.0 | MIT | @@ -215,6 +196,8 @@ binaries across the Windows, Linux, and macOS targets. First-party modules | `github.com/muesli/cancelreader` | v0.2.2 | MIT | | `github.com/muesli/termenv` | v0.16.0 | MIT | | `github.com/rivo/uniseg` | v0.4.7 | MIT | +| `github.com/shirou/gopsutil/v4` | v4.26.7 | BSD-3-Clause | +| `github.com/tklauser/go-sysconf` | v0.3.16 | BSD-3-Clause | | `github.com/xo/terminfo` | v0.0.0-20220910002029-abceb7e1c41e | MIT | | `github.com/yusufpapurcu/wmi` | v1.2.4 | MIT | | `golang.org/x/net` | v0.58.0 | BSD-3-Clause | @@ -226,93 +209,41 @@ binaries across the Windows, Linux, and macOS targets. First-party modules | `has-symbols` | 1.1.0 | MIT | | `has-tostringtag` | 1.0.2 | MIT | | `hasown` | 2.0.4 | MIT | -| `hast-util-from-parse5` | 8.0.3 | MIT | -| `hast-util-parse-selector` | 4.0.0 | MIT | -| `hast-util-raw` | 9.1.0 | MIT | -| `hast-util-sanitize` | 5.0.2 | MIT | | `hast-util-to-html` | 9.0.5 | MIT | -| `hast-util-to-jsx-runtime` | 2.3.6 | MIT | -| `hast-util-to-parse5` | 8.0.1 | MIT | | `hast-util-whitespace` | 3.0.0 | MIT | -| `hastscript` | 9.0.1 | MIT | | `howett.net/plist` | v1.0.2-0.20250314012144-ee69052608d9 | BSD-2-Clause AND BSD-3-Clause | | `html-encoding-sniffer` | 6.0.0 | MIT | -| `html-url-attributes` | 3.0.1 | MIT | | `html-void-elements` | 3.0.0 | MIT | -| `immediate` | 3.0.6 | MIT | -| `inherits` | 2.0.4 | ISC | -| `inline-style-parser` | 0.2.7 | MIT | -| `is-alphabetical` | 2.0.1 | MIT | -| `is-alphanumerical` | 2.0.1 | MIT | -| `is-decimal` | 2.0.1 | MIT | -| `is-hexadecimal` | 2.0.1 | MIT | -| `is-plain-obj` | 4.1.0 | MIT | +| `https-proxy-agent` | 5.0.1 | MIT | | `is-potential-custom-element-name` | 1.0.1 | MIT | -| `isarray` | 1.0.0 | MIT | | `isomorphic-dompurify` | 3.13.0 | MIT | | `js-tokens` | 4.0.0 | MIT | | `js-yaml` | 4.2.0 | MIT | | `jsdom` | 29.1.1 | MIT | | `jsonfile` | 6.2.1 | MIT | -| `jszip` | 3.10.1 | (MIT OR GPL-3.0-or-later) | | `lazy-val` | 1.0.5 | MIT | -| `lie` | 3.3.0 | MIT | | `lodash.escaperegexp` | 4.1.2 | MIT | | `lodash.isequal` | 4.5.0 | MIT | -| `longest-streak` | 3.1.0 | MIT | | `loose-envify` | 1.4.0 | MIT | -| `lop` | 0.4.2 | BSD-2-Clause | | `lru-cache` | 11.3.6 | BlueOak-1.0.0 | | `lucide-react` | 1.16.0 | ISC | -| `mammoth` | 1.12.0 | BSD-2-Clause | | `math-intrinsics` | 1.1.0 | MIT | -| `mdast-util-from-markdown` | 2.0.3 | MIT | -| `mdast-util-mdx-expression` | 2.0.1 | MIT | -| `mdast-util-mdx-jsx` | 3.2.0 | MIT | -| `mdast-util-mdxjs-esm` | 2.0.1 | MIT | -| `mdast-util-phrasing` | 4.1.0 | MIT | | `mdast-util-to-hast` | 13.2.1 | MIT | -| `mdast-util-to-markdown` | 2.1.2 | MIT | -| `mdast-util-to-string` | 4.0.0 | MIT | | `mdn-data` | 2.27.1 | CC0-1.0 | -| `micromark` | 4.0.2 | MIT | -| `micromark-core-commonmark` | 2.0.3 | MIT | -| `micromark-factory-destination` | 2.0.1 | MIT | -| `micromark-factory-label` | 2.0.1 | MIT | -| `micromark-factory-space` | 2.0.1 | MIT | -| `micromark-factory-title` | 2.0.1 | MIT | -| `micromark-factory-whitespace` | 2.0.1 | MIT | | `micromark-util-character` | 2.1.1 | MIT | -| `micromark-util-chunked` | 2.0.1 | MIT | -| `micromark-util-classify-character` | 2.0.1 | MIT | -| `micromark-util-combine-extensions` | 2.0.1 | MIT | -| `micromark-util-decode-numeric-character-reference` | 2.0.2 | MIT | -| `micromark-util-decode-string` | 2.0.1 | MIT | | `micromark-util-encode` | 2.0.1 | MIT | -| `micromark-util-html-tag-name` | 2.0.1 | MIT | -| `micromark-util-normalize-identifier` | 2.0.1 | MIT | -| `micromark-util-resolve-all` | 2.0.1 | MIT | | `micromark-util-sanitize-uri` | 2.0.1 | MIT | -| `micromark-util-subtokenize` | 2.1.0 | MIT | | `micromark-util-symbol` | 2.0.1 | MIT | | `micromark-util-types` | 2.0.2 | MIT | | `mime-db` | 1.52.0 | MIT | | `mime-types` | 2.1.35 | MIT | | `ms` | 2.1.3 | MIT | -| `node-readable-to-web-readable-stream` | 0.4.2 | MIT | | `object-assign` | 4.1.1 | MIT | | `oniguruma-parser` | 0.12.2 | MIT | | `oniguruma-to-es` | 4.3.6 | MIT | -| `option` | 0.2.4 | BSD-2-Clause | | `overlayscrollbars` | 2.15.1 | MIT | | `overlayscrollbars-react` | 0.5.6 | MIT | -| `pako` | 1.0.11 | (MIT AND Zlib) | -| `parse-entities` | 4.0.2 | MIT | -| `parse5` | 7.3.0 | MIT | | `parse5` | 8.0.1 | MIT | -| `path-is-absolute` | 1.0.1 | MIT | -| `pdfjs-dist` | 5.6.205 | Apache-2.0 | -| `process-nextick-args` | 2.0.1 | MIT | | `progress` | 2.0.3 | MIT | | `prop-types` | 15.8.1 | MIT | | `property-information` | 7.1.0 | MIT | @@ -325,34 +256,22 @@ binaries across the Windows, Linux, and macOS targets. First-party modules | `react-dom` | 19.2.5 | MIT | | `react-flip-toolkit` | 7.2.4 | MIT | | `react-is` | 16.13.1 | MIT | -| `react-markdown` | 10.1.0 | MIT | | `react-remove-scroll` | 2.7.2 | MIT | | `react-remove-scroll-bar` | 2.3.8 | MIT | | `react-style-singleton` | 2.2.3 | MIT | -| `readable-stream` | 2.3.8 | MIT | | `regex` | 6.1.0 | MIT | | `regex-recursion` | 6.0.2 | MIT | | `regex-utilities` | 2.3.0 | MIT | -| `rehype-raw` | 7.0.0 | MIT | -| `rehype-sanitize` | 6.0.0 | MIT | -| `remark-parse` | 11.0.0 | MIT | -| `remark-rehype` | 11.1.2 | MIT | | `rematrix` | 0.2.2 | MIT | | `require-from-string` | 2.0.2 | MIT | -| `safe-buffer` | 5.1.2 | MIT | | `sax` | 1.6.0 | BlueOak-1.0.0 | | `saxes` | 6.0.0 | ISC | | `scheduler` | 0.27.0 | MIT | | `semver` | 7.7.4 | ISC | | `semver` | 7.8.5 | ISC | -| `setimmediate` | 1.0.5 | MIT | | `source-map-js` | 1.2.1 | BSD-3-Clause | | `space-separated-tokens` | 2.0.2 | MIT | -| `sprintf-js` | 1.0.3 | BSD-3-Clause | -| `string_decoder` | 1.1.1 | MIT | | `stringify-entities` | 4.0.4 | MIT | -| `style-to-js` | 1.1.21 | MIT | -| `style-to-object` | 1.0.14 | MIT | | `sumchecker` | 3.0.1 | Apache-2.0 | | `symbol-tree` | 3.2.4 | MIT | | `tiny-typed-emitter` | 2.1.0 | MIT | @@ -361,12 +280,9 @@ binaries across the Windows, Linux, and macOS targets. First-party modules | `tough-cookie` | 6.0.1 | BSD-3-Clause | | `tr46` | 6.0.0 | MIT | | `trim-lines` | 3.0.1 | MIT | -| `trough` | 2.2.0 | MIT | | `tslib` | 2.8.1 | 0BSD | -| `underscore` | 1.13.8 | MIT | | `undici` | 7.28.0 | MIT | | `undici-types` | 7.18.2 | MIT | -| `unified` | 11.0.5 | MIT | | `unist-util-is` | 6.0.1 | MIT | | `unist-util-position` | 5.0.0 | MIT | | `unist-util-stringify-position` | 4.0.0 | MIT | @@ -376,17 +292,13 @@ binaries across the Windows, Linux, and macOS targets. First-party modules | `use-callback-ref` | 1.3.3 | MIT | | `use-sidecar` | 1.1.3 | MIT | | `use-sync-external-store` | 1.6.0 | MIT | -| `util-deprecate` | 1.0.2 | MIT | | `vfile` | 6.0.3 | MIT | -| `vfile-location` | 5.0.3 | MIT | | `vfile-message` | 4.0.3 | MIT | | `w3c-xmlserializer` | 5.0.0 | MIT | -| `web-namespaces` | 2.0.1 | MIT | | `webidl-conversions` | 8.0.1 | BSD-2-Clause | | `whatwg-mimetype` | 5.0.0 | MIT | | `whatwg-url` | 16.0.1 | MIT | | `xml-name-validator` | 5.0.0 | Apache-2.0 | -| `xmlbuilder` | 10.1.1 | MIT | | `xmlchars` | 2.2.0 | MIT | | `zustand` | 5.0.12 | MIT | | `zwitch` | 2.0.4 | MIT | @@ -400,26 +312,26 @@ License: MIT Repository: https://github.com/asamuzaK/cssColor ```text -MIT License - -Copyright (c) 2024 asamuzaK (Kazz) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +MIT License + +Copyright (c) 2024 asamuzaK (Kazz) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` @@ -430,26 +342,26 @@ License: MIT Repository: https://github.com/asamuzaK/domSelector ```text -MIT License - -Copyright (c) 2023 asamuzaK (Kazz) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +MIT License + +Copyright (c) 2023 asamuzaK (Kazz) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` @@ -1041,48 +953,6 @@ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### @napi-rs/canvas 0.1.99 - -License: MIT - -Repository: https://github.com/Brooooooklyn/canvas - -```text -MIT License - -Copyright (c) 2020 lynweklm@gmail.com - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` - -### @napi-rs/canvas-darwin-arm64 0.1.99 - -License: MIT - -Repository: https://github.com/Brooooooklyn/canvas - -```text -# `@napi-rs/canvas-darwin-arm64` - -This is the **aarch64-apple-darwin** binary for `@napi-rs/canvas` -``` - ### @noble/hashes 2.2.0 License: MIT @@ -3269,7 +3139,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### @types/debug 4.1.13 +### @types/hast 3.0.4 License: MIT @@ -3299,7 +3169,7 @@ MIT License SOFTWARE ``` -### @types/estree 1.0.8 +### @types/mdast 4.0.4 License: MIT @@ -3329,7 +3199,7 @@ MIT License SOFTWARE ``` -### @types/estree-jsx 1.0.5 +### @types/node 24.13.2 License: MIT @@ -3359,7 +3229,7 @@ MIT License SOFTWARE ``` -### @types/hast 3.0.4 +### @types/react 19.2.14 License: MIT @@ -3389,7 +3259,7 @@ MIT License SOFTWARE ``` -### @types/mdast 4.0.4 +### @types/react-dom 19.2.3 License: MIT @@ -3419,7 +3289,7 @@ MIT License SOFTWARE ``` -### @types/ms 2.1.0 +### @types/trusted-types 2.0.7 License: MIT @@ -3449,7 +3319,7 @@ MIT License SOFTWARE ``` -### @types/node 24.13.2 +### @types/unist 3.0.3 License: MIT @@ -3479,236 +3349,193 @@ MIT License SOFTWARE ``` -### @types/react 19.2.14 +### @ungap/structured-clone 1.3.0 -License: MIT +License: ISC -Repository: https://github.com/DefinitelyTyped/DefinitelyTyped +Repository: https://github.com/ungap/structured-clone ```text -MIT License - - Copyright (c) Microsoft Corporation. +ISC License - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: +Copyright (c) 2021, Andrea Giammarchi, @WebReflection - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE +OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. ``` -### @types/react-dom 19.2.3 +### agent-base 6.0.2 License: MIT -Repository: https://github.com/DefinitelyTyped/DefinitelyTyped +Repository: https://github.com/TooTallNate/node-agent-base ```text -MIT License +agent-base +========== +### Turn a function into an [`http.Agent`][http.Agent] instance +[![Build Status](https://github.com/TooTallNate/node-agent-base/workflows/Node%20CI/badge.svg)](https://github.com/TooTallNate/node-agent-base/actions?workflow=Node+CI) - Copyright (c) Microsoft Corporation. +This module provides an `http.Agent` generator. That is, you pass it an async +callback function, and it returns a new `http.Agent` instance that will invoke the +given callback function when sending outbound HTTP requests. - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: +#### Some subclasses: - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. +Here's some more interesting uses of `agent-base`. +Send a pull request to list yours! - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE -``` + * [`http-proxy-agent`][http-proxy-agent]: An HTTP(s) proxy `http.Agent` implementation for HTTP endpoints + * [`https-proxy-agent`][https-proxy-agent]: An HTTP(s) proxy `http.Agent` implementation for HTTPS endpoints + * [`pac-proxy-agent`][pac-proxy-agent]: A PAC file proxy `http.Agent` implementation for HTTP and HTTPS + * [`socks-proxy-agent`][socks-proxy-agent]: A SOCKS proxy `http.Agent` implementation for HTTP and HTTPS -### @types/trusted-types 2.0.7 -License: MIT +Installation +------------ -Repository: https://github.com/DefinitelyTyped/DefinitelyTyped +Install with `npm`: -```text -MIT License +``` bash +$ npm install agent-base +``` - Copyright (c) Microsoft Corporation. - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: +Example +------- - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. +Here's a minimal example that creates a new `net.Socket` connection to the server +for every HTTP request (i.e. the equivalent of `agent: false` option): - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE -``` +```js +var net = require('net'); +var tls = require('tls'); +var url = require('url'); +var http = require('http'); +var agent = require('agent-base'); + +var endpoint = 'http://nodejs.org/api/'; +var parsed = url.parse(endpoint); + +// This is the important part! +parsed.agent = agent(function (req, opts) { + var socket; + // `secureEndpoint` is true when using the https module + if (opts.secureEndpoint) { + socket = tls.connect(opts); + } else { + socket = net.connect(opts); + } + return socket; +}); -### @types/unist 2.0.11 +// Everything else works just like normal... +http.get(parsed, function (res) { + console.log('"response" event!', res.headers); + res.pipe(process.stdout); +}); +``` -License: MIT +Returning a Promise or using an `async` function is also supported: -Repository: https://github.com/DefinitelyTyped/DefinitelyTyped +```js +agent(async function (req, opts) { + await sleep(1000); + // etc… +}); +``` -```text -MIT License +Return another `http.Agent` instance to "pass through" the responsibility +for that HTTP request to that agent: - Copyright (c) Microsoft Corporation. +```js +agent(function (req, opts) { + return opts.secureEndpoint ? https.globalAgent : http.globalAgent; +}); +``` - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. +API +--- - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE -``` +## Agent(Function callback[, Object options]) → [http.Agent][] -### @types/unist 3.0.3 +Creates a base `http.Agent` that will execute the callback function `callback` +for every HTTP request that it is used as the `agent` for. The callback function +is responsible for creating a `stream.Duplex` instance of some kind that will be +used as the underlying socket in the HTTP request. -License: MIT +The `options` object accepts the following properties: -Repository: https://github.com/DefinitelyTyped/DefinitelyTyped + * `timeout` - Number - Timeout for the `callback()` function in milliseconds. Defaults to Infinity (optional). -```text -MIT License +The callback function should have the following signature: - Copyright (c) Microsoft Corporation. +### callback(http.ClientRequest req, Object options, Function cb) → undefined - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: +The ClientRequest `req` can be accessed to read request headers and +and the path, etc. The `options` object contains the options passed +to the `http.request()`/`https.request()` function call, and is formatted +to be directly passed to `net.connect()`/`tls.connect()`, or however +else you want a Socket to be created. Pass the created socket to +the callback function `cb` once created, and the HTTP request will +continue to proceed. - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. +If the `https` module is used to invoke the HTTP request, then the +`secureEndpoint` property on `options` _will be set to `true`_. - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE -``` -### @ungap/structured-clone 1.3.0 +License +------- -License: ISC +(The MIT License) -Repository: https://github.com/ungap/structured-clone +Copyright (c) 2013 Nathan Rajlich <nathan@tootallnate.net> -```text -ISC License +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: -Copyright (c) 2021, Andrea Giammarchi, @WebReflection +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE -OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. +[http-proxy-agent]: https://github.com/TooTallNate/node-http-proxy-agent +[https-proxy-agent]: https://github.com/TooTallNate/node-https-proxy-agent +[pac-proxy-agent]: https://github.com/TooTallNate/node-pac-proxy-agent +[socks-proxy-agent]: https://github.com/TooTallNate/node-socks-proxy-agent +[http.Agent]: https://nodejs.org/api/http.html#http_class_http_agent ``` -### @xmldom/xmldom 0.8.13 +### argparse 2.0.1 -License: MIT +License: Python-2.0 -Repository: https://github.com/xmldom/xmldom +Repository: https://github.com/nodeca/argparse ```text -Copyright 2019 - present Christopher J. Brody and other contributors, as listed in: https://github.com/xmldom/xmldom/graphs/contributors -Copyright 2012 - 2017 @jindw and other contributors, as listed in: https://github.com/jindw/xmldom/graphs/contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### argparse 1.0.10 - -License: MIT - -Repository: https://github.com/nodeca/argparse - -```text -(The MIT License) - -Copyright (C) 2012 by Vitaly Puzrin - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -``` - -### argparse 2.0.1 - -License: Python-2.0 - -Repository: https://github.com/nodeca/argparse - -```text -A. HISTORY OF THE SOFTWARE -========================== +A. HISTORY OF THE SOFTWARE +========================== Python was created in the early 1990s by Guido van Rossum at Stichting Mathematisch Centrum (CWI, see http://www.cwi.nl) in the Netherlands @@ -4023,7 +3850,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### axios 1.16.0 +### axios 1.18.0 License: MIT @@ -4039,67 +3866,6 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### bail 2.0.2 - -License: MIT - -Repository: https://github.com/wooorm/bail - -```text -(The MIT License) - -Copyright (c) 2015 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### base64-js 1.5.1 - -License: MIT - -Repository: https://github.com/beatgammit/base64-js - -```text -The MIT License (MIT) - -Copyright (c) 2014 Jameson Little - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -``` - ### bidi-js 1.0.3 License: MIT @@ -4131,36 +3897,6 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### bluebird 3.4.7 - -License: MIT - -Repository: https://github.com/petkaantonov/bluebird - -```text -The MIT License (MIT) - -Copyright (c) 2013-2015 Petka Antonov - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -``` - ### builder-util-runtime 9.7.0 License: MIT @@ -4252,37 +3988,6 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### character-entities 2.0.2 - -License: MIT - -Repository: https://github.com/wooorm/character-entities - -```text -(The MIT License) - -Copyright (c) 2015 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - ### character-entities-html4 2.1.0 License: MIT @@ -4345,37 +4050,6 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### character-reference-invalid 2.0.1 - -License: MIT - -Repository: https://github.com/wooorm/character-reference-invalid - -```text -(The MIT License) - -Copyright (c) 2015 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - ### class-variance-authority 0.7.1 License: Apache-2.0 @@ -4652,34 +4326,6 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### core-util-is 1.0.2 - -License: MIT - -Repository: https://github.com/isaacs/core-util-is - -```text -Copyright Node.js contributors. All rights reserved. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to -deal in the Software without restriction, including without limitation the -rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -sell copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -IN THE SOFTWARE. -``` - ### css-tree 3.2.1 License: MIT @@ -4847,40 +4493,9 @@ License: MIT Repository: https://github.com/MikeMcl/decimal.js ```text -The MIT Licence. - -Copyright (c) 2025 Michael Mclaughlin - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### decode-named-character-reference 1.3.0 - -License: MIT - -Repository: https://github.com/wooorm/decode-named-character-reference - -```text -(The MIT License) +The MIT Licence. -Copyright (c) Titus Wormer +Copyright (c) 2025 Michael Mclaughlin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the @@ -5021,147 +4636,59 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### dingbat-to-unicode 1.0.1 +### dompurify 3.4.13 -License: BSD-2-Clause +License: (MPL-2.0 OR Apache-2.0) -Repository: https://github.com/mwilliamson/dingbat-to-unicode +Repository: https://github.com/cure53/DOMPurify ```text -# dingbat-to-unicode +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -Mapping from Dingbat fonts, such as Symbol, Webdings and Wingdings, to Unicode code points. + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -The following fonts are supported: + 1. Definitions. -* Symbol -* Webdings -* Wingdings 1 -* Wingdings 2 -* Wingdings 3 + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. -Note that in some cases, such as docx files, -the dingbat code point may have 0xF000 added to it to shift the code point into the Unicode private use area. -You should subtract 0xF000 from the code point before passing it into this library. + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. -## Installation + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. - npm install dingbat-to-unicode + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. -## Usage + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. -Import using `require` or `import`: + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. -```javascript -const dingbatToUnicode = require("dingbat-to-unicode"); -// or -import * as dingbatToUnicode from "dingbat-to-unicode"; -``` + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). -You can then call one of the following functions, depending on the representation you have the dingbat code point in: - -* `dingbatToUnicode.codePoint(typeface: string, codePoint: number): UnicodeScalarValue | undefined` - -* `dingbatToUnicode.dec(typeface: string, dec: string): UnicodeScalarValue | undefined` - -* `dingbatToUnicode.hex(typeface: string, hex: string): UnicodeScalarValue | undefined` - -`UnicodeScalarValue` is an object with two properties: - -* `codePoint`: a `number` representing the Unicode code point -* `string`: a `string` representing the code point as a string - -## Examples - -```javascript -const result = dingbatToUnicode.codePoint("Wingdings", 41)!!; -assert.strictEqual(result.codePoint, 0x2706); -``` - -```javascript -const result = dingbatToUnicode.dec("Wingdings", "41")!!; -assert.strictEqual(result.codePoint, 0x2706); -``` - -```javascript -const result = dingbatToUnicode.hex("Wingdings", "29")!!; -assert.strictEqual(result.codePoint, 0x2706); -``` - -```javascript -const result = dingbatToUnicode.hex("Wingdings", "3E")!!; -assert.strictEqual(result.codePoint, 0x2707); -``` - -```javascript -const result = dingbatToUnicode.hex("Wingdings", "3e")!!; -assert.strictEqual(result.codePoint, 0x2707); -``` - -```javascript -const result = dingbatToUnicode.hex("Wingdings", "29")!!; -assert.strictEqual(result.string, "\u2706"); -``` - -```javascript -const result = dingbatToUnicode.hex("Wingdings", "28")!!; -assert.strictEqual(result.string, "🕿"); -``` -``` - -### dompurify 3.4.11 - -License: (MPL-2.0 OR Apache-2.0) - -Repository: https://github.com/cure53/DOMPurify - -```text -Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions @@ -5319,37 +4846,6 @@ Apache License limitations under the License. ``` -### duck 0.1.12 - -License: BSD* - -Repository: https://github.com/mwilliamson/duck.js - -```text -Copyright (c) 2013, Michael Williamson -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -``` - ### dunder-proto 1.0.1 License: MIT @@ -5380,7 +4876,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### electron 42.5.0 +### electron 42.10.0 License: MIT @@ -5470,26 +4966,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### entities 6.0.1 - -License: BSD-2-Clause - -Repository: https://github.com/fb55/entities - -```text -Copyright (c) Felix Böhm -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - -Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - -Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - -THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS, -EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -``` - ### entities 8.0.0 License: BSD-2-Clause @@ -5648,68 +5124,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### estree-util-is-identifier-name 3.0.0 - -License: MIT - -Repository: https://github.com/syntax-tree/estree-util-is-identifier-name - -```text -(The MIT License) - -Copyright (c) 2020 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### extend 3.0.2 - -License: MIT - -Repository: https://github.com/justmoon/node-extend - -```text -The MIT License (MIT) - -Copyright (c) 2014 Stefan Thomas - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - ### flip-toolkit 7.2.4 License: MIT @@ -6392,56 +5806,266 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f +### github.com/ebitengine/purego v0.10.2 -License: MIT +License: Apache-2.0 -Repository: https://github.com/erikgeiser/coninput +Repository: https://github.com/ebitengine/purego ```text -MIT License - -Copyright (c) 2021 Erik G. +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + 1. Definitions. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. -### github.com/go-ole/go-ole v1.2.6 + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. -License: MIT + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. -Repository: https://github.com/go-ole/go-ole + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. -```text -The MIT License (MIT) + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. -Copyright © 2013-2017 Yasuhiro Matsumoto, + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the “Software”), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +``` + +### github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f + +License: MIT + +Repository: https://github.com/erikgeiser/coninput + +```text +MIT License + +Copyright (c) 2021 Erik G. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +### github.com/go-ole/go-ole v1.2.6 + +License: MIT + +Repository: https://github.com/go-ole/go-ole + +```text +The MIT License (MIT) + +Copyright © 2013-2017 Yasuhiro Matsumoto, + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the “Software”), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, @@ -7153,55 +6777,163 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e +### github.com/shirou/gopsutil/v4 v4.26.7 -License: MIT +License: BSD-3-Clause -Repository: https://github.com/xo/terminfo +Repository: https://github.com/shirou/gopsutil/v4 ```text -The MIT License (MIT) +gopsutil is distributed under BSD license reproduced below. -Copyright (c) 2016 Anmol Sethi +Copyright (c) 2014, WAKAYAMA Shirou +All rights reserved. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name of the gopsutil authors nor the names of its contributors + may be used to endorse or promote products derived from this software without + specific prior written permission. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -### github.com/yusufpapurcu/wmi v1.2.4 -License: MIT +------- +internal/common/binary.go in the gopsutil is copied and modified from golang/encoding/binary.go. -Repository: https://github.com/yusufpapurcu/wmi -```text -The MIT License (MIT) -Copyright (c) 2013 Stack Exchange +Copyright (c) 2009 The Go Authors. All rights reserved. -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: -The above copyright notice and this permission notice shall be included in all + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + +### github.com/tklauser/go-sysconf v0.3.16 + +License: BSD-3-Clause + +Repository: https://github.com/tklauser/go-sysconf + +```text +BSD 3-Clause License + +Copyright (c) 2018-2022, Tobias Klauser +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + +### github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e + +License: MIT + +Repository: https://github.com/xo/terminfo + +```text +The MIT License (MIT) + +Copyright (c) 2016 Anmol Sethi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +### github.com/yusufpapurcu/wmi v1.2.4 + +License: MIT + +Repository: https://github.com/yusufpapurcu/wmi + +```text +The MIT License (MIT) + +Copyright (c) 2013 Stack Exchange + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR @@ -7522,11 +7254,11 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### hast-util-from-parse5 8.0.3 +### hast-util-to-html 9.0.5 License: MIT -Repository: https://github.com/syntax-tree/hast-util-from-parse5 +Repository: https://github.com/syntax-tree/hast-util-to-html ```text (The MIT License) @@ -7553,11 +7285,11 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### hast-util-parse-selector 4.0.0 +### hast-util-whitespace 3.0.0 License: MIT -Repository: https://github.com/syntax-tree/hast-util-parse-selector +Repository: https://github.com/syntax-tree/hast-util-whitespace ```text (The MIT License) @@ -7584,47 +7316,99 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### hast-util-raw 9.1.0 +### howett.net/plist v1.0.2-0.20250314012144-ee69052608d9 -License: MIT +License: BSD-2-Clause AND BSD-3-Clause -Repository: https://github.com/syntax-tree/hast-util-raw +Repository: https://howett.net/plist ```text -(The MIT License) +Copyright (c) 2013, Dustin L. Howett. All rights reserved. -Copyright (c) Titus Wormer +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +The views and conclusions contained in the software and documentation are those +of the authors and should not be interpreted as representing official policies, +either expressed or implied, of the FreeBSD Project. + +-------------------------------------------------------------------------------- +Parts of this package were made available under the license covering +the Go language and all attended core libraries. That license follows. +-------------------------------------------------------------------------------- + +Copyright (c) 2012 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + +### html-encoding-sniffer 6.0.0 + +License: MIT + +Repository: https://github.com/jsdom/html-encoding-sniffer + +```text +Copyright © Domenic Denicola + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### hast-util-sanitize 5.0.2 +### html-void-elements 3.0.0 License: MIT -Repository: https://github.com/syntax-tree/hast-util-sanitize +Repository: https://github.com/wooorm/html-void-elements ```text (The MIT License) -Copyright (c) Titus Wormer +Copyright (c) 2016 Titus Wormer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the @@ -7646,16 +7430,129 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### hast-util-to-html 9.0.5 +### https-proxy-agent 5.0.1 License: MIT -Repository: https://github.com/syntax-tree/hast-util-to-html +Repository: https://github.com/TooTallNate/node-https-proxy-agent ```text +https-proxy-agent +================ +### An HTTP(s) proxy `http.Agent` implementation for HTTPS +[![Build Status](https://github.com/TooTallNate/node-https-proxy-agent/workflows/Node%20CI/badge.svg)](https://github.com/TooTallNate/node-https-proxy-agent/actions?workflow=Node+CI) + +This module provides an `http.Agent` implementation that connects to a specified +HTTP or HTTPS proxy server, and can be used with the built-in `https` module. + +Specifically, this `Agent` implementation connects to an intermediary "proxy" +server and issues the [CONNECT HTTP method][CONNECT], which tells the proxy to +open a direct TCP connection to the destination server. + +Since this agent implements the CONNECT HTTP method, it also works with other +protocols that use this method when connecting over proxies (i.e. WebSockets). +See the "Examples" section below for more. + + +Installation +------------ + +Install with `npm`: + +``` bash +$ npm install https-proxy-agent +``` + + +Examples +-------- + +#### `https` module example + +``` js +var url = require('url'); +var https = require('https'); +var HttpsProxyAgent = require('https-proxy-agent'); + +// HTTP/HTTPS proxy to connect to +var proxy = process.env.http_proxy || 'http://168.63.76.32:3128'; +console.log('using proxy server %j', proxy); + +// HTTPS endpoint for the proxy to connect to +var endpoint = process.argv[2] || 'https://graph.facebook.com/tootallnate'; +console.log('attempting to GET %j', endpoint); +var options = url.parse(endpoint); + +// create an instance of the `HttpsProxyAgent` class with the proxy server information +var agent = new HttpsProxyAgent(proxy); +options.agent = agent; + +https.get(options, function (res) { + console.log('"response" event!', res.headers); + res.pipe(process.stdout); +}); +``` + +#### `ws` WebSocket connection example + +``` js +var url = require('url'); +var WebSocket = require('ws'); +var HttpsProxyAgent = require('https-proxy-agent'); + +// HTTP/HTTPS proxy to connect to +var proxy = process.env.http_proxy || 'http://168.63.76.32:3128'; +console.log('using proxy server %j', proxy); + +// WebSocket endpoint for the proxy to connect to +var endpoint = process.argv[2] || 'ws://echo.websocket.org'; +var parsed = url.parse(endpoint); +console.log('attempting to connect to WebSocket %j', endpoint); + +// create an instance of the `HttpsProxyAgent` class with the proxy server information +var options = url.parse(proxy); + +var agent = new HttpsProxyAgent(options); + +// finally, initiate the WebSocket connection +var socket = new WebSocket(endpoint, { agent: agent }); + +socket.on('open', function () { + console.log('"open" event!'); + socket.send('hello world'); +}); + +socket.on('message', function (data, flags) { + console.log('"message" event! %j %j', data, flags); + socket.close(); +}); +``` + +API +--- + +### new HttpsProxyAgent(Object options) + +The `HttpsProxyAgent` class implements an `http.Agent` subclass that connects +to the specified "HTTP(s) proxy server" in order to proxy HTTPS and/or WebSocket +requests. This is achieved by using the [HTTP `CONNECT` method][CONNECT]. + +The `options` argument may either be a string URI of the proxy server to use, or an +"options" object with more specific properties: + + * `host` - String - Proxy host to connect to (may use `hostname` as well). Required. + * `port` - Number - Proxy port to connect to. Required. + * `protocol` - String - If `https:`, then use TLS to connect to the proxy. + * `headers` - Object - Additional HTTP headers to be sent on the HTTP CONNECT method. + * Any other options given are passed to the `net.connect()`/`tls.connect()` functions. + + +License +------- + (The MIT License) -Copyright (c) Titus Wormer +Copyright (c) 2013 Nathan Rajlich <nathan@tootallnate.net> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the @@ -7675,22 +7572,22 @@ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +[CONNECT]: http://en.wikipedia.org/wiki/HTTP_tunnel#HTTP_CONNECT_Tunneling ``` -### hast-util-to-jsx-runtime 2.3.6 +### is-potential-custom-element-name 1.0.1 License: MIT -Repository: https://github.com/syntax-tree/hast-util-to-jsx-runtime +Repository: https://github.com/mathiasbynens/is-potential-custom-element-name ```text -(The MIT License) - -Copyright (c) Titus Wormer +Copyright Mathias Bynens Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including +"Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to @@ -7699,235 +7596,204 @@ the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### hast-util-to-parse5 8.0.1 +### isomorphic-dompurify 3.13.0 License: MIT -Repository: https://github.com/syntax-tree/hast-util-to-parse5 +Repository: https://github.com/kkomelin/isomorphic-dompurify ```text -(The MIT License) +MIT License -Copyright (c) Titus Wormer +Copyright (c) 2020 Konstantin Komelin -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ``` -### hast-util-whitespace 3.0.0 +### js-tokens 4.0.0 License: MIT -Repository: https://github.com/syntax-tree/hast-util-whitespace +Repository: https://github.com/lydell/js-tokens ```text -(The MIT License) +The MIT License (MIT) -Copyright (c) 2016 Titus Wormer +Copyright (c) 2014, 2015, 2016, 2017, 2018 Simon Lydell -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. ``` -### hastscript 9.0.1 +### js-yaml 4.2.0 License: MIT -Repository: https://github.com/syntax-tree/hastscript +Repository: https://github.com/nodeca/js-yaml ```text (The MIT License) -Copyright (c) Titus Wormer +Copyright (C) 2011-2015 by Vitaly Puzrin -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. ``` -### howett.net/plist v1.0.2-0.20250314012144-ee69052608d9 +### jsdom 29.1.1 -License: BSD-2-Clause AND BSD-3-Clause +License: MIT -Repository: https://howett.net/plist +Repository: https://github.com/jsdom/jsdom ```text -Copyright (c) 2013, Dustin L. Howett. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -The views and conclusions contained in the software and documentation are those -of the authors and should not be interpreted as representing official policies, -either expressed or implied, of the FreeBSD Project. - --------------------------------------------------------------------------------- -Parts of this package were made available under the license covering -the Go language and all attended core libraries. That license follows. --------------------------------------------------------------------------------- - -Copyright (c) 2012 The Go Authors. All rights reserved. +Copyright (c) 2010 Elijah Insua -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. ``` -### html-encoding-sniffer 6.0.0 +### jsonfile 6.2.1 License: MIT -Repository: https://github.com/jsdom/html-encoding-sniffer +Repository: https://github.com/jprichardson/node-jsonfile ```text -Copyright © Domenic Denicola +(The MIT License) -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Copyright (c) 2012-2015, JP Richardson + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files +(the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, + merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, + ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### html-url-attributes 3.0.1 +### lazy-val 1.0.5 License: MIT -Repository: https://github.com/rehypejs/rehype-minify.git#main +Repository: https://github.com/develar/lazy-val ```text -(The MIT License) - -Copyright (c) Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +## lazy-val -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +Lazy value. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +```typescript +class Lazy { + constructor(creator: () => Promise) + readonly hasValue: boolean + value: Promise +} +``` ``` -### html-void-elements 3.0.0 +### lodash.escaperegexp 4.1.2 License: MIT -Repository: https://github.com/wooorm/html-void-elements +Repository: https://github.com/lodash/lodash ```text -(The MIT License) +Copyright jQuery Foundation and other contributors -Copyright (c) 2016 Titus Wormer +Based on Underscore.js, copyright Jeremy Ashkenas, +DocumentCloud and Investigative Reporters & Editors + +This software consists of voluntary contributions made by many +individuals. For exact contribution history, see the revision history +available at https://github.com/lodash/lodash + +The following license applies to all parts of this software except as +documented below: + +==== Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including +"Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to @@ -7936,23 +7802,50 @@ the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==== + +Copyright and related rights for sample code are waived via CC0. Sample +code is defined as all source code displayed within the prose of the +documentation. + +CC0: http://creativecommons.org/publicdomain/zero/1.0/ + +==== + +Files located in the node_modules and vendor directories are externally +maintained libraries used by this software which have their own +licenses; we recommend you read them, as their terms may differ from the +terms above. ``` -### immediate 3.0.6 +### lodash.isequal 4.5.0 License: MIT -Repository: https://github.com/calvinmetcalf/immediate +Repository: https://github.com/lodash/lodash ```text -Copyright (c) 2012 Barnesandnoble.com, llc, Donavon West, Domenic Denicola, Brian Cavalier +Copyright JS Foundation and other contributors + +Based on Underscore.js, copyright Jeremy Ashkenas, +DocumentCloud and Investigative Reporters & Editors + +This software consists of voluntary contributions made by many +individuals. For exact contribution history, see the revision history +available at https://github.com/lodash/lodash + +The following license applies to all parts of this software except as +documented below: + +==== Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the @@ -7972,306 +7865,156 @@ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` -### inherits 2.0.4 - -License: ISC - -Repository: https://github.com/isaacs/inherits +==== -```text -The ISC License +Copyright and related rights for sample code are waived via CC0. Sample +code is defined as all source code displayed within the prose of the +documentation. -Copyright (c) Isaac Z. Schlueter +CC0: http://creativecommons.org/publicdomain/zero/1.0/ -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. +==== -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND -FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. +Files located in the node_modules and vendor directories are externally +maintained libraries used by this software which have their own +licenses; we recommend you read them, as their terms may differ from the +terms above. ``` -### inline-style-parser 0.2.7 +### loose-envify 1.4.0 License: MIT -Repository: https://github.com/remarkablemark/inline-style-parser +Repository: https://github.com/zertosh/loose-envify ```text -(The MIT License) +The MIT License (MIT) -Copyright (c) 2012 TJ Holowaychuk +Copyright (c) 2015 Andres Suarez -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. ``` -### is-alphabetical 2.0.1 +### lru-cache 11.3.6 -License: MIT +License: BlueOak-1.0.0 -Repository: https://github.com/wooorm/is-alphabetical +Repository: https://github.com/isaacs/node-lru-cache ```text -(The MIT License) - -Copyright (c) 2016 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +# Blue Oak Model License -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` +Version 1.0.0 -### is-alphanumerical 2.0.1 +## Purpose -License: MIT +This license gives everyone as much permission to work with +this software as possible, while protecting contributors +from liability. -Repository: https://github.com/wooorm/is-alphanumerical +## Acceptance -```text -(The MIT License) +In order to receive this license, you must agree to its +rules. The rules of this license are both obligations +under that agreement and conditions to your license. +You must not do anything with this software that triggers +a rule that you cannot or will not follow. -Copyright (c) 2016 Titus Wormer +## Copyright -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: +Each contributor licenses you to do everything with this +software that would otherwise infringe that contributor's +copyright in it. -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +## Notices -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` +You must ensure that everyone who gets a copy of +any part of this software from you, with or without +changes, also gets the text of this license or a link to +. -### is-decimal 2.0.1 +## Excuse -License: MIT +If anyone notifies you in writing that you have not +complied with [Notices](#notices), you can keep your +license by taking all practical steps to comply within 30 +days after the notice. If you do not do so, your license +ends immediately. -Repository: https://github.com/wooorm/is-decimal +## Patent -```text -(The MIT License) +Each contributor licenses you to do everything with this +software that would otherwise infringe any patent claims +they can license or become able to license. -Copyright (c) 2016 Titus Wormer +## Reliability -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: +No contributor can revoke this license. -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +## No Liability -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +***As far as the law allows, this software comes as is, +without any warranty or condition, and no contributor +will be liable to anyone for any damages related to this +software or this license, under any kind of legal claim.*** ``` -### is-hexadecimal 2.0.1 +### lucide-react 1.16.0 -License: MIT +License: ISC -Repository: https://github.com/wooorm/is-hexadecimal +Repository: https://github.com/lucide-icons/lucide ```text -(The MIT License) +ISC License -Copyright (c) 2016 Titus Wormer +Copyright (c) 2026 Lucide Icons and Contributors -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` +--- -### is-plain-obj 4.1.0 +The following Lucide icons are derived from the Feather project: -License: MIT +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out -Repository: https://github.com/sindresorhus/is-plain-obj +The MIT License (MIT) (for the icons listed above) -```text -MIT License +Copyright (c) 2013-present Cole Bemis -Copyright (c) Sindre Sorhus (https://sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### is-potential-custom-element-name 1.0.1 - -License: MIT - -Repository: https://github.com/mathiasbynens/is-potential-custom-element-name - -```text -Copyright Mathias Bynens - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### isarray 1.0.0 - -License: MIT - -Repository: https://github.com/juliangruber/isarray - -```text -# isarray - -`Array#isArray` for older browsers. - -[![build status](https://secure.travis-ci.org/juliangruber/isarray.svg)](http://travis-ci.org/juliangruber/isarray) -[![downloads](https://img.shields.io/npm/dm/isarray.svg)](https://www.npmjs.org/package/isarray) - -[![browser support](https://ci.testling.com/juliangruber/isarray.png) -](https://ci.testling.com/juliangruber/isarray) - -## Usage - -```js -var isArray = require('isarray'); - -console.log(isArray([])); // => true -console.log(isArray({})); // => false -``` - -## Installation - -With [npm](http://npmjs.org) do - -```bash -$ npm install isarray -``` - -Then bundle for the browser with -[browserify](https://github.com/substack/browserify). - -With [component](http://component.io) do - -```bash -$ component install juliangruber/isarray -``` - -## License - -(MIT) - -Copyright (c) 2013 Julian Gruber <julian@juliangruber.com> - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` - -### isomorphic-dompurify 3.13.0 - -License: MIT - -Repository: https://github.com/kkomelin/isomorphic-dompurify - -```text -MIT License - -Copyright (c) 2020 Konstantin Komelin - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. @@ -8285,1954 +8028,46 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### js-tokens 4.0.0 - -License: MIT - -Repository: https://github.com/lydell/js-tokens - -```text -The MIT License (MIT) - -Copyright (c) 2014, 2015, 2016, 2017, 2018 Simon Lydell - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -``` - -### js-yaml 4.2.0 - -License: MIT - -Repository: https://github.com/nodeca/js-yaml - -```text -(The MIT License) - -Copyright (C) 2011-2015 by Vitaly Puzrin - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -``` - -### jsdom 29.1.1 - -License: MIT - -Repository: https://github.com/jsdom/jsdom - -```text -Copyright (c) 2010 Elijah Insua - -Permission is hereby granted, free of charge, to any person -obtaining a copy of this software and associated documentation -files (the "Software"), to deal in the Software without -restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. -``` - -### jsonfile 6.2.1 - -License: MIT - -Repository: https://github.com/jprichardson/node-jsonfile - -```text -(The MIT License) - -Copyright (c) 2012-2015, JP Richardson - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files -(the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, - merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE -WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS -OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, - ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### jszip 3.10.1 - -License: (MIT OR GPL-3.0-or-later) - -Repository: https://github.com/Stuk/jszip - -```text -JSZip is dual licensed. At your choice you may use it under the MIT license *or* the GPLv3 -license. - -The MIT License -=============== - -Copyright (c) 2009-2016 Stuart Knightley, David Duponchel, Franz Buchinger, António Afonso - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - -GPL version 3 -============= - - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS -``` - -### lazy-val 1.0.5 - -License: MIT - -Repository: https://github.com/develar/lazy-val - -```text -## lazy-val - -Lazy value. - -```typescript -class Lazy { - constructor(creator: () => Promise) - readonly hasValue: boolean - value: Promise -} -``` -``` - -### lie 3.3.0 - -License: MIT - -Repository: https://github.com/calvinmetcalf/lie - -```text -#Copyright (c) 2014-2018 Calvin Metcalf, Jordan Harband - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -**THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.** -``` - -### lodash.escaperegexp 4.1.2 - -License: MIT - -Repository: https://github.com/lodash/lodash - -```text -Copyright jQuery Foundation and other contributors - -Based on Underscore.js, copyright Jeremy Ashkenas, -DocumentCloud and Investigative Reporters & Editors - -This software consists of voluntary contributions made by many -individuals. For exact contribution history, see the revision history -available at https://github.com/lodash/lodash - -The following license applies to all parts of this software except as -documented below: - -==== - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -==== - -Copyright and related rights for sample code are waived via CC0. Sample -code is defined as all source code displayed within the prose of the -documentation. - -CC0: http://creativecommons.org/publicdomain/zero/1.0/ - -==== - -Files located in the node_modules and vendor directories are externally -maintained libraries used by this software which have their own -licenses; we recommend you read them, as their terms may differ from the -terms above. -``` - -### lodash.isequal 4.5.0 - -License: MIT - -Repository: https://github.com/lodash/lodash - -```text -Copyright JS Foundation and other contributors - -Based on Underscore.js, copyright Jeremy Ashkenas, -DocumentCloud and Investigative Reporters & Editors - -This software consists of voluntary contributions made by many -individuals. For exact contribution history, see the revision history -available at https://github.com/lodash/lodash - -The following license applies to all parts of this software except as -documented below: - -==== - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -==== - -Copyright and related rights for sample code are waived via CC0. Sample -code is defined as all source code displayed within the prose of the -documentation. - -CC0: http://creativecommons.org/publicdomain/zero/1.0/ - -==== - -Files located in the node_modules and vendor directories are externally -maintained libraries used by this software which have their own -licenses; we recommend you read them, as their terms may differ from the -terms above. -``` - -### longest-streak 3.1.0 - -License: MIT - -Repository: https://github.com/wooorm/longest-streak - -```text -(The MIT License) - -Copyright (c) 2015 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### loose-envify 1.4.0 - -License: MIT - -Repository: https://github.com/zertosh/loose-envify - -```text -The MIT License (MIT) - -Copyright (c) 2015 Andres Suarez - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -``` - -### lop 0.4.2 - -License: BSD-2-Clause - -Repository: https://github.com/mwilliamson/lop - -```text -Copyright (c) 2013, Michael Williamson -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -``` - -### lru-cache 11.3.6 - -License: BlueOak-1.0.0 - -Repository: https://github.com/isaacs/node-lru-cache - -```text -# Blue Oak Model License - -Version 1.0.0 - -## Purpose - -This license gives everyone as much permission to work with -this software as possible, while protecting contributors -from liability. - -## Acceptance - -In order to receive this license, you must agree to its -rules. The rules of this license are both obligations -under that agreement and conditions to your license. -You must not do anything with this software that triggers -a rule that you cannot or will not follow. - -## Copyright - -Each contributor licenses you to do everything with this -software that would otherwise infringe that contributor's -copyright in it. - -## Notices - -You must ensure that everyone who gets a copy of -any part of this software from you, with or without -changes, also gets the text of this license or a link to -. - -## Excuse - -If anyone notifies you in writing that you have not -complied with [Notices](#notices), you can keep your -license by taking all practical steps to comply within 30 -days after the notice. If you do not do so, your license -ends immediately. - -## Patent - -Each contributor licenses you to do everything with this -software that would otherwise infringe any patent claims -they can license or become able to license. - -## Reliability - -No contributor can revoke this license. - -## No Liability - -***As far as the law allows, this software comes as is, -without any warranty or condition, and no contributor -will be liable to anyone for any damages related to this -software or this license, under any kind of legal claim.*** -``` - -### lucide-react 1.16.0 - -License: ISC - -Repository: https://github.com/lucide-icons/lucide - -```text -ISC License - -Copyright (c) 2026 Lucide Icons and Contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - ---- - -The following Lucide icons are derived from the Feather project: - -airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out - -The MIT License (MIT) (for the icons listed above) - -Copyright (c) 2013-present Cole Bemis - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` - -### mammoth 1.12.0 - -License: BSD-2-Clause - -Repository: https://github.com/mwilliamson/mammoth.js - -```text -Copyright (c) 2013, Michael Williamson -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -``` - -### math-intrinsics 1.1.0 - -License: MIT - -Repository: https://github.com/es-shims/math-intrinsics - -```text -MIT License - -Copyright (c) 2024 ECMAScript Shims - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` - -### mdast-util-from-markdown 2.0.3 - -License: MIT - -Repository: https://github.com/syntax-tree/mdast-util-from-markdown - -```text -(The MIT License) - -Copyright (c) Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### mdast-util-mdx-expression 2.0.1 - -License: MIT - -Repository: https://github.com/syntax-tree/mdast-util-mdx-expression - -```text -(The MIT License) - -Copyright (c) 2020 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### mdast-util-mdx-jsx 3.2.0 - -License: MIT - -Repository: https://github.com/syntax-tree/mdast-util-mdx-jsx - -```text -(The MIT License) - -Copyright (c) 2020 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### mdast-util-mdxjs-esm 2.0.1 - -License: MIT - -Repository: https://github.com/syntax-tree/mdast-util-mdxjs-esm - -```text -(The MIT License) - -Copyright (c) 2020 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### mdast-util-phrasing 4.1.0 - -License: MIT - -Repository: https://github.com/syntax-tree/mdast-util-phrasing - -```text -(The MIT License) - -Copyright (c) 2017 Titus Wormer -Copyright (c) 2017 Victor Felder - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### mdast-util-to-hast 13.2.1 - -License: MIT - -Repository: https://github.com/syntax-tree/mdast-util-to-hast - -```text -(The MIT License) - -Copyright (c) 2016 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### mdast-util-to-markdown 2.1.2 - -License: MIT - -Repository: https://github.com/syntax-tree/mdast-util-to-markdown - -```text -(The MIT License) - -Copyright (c) Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### mdast-util-to-string 4.0.0 - -License: MIT - -Repository: https://github.com/syntax-tree/mdast-util-to-string - -```text -(The MIT License) - -Copyright (c) 2015 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### mdn-data 2.27.1 - -License: CC0-1.0 - -Repository: https://github.com/mdn/data - -```text -CC0 1.0 Universal - -Statement of Purpose - -The laws of most jurisdictions throughout the world automatically confer -exclusive Copyright and Related Rights (defined below) upon the creator and -subsequent owner(s) (each and all, an "owner") of an original work of -authorship and/or a database (each, a "Work"). - -Certain owners wish to permanently relinquish those rights to a Work for the -purpose of contributing to a commons of creative, cultural and scientific -works ("Commons") that the public can reliably and without fear of later -claims of infringement build upon, modify, incorporate in other works, reuse -and redistribute as freely as possible in any form whatsoever and for any -purposes, including without limitation commercial purposes. These owners may -contribute to the Commons to promote the ideal of a free culture and the -further production of creative, cultural and scientific works, or to gain -reputation or greater distribution for their Work in part through the use and -efforts of others. - -For these and/or other purposes and motivations, and without any expectation -of additional consideration or compensation, the person associating CC0 with a -Work (the "Affirmer"), to the extent that he or she is an owner of Copyright -and Related Rights in the Work, voluntarily elects to apply CC0 to the Work -and publicly distribute the Work under its terms, with knowledge of his or her -Copyright and Related Rights in the Work and the meaning and intended legal -effect of CC0 on those rights. - -1. Copyright and Related Rights. A Work made available under CC0 may be -protected by copyright and related or neighboring rights ("Copyright and -Related Rights"). Copyright and Related Rights include, but are not limited -to, the following: - - i. the right to reproduce, adapt, distribute, perform, display, communicate, - and translate a Work; - - ii. moral rights retained by the original author(s) and/or performer(s); - - iii. publicity and privacy rights pertaining to a person's image or likeness - depicted in a Work; - - iv. rights protecting against unfair competition in regards to a Work, - subject to the limitations in paragraph 4(a), below; - - v. rights protecting the extraction, dissemination, use and reuse of data in - a Work; - - vi. database rights (such as those arising under Directive 96/9/EC of the - European Parliament and of the Council of 11 March 1996 on the legal - protection of databases, and under any national implementation thereof, - including any amended or successor version of such directive); and - - vii. other similar, equivalent or corresponding rights throughout the world - based on applicable law or treaty, and any national implementations thereof. - -2. Waiver. To the greatest extent permitted by, but not in contravention of, -applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and -unconditionally waives, abandons, and surrenders all of Affirmer's Copyright -and Related Rights and associated claims and causes of action, whether now -known or unknown (including existing as well as future claims and causes of -action), in the Work (i) in all territories worldwide, (ii) for the maximum -duration provided by applicable law or treaty (including future time -extensions), (iii) in any current or future medium and for any number of -copies, and (iv) for any purpose whatsoever, including without limitation -commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes -the Waiver for the benefit of each member of the public at large and to the -detriment of Affirmer's heirs and successors, fully intending that such Waiver -shall not be subject to revocation, rescission, cancellation, termination, or -any other legal or equitable action to disrupt the quiet enjoyment of the Work -by the public as contemplated by Affirmer's express Statement of Purpose. - -3. Public License Fallback. Should any part of the Waiver for any reason be -judged legally invalid or ineffective under applicable law, then the Waiver -shall be preserved to the maximum extent permitted taking into account -Affirmer's express Statement of Purpose. In addition, to the extent the Waiver -is so judged Affirmer hereby grants to each affected person a royalty-free, -non transferable, non sublicensable, non exclusive, irrevocable and -unconditional license to exercise Affirmer's Copyright and Related Rights in -the Work (i) in all territories worldwide, (ii) for the maximum duration -provided by applicable law or treaty (including future time extensions), (iii) -in any current or future medium and for any number of copies, and (iv) for any -purpose whatsoever, including without limitation commercial, advertising or -promotional purposes (the "License"). The License shall be deemed effective as -of the date CC0 was applied by Affirmer to the Work. Should any part of the -License for any reason be judged legally invalid or ineffective under -applicable law, such partial invalidity or ineffectiveness shall not -invalidate the remainder of the License, and in such case Affirmer hereby -affirms that he or she will not (i) exercise any of his or her remaining -Copyright and Related Rights in the Work or (ii) assert any associated claims -and causes of action with respect to the Work, in either case contrary to -Affirmer's express Statement of Purpose. - -4. Limitations and Disclaimers. - - a. No trademark or patent rights held by Affirmer are waived, abandoned, - surrendered, licensed or otherwise affected by this document. - - b. Affirmer offers the Work as-is and makes no representations or warranties - of any kind concerning the Work, express, implied, statutory or otherwise, - including without limitation warranties of title, merchantability, fitness - for a particular purpose, non infringement, or the absence of latent or - other defects, accuracy, or the present or absence of errors, whether or not - discoverable, all to the greatest extent permissible under applicable law. - - c. Affirmer disclaims responsibility for clearing rights of other persons - that may apply to the Work or any use thereof, including without limitation - any person's Copyright and Related Rights in the Work. Further, Affirmer - disclaims responsibility for obtaining any necessary consents, permissions - or other rights required for any use of the Work. - - d. Affirmer understands and acknowledges that Creative Commons is not a - party to this document and has no duty or obligation with respect to this - CC0 or use of the Work. - -For more information, please see - -``` - -### micromark 4.0.2 - -License: MIT - -Repository: https://github.com/micromark/micromark.git#main - -```text -(The MIT License) - -Copyright (c) Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### micromark-core-commonmark 2.0.3 - -License: MIT - -Repository: https://github.com/micromark/micromark.git#main - -```text -(The MIT License) - -Copyright (c) Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### micromark-factory-destination 2.0.1 - -License: MIT - -Repository: https://github.com/micromark/micromark.git#main - -```text -(The MIT License) - -Copyright (c) Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### micromark-factory-label 2.0.1 - -License: MIT - -Repository: https://github.com/micromark/micromark.git#main - -```text -(The MIT License) - -Copyright (c) Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### micromark-factory-space 2.0.1 - -License: MIT - -Repository: https://github.com/micromark/micromark.git#main - -```text -(The MIT License) - -Copyright (c) Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### micromark-factory-title 2.0.1 - -License: MIT - -Repository: https://github.com/micromark/micromark.git#main - -```text -(The MIT License) - -Copyright (c) Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### micromark-factory-whitespace 2.0.1 - -License: MIT - -Repository: https://github.com/micromark/micromark.git#main - -```text -(The MIT License) - -Copyright (c) Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### micromark-util-character 2.1.1 - -License: MIT - -Repository: https://github.com/micromark/micromark.git#main - -```text -(The MIT License) - -Copyright (c) Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### micromark-util-chunked 2.0.1 - -License: MIT - -Repository: https://github.com/micromark/micromark.git#main - -```text -(The MIT License) - -Copyright (c) Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### micromark-util-classify-character 2.0.1 - -License: MIT - -Repository: https://github.com/micromark/micromark.git#main - -```text -(The MIT License) - -Copyright (c) Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### micromark-util-combine-extensions 2.0.1 - -License: MIT - -Repository: https://github.com/micromark/micromark.git#main - -```text -(The MIT License) - -Copyright (c) Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### micromark-util-decode-numeric-character-reference 2.0.2 +### math-intrinsics 1.1.0 License: MIT -Repository: https://github.com/micromark/micromark.git#main +Repository: https://github.com/es-shims/math-intrinsics ```text -(The MIT License) +MIT License -Copyright (c) Titus Wormer +Copyright (c) 2024 ECMAScript Shims -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ``` -### micromark-util-decode-string 2.0.1 +### mdast-util-to-hast 13.2.1 License: MIT -Repository: https://github.com/micromark/micromark.git#main +Repository: https://github.com/syntax-tree/mdast-util-to-hast ```text (The MIT License) -Copyright (c) Titus Wormer +Copyright (c) 2016 Titus Wormer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the @@ -10254,100 +8089,132 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### micromark-util-encode 2.0.1 +### mdn-data 2.27.1 -License: MIT +License: CC0-1.0 -Repository: https://github.com/micromark/micromark.git#main +Repository: https://github.com/mdn/data ```text -(The MIT License) +CC0 1.0 Universal -Copyright (c) Titus Wormer +Statement of Purpose -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator and +subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +Certain owners wish to permanently relinquish those rights to a Work for the +purpose of contributing to a commons of creative, cultural and scientific +works ("Commons") that the public can reliably and without fear of later +claims of infringement build upon, modify, incorporate in other works, reuse +and redistribute as freely as possible in any form whatsoever and for any +purposes, including without limitation commercial purposes. These owners may +contribute to the Commons to promote the ideal of a free culture and the +further production of creative, cultural and scientific works, or to gain +reputation or greater distribution for their Work in part through the use and +efforts of others. -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` +For these and/or other purposes and motivations, and without any expectation +of additional consideration or compensation, the person associating CC0 with a +Work (the "Affirmer"), to the extent that he or she is an owner of Copyright +and Related Rights in the Work, voluntarily elects to apply CC0 to the Work +and publicly distribute the Work under its terms, with knowledge of his or her +Copyright and Related Rights in the Work and the meaning and intended legal +effect of CC0 on those rights. -### micromark-util-html-tag-name 2.0.1 +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not limited +to, the following: -License: MIT + i. the right to reproduce, adapt, distribute, perform, display, communicate, + and translate a Work; -Repository: https://github.com/micromark/micromark.git#main + ii. moral rights retained by the original author(s) and/or performer(s); -```text -(The MIT License) + iii. publicity and privacy rights pertaining to a person's image or likeness + depicted in a Work; -Copyright (c) Titus Wormer + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: + v. rights protecting the extraction, dissemination, use and reuse of data in + a Work; -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation thereof, + including any amended or successor version of such directive); and -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` + vii. other similar, equivalent or corresponding rights throughout the world + based on applicable law or treaty, and any national implementations thereof. -### micromark-util-normalize-identifier 2.0.1 +2. Waiver. To the greatest extent permitted by, but not in contravention of, +applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and +unconditionally waives, abandons, and surrenders all of Affirmer's Copyright +and Related Rights and associated claims and causes of action, whether now +known or unknown (including existing as well as future claims and causes of +action), in the Work (i) in all territories worldwide, (ii) for the maximum +duration provided by applicable law or treaty (including future time +extensions), (iii) in any current or future medium and for any number of +copies, and (iv) for any purpose whatsoever, including without limitation +commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes +the Waiver for the benefit of each member of the public at large and to the +detriment of Affirmer's heirs and successors, fully intending that such Waiver +shall not be subject to revocation, rescission, cancellation, termination, or +any other legal or equitable action to disrupt the quiet enjoyment of the Work +by the public as contemplated by Affirmer's express Statement of Purpose. -License: MIT +3. Public License Fallback. Should any part of the Waiver for any reason be +judged legally invalid or ineffective under applicable law, then the Waiver +shall be preserved to the maximum extent permitted taking into account +Affirmer's express Statement of Purpose. In addition, to the extent the Waiver +is so judged Affirmer hereby grants to each affected person a royalty-free, +non transferable, non sublicensable, non exclusive, irrevocable and +unconditional license to exercise Affirmer's Copyright and Related Rights in +the Work (i) in all territories worldwide, (ii) for the maximum duration +provided by applicable law or treaty (including future time extensions), (iii) +in any current or future medium and for any number of copies, and (iv) for any +purpose whatsoever, including without limitation commercial, advertising or +promotional purposes (the "License"). The License shall be deemed effective as +of the date CC0 was applied by Affirmer to the Work. Should any part of the +License for any reason be judged legally invalid or ineffective under +applicable law, such partial invalidity or ineffectiveness shall not +invalidate the remainder of the License, and in such case Affirmer hereby +affirms that he or she will not (i) exercise any of his or her remaining +Copyright and Related Rights in the Work or (ii) assert any associated claims +and causes of action with respect to the Work, in either case contrary to +Affirmer's express Statement of Purpose. -Repository: https://github.com/micromark/micromark.git#main +4. Limitations and Disclaimers. -```text -(The MIT License) + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. -Copyright (c) Titus Wormer + b. Affirmer offers the Work as-is and makes no representations or warranties + of any kind concerning the Work, express, implied, statutory or otherwise, + including without limitation warranties of title, merchantability, fitness + for a particular purpose, non infringement, or the absence of latent or + other defects, accuracy, or the present or absence of errors, whether or not + discoverable, all to the greatest extent permissible under applicable law. -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without limitation + any person's Copyright and Related Rights in the Work. Further, Affirmer + disclaims responsibility for obtaining any necessary consents, permissions + or other rights required for any use of the Work. -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to this + CC0 or use of the Work. -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +For more information, please see + ``` -### micromark-util-resolve-all 2.0.1 +### micromark-util-character 2.1.1 License: MIT @@ -10378,7 +8245,7 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### micromark-util-sanitize-uri 2.0.1 +### micromark-util-encode 2.0.1 License: MIT @@ -10409,7 +8276,7 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### micromark-util-subtokenize 2.1.0 +### micromark-util-sanitize-uri 2.0.1 License: MIT @@ -10596,36 +8463,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### node-readable-to-web-readable-stream 0.4.2 - -License: MIT - -Repository: https://github.com/Borewit/node-readable-to-web-readable-stream - -```text -MIT License - -Copyright (c) 2025 Borewit - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` - ### object-assign 4.1.1 License: MIT @@ -10705,46 +8542,15 @@ copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` - -### option 0.2.4 - -License: BSD-2-Clause - -Repository: https://github.com/mwilliamson/node-options - -```text -Copyright (c) 2013, Michael Williamson -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ``` ### overlayscrollbars 2.15.1 @@ -12355,404 +10161,72 @@ Additionally it has custom optional properties: options={{ scrollbars: { autoHide: 'scroll' } }} events={{ scroll: () => { /* ... */ } }} defer -/> -``` - -### Ref - -The `ref` of the `OverlayScrollbarsComponent` will give you an object with which you can access the OverlayScrollbars `instance` and the root `element` of the component. -The ref object has two properties: - -- `osInstance`: a function which returns the OverlayScrollbars instance. -- `getElement`: a function which returns the root element. - -## Hook - -In case the `OverlayScrollbarsComponent` is not enough, you can also use the `useOverlayScrollbars` hook: - -```jsx -import { useOverlayScrollbars } from "overlayscrollbars-react"; - -// example usage -const Component = () => { - const ref = useRef(); - const [initialize, instance] = useOverlayScrollbars({ options, events, defer }); - - useEffect(() => { - initialize(ref.current); - }, [initialize]); - - return
-} -``` - -The hook is for advanced usage and lets you control the whole initialization process. This is useful if you want to integrate it with other plugins such as `react-window` or `react-virtualized`. - -The hook will destroy the instance automatically if the component unmounts. - -### Parameters - -Parameters are optional and similar to the `OverlayScrollbarsComponent`. -Its an `object` with optional properties: - -- `options`: accepts an `object` which represents the OverlayScrollbars options. -- `events`: accepts an `object` which represents the OverlayScrollbars events. -- `defer`: accepts an `boolean` or `object`. Defers the initialization to a point in time when the browser is idle. - -### Return - -The `useOverlayScrollbars` hook returns a `tuple` with two values: - -- The first value is the `initialization` function, it takes one argument which is the `InitializationTarget`. -- The second value is a function which returns the current OverlayScrollbars instance or `null` if not initialized. - -> __Note__: The identity of both functions is stable and won't change, thus they can safely be used in any dependency array. - -## License - -MIT -``` - -### pako 1.0.11 - -License: (MIT AND Zlib) - -Repository: https://github.com/nodeca/pako - -```text -(The MIT License) - -Copyright (C) 2014-2017 by Vitaly Puzrin and Andrei Tuputcyn - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -``` - -### parse-entities 4.0.2 - -License: MIT - -Repository: https://github.com/wooorm/parse-entities - -```text -(The MIT License) - -Copyright (c) Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### parse5 7.3.0 - -License: MIT - -Repository: https://github.com/inikulin/parse5 - -```text -Copyright (c) 2013-2019 Ivan Nikulin (ifaaan@gmail.com, https://github.com/inikulin) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -``` - -### parse5 8.0.1 - -License: MIT - -Repository: https://github.com/inikulin/parse5 - -```text -Copyright (c) 2013-2019 Ivan Nikulin (ifaaan@gmail.com, https://github.com/inikulin) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -``` - -### path-is-absolute 1.0.1 - -License: MIT - -Repository: https://github.com/sindresorhus/path-is-absolute - -```text -The MIT License (MIT) - -Copyright (c) Sindre Sorhus (sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -``` - -### pdfjs-dist 5.6.205 - -License: Apache-2.0 - -Repository: https://github.com/mozilla/pdf.js - -```text -Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. +/> +``` - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." +### Ref - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. +The `ref` of the `OverlayScrollbarsComponent` will give you an object with which you can access the OverlayScrollbars `instance` and the root `element` of the component. +The ref object has two properties: - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. +- `osInstance`: a function which returns the OverlayScrollbars instance. +- `getElement`: a function which returns the root element. - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. +## Hook - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: +In case the `OverlayScrollbarsComponent` is not enough, you can also use the `useOverlayScrollbars` hook: - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and +```jsx +import { useOverlayScrollbars } from "overlayscrollbars-react"; - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and +// example usage +const Component = () => { + const ref = useRef(); + const [initialize, instance] = useOverlayScrollbars({ options, events, defer }); + + useEffect(() => { + initialize(ref.current); + }, [initialize]); + + return
+} +``` - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and +The hook is for advanced usage and lets you control the whole initialization process. This is useful if you want to integrate it with other plugins such as `react-window` or `react-virtualized`. - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. +The hook will destroy the instance automatically if the component unmounts. - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. +### Parameters - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. +Parameters are optional and similar to the `OverlayScrollbarsComponent`. +Its an `object` with optional properties: - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. +- `options`: accepts an `object` which represents the OverlayScrollbars options. +- `events`: accepts an `object` which represents the OverlayScrollbars events. +- `defer`: accepts an `boolean` or `object`. Defers the initialization to a point in time when the browser is idle. - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. +### Return - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. +The `useOverlayScrollbars` hook returns a `tuple` with two values: - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. +- The first value is the `initialization` function, it takes one argument which is the `InitializationTarget`. +- The second value is a function which returns the current OverlayScrollbars instance or `null` if not initialized. - END OF TERMS AND CONDITIONS +> __Note__: The identity of both functions is stable and won't change, thus they can safely be used in any dependency array. + +## License + +MIT ``` -### process-nextick-args 2.0.1 +### parse5 8.0.1 License: MIT -Repository: https://github.com/calvinmetcalf/process-nextick-args +Repository: https://github.com/inikulin/parse5 ```text -# Copyright (c) 2015 Calvin Metcalf +Copyright (c) 2013-2019 Ivan Nikulin (ifaaan@gmail.com, https://github.com/inikulin) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -12761,16 +10235,16 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. -**THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE.** +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. ``` ### progress 2.0.3 @@ -13633,36 +11107,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### react-markdown 10.1.0 - -License: MIT - -Repository: https://github.com/remarkjs/react-markdown - -```text -The MIT License (MIT) - -Copyright (c) Espen Hovlandsdal - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` - ### react-remove-scroll 2.7.2 License: MIT @@ -13749,107 +11193,21 @@ All code is a result of a [react-scroll-locky](https://github.com/theKashey/reac # Article There is a medium article about preventing the body scroll - [How to fight the scroll](https://medium.com/@antonkorzunov/how-to-fight-the-body-scroll-2b00267b37ac) - -# License -MIT -``` - -### react-style-singleton 2.2.3 - -License: MIT - -Repository: https://github.com/theKashey/react-style-singleton - -```text -MIT License - -Copyright (c) 2017 Anton Korzunov - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` - -### readable-stream 2.3.8 - -License: MIT - -Repository: https://github.com/nodejs/readable-stream - -```text -Node.js is licensed for use as follows: - -""" -Copyright Node.js contributors. All rights reserved. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to -deal in the Software without restriction, including without limitation the -rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -sell copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -IN THE SOFTWARE. -""" - -This license applies to parts of Node.js originating from the -https://github.com/joyent/node repository: - -""" -Copyright Joyent, Inc. and other Node contributors. All rights reserved. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to -deal in the Software without restriction, including without limitation the -rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -sell copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -IN THE SOFTWARE. -""" + +# License +MIT ``` -### regex 6.1.0 +### react-style-singleton 2.2.3 License: MIT -Repository: https://github.com/slevithan/regex +Repository: https://github.com/theKashey/react-style-singleton ```text MIT License -Copyright (c) 2025 Steven Levithan +Copyright (c) 2017 Anton Korzunov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -13870,11 +11228,11 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### regex-recursion 6.0.2 +### regex 6.1.0 License: MIT -Repository: https://github.com/slevithan/regex-recursion +Repository: https://github.com/slevithan/regex ```text MIT License @@ -13900,16 +11258,16 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### regex-utilities 2.3.0 +### regex-recursion 6.0.2 License: MIT -Repository: https://github.com/slevithan/regex-utilities +Repository: https://github.com/slevithan/regex-recursion ```text MIT License -Copyright (c) 2024 Steven Levithan +Copyright (c) 2025 Steven Levithan Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -13930,78 +11288,16 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### rehype-raw 7.0.0 - -License: MIT - -Repository: https://github.com/rehypejs/rehype-raw - -```text -(The MIT License) - -Copyright (c) 2016 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### rehype-sanitize 6.0.0 - -License: MIT - -Repository: https://github.com/rehypejs/rehype-sanitize - -```text -(The MIT License) - -Copyright (c) 2016 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### remark-parse 11.0.0 +### regex-utilities 2.3.0 License: MIT -Repository: https://github.com/remarkjs/remark.git#main +Repository: https://github.com/slevithan/regex-utilities ```text -(The MIT License) +MIT License -Copyright (c) 2014 Titus Wormer +Copyright (c) 2024 Steven Levithan Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -14010,47 +11306,16 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -``` - -### remark-rehype 11.1.2 - -License: MIT - -Repository: https://github.com/remarkjs/remark-rehype - -```text -(The MIT License) - -Copyright (c) Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ``` ### rematrix 0.2.2 @@ -14113,36 +11378,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### safe-buffer 5.1.2 - -License: MIT - -Repository: https://github.com/feross/safe-buffer - -```text -The MIT License (MIT) - -Copyright (c) Feross Aboukhadijeh - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -``` - ### sax 1.6.0 License: BlueOak-1.0.0 @@ -14617,35 +11852,6 @@ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ``` -### setimmediate 1.0.5 - -License: MIT - -Repository: https://github.com/YuzuJS/setImmediate - -```text -Copyright (c) 2012 Barnesandnoble.com, llc, Donavon West, and Domenic Denicola - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - ### source-map-js 1.2.1 License: BSD-3-Clause @@ -14686,132 +11892,12 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. License: MIT -Repository: https://github.com/wooorm/space-separated-tokens - -```text -(The MIT License) - -Copyright (c) 2016 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### sprintf-js 1.0.3 - -License: BSD-3-Clause - -Repository: https://github.com/alexei/sprintf.js - -```text -Copyright (c) 2007-2014, Alexandru Marasteanu -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: -* Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. -* Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. -* Neither the name of this software nor the names of its contributors may be - used to endorse or promote products derived from this software without - specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -``` - -### string_decoder 1.1.1 - -License: MIT - -Repository: https://github.com/nodejs/string_decoder - -```text -Node.js is licensed for use as follows: - -""" -Copyright Node.js contributors. All rights reserved. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to -deal in the Software without restriction, including without limitation the -rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -sell copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -IN THE SOFTWARE. -""" - -This license applies to parts of Node.js originating from the -https://github.com/joyent/node repository: - -""" -Copyright Joyent, Inc. and other Node contributors. All rights reserved. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to -deal in the Software without restriction, including without limitation the -rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -sell copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -IN THE SOFTWARE. -""" -``` - -### stringify-entities 4.0.4 - -License: MIT - -Repository: https://github.com/wooorm/stringify-entities +Repository: https://github.com/wooorm/space-separated-tokens ```text (The MIT License) -Copyright (c) 2015 Titus Wormer +Copyright (c) 2016 Titus Wormer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the @@ -14833,51 +11919,20 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### style-to-js 1.1.21 - -License: MIT - -Repository: https://github.com/remarkablemark/style-to-js - -```text -The MIT License (MIT) - -Copyright (c) 2020 Menglin "Mark" Xu - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -### style-to-object 1.0.14 +### stringify-entities 4.0.4 License: MIT -Repository: https://github.com/remarkablemark/style-to-object +Repository: https://github.com/wooorm/stringify-entities ```text -The MIT License (MIT) +(The MIT License) -Copyright (c) 2017 Menglin "Mark" Xu +Copyright (c) 2015 Titus Wormer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including +'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to @@ -14886,13 +11941,13 @@ the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` ### sumchecker 3.0.1 @@ -15291,36 +12346,6 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### trough 2.2.0 - -License: MIT - -Repository: https://github.com/wooorm/trough - -```text -(The MIT License) - -Copyright (c) 2016 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -``` - ### tslib 2.8.1 License: 0BSD @@ -15328,49 +12353,18 @@ License: 0BSD Repository: https://github.com/Microsoft/tslib ```text -Copyright (c) Microsoft Corporation. - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. -``` - -### underscore 1.13.8 - -License: MIT - -Repository: https://github.com/jashkenas/underscore - -```text -Copyright (c) 2009-2022 Jeremy Ashkenas, Julian Gonggrijp, and DocumentCloud and Investigative Reporters & Editors - -Permission is hereby granted, free of charge, to any person -obtaining a copy of this software and associated documentation -files (the "Software"), to deal in the Software without -restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following -conditions: +Copyright (c) Microsoft Corporation. -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. ``` ### undici 7.28.0 @@ -15433,36 +12427,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### unified 11.0.5 - -License: MIT - -Repository: https://github.com/unifiedjs/unified - -```text -(The MIT License) - -Copyright (c) 2015 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -``` - ### unist-util-is 6.0.1 License: MIT @@ -15737,39 +12701,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### util-deprecate 1.0.2 - -License: MIT - -Repository: https://github.com/TooTallNate/util-deprecate - -```text -(The MIT License) - -Copyright (c) 2014 Nathan Rajlich - -Permission is hereby granted, free of charge, to any person -obtaining a copy of this software and associated documentation -files (the "Software"), to deal in the Software without -restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. -``` - ### vfile 6.0.3 License: MIT @@ -15800,37 +12731,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### vfile-location 5.0.3 - -License: MIT - -Repository: https://github.com/vfile/vfile-location - -```text -(The MIT License) - -Copyright (c) 2016 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - ### vfile-message 4.0.3 License: MIT @@ -15896,37 +12796,6 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### web-namespaces 2.0.1 - -License: MIT - -Repository: https://github.com/wooorm/web-namespaces - -```text -(The MIT License) - -Copyright (c) 2016 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - ### webidl-conversions 8.0.1 License: BSD-2-Clause @@ -16179,36 +13048,6 @@ Apache License END OF TERMS AND CONDITIONS ``` -### xmlbuilder 10.1.1 - -License: MIT - -Repository: https://github.com/oozcitak/xmlbuilder-js - -```text -The MIT License (MIT) - -Copyright (c) 2013 Ozgur Ozcitak - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -``` - ### xmlchars 2.2.0 License: MIT diff --git a/desktop/package-lock.json b/desktop/package-lock.json index d6191db3..cad31a4c 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1647,9 +1647,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1667,9 +1664,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1687,9 +1681,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1707,9 +1698,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1727,9 +1715,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1747,9 +1732,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1767,9 +1749,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1787,9 +1766,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1996,9 +1972,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2013,9 +1986,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2030,9 +2000,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2047,9 +2014,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2064,9 +2028,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2081,9 +2042,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2098,9 +2056,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2115,9 +2070,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3742,9 +3694,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3759,9 +3708,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3776,9 +3722,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3793,9 +3736,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3810,9 +3750,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3827,9 +3764,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3844,9 +3778,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3861,9 +3792,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3878,9 +3806,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3895,9 +3820,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3912,9 +3834,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3929,9 +3848,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3946,9 +3862,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4270,9 +4183,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4290,9 +4200,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4310,9 +4217,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4330,9 +4234,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9529,9 +9430,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9553,9 +9451,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9577,9 +9472,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9601,9 +9493,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/desktop/package.json b/desktop/package.json index 0b0b1b2d..9f3d034c 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "nvpair", - "version": "0.1.1", + "version": "0.1.1+mlx", "description": "Personal AI Router", "engines": { "node": ">=25.5.0" diff --git a/desktop/scripts/generate-licenses.ts b/desktop/scripts/generate-licenses.ts index 65a2aee7..3f3303a2 100644 --- a/desktop/scripts/generate-licenses.ts +++ b/desktop/scripts/generate-licenses.ts @@ -149,6 +149,7 @@ function renderMarkdown(entries: Entry[]): string { 'binaries across the Windows, Linux, and macOS targets. First-party modules', '(`nvpair-shared`, `eapnoob`) are excluded.', '', + '', '## Components', '', '| Component | Version | License |', diff --git a/scripts/spdx-headers.mjs b/scripts/spdx-headers.mjs index 10e2585d..4dc8508e 100644 --- a/scripts/spdx-headers.mjs +++ b/scripts/spdx-headers.mjs @@ -29,7 +29,22 @@ import { fileURLToPath } from 'node:url' const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') -const COPYRIGHT_HOLDER = 'NVIDIA CORPORATION & AFFILIATES. All rights reserved.' +// Two holders are valid in this fork, and which one a file carries is not a +// style choice. +// +// Files inherited from upstream keep NVIDIA's notice: Apache-2.0 section 4 +// requires retaining the copyright notices of the work you are deriving from, +// so a modified upstream file must not have its notice rewritten. Files that +// exist only here are this fork's own work and carry its author instead -- +// leaving them stamped NVIDIA would attribute someone else's copyright to code +// NVIDIA never wrote. +// +// FORK_COPYRIGHT_HOLDER is what `--fix` inserts, because anything missing a +// header in this repo is by definition a new file. +const UPSTREAM_COPYRIGHT_HOLDER = 'NVIDIA CORPORATION & AFFILIATES. All rights reserved.' +const FORK_COPYRIGHT_HOLDER = 'Denis Akimov' +const ACCEPTED_COPYRIGHT_HOLDERS = [UPSTREAM_COPYRIGHT_HOLDER, FORK_COPYRIGHT_HOLDER] +const COPYRIGHT_HOLDER = FORK_COPYRIGHT_HOLDER const LICENSE_IDENTIFIER = 'Apache-2.0' // A header may sit below a shebang, an XML prologue, or YAML frontmatter, so the @@ -201,7 +216,7 @@ function inspect(text) { // Strip a block-comment terminator the tag regex swept up on a one-line header. const notice = copyright[1].replace(/\s*(-->|\*\/)\s*$/, '').trim() const parsed = notice.match(COPYRIGHT_TEXT) - if (parsed === null || parsed[1] !== COPYRIGHT_HOLDER) { + if (parsed === null || !ACCEPTED_COPYRIGHT_HOLDERS.includes(parsed[1])) { return { state: 'review', detail: `nonstandard copyright line: ${notice}` } } return { state: 'ok' } @@ -246,6 +261,7 @@ function gitPaths(args) { return stdout.split('\0').filter(entry => entry !== '') } + function candidates(staged, prefixes) { // Tracked plus untracked-but-not-ignored, so a file created and not yet added // is checked before the dev commits it. From 9e31a6bcaceda22ea24a3b57900ec8ed2279af3a Mon Sep 17 00:00:00 2001 From: Denis Akimov Date: Mon, 14 Sep 2026 11:47:39 -0700 Subject: [PATCH 12/12] docs: MLX guide, quick start, and the A/B benchmark README covers only what the fork changes and points at upstream for the rest. tests/ab_bench.py measures one node, the other, then both, and attributes every response to a node by its system_fingerprint rather than assuming the routing worked. Signed-off-by: Denis Akimov --- Makefile | 131 +++++++- README.md | 314 +++---------------- assets/mlx-two-nodes.png | Bin 0 -> 285604 bytes desktop/docs/services-api.md | 34 +++ docs/mlx.mdx | 411 +++++++++++++++++++++++++ fern/docs.yml | 3 + scripts/mlx.mjs | 241 +++++++++++++++ tests/README.md | 123 ++++++++ tests/ab_bench.py | 568 +++++++++++++++++++++++++++++++++++ 9 files changed, 1556 insertions(+), 269 deletions(-) create mode 100644 assets/mlx-two-nodes.png create mode 100644 docs/mlx.mdx create mode 100644 scripts/mlx.mjs create mode 100644 tests/README.md create mode 100755 tests/ab_bench.py diff --git a/Makefile b/Makefile index 496354b3..37dbe37f 100644 --- a/Makefile +++ b/Makefile @@ -21,8 +21,10 @@ MIN_GO := 1.25 MIN_NODE := 25.5.0 .PHONY: help dev tools deps-go deps-node build build-binaries build-desktop \ - build-services run check verify lint typecheck contracts headers \ - headers-fix test test-desktop test-services clean + build-services macos-dev-local-network ab ab-context run check verify lint typecheck contracts headers \ + headers-fix test test-desktop test-services clean \ + mlx mlx-install mlx-status mlx-models mlx-pull mlx-delete mlx-set-port \ + mlx-serve mlx-port mlx-ask mlx-ab mlx-reload-cost mlx-uninstall help: ## List available targets @printf 'Personal AI Router — development targets\n\n' @@ -56,6 +58,98 @@ clean: ## Remove built binaries, bundles, and packages done @printf 'Removed build output. Dependencies in %s are untouched.\n' '$(NODE_MODULES)' +# --------------------------------------------------------------------------- +# MLX (Apple Silicon). See docs/mlx.mdx. Every target here is a thin wrapper -- +# scripts/mlx.mjs holds the other end of nvpair-engine-manager's stdio JSON-RPC +# pipe, because that service has no HTTP control surface to curl. +# --------------------------------------------------------------------------- + +# A default small enough to download in seconds and text-only, which matters: +# mlx_lm serves text models, so a vision model (Qwen3-VL, ...) will list in the +# catalogue and then fail to load. Override on any target: make mlx-pull MODEL=... +MODEL ?= mlx-community/Llama-3.2-1B-Instruct-4bit + +# Where mlx_lm.server listens. Change it with mlx-set-port, which persists. +ENGINE_PORT ?= 8081 + +# The proxy prefers :8080 (mlx_lm.server's documented port) and falls back from +# :8090 when something else already holds it -- which is common. Ask the running +# process rather than assuming. +# -a is load-bearing: lsof ORs its selection options by default, so without it +# `-c mlx-proxy -iTCP` lists every listening socket on the machine and the first +# match is some unrelated process. +MLX_PORT = $(shell lsof -nP -a -c mlx-proxy -iTCP -sTCP:LISTEN -Fn 2>/dev/null \ + | sed -n 's/^n.*:\([0-9][0-9]*\)$$/\1/p' | head -1) + +mlx: build-services mlx-install ## Build PAIR and install the MLX engine (start here) + @printf '\nMLX installed. Next: make mlx-pull, then make mlx-serve.\n' + +mlx-install: ## Install the MLX engine (uv + a virtualenv + mlx-lm) + node scripts/mlx.mjs install + +mlx-status: ## Show installed / running / healthy / port for the MLX engine + node scripts/mlx.mjs status + +mlx-models: ## List downloaded MLX models, marking the one resident in memory + node scripts/mlx.mjs models + +mlx-pull: ## Download a model (MODEL=) + node scripts/mlx.mjs pull $(MODEL) + +mlx-delete: ## Delete a model from the shared Hugging Face cache (MODEL=...) + node scripts/mlx.mjs delete $(MODEL) + +mlx-uninstall: ## Remove the MLX engine and its virtualenv + node scripts/mlx.mjs uninstall + +# Also the way to adopt a server you run yourself: point the engine at its port +# and `make mlx-serve` will route to that process rather than spawning its own. +mlx-set-port: ## Persistently move the MLX engine to a port (ENGINE_PORT=8089) + node scripts/mlx.mjs port $(ENGINE_PORT) + +# The whole router, headless: the broker spawns discovery, the scheduler and all +# three engine proxies, then advertises this node; the script also starts the +# MLX engine, which the broker does not do on its own. Runs in the foreground -- +# Ctrl-C to stop. `make run` is the same thing with the desktop app on top. +mlx-serve: ## Run the router and the MLX engine in the foreground (Ctrl-C to stop) + node scripts/mlx.mjs serve + +mlx-port: ## Print the port mlx-proxy is listening on + @if [ -n '$(MLX_PORT)' ]; then printf '%s\n' '$(MLX_PORT)'; \ + else printf 'mlx-proxy is not running. Start it with: make mlx-serve\n'; exit 1; fi + +# The first request for a model is also what loads it, so this can take a while +# on a cold engine and be instant afterwards. That is the routing policy working, +# not a stall. +# +# MAX_TOKENS is generous because a thinking model spends its budget reasoning +# before it emits a single character of content: at 60 tokens a Qwen3.8 with +# thinking enabled returns finish_reason=length and an empty content field, +# which reads exactly like a broken route and is not one. +PROMPT ?= In one sentence, what does a router do? +MAX_TOKENS ?= 512 + +mlx-ask: ## Send a chat completion through mlx-proxy (MODEL=..., PROMPT=..., needs mlx-serve) + @if [ -z '$(MLX_PORT)' ]; then \ + printf 'mlx-proxy is not running. In another terminal: make mlx-serve\n'; exit 1; fi + @printf 'routing through mlx-proxy on :%s\n\n' '$(MLX_PORT)' + @jq -n --arg m '$(MODEL)' --arg p '$(PROMPT)' --argjson t $(MAX_TOKENS) \ + '{model:$$m, messages:[{role:"user",content:$$p}], max_tokens:$$t}' \ + | curl -sS http://127.0.0.1:$(MLX_PORT)/v1/chat/completions \ + -H 'Content-Type: application/json' --data-binary @- \ + | (jq -r '.choices[0].message.content // .choices[0].message.reasoning_content // .' 2>/dev/null || cat) + +# The two halves of the routing measurement in docs/mlx.mdx. mlx-ab needs no +# engine at all; mlx-reload-cost needs the engine installed. +mlx-ab: ## A/B the residency-preferring routing policy against the control arm + cd $(SERVICES)/mlx-proxy && go test -run TestRoutingPolicyAB -v . + +mlx-reload-cost: ## Measure what one MLX model swap costs, in seconds + python3 $(SERVICES)/mlx-proxy/bench/reload_cost.py \ + --server "$$HOME/Library/Application Support/Nvidia Corporation/Personal AI Router/engine-bin/mlx/venv/bin/mlx_lm.server" \ + --model-a mlx-community/Llama-3.2-1B-Instruct-4bit \ + --model-b mlx-community/Qwen2.5-0.5B-Instruct-4bit + tools: ## Report the required toolchain versions @at_least() { printf '%s\n%s\n' "$$2" "$$1" | sort -V -C; }; \ missing=''; \ @@ -132,6 +226,39 @@ build-binaries: $(NODE_MODULES) ## Compile the Go service binaries into desktop/ build-desktop: $(NODE_MODULES) ## Build the Electron main, preload, renderer, and CLI bundles cd $(DESKTOP) && npm run build +# macOS 15+ gates mDNS behind a per-app Local Network grant, and only offers it +# to a bundle carrying a usage string. The Electron npm installs has none, so a +# dev run cannot discover nodes and is never prompted. Re-run after `npm ci`. +# See docs/macos-local-network.md -- the grant follows the app that LAUNCHED the +# tree, so this alone is not enough from an editor's integrated terminal. +macos-dev-local-network: ## Make the dev Electron promptable for macOS Local Network access + ./scripts/macos-dev-local-network.sh + +# A/B the cluster: one node, the other, then both. MODEL_A/MODEL_B are the ids +# the router advertises -- a locally built model is addressed by absolute path, +# so the path IS the node selector (see tests/README.md). +MODEL_A ?= $(HOME)/models/Qwen3-VL-8B-Instruct-4bit +# The peer's model id. A locally built model is addressed by absolute path, and +# that path contains the OWNER's home directory -- so this is the peer's path, +# not yours. Override per run: make ab MODEL_B=/Users//models/ +MODEL_B ?= $(error set MODEL_B to the peer node's model id, e.g. /Users//models/) +# Derived, not hardcoded: these are only labels in the report, and a hostname +# that gets renamed would otherwise leave the benchmark quietly mislabelling its +# own output. +NAME_A ?= $(shell scutil --get LocalHostName 2>/dev/null || hostname -s) +NAME_B ?= peer + +ab: ## Benchmark node A, node B, then both (vars: MODEL_A MODEL_B NAME_A NAME_B) + python3 tests/ab_bench.py \ + --model-a "$(MODEL_A)" --model-b "$(MODEL_B)" \ + --name-a "$(NAME_A)" --name-b "$(NAME_B)" $(AB_ARGS) + +ab-context: ## Find each node's usable context window + python3 tests/ab_bench.py \ + --model-a "$(MODEL_A)" --model-b "$(MODEL_B)" \ + --name-a "$(NAME_A)" --name-b "$(NAME_B)" \ + --mode a --repeat 1 --max-tokens 8 --prompt-sizes 8 --context-probe + # The standalone bundle the TUI and the services installers use. The desktop app # runs desktop/cli-bin instead, which build-binaries produces. build-services: ## Stage the standalone services bundle in services/build/bin diff --git a/README.md b/README.md index 0f0a7242..b3a63473 100644 --- a/README.md +++ b/README.md @@ -1,294 +1,74 @@ -# NVIDIA Personal AI Router (PAIR) +# PAIR — MLX fork -[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) -[![Security Policy](https://img.shields.io/badge/security-policy-green.svg)](SECURITY.md) +A fork of [NVIDIA Personal AI Router](https://github.com/NVIDIA/Personal-AI-Router) +that adds Apple [MLX](https://github.com/ml-explore/mlx-lm) as a third inference +engine on `darwin/arm64`. Ollama and LM Studio are untouched. -NVIDIA Personal AI Router (PAIR) is a local inference router for a group of -compatible computers on the same network. It discovers participating nodes, -manages supported inference engines, and presents Ollama-compatible and -OpenAI-compatible proxy endpoints to applications and agents. Independent -requests can be routed to eligible nodes according to engine availability, -model availability, and current workload. +![Two Macs paired in one PAIR cluster, MLX running on both. The second node's model +list offers every model held only by the first, each with a Get from button.](assets/mlx-two-nodes.png) -PAIR is useful for concurrent local workloads such as multi-agent applications. -Prompts and responses are intended to remain on the local network when every -configured client, model source, engine, and node is local. - -> PAIR routes each independent request to one node. It does **not** pool GPU -> memory, combine GPUs into a larger logical GPU, shard one model across -> machines, or split an in-flight inference request between nodes. - -![Two paired machines in PAIR's Overview. Requests arrive on one and are routed -across both, with each node reporting live GPU and memory use.](assets/pair-demo.gif) - -*Two paired machines: requests arrive on one, run on whichever node suits each -one, and both report live GPU and memory use throughout. -[Watch the full clip](assets/pair-demo.mp4).* - -## What is supported - -| | | -| --- | --- | -| **Operating systems** | Windows 11; Linux; macOS | -| **Architectures** | x64 and arm64 on all three. Windows on ARM is experimental. | -| **Installers** | Windows `.exe`; Linux `.deb`; macOS `.dmg`. On other Linux distributions, [build from source](docs/building.mdx). | -| **Mixing nodes** | Windows, Linux, and macOS nodes can all be paired with each other | -| **Inference engines** | Ollama and LM Studio | - -**PAIR running on a machine does not mean an engine will.** PAIR itself runs on -any supported Windows, Linux, or macOS machine. Each engine sets its own requirements -for the operating system, GPU, and drivers, and each model needs enough memory to -load. Whether a particular engine and model work on a particular machine is -between that engine and that machine, so check the engine's own documentation -before assuming a node can serve a model. A node only becomes a candidate for a -request once it is actually running a compatible engine, and PAIR prefers the -nodes it already knows hold the model. +For what PAIR is, how to install it, and everything not listed below, read +[upstream's README](https://github.com/NVIDIA/Personal-AI-Router#readme). This +file covers only what the fork changes. ## Quick start -Download a released build and use the desktop application. That is the path we -recommend and the one the rest of this guide assumes. Building from source and -the terminal interface both exist for good reasons — changing PAIR, and machines -with no desktop — but neither is the ordinary way in. Those are covered in -[Building and running PAIR from source](docs/building.mdx) and -[Terminal interface](docs/terminal-interface.mdx). - -### Download a release - -A released installer is signed, sets up the background services and the desktop -application together, and adds the firewall rules PAIR needs on Windows. It also -tells you when a newer release exists and installs it on your say-so from -**Settings → Service**. A build you make yourself is unsigned and checks no -update feed, so you would upgrade it by pulling and rebuilding. - -Download PAIR from the -[GitHub releases page](https://github.com/NVIDIA/Personal-AI-Router/releases). -Release downloads include: - -- a Windows installer; -- a Debian package for Linux; and -- a macOS disk image. - -**On Windows and macOS,** double-click the download and follow the installer's -usual prompts — on macOS that means dragging NVIDIA Personal AI Router to your -**Applications** folder. - -**On Linux,** install the package from the directory you downloaded it into: - -```bash -sudo apt install ./NVPAIR-Setup-*.deb -``` - -If you have kept more than one PAIR package in that directory, install the one -you want by its full filename instead. - -### Run it - -- **Open PAIR** the way you would any application — the Start menu on Windows, - Launchpad or the Applications folder on macOS, your applications list on Linux. - On a machine with no desktop environment, drive it from the - [terminal interface](docs/terminal-interface.mdx) instead, which starts the same - background services and gives you a full-screen view in the terminal. - -- **Let it finish starting.** **Overview** shows this machine once the services - are up. If it stays on **Loading...**, open **Settings → Service** and read the - status there. - -- **Get an engine running.** On the node's card, open **Engine settings** and - select **Install** next to Ollama or LM Studio. PAIR downloads and sets the - engine up for you, so nothing needs to be in place beforehand. If PAIR already - found an engine you installed yourself, start that one instead. - - ![The Install engines dialog with Ollama downloading, reporting progress as it installs.](docs/assets/onboarding/engine-lifecycle/01-engine-installing.png) - -- **Add a model.** Select **Add model** on the same card and download one. - `qwen4:12b` is used for this example; it can be replaced with a model of your - choice. - - ![A node card with its engine expanded, one model pulling and the Add model button beside the list.](docs/assets/onboarding/getting-started/07-add-model.png) - -- **Send a request.** Two equally good options: - - - **Let PAIR generate the traffic.** Select **Test** on - **Settings → Service** and PAIR sends a minute of inference through the same - path, so you can watch the jobs appear without writing anything. - - ![Overview during a test run, with jobs in flight across both machines in the cluster.](docs/assets/onboarding/getting-started/12-demo-traffic.png) - - - **Send one yourself.** Use the `curl` call below. The job then appears under - **Jobs**, naming the node that served it. - -With Ollama on its default port, this runs as written: - ```bash -curl http://127.0.0.1:11434/v1/chat/completions \ - -H "Content-Type: application/json" \ - -d '{"model":"qwen4:12b","messages":[{"role":"user","content":"In one sentence, what does a router do?"}]}' -``` - -The reply is ordinary OpenAI-shaped JSON, abbreviated here: - -```json -{ - "object": "chat.completion", - "model": "qwen4:12b", - "choices": [ - { - "message": { - "role": "assistant", - "content": "A router decides where each incoming message should go and forwards it there." - }, - "finish_reason": "stop" - } - ] -} -``` - -If you changed a port, or you are using LM Studio rather than Ollama, copy the -URL from **Endpoints → API endpoints** instead of assuming the one above. - -That is a single machine working. To route across machines, pair a second one -from **Settings → Cluster** and repeat the engine and model steps there. The -inviting machine shows a six-digit PIN, and you enter that PIN on the machine you -invited. - -![The six-digit pairing PIN on the inviting machine beside the Cluster invitation modal on the machine being invited.](docs/assets/onboarding/getting-started/04-pairing-pin.png) - -The [Getting Started Guide](docs/getting-started.mdx) covers the same ground in -detail, plus pairing, ports, and connecting your own applications. - -## Uninstalling - -Removing PAIR and removing your data are separate steps, and the default is to -keep your data. - -**Windows.** Uninstall from Apps & features, or the Start menu entry. The -uninstaller stops PAIR, removes its firewall rules, and asks whether to delete -your data. Decline and it stays; accept and it is removed. - -**Linux.** `sudo apt remove nvpair` uninstalls the application and keeps your -data. Use `sudo apt purge nvpair` to remove the data as well. Run -`dpkg -l | grep -i pair` first if you need to confirm the installed package name. - -**macOS.** Run the uninstaller that ships inside the app bundle. It stops PAIR, -removes its firewall rules, unregisters its privileged helper, and then removes -the application: +make mlx # install the MLX engine (fetches uv, builds a venv, installs mlx-lm) +make mlx-pull # download a model +make run # open the desktop app -```bash -sudo "/Applications/PAIR.app/Contents/Resources/installer-tools/uninstall-macos.sh" +make mlx-serve # or stay in the terminal: start the router +make mlx-ask # and send a request through it ``` -Add `--purge` to remove your data as well. Dragging PAIR to the Trash instead -leaves the privileged helper registered, so use the uninstaller. - -Your data means settings, logs, cluster identity and certificates, and any engine -PAIR installed for you. **Model weights are not touched** — they live in the -engine's own storage, such as `~/.ollama`, so removing PAIR does not delete the -models you downloaded. Delete those through the engine, or by removing its -directory. - -**To clear your data without uninstalling,** use **Settings → Service → Reset app -data**. It removes the same set — settings, logs, cluster identity and -certificates, and PAIR-installed engines — then restarts the application as if it -were newly installed. Model libraries are left alone here too. This is the quickest -way to start over after a broken cluster or a bad engine install. - -If this machine belongs to a cluster, deal with membership too — otherwise the -other nodes keep listing it as a member. You have two options: - -- **Leave from this machine** before uninstalling: **Settings → Cluster → - Leave**, or press `L` on the terminal interface's **Cluster** tab. -- **Remove it from another node**, which any member can do from - **Settings → Cluster** by removing that node from the list. - -The second option works after the fact as well, so forgetting to leave first is -recoverable. - -## Documentation - -If you are new to PAIR, reading in this order will get you productive fastest. -Each entry assumes the ones before it. - -1. **[Overview](docs/overview.mdx)** — what PAIR does and how its pieces fit - together. Start here so the vocabulary in every other document makes sense. -2. **[Getting started](docs/getting-started.mdx)** — install it, pair two - machines, prepare a model, and send a first request. This is the only document - most users need. -3. **[Managing engines](docs/engine-lifecycle.mdx)** — install, start, stop, - update, and uninstall engines; what PAIR restores after you quit or relaunch. -4. **[Terminal interface](docs/terminal-interface.mdx)** — the same tasks from a - terminal, for a machine with no desktop environment. Skip it if every machine - you run has a desktop. -5. **[Troubleshooting](docs/troubleshooting.mdx)** — worth skimming once before - you need it, so you know where the diagnostics live. Alongside it, - **[Known issues](docs/known-issues.mdx)** lists the significant limitations we - are already aware of, and - **[Collecting and sanitizing logs](docs/log-collection.mdx)** covers preparing - a log you can share. -6. **[Architecture](docs/architecture.mdx)** — the process model, how a request is - routed, and where the trust boundaries are. Read this before changing - anything, or if you want to know why PAIR behaves the way it does. -7. **[Building and running](docs/building.mdx)** — prerequisites, building from - source, running the services without the desktop application, and writing - your own client against the JSON-RPC API. -8. **[Developer guide](docs/developing.mdx)** — read this before contributing: - where the code lives, how a change travels through the layers, and the - conventions the project enforces. - -Component references, for when you already know what you are looking for: - -- [Services](services/readme.md) — the background services, and from there each - component's own reference -- [Desktop application](desktop/README.md) — working in the application, and from - there its architecture, contracts, and CLI documentation - -## Releases - -See the [releases page](https://github.com/NVIDIA/Personal-AI-Router/releases) -for what changed in each release. - -## Where PAIR is going +[docs/mlx.mdx](docs/mlx.mdx) is the full guide. -We have plenty of ideas about where to take PAIR, and no fixed commitments about -which of them land or when. If you have a thought about the product's direction, -something that would make it more useful to you, a workflow it does not support -yet, or a use we have not considered — we would like to hear it. Open an issue -and start the conversation. +## What the fork adds -**Routing is the clearest example.** Today PAIR ships a single scheduling policy -that combines queued work with a coarse, smoothed GPU-utilization signal. It does -not consider GPU model, available memory, model warmness, or how expensive a -request looks, which still makes it a better fit for similar machines than a -highly mixed cluster. Making that smarter, and likely letting you choose a -policy, is something we want to do — and hearing which of those signals matters -on your hardware is exactly the kind of input that would shape it. +**An MLX engine.** `mlx_lm.server` holds exactly one model and has no unload, so +the fork runs `mlx-pool` in front of it: one model per child process, least +recently used evicted, and ending a child *is* the unload. A model serving a +request is never evicted. -Feedback from people running PAIR on their own hardware is more useful to us than -any plan written in advance. +**Routing that prefers residency.** Because a node holds one model at a time, +sending a request to a node that must reload first is the expensive mistake. +`mlx-proxy` routes to an owner that already has the model resident and falls +back to on-disk owners only when nobody does. Measured against upstream's +load-only behaviour: **100.0% resident hits vs 31.8%**, against a **7.2 s** +reload — `go test -run TestRoutingPolicyAB` runs both arms back to back. -## Contributing and governance +**Model transfer between nodes.** A model you quantized yourself has no repo id +and is addressed by path, which the cache transfer could not carry. Directory +models now transfer over the cluster's mTLS link, each file verified against a +digest before it lands, resumable, and over several connections. -- [Contributing](CONTRIBUTING.md) -- [Code of Conduct](CODE_OF_CONDUCT.md) -- [Governance](GOVERNANCE.md) +**A benchmark.** [`tests/ab_bench.py`](tests/README.md) measures one node, the +other, then both — time to first token, decode rate, end-to-end latency and +context — attributing every response to a node by its `system_fingerprint`. -## Support +## mlx-lm -See [SUPPORT.md](SUPPORT.md) for public support channels and scope. +The engine installs `mlx-lm` from PyPI at a pinned version. Upstream's +OpenAI-compatible server has known gaps in tool calling and thinking-mode +handling; if you carry a fork that fixes them, point the engine at it with a user +manifest rather than editing the bundled one — see +[Running a different mlx-lm](docs/mlx.mdx). -## Security +## Scope -PAIR includes local HTTP endpoints, LAN discovery, a PIN-based trust bootstrap, -and cluster networking. Read [SECURITY.md](SECURITY.md) before deploying it on -an untrusted or shared network. Do not report vulnerabilities in a public issue. +MLX is Apple Silicon only. PAIR routes each independent request to one node; it +does not pool GPU memory, shard a model across machines, or split an in-flight +request. Nothing here changes that. ## License -This project is licensed under the [Apache License 2.0](LICENSE). See -[Third-Party Software Notices](THIRD_PARTY_NOTICES.md) for bundled dependencies. -Inference engines, models, and other software used with PAIR may have separate terms. +Apache-2.0, as upstream. See [LICENSE](LICENSE) and +[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Files inherited from upstream +keep NVIDIA's copyright notice; files added by this fork carry their author's. diff --git a/assets/mlx-two-nodes.png b/assets/mlx-two-nodes.png new file mode 100644 index 0000000000000000000000000000000000000000..345816f56f96a8ed7c2bc944359688236772744a GIT binary patch literal 285604 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 9(L&vnfd;Og66VIPu-mR4B8T2-Z* z0&9UlBAxEdq4t27x$Em^6oBvnYKty+ChFrS>eF3Z!h#4}yK%{5wV<1OM@$#sRe9hE z%$VjEKZLt#hU=Zc{HFXdf?RigdJ06W9l*^SAY>8r^wg(KJA-Lbp~=0-I-tq}0QBvfH(O!wo#Av2_341^;wWJ2PR`Ap zj|z-?dUe~6YUr&9!gRIEE@DKB6$+k^fU;6qzH;_SnCF^uVHN3)gM>AUZ zE+Kv3SIJIIv!B`vwuZSTD(bNnffaMIT>dgT^ME)`VKnOu3Uqa?1;jvmyP0+xD93hpcLzu>Pfqgj z@qsnoGH{v}7VxkpR#%_Vlc49>!!hvjX)0j_Tn;#^nt=QWV8Jpuji%sdfl#}&FMbZiuS#l zHqhw?(b{tOo6DEg%YL`l=YxZTkXrJXHrIk2DWJ_dK?4AQy@M`S0|-NGDxin9`TUXw zZSfD+-#;L>1#nj2Xja(cSN=nMVQ<)tz zKYsWhd>4{M+iL|{u`!6fnsBzjz`j?56{M#>wccv-ttU?1eM`Eb5JdGI&(P=>xu@tsd^Bq7tL3g| zd0xtj*5q~XCfZXtyG{CE3nWEstuPyEhZ17#mqaIgdC(dm@q{eqV=e&}7UOGC8j`gb zj#v+`>s|RBp{BgMzLioT=`07Z3ue z1Q>bqw4dleXxeZGr)vZg@lLOui%alsp% z`>?REeoHq%MT4uS2!z5;z^KDNQk)3DlXsjJ$N≺) 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%0A774_cCjB^_aT1dxO4C_0z@nQeCF(P|G~a_ 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 z%0D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 z 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 Q*vf0D8hYygs#yp9AO9$9xc~qF literal 0 HcmV?d00001 diff --git a/desktop/docs/services-api.md b/desktop/docs/services-api.md index 590e7264..f4fc4ac8 100644 --- a/desktop/docs/services-api.md +++ b/desktop/docs/services-api.md @@ -18,6 +18,8 @@ ### Requests the backend handles but the bridge never calls (unused capability) - ⚠️ lmstudio-proxy → node/selected - ⚠️ lmstudio-proxy → node/set-local-backend +- ⚠️ mlx-proxy → node/selected +- ⚠️ mlx-proxy → node/set-local-backend - ⚠️ nvpair-engine-manager → engine:describe - ⚠️ nvpair-engine-manager → engine:errors - ⚠️ nvpair-engine-manager → engine:logs @@ -70,6 +72,35 @@ **Dynamic / unresolved notify sites (verify by hand — `npm run service-contracts` prints the line numbers):** - `method (var) (proxy.go)` +## mlx-pool + +_No JSON-RPC methods detected (HTTP-only binary, or source not present)._ + +## mlx-proxy + +| Method | Direction | In bridge? | +|---|---|---| +| `error` | notification (we consume) | ✅ yes | +| `errors:clear` | notification (we consume) | ✅ yes | +| `errors:report` | notification (we consume) | ✅ yes | +| `node/discovered` | notification (we consume) | ✅ yes | +| `node/removed` | notification (we consume) | ✅ yes | +| `node/selection-changed` | notification (we consume) | ➖ ignored | +| `node/updated` | notification (we consume) | ✅ yes | +| `proxy/request` | notification (we consume) | ✅ yes | +| `proxy/request-started` | notification (we consume) | ➖ ignored | +| `ready` | notification (we consume) | ✅ yes | +| `node/add-manual` | request (we call) | ✅ yes | +| `node/remove-manual` | request (we call) | ✅ yes | +| `node/select` | request (we call) | ✅ yes | +| `node/selected` | request (we call) | ⚠️ not called | +| `node/set-local-backend` | request (we call) | ⚠️ not called | +| `node/set-priority` | request (we call) | ✅ yes | +| `nodes/list` | request (we call) | ✅ yes | + +**Dynamic / unresolved notify sites (verify by hand — `npm run service-contracts` prints the line numbers):** +- `method (var) (proxy.go)` + ## nvpair-cluster-manager | Method | Direction | In bridge? | @@ -107,6 +138,7 @@ | `errors:clear` | notification (we consume) | ✅ yes | | `errors:report` | notification (we consume) | ✅ yes | | `engine:action` | request (we call) | ✅ yes | +| `engine:copy-model-from` | request (we call) | ✅ yes | | `engine:describe` | request (we call) | ⚠️ not called | | `engine:errors` | request (we call) | ⚠️ not called | | `engine:get-installed` | request (we call) | ✅ yes | @@ -114,6 +146,7 @@ | `engine:logs` | request (we call) | ⚠️ not called | | `engine:models` | request (we call) | ✅ yes | | `engine:prepare-shutdown` | request (we call) | ✅ yes | +| `engine:remote-copy-model` | request (we call) | ✅ yes | | `engine:remote-delete-model` | request (we call) | ✅ yes | | `engine:remote-get-installed` | request (we call) | ✅ yes | | `engine:remote-install` | request (we call) | ✅ yes | @@ -264,6 +297,7 @@ - `method (var) (clustermanager.go)` - `method (var) (errors.go)` - `lmstudio-proxy:* (lmstudioproxy.go)` +- `mlx-proxy:* (mlxproxy.go)` - `method (var) (proxy.go)` - `method (var) (rpcworker.go, 2 sites)` diff --git a/docs/mlx.mdx b/docs/mlx.mdx new file mode 100644 index 00000000..a494dfe1 --- /dev/null +++ b/docs/mlx.mdx @@ -0,0 +1,411 @@ + + +# MLX on Apple Silicon + +This fork adds Apple's [MLX](https://github.com/ml-explore/mlx-lm) as a third +inference engine beside Ollama and LM Studio. It runs only on `darwin/arm64`; +on every other platform the engine simply does not appear, and nothing else +about PAIR changes. + +## Quick start + +Apple Silicon, macOS. Needs Go 1.25+, Node 25.5+, `jq` and `python3` — `make tools` +reports what is missing. Every step is a `make` target; run them from the +repository root. + +```bash +# 1. Build PAIR and install the MLX engine. +# Fetches uv, builds a virtualenv, installs mlx-lm into it (~390 MB, ~1 min). +make mlx + +# 2. Download a model. The default is small and text-only; override with MODEL=. +make mlx-pull +make mlx-pull MODEL=mlx-community/Qwen3-4B-4bit + +# 3. Start the router. Holds the terminal; it prints the port to use. +make mlx-serve + +# 4. In another terminal, send a request through it. +make mlx-ask +make mlx-ask MODEL=mlx-community/Qwen3-4B-4bit +``` + +Step 4 is also the load: the first request for a model is what makes it resident, +which is why it takes ~11 s cold and ~4 s afterwards. That gap is the routing +policy's whole reason for existing — see below. + +Useful after that: + +```bash +make mlx-models # downloaded models, with * on the one resident in memory +make mlx-status # installed / running / healthy / port +make mlx-port # the port mlx-proxy actually bound +make mlx-ab # A/B the routing policy against the control arm +make mlx-reload-cost # measure what one model swap costs, in seconds +make mlx-delete MODEL=… # remove a model from the shared Hugging Face cache +make mlx-uninstall # remove the engine and its virtualenv +``` + +`make mlx-serve` is the headless equivalent of the desktop app. `make run` +starts the app instead, where MLX appears beside Ollama and LM Studio and the +same steps are buttons. + +Any endpoint that speaks OpenAI works against the printed port: + +```bash +curl http://127.0.0.1:$(make -s mlx-port)/v1/chat/completions \ + -H 'Content-Type: application/json' \ + -d '{"model":"mlx-community/Llama-3.2-1B-Instruct-4bit", + "messages":[{"role":"user","content":"hello"}]}' +``` + +## Running a different mlx-lm + +The engine installs `mlx-lm==0.31.3` from PyPI, pinned. uv verifies the published +hashes, nothing has to ship inside the app, and the pin means an upstream release +cannot change inference behaviour underneath you. + +Upstream's OpenAI-compatible server has known gaps you may hit: tool calls in +conversation history can be mangled before the chat template sees them +([#1065](https://github.com/ml-explore/mlx-lm/issues/1065)), the Qwen3-Coder tool +parser crashes on some argument shapes +([#1236](https://github.com/ml-explore/mlx-lm/issues/1236), +[#1604](https://github.com/ml-explore/mlx-lm/issues/1604)) and silently drops +float-formatted integers ([#1627](https://github.com/ml-explore/mlx-lm/issues/1627)), +and streaming tool calls are not incrementally OpenAI-shaped. Ordinary chat +completions are unaffected. + +If you carry a fork that fixes these, point the engine at it with a **user +manifest** rather than editing the bundled one. Drop a partial `mlx.json` in the +app's engine directory: + +``` +~/Library/Application Support/Nvidia Corporation/Personal AI Router/engines/mlx.json +``` + +```json +{ + "engine": "mlx", + "platforms": { + "darwin/arm64": { + "install": { + "run": [ + "sh", + "-c", + "set -e; tar -xzf \"{download}\" -C \"{install_dir}\" --strip-components=1; \"{install_dir}/uv\" venv --no-config --clear --python 3.13 \"{install_dir}/venv\"; \"{install_dir}/uv\" pip install --no-config --refresh-package mlx-lm --python \"{install_dir}/venv/bin/python\" /path/to/your/mlx-lm; \"{install_dir}/venv/bin/python\" -c 'import mlx.core, mlx_lm'" + ] + } + } + } +} +``` + +It is **deep-merged** over the bundled manifest, so this overrides only the +install step and everything else -- runtime flags, health probes, actions -- +keeps inheriting. A malformed user manifest is logged and skipped rather than +breaking startup. Reinstall the engine to apply it. + +The same hook takes a git ref instead of a path, if your fork is published: + +``` +"mlx-lm @ git+https://github.com//mlx-lm@" +``` + +Prefer a commit SHA over a branch: a branch moves, and a silent change to the +inference server is the hardest kind of regression to attribute. + +## Adopting a server you run yourself + +`engine:start` **adopts** an mlx_lm.server already answering on the configured +port instead of spawning one. So a hand-tuned server — its own virtualenv, its +own flags, its own model, started however you like — keeps running exactly as +you launched it, and PAIR only discovers, advertises and routes to it. PAIR +will not stop it either: `engine:stop` refuses an externally-managed process +and tells you to stop it in its own application. + +Point the engine at that port once (persisted as a manifest override, so it +survives restarts): + +```bash +make mlx-set-port ENGINE_PORT=8089 +``` + +Then start your server the way you already do, and start the router: + +```bash +# terminal 1 — your server, your flags, unchanged +mlx_lm.server --model ~/models/ --port 8089 + +# terminal 2 — the router adopts it +make mlx-serve + +# terminal 3 — MODEL is the path you passed to --model above +make mlx-ask MODEL=~/models/ +``` + +`make mlx-models` will show the adopted model marked resident. Note the model +**id is the path you passed to `--model`**, not a Hugging Face repo id: that is +what `/health` reports and therefore what routing keys on. Put `8081` back with +`make mlx-set-port ENGINE_PORT=8081` to return to a PAIR-managed engine. + +A thinking model needs room to answer. `mlx-ask` defaults to `MAX_TOKENS=512` +because at 60 a Qwen3.8 with thinking enabled spends the whole budget reasoning +and returns `finish_reason=length` with empty content — which reads exactly like +a broken route and is not one. + +## Why a separate engine + +LM Studio already ships an MLX runtime on Apple Silicon and PAIR already +supports LM Studio, so this is a fair question to ask before reading further. + +Native `mlx_lm` wins when you need the model layer itself: `mlx_lm.lora` +adapters, `mlx_lm.convert` and the quantisation tools (`awq`, `dwq`, `gptq`, +`dynamic_quant`), the `mlx-community` catalogue the day it lands rather than +when a runtime is updated, a server you can patch, and `mx.distributed` — +sharding one model across several Macs, which no other engine here can do. + +It strictly loses on model management. LM Studio has a real CLI with load, +unload, and progress reporting. `mlx_lm.server` has none of that, and the gaps +below are the direct consequence. + +## What the engine can and cannot do + +| | | +|---|---| +| **Install** | PAIR builds it: fetches the pinned `uv` binary, creates a virtualenv with a managed CPython, installs `mlx-lm` into it. ~390 MB, about a minute. | +| **Models resident at once** | **One.** This is the fact the rest of the design answers to. | +| **Model catalogue** | `GET /v1/models`, which scans the whole Hugging Face cache. | +| **Which model is loaded** | `GET /health` → `{"status":"ok","model":""}`, or `null`. | +| **Load** | No endpoint. PAIR sends a one-token completion and discards the token. | +| **Unload** | **No mechanism at all.** The Eject control is hidden for MLX rather than shown doing nothing. | +| **Pull** | `hf download {model}`. Terminal result only — no streamed progress, the same as LM Studio's `lms get`. | +| **Delete** | `hf cache rm -y {model}`. Visible immediately: mlx-lm rescans the cache on every `/v1/models`, so unlike LM Studio no restart is needed. Note this deletes from the Hugging Face cache **shared with every other tool on the machine**. | + +## The one-model problem, and how routing answers it + +PAIR routes a request to a node that advertises the requested model. Every other +engine can hold several; MLX holds one, and serving a different one costs a full +unload and reload of multi-gigabyte weights. + +`mlx-proxy` therefore ranks owners in two tiers — **resident first, on-disk as +fallback**. The fallback is not a hedge: routing to resident owners alone +deadlocks a cold cluster, because with nothing loaded anywhere every node is +ineligible and the first request fails on a healthy system. With the fallback, +the first request pays one load and every request after it lands on the node +that paid. The cluster converges with no lease, no warm-up job, and no +coordinator. + +The rule is measured, not asserted — `NVPAIR_MLX_ROUTING=any` restores the +inherited load-only ranking as a control arm. On an M5 Max, 600 requests over 3 +nodes each holding one of 3 models: **100.0% resident hits with the policy, +31.8% without** (the ~1/3 random selection predicts), against a **7.2 s median +reload** between two *small* models. See +[`services/mlx-proxy/README.md`](../services/mlx-proxy/README.md#residency-preferring-routing). + +### Why `/health` is safe to route on + +It reports a single scalar, which sounds too thin to drive routing — it cannot +distinguish idle from loading from serving. It does not need to. +`ModelProvider._load` clears `model_key` before it loads and sets it only after +the weights are in, so a node part-way through a load reports `null` and is +correctly ineligible. It never advertises a model it cannot yet serve. + +## Ports + +| | | +|---|---| +| `mlx-proxy` | **8080** — `mlx_lm.server`'s documented port, so a client already pointed at a local MLX server is routed with no reconfiguration. | +| `mlx_lm.server` | **8081** — set explicitly by the manifest, which is why MLX needs none of the port-ownership machinery LM Studio does. | + +`:8080` is also the most contended port on a developer machine. When it is +taken, the proxy reports `bind-failed`, the broker picks the next free port from +**8090**, and the next spawn takes it. This is the common case, not an edge +case — it fired on the first run of the development machine, where Docker +Desktop held `:8080`. + +## Distributed MLX + +`mx.distributed` can shard one model across several Macs, which PAIR explicitly +does not do for any other engine. The routing model here was designed so that +adding it is **additive rather than a rewrite**, and the reason is one line of +`mlx_lm/server.py`: + +```python +if group.rank() == 0: + _run_http_server(host, port, response_generator) +else: + response_generator.join() +``` + +**Only rank 0 serves HTTP.** Worker ranks open no listener at all. Everything +follows from that: + +- A shard group presents exactly **one** endpoint, so to PAIR a group already + looks like a single node. No new concept is needed in discovery or routing. +- Worker ranks fail PAIR's `/health` readiness probe because there is nothing + listening, so the advertiser never registers `mx` for them. They are invisible + to routing **for free** — no rank flag, no group-membership plumbing. +- `loaded_models` on rank 0 reports the *group's* resident model, so + residency-preferring routing is already correct for a group. The abstraction + generalises unchanged. + +Two things would still have to be built, and neither disturbs the above: + +1. **Launching.** PAIR spawns `mlx_lm.server` directly; a distributed run goes + through `mlx.launch` with a hostfile. That is a second manifest (or a + manifest override) selecting a different `runtime.bin` and `args` — the + manifest schema already expresses it. +2. **Capacity accounting.** This is the real gap. The job scheduler ranks nodes + least-loaded-first using per-node GPU telemetry. A group of four Macs would + report as one node with one node's telemetry, so the scheduler would + under-estimate its capacity and under-use it. Fixing that means letting a + node declare the group it fronts — additive to the scheduler, and orthogonal + to routing. + +`mlx_lm.share` is the companion piece: it distributes weights across the group's +machines, which is what makes a hostfile-launched group practical. + +## Network egress + +Audited 2026-09-07 by static analysis plus a live capture of every socket owned +by a PAIR process. Findings for the fork as a whole, not just the MLX parts. + +**No telemetry.** No analytics SDK, no `crashReporter`, no metrics endpoint, in +either tree. The renderer additionally *cannot* reach the internet: the CSP is +`default-src 'none'` with `connect-src` limited to `'self'` plus loopback. +`initializeUpdater()` returns early unless `app.isPackaged`, and the public +build sets `publish: null`, so a source build performs no update check at all. + +**Unprompted egress: none, after one fix.** Upstream warms the LM Studio +catalogue at window-ready with +`GET https://huggingface.co/api/models?author=lmstudio-community` — headers are +`User-Agent: PAIR/1.0` and `Accept`, no identifier of any kind, cached 6 h — and +it fired whether or not LM Studio was installed. That is now gated on the engine +actually being installed on this node, so an Ollama- or MLX-only machine makes +**no unprompted outbound request at all**. Warming is only a latency +optimisation: `getEngineHubModels` awaits `ensureLoaded()` when the modal opens, +so a gated-off machine that later installs LM Studio pays one fetch on first +open rather than losing the catalogue. Ollama's catalogue is a bundled JSON file +and never made a request. Verified live: a launch with LM Studio absent produced +zero non-loopback sockets. + +**MLX egress is user-initiated only**, and hardened against the incidental kind: + +| Action | Contacts | Notes | +|---|---|---| +| `mlx-install` | `github.com` (uv), `pypi.org` (deps) | uv is **sha256-pinned**; `--no-config` stops a user `uv.toml` injecting an index or credentials | +| `mlx-pull` | `huggingface.co` + its CDN | telemetry, implicit token and update check all disabled | +| `mlx-delete` | *nothing* | it would otherwise GET `pypi.org` — see below | +| serving | *nothing* | unless a request names a model not in the cache, which downloads it | + +The `hf` CLI GETs `https://pypi.org/pypi//json` on startup to advertise +upgrades, so `hf cache rm` — deleting local files — reached the network for a +reason the operation does not have. `huggingface_hub` also enables its own +telemetry by default and sends a stored token on requests that do not need it. +All three are suppressed via `runtime.env` **and** on the action argv, because +`runCmdAction` does not pass manifest env to a CLI action: + +```json +"env": { + "HF_HUB_DISABLE_TELEMETRY": "1", + "HF_HUB_DISABLE_IMPLICIT_TOKEN": "1", + "HF_HUB_DISABLE_UPDATE_CHECK": "1" +} +``` + +**LAN exposure — fixed.** `/v1/node-info` on `:14318` was answerable by any +device on the network and returns GPU/CPU model, total and used memory, live +utilisation, and a stable `hostUuid` — a durable fingerprint plus an activity +signal. It now answers **loopback and addresses the broker currently sees a PAIR +peer on**, and refuses the rest with a 403. + +The peer set is pushed from the broker on every discovery change +(`nodeinfo:set-trusted-readers`), because node-info knows who is asking and only +the broker knows who the real peers are. It is keyed on discovery rather than on +cluster pins for a specific reason: the desktop app polls a *peer's* node-info +directly and holds no cluster identity, so pin-based gating would blank the UI's +node cards — the regression `spawnNodeInfo` warns about. It fails **open** until +the first push, so node-info run standalone, or under a supervisor that does not +push, behaves exactly as before. + +Be clear about what this is: an **exposure reduction, not an authentication +boundary**. Source addresses are forgeable on a LAN, and anything advertising +`_nvpair-node._tcp` joins the set by design. What it removes is the passive +case — reading a machine's hardware and load without announcing yourself as a +node, which every peer's UI would show. The stable UUID itself is *already* +public on the LAN in the mDNS TXT record, so gating node-info does not hide +identity; it hides hardware and activity. +The model list on `:14322` is *not* exposed — it refuses plaintext from +anything but loopback and otherwise requires cluster mTLS from a pinned peer, +which matters here because an MLX model id can be a filesystem path containing +your username. The mDNS TXT record carries only schema version, host UUID, +cluster UUID and addresses — no model names. + +**Both fixes were adversarially reviewed after the fact, and the review found a +real hole.** `allows()` originally returned `true` when the source address +failed to parse — and `net.ParseIP` rejects a *zoned* IPv6 literal +(`fe80::1%en0`), which is exactly the form a link-local LAN caller arrives as. +Any device reaching the host over IPv6 link-local walked straight past the gate. +It now uses `net/netip` (`Unmap()` folds `::ffff:1.2.3.4`, `WithZone("")` drops +the interface scope, which names the *receiver's* NIC and cannot identify a +peer) and **denies** on a parse failure. Also closed in that round: a +`readers != nil` test in the handler that turned any missing initialisation into +a full bypass; addresses the broker pushes that fail to parse are now warned +about rather than silently dropped (a dropped address means a real peer is +refused while the broker believes policy landed); an unreadable pin record now +refuses instead of reading as "no pins" (a trust reset an attacker can cause by +truncating the file); a pin that cannot be persisted now fails the install +rather than executing under a control that did not engage; and the record is +taken under a cross-process lock, because this fork really does run two +engine-managers at once. + +**Unpinned installers — fixed.** All 6 Ollama fetches were unpinned against +versionless `ollama.com/download/...` URLs. They now point at **immutable +GitHub release URLs with the vendor-published SHA-256** +(`releases/download/v0.33.3/...`), which is the model Homebrew and winget use: a +new vendor release becomes a reviewed manifest update instead of arbitrary +replacement bytes. Verified before pinning — the digest was confirmed by hashing +the artefact, and the versionless endpoint served identical bytes, so this is a +no-op for users today and pure gain. + +LM Studio cannot be pinned that way: its installer is a versionless +`https://lmstudio.ai/install.sh` with no published checksum, executed via `bash`. +For that case the engine-manager now does **trust on first use, failing closed** +— the first download's digest is recorded in `installer-pins.json`, and a later +change refuses the install and names both digests. TOFU cannot make the first +download trustworthy; what it does is turn every later change from invisible +into a decision. A vendor shipping a new installer trips it too, which is not a +false positive for an artefact about to execute as you: deleting the record file +is the confirmation, so there is no flag to click through by accident. + +| Manifest | fetch URLs | pinned | fallback | +|---|---|---|---| +| ollama.json | 6 | **6** | — | +| mlx.json | 1 | **1** | — | +| lmstudio.json | 3 | 0 | TOFU, fail-closed | + +**What this audit does not cover:** the packaged `.dmg` (only the source build +was examined), Chromium's own background subsystems over a longer run than the +capture window, and short-lived connections that could open and close between +`lsof` samples. + +## Known limitations + +- **Manual nodes carry no residency.** A node added by address is probed by + `nvpair-manual-nodes`, which reports a flat model list and no loaded set, so a + manual MLX node always lands in the on-disk tier. Discovery-found nodes are + unaffected. Closing this means teaching the manual probe to read `/health`. +- **No pull progress.** A `cmd`-driven pull reports only its terminal result; + the UI shows a pull in flight and then a result, with nothing between. +- **No unload.** There is nothing to call. A model leaves memory when another + replaces it or the engine stops. +- **The install pins a source checkout.** The manifest installs `mlx-lm` from a + local path, so what you get is whatever that tree held at install time. The + version and source path are recorded in `installed.json` in the engine's + install directory so a build is at least identifiable after the fact. +- **`mlx_lm` serves text models only.** Vision models (`Qwen3-VL`, …) are in the + same cache and are listed by `/v1/models`, but loading one fails. That is an + mlx-lm boundary (`mlx-vlm` is the separate project), not a PAIR one. diff --git a/fern/docs.yml b/fern/docs.yml index 496b83cc..f749c273 100644 --- a/fern/docs.yml +++ b/fern/docs.yml @@ -31,6 +31,9 @@ navigation: - page: Managing Engines path: ../docs/engine-lifecycle.mdx slug: engine-lifecycle + - page: MLX on Apple Silicon + path: ../docs/mlx.mdx + slug: mlx - page: Terminal Interface path: ../docs/terminal-interface.mdx slug: terminal-interface diff --git a/scripts/mlx.mjs b/scripts/mlx.mjs new file mode 100644 index 00000000..eab9cc9a --- /dev/null +++ b/scripts/mlx.mjs @@ -0,0 +1,241 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +// SPDX-License-Identifier: Apache-2.0 + +// Headless control for the MLX engine, for driving PAIR without the desktop app. +// The Go services speak newline-delimited JSON-RPC 2.0 over stdio and expose no +// control surface to curl, so this holds the other end of that pipe. +// +// node scripts/mlx.mjs install install the engine (uv + venv + mlx-lm) +// node scripts/mlx.mjs serve run the router and the engine (Ctrl-C to stop) +// node scripts/mlx.mjs status installed / running / healthy / port +// node scripts/mlx.mjs models downloaded catalogue, marking what is resident +// node scripts/mlx.mjs pull hf download +// node scripts/mlx.mjs delete remove from the Hugging Face cache +// node scripts/mlx.mjs port move the engine to a port, persistently +// node scripts/mlx.mjs uninstall remove the engine +// +// There is deliberately no `start`, `stop` or `load`. engine-manager owns +// mlx_lm.server as a child and SIGTERMs it on shutdown, so a one-shot invocation +// always takes the engine down with it -- "start it and exit" is not a thing +// this architecture can do. `serve` is the long-lived owner instead, and a model +// becomes resident on the first request that asks for it, which is exactly what +// mlx-proxy's on-disk fallback exists for. + +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { createInterface } from 'node:readline' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +// desktop/cli-bin is what `make build-binaries` produces and what the app runs; +// services/build/bin is what `make build-services` stages. Either will do. +const BIN_DIRS = [ + path.join(ROOT, 'desktop', 'cli-bin'), + path.join(ROOT, 'services', 'build', 'bin') +] + +function binary(name) { + const found = BIN_DIRS.map(dir => path.join(dir, name)).find(existsSync) + if (!found) { + console.error(`${name} not found. Build it first:\n make build-services`) + process.exit(1) + } + return found +} + +// Install downloads uv, builds a virtualenv and installs mlx-lm into it; a pull +// can be many gigabytes. Neither belongs behind a short deadline. +const TIMEOUT = { install: 30 * 60_000, pull: 60 * 60_000, short: 30_000, models: 90_000 } + +function progressLine(msg) { + if (msg.method === 'engine:install-progress' || msg.method === 'engine:pull-progress') { + const p = msg.params ?? {} + const text = `${p.stage ?? ''} ${p.percent ?? ''} ${p.message ?? ''}`.trim() + if (text) console.error(` ${text}`) + return true + } + return false +} + +/** + * Run `calls` in order against one short-lived worker and resolve with the last + * result. Sequencing them inside a single process is the point: state that lives + * in the worker (a running engine, above all) does not survive it. + */ +function session(bin, calls, timeoutMs) { + return new Promise((resolve, reject) => { + const child = spawn(binary(bin), [], { stdio: ['pipe', 'pipe', 'pipe'] }) + const timer = setTimeout(() => { + child.kill() + reject(new Error(`timed out after ${Math.round(timeoutMs / 1000)}s`)) + }, timeoutMs) + + createInterface({ input: child.stderr }).on('line', line => { + if (/ERROR|WARN/.test(line)) console.error(` ${line}`) + }) + + let index = 0 + const send = () => { + const [method, params] = calls[index] + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: index + 1, method, params }) + '\n') + } + + createInterface({ input: child.stdout }).on('line', line => { + let msg + try { + msg = JSON.parse(line) + } catch { + return + } + if (progressLine(msg)) return + if (msg.id !== index + 1) return + if (msg.error) { + clearTimeout(timer) + child.kill() + reject(new Error(`${calls[index][0]}: ${msg.error.message}`)) + return + } + index += 1 + if (index < calls.length) { + send() + return + } + clearTimeout(timer) + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 999, method: 'shutdown' }) + '\n') + child.stdin.end() + resolve(msg.result) + }) + + child.on('error', reject) + send() + }) +} + +/** + * Bring up the whole router and hold it. The broker spawns discovery, the + * scheduler and all three engine proxies at startup, but it does not start an + * engine on its own -- that is a user action in the app, and this is its + * headless equivalent. + */ +function serve() { + const child = spawn(binary('nvpair-ui-broker'), [], { + stdio: ['pipe', 'pipe', 'inherit'], + cwd: path.dirname(binary('nvpair-ui-broker')) + }) + + let proxyPort = null + createInterface({ input: child.stdout }).on('line', line => { + let msg + try { + msg = JSON.parse(line) + } catch { + return + } + if (msg.method === 'mlx-proxy:ready' && msg.params?.port) { + proxyPort = msg.params.port + announce() + } + if (msg.id === 1) { + if (msg.error) console.error(`\nMLX engine failed to start: ${msg.error.message}\n`) + else announce() + } + }) + + let announced = false + const announce = () => { + if (announced || proxyPort === null) return + announced = true + console.error( + `\n Router up. MLX requests go to http://127.0.0.1:${proxyPort}/v1/chat/completions` + + `\n Try: make mlx-ask\n Ctrl-C to stop.\n` + ) + } + + // Subscribing is what makes the broker relay mlx-proxy:ready, which is the + // only place the bound port is reported -- :8080 is contended often enough + // that assuming it would be wrong as often as right. + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'mlx-proxy:subscribe' }) + '\n') + child.stdin.write( + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'engine:start', params: { engine: 'mlx' } }) + '\n' + ) + + const stop = () => { + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 998, method: 'shutdown' }) + '\n') + setTimeout(() => child.kill(), 15_000) + } + process.on('SIGINT', stop) + process.on('SIGTERM', stop) + child.on('exit', code => process.exit(code ?? 0)) +} + +function requireModel(value, command) { + if (value) return value + console.error(`usage: node scripts/mlx.mjs ${command} `) + console.error('example: mlx-community/Llama-3.2-1B-Instruct-4bit') + process.exit(1) +} + +const [command, argument] = process.argv.slice(2) +const model = argument +const EM = 'nvpair-engine-manager' +const action = (name, params, timeout) => + session(EM, [['engine:action', { engine: 'mlx', action: name, params }]], timeout) + +const commands = { + serve, + install: () => session(EM, [['engine:install', { engine: 'mlx', start: true }]], TIMEOUT.install), + status: () => session(EM, [['engine:status', { engine: 'mlx' }]], TIMEOUT.short), + uninstall: () => session(EM, [['engine:uninstall', { engine: 'mlx' }]], TIMEOUT.install), + // list_models is an HTTP action, so the engine has to be up to answer it. + // Starting it in the same session is honest: it comes up, reports, and goes + // back down with its parent. + models: () => + session(EM, [['engine:start', { engine: 'mlx' }], ['engine:models', undefined]], TIMEOUT.models), + pull: () => action('pull_model', { model: requireModel(model, 'pull') }, TIMEOUT.pull), + delete: () => action('delete_model', { model: requireModel(model, 'delete') }, TIMEOUT.install), + // Persist the engine's port as a manifest override. Two reasons to use it: + // to get off a port something else wants, and -- the interesting one -- to + // point PAIR at an mlx_lm.server you run yourself. engine:start ADOPTS a + // server already answering on the configured port instead of spawning one, + // so a hand-tuned server (its own venv, its own flags, its own model) keeps + // running exactly as you launched it and PAIR just routes to it. + port: () => { + const value = Number(argument) + if (!Number.isInteger(value) || value < 1 || value > 65535) { + console.error('usage: node scripts/mlx.mjs port <1-65535>') + process.exit(1) + } + return session(EM, [['engine:set-port', { engine: 'mlx', port: value }]], TIMEOUT.short) + } +} + +const run = commands[command] +if (!run) { + console.error(`unknown command: ${command ?? '(none)'}`) + console.error(`commands: ${Object.keys(commands).join(', ')}`) + process.exit(1) +} + +if (command === 'serve') { + serve() +} else { + try { + const result = await run() + // `models` is the interesting one: the split between what is downloaded + // and what is resident is exactly what routing keys on. + if (command === 'models') { + const downloaded = result?.modelsByEngine?.mlx ?? [] + const loaded = result?.loadedByEngine?.mlx ?? [] + console.log(`downloaded (${downloaded.length}):`) + for (const m of downloaded) console.log(` ${loaded.includes(m) ? '*' : ' '} ${m}`) + console.log(loaded.length ? `\nresident: ${loaded.join(', ')}` : '\nresident: nothing loaded') + } else { + console.log(JSON.stringify(result, null, 2)) + } + } catch (err) { + console.error(String(err.message ?? err)) + process.exit(1) + } +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..05f3eb75 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,123 @@ + + +# A/B benchmark: one node, the other node, both + +`ab_bench.py` measures MLX inference on each cluster node in turn and then on +both at once, so "is the second machine worth it" has an answer instead of an +opinion. Standard library only — it runs on either laptop with nothing installed. + +## Run it + +```bash +# 1. one node, then the other, then both (the default) +python3 tests/ab_bench.py \ + --model-a ~/models/ \ + --model-b /Users//models/ \ + --name-a "$(scutil --get LocalHostName)" --name-b peer + +# 2. just one side while you change something +python3 tests/ab_bench.py --mode a --model-a … --model-b … + +# 3. heavier sample, keep the raw numbers +python3 tests/ab_bench.py --repeat 5 --max-tokens 256 \ + --prompt-sizes 8 500 2000 --json run.json --model-a … --model-b … + +# 4. how much context each node can actually serve +python3 tests/ab_bench.py --context-probe --model-a … --model-b … +``` + +`make ab` runs the default sweep with the ids currently loaded. + +## What it reports + +| column | meaning | +|---|---| +| `ttft p50/p95` | ms to the first token **with content** — an empty role-only delta is protocol overhead, not a token | +| `decode/s` | tokens/s while generating, excluding the wait for token one — the model's steady-state rate | +| `e2e/s` | completion tokens ÷ total request time — what a caller actually experiences | +| `total p50/p95` | full request duration, ms | +| `aggregate` | tokens produced per **wall-clock** second for the whole run | + +Prompt sizes appear as `500w/528t` — words fed in, and the token count **the +server itself reported**. Throughput is never computed from a client-side token +estimate. + +## Two things worth knowing before you read a result + +**Node attribution is measured, not assumed.** Every response carries +`system_fingerprint`, which embeds the OS version and GPU — `macOS-26.4/applegpu_g17s` +against `macOS-15.0.1/applegpu_g13g`. The tool reports the fingerprint of each +individual response, so a `both` run *proves* the work was split rather than +claiming it. + +**The model id selects the node here.** `mlx-pool` binds loopback only, so a +peer's engine isn't directly reachable; everything goes through the router +(`:8090`). A locally built model is advertised by its absolute path, and that +path contains the **owner's** home directory — so `/Users//models/X` and +`/Users//models/X` are the same weights on two different machines. + +That also means the two nodes serve the **same weights under different ids**, so +the router cannot load-balance one id across both. `both` therefore drives the +two ids concurrently and measures aggregate cluster throughput. + +One-id balancing needs the same id on each node — which needs **no download**, +because the weights are already there. A directory model's id *is* its absolute +path, and the paths differ only by the home directory, so the cheap fix is to put +the model at a home-independent path on both machines: + +```bash +# on each node, same path on both — an APFS clone, so no extra disk +sudo mkdir -p /Users/Shared/models +cp -c -R ~/models/ /Users/Shared/models/ +# then point the engine at it (manifest runtime env, or MLX_MODELS_DIRS) +``` + +Both then advertise `/Users/Shared/models/Qwen3-VL-8B-Instruct-4bit`. Note a +symlink will **not** do: `scanModelsDir` lists with `os.ReadDir` and skips entries +that are not directories, and a symlink reports as a link. + +**But check whether you want this at all.** On this pair the benchmark says +`both` is ~20% *slower* than the fast node alone, so letting the router split one +id evenly across a fast and a 3× slower node is the thing that caused the +regression. One-id balancing pays off when the nodes are comparable; when they +are not, pinning the model to the fast node is the better configuration. + +## Interpreting `both` + +The closing table compares aggregate throughput, because per-request speed +necessarily drops when a slower node takes half the work — comparing decode +rates would make a faster cluster look worse. + +Measured on one such pair (M5 Max vs M1 16 GB, Qwen3-VL-8B-Instruct-4bit, +4 requests each; node A is the M5 Max): + +``` +node A 27.4 tok/s +node B 10.2 tok/s +both (concurrent) 21.3 tok/s +-> -22% vs node A alone +``` + +Two nodes were **slower than one**. Work is split evenly while the nodes are +~3× apart in speed, so wall time is set by the slower one and the fast node +finishes early and idles. An even split across unequal nodes is a scheduling +choice that costs throughput; the fix is a split weighted by measured +throughput, or keeping the model on the fast node and using the second machine +for different work. + +This is the number to re-check after any routing change. + +## Caveats + +- `prefill/s` (in the JSON, not the table) divides prompt tokens by time-to-first-token, + which also contains queueing and template rendering. Fine for comparing nodes + on identical prompts; not an absolute prefill rate. +- `temperature: 0` so repeated runs compare like with like. Sampling speed is + unaffected, but generated text will be repetitive — that is expected. +- The context probe reports what the node **served**, which can be far below the + architecture's declared `max_position_embeddings` (262144 for this model): a + KV-cache budget or simply free memory can cap it first. +- A run where every request failed exits non-zero rather than printing zeros. diff --git a/tests/ab_bench.py b/tests/ab_bench.py new file mode 100755 index 00000000..b7f4c362 --- /dev/null +++ b/tests/ab_bench.py @@ -0,0 +1,568 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 Denis Akimov +# SPDX-License-Identifier: Apache-2.0 +"""A/B benchmark for MLX inference across PAIR cluster nodes. + +Measures one node, then the other, then both together, and reports time to first +token, decode and prefill throughput, end-to-end latency and context limits. + +WHY IT ROUTES BY MODEL ID +------------------------- +mlx-pool binds loopback only, so a peer's engine is not reachable directly from +here. Everything therefore goes through the router (mlx-proxy, :8090) and the +MODEL ID selects the node: a locally built model is advertised by absolute path, +and that path contains the OWNER's home directory, so `/Users//models/X` and +`/Users//models/X` are the same weights on two different machines. That is a +property of how such models are addressed, not a design choice -- see the note +on `both` in `--help`. + +HOW A NODE IS ATTRIBUTED +------------------------ +Not by which id was asked for -- that would assume the answer. Every response +carries `system_fingerprint`, which embeds the OS version and GPU +(`macOS-26.4-...-applegpu_g17s` vs `macOS-15.0.1-...-applegpu_g13g`). The +fingerprint of each individual response is what this reports, so a `both` run +proves the split actually happened rather than asserting it. + +Streaming is used for every request because time to first token cannot be +recovered from a buffered response, and `stream_options.include_usage` returns +the server's own token counts -- so throughput is computed from what the model +actually tokenized, never from a client-side estimate. + +Standard library only: it has to run on either laptop with nothing installed. +""" + +from __future__ import annotations + +import argparse +import json +import re +import statistics +import sys +import time +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field, asdict +from typing import Any, Iterable + +DEFAULT_ROUTER = "http://127.0.0.1:8090" + +# Prompts are sized in words rather than tokens: the exact token count comes back +# from the server in `usage`, so the only job here is to span a useful range of +# context lengths deterministically. +PROMPT_WORD_SIZES = (8, 200, 1000) + +FILLER = ( + "The quick brown fox jumps over the lazy dog while the engineer measures " + "throughput latency and context window behaviour on a local inference node. " +) + + +@dataclass +class Sample: + """One completed request.""" + + ok: bool + node: str # from system_fingerprint, or "" when unknown + model: str + prompt_words: int + ttft_ms: float | None = None + total_ms: float | None = None + prompt_tokens: int | None = None + completion_tokens: int | None = None + error: str = "" + + @property + def decode_tps(self) -> float | None: + """Tokens per second while generating, excluding the wait for token one. + + Reported separately from the end-to-end rate because they answer + different questions: decode speed is the model's steady-state generation + rate, while the end-to-end rate is what a caller actually experiences, + prefill and queueing included. + """ + if not self.ok or not self.completion_tokens or self.ttft_ms is None: + return None + gen_ms = (self.total_ms or 0) - self.ttft_ms + if gen_ms <= 0 or self.completion_tokens <= 1: + return None + # The first token arrived at ttft, so it is not part of the decode window. + return (self.completion_tokens - 1) / (gen_ms / 1000.0) + + @property + def prefill_tps(self) -> float | None: + """Prompt tokens per second, inferred from time to first token. + + An upper bound on prompt processing rather than a pure prefill number: + ttft also contains queueing, template rendering and the first decode + step. Useful for comparing nodes on identical prompts, not as an absolute. + """ + if not self.ok or not self.prompt_tokens or not self.ttft_ms: + return None + return self.prompt_tokens / (self.ttft_ms / 1000.0) + + @property + def e2e_tps(self) -> float | None: + if not self.ok or not self.completion_tokens or not self.total_ms: + return None + return self.completion_tokens / (self.total_ms / 1000.0) + + +@dataclass +class Target: + name: str + base_url: str + model: str + node: str = "" # discovered fingerprint + + +@dataclass +class Result: + label: str + samples: list[Sample] = field(default_factory=list) + + def ok(self) -> list[Sample]: + return [s for s in self.samples if s.ok] + + +def short_node(fingerprint: str) -> str: + """A readable node tag from mlx-lm's system_fingerprint. + + The fingerprint is `---`; the platform and GPU + are what differ between machines, so they are what identify one. + """ + if not fingerprint: + return "?" + # The version follows "macOS" as its own hyphen-separated field, so a plain + # startswith() match returns the bare word and silently drops the number that + # distinguishes the machines. + m = re.search(r"macOS-[\d.]+", fingerprint) + os_part = m.group(0) if m else "" + idx = fingerprint.find("applegpu") + gpu = fingerprint[idx:] if idx >= 0 else "" + tag = "/".join(p for p in (os_part, gpu) if p) + return tag or fingerprint[:24] + + +def build_prompt(words: int) -> str: + filler_words = FILLER.split() + out: list[str] = [] + while len(out) < words: + out.extend(filler_words) + return " ".join(out[:words]) + + +def stream_request( + target: Target, prompt: str, max_tokens: int, timeout: float, prompt_words: int +) -> Sample: + """Issue one streaming completion and time it. + + Timing starts before the request is sent, so `ttft` includes connection and + queueing -- that is the number a caller feels, and excluding them would + flatter the router. + """ + body = json.dumps( + { + "model": target.model, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": max_tokens, + "stream": True, + "stream_options": {"include_usage": True}, + # Deterministic so repeated runs compare like with like. + "temperature": 0.0, + } + ).encode() + req = urllib.request.Request( + f"{target.base_url}/v1/chat/completions", + data=body, + headers={"Content-Type": "application/json"}, + ) + + sample = Sample(ok=False, node=target.node, model=target.model, prompt_words=prompt_words) + start = time.perf_counter() + first_token_at: float | None = None + fingerprint = "" + usage: dict[str, Any] = {} + + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + for raw in resp: + line = raw.decode("utf-8", "replace").strip() + # SSE: blank separators and `:` comments (the server's keepalives) + # carry no payload and must not be mistaken for a token. + if not line or line.startswith(":"): + continue + if not line.startswith("data:"): + continue + payload = line[5:].strip() + if payload == "[DONE]": + break + try: + chunk = json.loads(payload) + except json.JSONDecodeError: + continue + fingerprint = chunk.get("system_fingerprint") or fingerprint + if chunk.get("usage"): + usage = chunk["usage"] + for choice in chunk.get("choices", []): + delta = choice.get("delta") or {} + # First chunk with actual content: an empty role-only delta is + # protocol overhead, not a token, and counting it would report + # a time to first token the user never experiences. + if delta.get("content") and first_token_at is None: + first_token_at = time.perf_counter() + end = time.perf_counter() + except (urllib.error.URLError, TimeoutError, OSError) as exc: + sample.error = f"{type(exc).__name__}: {exc}" + sample.total_ms = (time.perf_counter() - start) * 1000 + return sample + + sample.ok = True + sample.node = short_node(fingerprint) if fingerprint else target.node + sample.total_ms = (end - start) * 1000 + sample.ttft_ms = (first_token_at - start) * 1000 if first_token_at else None + sample.prompt_tokens = usage.get("prompt_tokens") + sample.completion_tokens = usage.get("completion_tokens") + return sample + + +def discover_node(target: Target, timeout: float) -> str: + """Ask the target who serves it, so runs are labelled by fact not assumption.""" + s = stream_request(target, "hi", max_tokens=1, timeout=timeout, prompt_words=1) + return s.node if s.ok else "" + + +def pct(values: list[float], p: float) -> float | None: + if not values: + return None + ordered = sorted(values) + if len(ordered) == 1: + return ordered[0] + k = (len(ordered) - 1) * p + lo, hi = int(k), min(int(k) + 1, len(ordered) - 1) + return ordered[lo] + (ordered[hi] - ordered[lo]) * (k - lo) + + +def summarize(values: Iterable[float | None]) -> dict[str, float | None]: + vals = [v for v in values if v is not None] + if not vals: + return {"n": 0, "mean": None, "p50": None, "p95": None, "min": None, "max": None} + return { + "n": len(vals), + "mean": statistics.fmean(vals), + "p50": pct(vals, 0.50), + "p95": pct(vals, 0.95), + "min": min(vals), + "max": max(vals), + } + + +def run_sequential(target: Target, args: argparse.Namespace) -> Result: + res = Result(label=target.name) + # Wall time is recorded for the single-node runs too, not just the concurrent + # one. Without it there is nothing to compare `both` against: "18 requests in + # 91s" only means something next to "the same work on one node took N". + started = time.perf_counter() + for words in args.prompt_sizes: + prompt = build_prompt(words) + for _ in range(args.repeat): + res.samples.append( + stream_request(target, prompt, args.max_tokens, args.timeout, words) + ) + setattr(res, "wall_s", time.perf_counter() - started) + return res + + +def run_concurrent(targets: list[Target], args: argparse.Namespace) -> Result: + """Drive every target at once: the question `both` actually asks. + + Aggregate throughput is the point -- two nodes serving in parallel should beat + either alone, and the per-node split in the output is what shows whether the + work really landed on both. + """ + res = Result(label="both (concurrent)") + jobs: list[tuple[Target, str, int]] = [] + for words in args.prompt_sizes: + prompt = build_prompt(words) + for _ in range(args.repeat): + for t in targets: + jobs.append((t, prompt, words)) + + started = time.perf_counter() + with ThreadPoolExecutor(max_workers=max(2, len(targets) * args.concurrency)) as pool: + futures = [ + pool.submit(stream_request, t, p, args.max_tokens, args.timeout, w) + for (t, p, w) in jobs + ] + res.samples = [f.result() for f in futures] + res_wall = time.perf_counter() - started + setattr(res, "wall_s", res_wall) + return res + + +def probe_context(target: Target, args: argparse.Namespace) -> dict[str, Any]: + """Find the largest prompt the node accepts, by binary search on word count. + + Empirical rather than read from config.json: the served limit is what a + caller can actually use, and it can be lower than the architecture's + `max_position_embeddings` (a server flag, a KV-cache budget, or free memory + can all cap it first). Reports the token count the server itself reported for + the largest accepted prompt, not the word count fed in. + """ + lo, hi = 0, args.context_max_words + best: dict[str, Any] = {"accepted_words": 0, "accepted_prompt_tokens": None, "failed_at": None} + + # Grow first, so a node with a small limit is found without paying for the + # full binary search range. + # + # Progress is printed and flushed: a single probe at tens of thousands of + # tokens takes minutes on a small machine, and a silent tool that long is + # indistinguishable from a hung one. + probe = 256 + while probe <= hi: + print(f" {target.name}: trying {probe} words...", flush=True) + s = stream_request(target, build_prompt(probe), 1, args.context_timeout, probe) + if not s.ok: + best["failed_at"] = probe + hi = probe - 1 + break + best["accepted_words"] = probe + best["accepted_prompt_tokens"] = s.prompt_tokens + print(f" accepted ({s.prompt_tokens} prompt tokens)", flush=True) + lo = probe + probe *= 4 + else: + return best # never failed within the ceiling + + while lo < hi: + mid = (lo + hi + 1) // 2 + print(f" {target.name}: narrowing, trying {mid} words...", flush=True) + s = stream_request(target, build_prompt(mid), 1, args.context_timeout, mid) + if s.ok: + best["accepted_words"] = mid + best["accepted_prompt_tokens"] = s.prompt_tokens + lo = mid + else: + best["failed_at"] = mid + hi = mid - 1 + return best + + +def fmt(v: float | None, digits: int = 1) -> str: + return "-" if v is None else f"{v:.{digits}f}" + + +def print_result(res: Result, args: argparse.Namespace) -> None: + ok = res.ok() + failed = [s for s in res.samples if not s.ok] + print(f"\n=== {res.label} ===") + if not ok: + print(" no successful requests") + for s in failed[:3]: + print(f" error: {s.error}") + return + + by_node: dict[str, int] = {} + for s in ok: + by_node[s.node] = by_node.get(s.node, 0) + 1 + print(f" served by: {', '.join(f'{n} x{c}' for n, c in sorted(by_node.items()))}") + if failed: + print(f" failures: {len(failed)}/{len(res.samples)} e.g. {failed[0].error[:80]}") + + print( + f" {'prompt':<12} {'ttft p50':>9} {'p95':>8} " + f"{'decode/s':>9} {'p95':>8} {'e2e/s':>8} {'total p50':>10} {'p95':>9}" + ) + for words in args.prompt_sizes: + rows = [s for s in ok if s.prompt_words == words] + if not rows: + continue + ttft = summarize(s.ttft_ms for s in rows) + dec = summarize(s.decode_tps for s in rows) + e2e = summarize(s.e2e_tps for s in rows) + tot = summarize(s.total_ms for s in rows) + ptoks = [s.prompt_tokens for s in rows if s.prompt_tokens] + label = f"{words}w" + (f"/{ptoks[0]}t" if ptoks else "") + print( + f" {label:<12} {fmt(ttft['p50'],0):>9} {fmt(ttft['p95'],0):>8} " + f"{fmt(dec['p50']):>9} {fmt(dec['p95']):>8} {fmt(e2e['p50']):>8} " + f"{fmt(tot['p50'],0):>10} {fmt(tot['p95'],0):>9}" + ) + + wall = getattr(res, "wall_s", None) + total_completion = sum(s.completion_tokens or 0 for s in ok) + if wall: + # The number that decides whether two nodes were worth it: tokens the + # CLUSTER produced per wall-clock second, not per-request speed. + print(f" aggregate: {total_completion} tokens in {wall:.1f}s " + f"= {total_completion / wall:.1f} tok/s across {len(ok)} requests") + + +def print_comparison(results: list[Result]) -> None: + """State whether adding the second node actually bought anything. + + Aggregate throughput is the only fair basis: per-request speed necessarily + drops when a slower node takes half the work, so comparing decode rates would + make a faster cluster look worse. What matters is tokens per wall-clock + second for the same body of work. + """ + rates: list[tuple[str, float]] = [] + for r in results: + wall = getattr(r, "wall_s", None) + toks = sum(s.completion_tokens or 0 for s in r.ok()) + if wall and toks: + rates.append((r.label, toks / wall)) + if len(rates) < 2: + return + + print("\n=== aggregate throughput (same work, wall clock) ===") + best_single = max((r for r in rates if not r[0].startswith("both")), key=lambda x: x[1], + default=None) + for label, rate in rates: + bar = "#" * max(1, round(rate / max(r[1] for r in rates) * 40)) + print(f" {label:<22} {rate:7.1f} tok/s {bar}") + both = next((r for r in rates if r[0].startswith("both")), None) + if not (both and best_single): + return + delta = (both[1] / best_single[1] - 1) * 100 + if delta > 15: + verdict = "the second node is carrying real load" + elif delta >= -15: + verdict = "no material gain; the fast node finishes early and waits" + else: + # Splitting work evenly across unequal nodes means wall time is set by the + # slower one, so the cluster can finish LATER than the fast node would + # have alone. That is a scheduling result, not measurement noise, and + # calling it noise would hide the one thing this benchmark exists to find. + verdict = ( + "SLOWER than one node: an even split across unequal nodes is bounded " + "by the slower one. Weight the split by measured throughput, or keep " + "this model on the fast node only" + ) + print(f" -> {delta:+.0f}% vs {best_single[0]} alone -- {verdict}") + + +def main() -> int: + ap = argparse.ArgumentParser( + description="A/B benchmark MLX nodes through the PAIR router.", + epilog=( + "NOTE ON `both`: the two nodes here advertise the same weights under " + "DIFFERENT ids, because a locally built model is addressed by its " + "absolute path and that path contains the owner's home directory. So " + "`both` drives the two ids concurrently and measures aggregate cluster " + "throughput. For the router to load-balance ONE id across both nodes, " + "the model must be present under the same id on each -- which needs no " + "download if the weights are already on both: put the directory at a " + "home-independent path (e.g. /Users/Shared/models/...) on each node, " + "since a directory model's id IS its absolute path. Check the " + "aggregate table first, though: when the nodes are far apart in speed, " + "splitting one id evenly across them is what makes the cluster slower." + ), + ) + ap.add_argument("--router", default=DEFAULT_ROUTER, help="mlx-proxy base URL") + ap.add_argument("--model-a", required=True, help="model id served by node A") + ap.add_argument("--model-b", required=True, help="model id served by node B") + ap.add_argument("--name-a", default="node A") + ap.add_argument("--name-b", default="node B") + ap.add_argument( + "--mode", + default="all", + choices=["a", "b", "both", "all"], + help="'all' runs A, then B, then both (default)", + ) + ap.add_argument("--repeat", type=int, default=3, help="requests per prompt size") + ap.add_argument("--max-tokens", type=int, default=128) + ap.add_argument( + "--prompt-sizes", + type=int, + nargs="+", + default=list(PROMPT_WORD_SIZES), + help="prompt sizes in words", + ) + ap.add_argument("--concurrency", type=int, default=1, help="parallel requests per target in 'both'") + ap.add_argument("--timeout", type=float, default=300.0) + ap.add_argument("--context-probe", action="store_true", help="find each node's usable context") + ap.add_argument("--context-max-words", type=int, default=200_000) + ap.add_argument("--context-timeout", type=float, default=600.0) + ap.add_argument("--json", dest="json_out", help="write full results here") + args = ap.parse_args() + + a = Target(args.name_a, args.router.rstrip("/"), args.model_a) + b = Target(args.name_b, args.router.rstrip("/"), args.model_b) + + print("Discovering which node answers for each model id...") + for t in (a, b): + t.node = discover_node(t, args.timeout) + print(f" {t.name:<10} {t.model}\n -> {t.node or 'UNREACHABLE'}") + if a.node and a.node == b.node: + print( + "\n WARNING: both ids resolved to the SAME node. An A/B across one " + "machine measures nothing about the cluster; check that the peer's " + "engine is running and its model is loaded.", + file=sys.stderr, + ) + + results: list[Result] = [] + if args.mode in ("a", "all"): + print(f"\nRunning {a.name} alone...") + results.append(run_sequential(a, args)) + if args.mode in ("b", "all"): + print(f"Running {b.name} alone...") + results.append(run_sequential(b, args)) + if args.mode in ("both", "all"): + print("Running both concurrently...") + results.append(run_concurrent([a, b], args)) + + for res in results: + print_result(res, args) + + print_comparison(results) + + context: dict[str, Any] = {} + if args.context_probe: + print("\n=== usable context (empirical) ===") + for t in (a, b): + got = probe_context(t, args) + context[t.name] = got + print( + f" {t.name:<10} accepted {got['accepted_words']} words " + f"= {got['accepted_prompt_tokens']} prompt tokens" + + (f", refused at {got['failed_at']} words" if got["failed_at"] else + f" (no limit found below {args.context_max_words} words)") + ) + + if args.json_out: + payload = { + "router": args.router, + "targets": [asdict(t) for t in (a, b)], + "settings": { + "repeat": args.repeat, + "max_tokens": args.max_tokens, + "prompt_sizes": args.prompt_sizes, + "concurrency": args.concurrency, + }, + "runs": [ + { + "label": r.label, + "wall_s": getattr(r, "wall_s", None), + "samples": [ + {**asdict(s), "decode_tps": s.decode_tps, "e2e_tps": s.e2e_tps, + "prefill_tps": s.prefill_tps} + for s in r.samples + ], + } + for r in results + ], + "context": context, + } + with open(args.json_out, "w") as fh: + json.dump(payload, fh, indent=2) + print(f"\nwrote {args.json_out}") + + # A run where nothing succeeded is a failed run, not a report of zeros. + return 0 if any(r.ok() for r in results) else 1 + + +if __name__ == "__main__": + sys.exit(main())