diff --git a/server/app.go b/server/app.go
index f6b7498..37c7be3 100644
--- a/server/app.go
+++ b/server/app.go
@@ -36,7 +36,7 @@ func newApplication(store *playgroundStore, packages map[string]memoryAsset, mon
return nil, err
}
pages := make(map[string][]byte)
- for _, name := range []string{"home.html", "docs.html", "playground.html", "gltf.html"} {
+ for _, name := range []string{"home.html", "docs.html", "playground.html", "gltf.html", "assets/sculpt/index.html"} {
content, err := webFiles.ReadFile("web/" + name)
if err != nil {
return nil, err
@@ -89,6 +89,8 @@ func (app *application) routes() http.Handler {
mux.HandleFunc("/playground/", app.servePlayground)
mux.HandleFunc("/view/gltf", app.servePage("gltf.html"))
mux.HandleFunc("/view/gltf/", app.servePage("gltf.html"))
+ mux.HandleFunc("/view/sculpt", app.servePage("assets/sculpt/index.html"))
+ mux.HandleFunc("/view/sculpt/", app.servePage("assets/sculpt/index.html"))
mux.HandleFunc("/", app.serveHome)
return app.headers(mux)
}
diff --git a/server/web/assets/sculpt/README.md b/server/web/assets/sculpt/README.md
new file mode 100644
index 0000000..e4fb8e1
--- /dev/null
+++ b/server/web/assets/sculpt/README.md
@@ -0,0 +1,50 @@
+# Yawn Sculpt
+
+Open `/view/sculpt` on the Yawn server (`npm run server`). All example code lives here; the only integration outside this folder is the embedded-page route in `server/app.go`. Rendering imports **only `@yawn/core`**. No handles, third-party scene graph, or remote document storage.
+
+## Workflow
+
+Five brushes: Draw, Inflate, Smooth, Flatten, Grab. X symmetry merges reflected displacement without double deposition. Pen pressure modulates strength through `clamp(gain × pressure^gamma, 0, 1)`; pressure-radius is optional. Mouse/touch use constant pressure. Settings and temporary Shift-smooth / Alt-invert are captured at stroke start. Grab uses a fixed camera-facing drag plane and stroke-start positions.
+
+The compact left icon rail contains brushes, brush settings, undo/redo and Settings. Hover or tap the settings icons to reveal their panels; tap inside to keep one open. Projects and palm rejection live in Settings, with less-used pen/display controls in disclosures. Panels overlay the canvas without resizing it and close on viewport resize. Orientation changes preserve apparent zoom, pan and rotation.
+
+Drag the top-right XYZ gizmo to orbit, or its neighboring pan/zoom icons to move the view. Click an axis endpoint to align the camera; click the facing endpoint again to flip sides. The scene is Y-up. Navigation captures its pointer outside the controls and never sculpts; Escape, lost capture, blur and viewport changes restore the starting view. Navigation waits for an unfinished sculpt transaction to settle. Axis buttons and navigation controls also support keyboard use.
+
+Controls use the locally vendored Basecoat 1.0.2 standalone stylesheet (shadcn-style native HTML components); provenance and license are in `vendor/basecoat/`. No React, UI JavaScript runtime, CDN, or additional build step. Exclude the unmodified vendor stylesheet from formatters. `styles.css` handles layout/density; `Menus` remains the visibility controller.
+
+Palm rejection defaults on: canvas touch is ignored, including outside-menu dismissal, while touch controls remain usable. With rejection off, one finger sculpts and two fingers pan/pinch, rolling back any provisional one-finger sculpt. Right-drag rotates, middle-drag pans, and the wheel zooms. Escape, blur, canceled input and unexpected capture loss roll back the stroke. Undo/redo acts on whole strokes. Native `.yawn-sculpt` projects preserve mesh geometry and density (not history or settings); OBJ export is one-way. Download before leaving—there is no automatic persistence.
+
+## Performance bounds and limitations
+
+| New sphere | Vertices | Triangles | History cap |
+| ---------- | -------: | --------: | ----------: |
+| Simple | 2,562 | 5,120 | 8 MiB |
+| Balanced | 10,242 | 20,480 | 16 MiB |
+| Fine | 40,962 | 81,920 | 32 MiB |
+
+History is capped by bytes and 50 entries, with a separate bounded active-stroke position snapshot. A refitted triangle BVH accelerates picking and brush neighborhoods. Affected normals include all incident faces at brush boundaries. Display quality changes pixel budgets (~0.6/1/2 MP) and frame caps (30/60/60), never mesh density. The 8 MiB shared-row arena is **not** the application's total memory budget.
+
+Input occupies **one 64-byte `sculptSample` row in core's SAB**, not a sample queue. Main publishes the latest endpoint/pressure; subtracting the previously consumed endpoint gives the accumulated movement. Atomic version tokens protect a coherent snapshot and retire only the consumed update, leaving concurrent newer input pending. A dedicated worker samples this slot with its own `requestAnimationFrame` and performs brush raycasts, deformation, normals and BVH refits. It interpolates straight segments between consumed endpoints; this frame-level approximation does not retain intermediate bends or pressure changes. Grab separately preserves the first effective pressure that establishes its influence.
+
+The worker retains at most one unfinished segment plus the latest shared endpoint. Resumable interpolation yields after a soft 4 ms budget or 64 brush attempts; an unfinished segment exceeding 250 ms cancels the provisional stroke. One synchronous dab can exceed the budget, especially for large brushes and Detail. Shared memory avoids an input message queue, not the CPU cost of sculpting. Worker and renderer animation frames are independently scheduled, not a same-frame presentation guarantee.
+
+Worker geometry packets are immutable, transferred, and single-flight; main applies them to its document mirror and acknowledges them. GPU uploads retain their separate single-flight staging guard. Initial picking, document/history operations, and one face-normal/BVH maintenance pass at stroke completion remain on main. Completed geometry/history survive renderer or sculpt-worker failure; unfinished work rolls back. Resize recreates both workers and the shared row because core does not expose surface resizing. Viewport rectangle changes cancel unfinished strokes immediately; new strokes wait for cancellation acknowledgement and the matching renderer size. The sculpt worker stops its animation loop between strokes; core's renderer still has its own loop.
+
+Requires WebGPU, OffscreenCanvas, shared memory, cross-origin isolation, and dedicated-worker animation frames. Observable initialization/upload failures preserve the document and expose Retry plus Save/Export; core does not expose comprehensive device-loss detection. A front-facing brush filter is not geodesic/occlusion isolation on folded surfaces. This is a fixed-topology, single-sphere sculpting studio inspired by Nomad, **not Nomad parity**: no remesh, dynamic topology, layers, painting, multi-object scenes or arbitrary mesh import. Coordinates are bounded to ±8 model units.
+
+## Verification
+
+```sh
+node --test server/web/assets/sculpt/*.test.js
+go test ./server
+```
+
+Tests exercise topology, brute-force picking comparisons, local normals, pressure, symmetry, history, projects, resumable sampling, input transactions and staged uploads. Benchmark output is orb CPU timing, not a low-end hardware or frame-rate guarantee. Physical pen feel, palm behavior and constrained-device performance require real-device testing.
+
+In a fresh browser page, `await (await import('/assets/sculpt/latency-check.js')).check()` replays Draw and Grab at 1, 4, and 16 coalesced samples per animation frame on Balanced-density spheres. It verifies zero main-thread dabs and fixed shared-input storage, and reports main input/packet handling, uploads, release-to-Save-enabled delay and cancellations separately. This synthetic check does not measure physical pen-to-display latency. Unit tests include a real two-thread SAB stress test and deterministic delayed-publication/cancellation races.
+
+For visual checks in the orb, use `agent-browser --headed --webgpu`: this environment's headless WebGPU presentation can be blank even when rendering succeeds. Inspect desktop, narrow portrait/landscape, settings open, error, and modified/undo/redo states. Test save/reopen and rejected files. Use the page's Help for all gestures and shortcuts.
+
+Run `await (await import('/assets/sculpt/browser-check.js')).check()` on a fresh page for transaction and UI regressions. This harness stubs pointer capture; native acceptance must use a separate fresh page. Through the browser CDP endpoint (`agent-browser get cdp-url`), use `Input.dispatchMouseEvent` with `pointerType: 'pen'` to drag each navigation control beyond its bounds. Assert actual `hasPointerCapture(event.pointerId)`, release outside, and verify Escape restores all camera fields. During a visibly deforming stroke, focus Center view, send Escape with `Input.dispatchKeyEvent`, release the pen, and verify exact geometry and unchanged history after settlement.
+
+For rail scrolling, enable `Emulation.setTouchEmulationEnabled` and set a 640×360 CSS-pixel viewport. Verify `(pointer: coarse)` and `scrollHeight > clientHeight`, then use `Input.dispatchTouchEvent` to swipe upward from the center of a brush button. Assert increasing rail `scrollTop`, unchanged selected brush and geometry, and successful tapping of Settings at the bottom. Temporarily overriding rail buttons to `touch-action: none` should reproduce zero scrolling. Do not substitute dispatched DOM pointer events, wheel scrolling, or assigning `scrollTop` for this native gesture check. Browser emulation is not physical-tablet verification.
diff --git a/server/web/assets/sculpt/app.js b/server/web/assets/sculpt/app.js
new file mode 100644
index 0000000..80b7fb4
--- /dev/null
+++ b/server/web/assets/sculpt/app.js
@@ -0,0 +1,573 @@
+import {
+ Sculpt,
+ defaults,
+ pressureValue,
+ saveProject,
+ loadProject,
+ exportOBJ,
+} from "./engine.js";
+import { sphere } from "./mesh.js";
+import { Renderer } from "./renderer.js";
+import { Input } from "./input.js";
+import { StrokeClient } from "./stroke-client.js";
+import { clamp, clampZoom, cameraBasis } from "./math.js";
+import { Menus } from "./menus.js";
+import { Navigation } from "./navigation.js";
+
+const $ = (id) => document.getElementById(id),
+ viewport = $("viewport");
+const settings = { ...defaults },
+ camera = {
+ yaw: 0,
+ pitch: 0,
+ zoom: 1.5,
+ target: [0, 0, 0],
+ color: [0.5, 0.3, 0.2],
+ };
+let engine = new Sculpt(),
+ renderer = null,
+ initializing = false,
+ pendingResize = false,
+ frameRequest = 0,
+ publishing = false;
+let viewRevision = 0,
+ published = -1,
+ generation = 0,
+ lastSize = "",
+ modified = false,
+ saved = engine.mesh.positions.slice(),
+ fileBusy = false;
+const brushes = ["draw", "inflate", "smooth", "flatten", "grab"];
+let viewNavigation, cameraAspect, messageTimer;
+const input = new Input(viewport, {
+ engine: () => engine,
+ processor: () => renderer.processor,
+ camera,
+ settings,
+ ready: () =>
+ !!renderer &&
+ !initializing &&
+ !fileBusy &&
+ !viewNavigation?.busy &&
+ sizeKey() === lastSize,
+ blocked: () => !!viewNavigation?.busy,
+ navigation: () => "sculpt",
+ palmRejection: () => $("palm-rejection").checked,
+ schedule,
+ invalidate,
+ changed: updateUI,
+ committed: checkModified,
+ message,
+ cursor,
+ pressure,
+});
+const menus = new Menus(
+ () => !interactionBusy(),
+ (e) => e.target === viewport.querySelector("canvas") && input.rejectsTouch(e),
+);
+viewNavigation = new Navigation($("view-navigation"), viewport, {
+ camera,
+ ready: () =>
+ !!renderer &&
+ !initializing &&
+ !fileBusy &&
+ !input.busy &&
+ sizeKey() === lastSize &&
+ !document.querySelector("dialog[open]"),
+ start: () => {
+ menus.close(false);
+ cursor(null);
+ },
+ changed: updateUI,
+ invalidate,
+});
+function interactionBusy() {
+ return input.busy || !!viewNavigation?.busy;
+}
+$("palm-rejection").onchange = () => {
+ if ($("palm-rejection").checked && input.active?.type === "touch")
+ input.cancel();
+};
+
+function message(text) {
+ $("status").textContent = text;
+ $("status").classList.add("visible");
+ clearTimeout(messageTimer);
+ messageTimer = setTimeout(
+ () => $("status").classList.remove("visible"),
+ 3500,
+ );
+}
+function checkModified() {
+ modified = !engine.mesh.positions.every((v, i) => v === saved[i]);
+ updateUI();
+}
+function updateUI() {
+ const busy = interactionBusy() || initializing || fileBusy;
+ $("undo").disabled = busy || !engine.undoStack.length;
+ $("redo").disabled = busy || !engine.redoStack.length;
+ for (const id of ["new", "open", "quality"]) $(id).disabled = busy;
+ for (const id of ["save", "export"])
+ $(id).disabled = interactionBusy() || fileBusy;
+ $("modified").hidden = !modified;
+ if (pendingResize && !resizeTimer && !interactionBusy() && !initializing) {
+ queueMicrotask(() => {
+ if (pendingResize && !resizeTimer) recreate();
+ });
+ }
+}
+function cursor(sample) {
+ const el = $("cursor");
+ el.hidden = !sample || !renderer || initializing || !!viewNavigation?.busy;
+ if (!sample) return;
+ const s = engine.stroke?.settings ?? settings;
+ const p = pressureValue(sample.pressure, s.gain, s.gamma),
+ radius =
+ (s.radius *
+ (s.pressureRadius ? 0.25 + 0.75 * p : 1) *
+ viewport.clientHeight) /
+ (2 * camera.zoom);
+ el.style.width = el.style.height = `${radius * 2}px`;
+ el.style.left = `${sample.x}px`;
+ el.style.top = `${sample.y}px`;
+}
+function pressure(value) {
+ $("pen-state").textContent =
+ value === null ? "Mouse / touch" : `Pen · ${Math.round(value * 100)}%`;
+ $("pressure-fill").style.width =
+ `${value === null ? 0 : pressureValue(value, settings.gain, settings.gamma) * 100}%`;
+}
+function invalidate() {
+ viewRevision++;
+ schedule();
+}
+function schedule() {
+ if (!frameRequest) frameRequest = requestAnimationFrame(tick);
+}
+function fail(error) {
+ viewNavigation?.cancel();
+ generation++;
+ renderer?.dispose();
+ renderer = null;
+ engine.markAll();
+ input.cancel();
+ $("loading").hidden = true;
+ $("error").hidden = false;
+ console.error(error);
+ $("error-detail").textContent =
+ "The canvas could not start. Try again, or open this page in an up-to-date browser. You can still download your project in Settings.";
+ message("Canvas paused. Your completed work is still here.");
+ updateUI();
+}
+function tick() {
+ frameRequest = 0;
+ viewNavigation.checkViewport();
+ viewNavigation.render();
+ input.checkViewport();
+ if (
+ renderer &&
+ !initializing &&
+ !publishing &&
+ (published !== viewRevision || engine.dirty.size)
+ ) {
+ const active = renderer,
+ token = generation,
+ revision = viewRevision;
+ const snapshot = {
+ ...camera,
+ target: camera.target.slice(),
+ color: camera.color.slice(),
+ };
+ publishing = true;
+ active
+ .publish(engine, snapshot)
+ .then(() => {
+ if (token === generation) published = revision;
+ })
+ .catch((error) => {
+ if (token === generation) fail(error);
+ })
+ .finally(() => {
+ publishing = false;
+ if (
+ renderer &&
+ !initializing &&
+ (published !== viewRevision || engine.dirty.size)
+ )
+ schedule();
+ });
+ }
+}
+function sizeKey() {
+ const rect = viewport.getBoundingClientRect();
+ return `${rect.width}:${rect.height}:${devicePixelRatio}:${$("quality").value}`;
+}
+async function recreate() {
+ if (interactionBusy() || initializing) {
+ pendingResize = true;
+ return;
+ }
+ if (!viewport.clientWidth || !viewport.clientHeight) return;
+ const aspect = viewport.clientWidth / viewport.clientHeight;
+ if (cameraAspect)
+ camera.zoom *= Math.min(1, cameraAspect) / Math.min(1, aspect);
+ cameraAspect = aspect;
+ clearTimeout(resizeTimer);
+ resizeTimer = 0;
+ pendingResize = false;
+ initializing = true;
+ const token = ++generation;
+ lastSize = sizeKey();
+ renderer?.dispose();
+ renderer = null;
+ $("loading").hidden = false;
+ $("error").hidden = true;
+ cursor(null);
+ updateUI();
+ try {
+ if (!crossOriginIsolated || typeof SharedArrayBuffer === "undefined")
+ throw new Error("Cross-origin isolation is required");
+ if (!navigator.gpu) throw new Error("WebGPU is not available");
+ const canvas = document.createElement("canvas");
+ canvas.setAttribute("aria-label", "3D sculpt");
+ viewport.querySelector("canvas").replaceWith(canvas);
+ const next = await Renderer.create(
+ canvas,
+ engine.mesh,
+ camera,
+ $("quality").value,
+ );
+ if (token !== generation) {
+ next.dispose();
+ return;
+ }
+ renderer = next;
+ next.processor = new StrokeClient(
+ next.sample,
+ engine.mesh.level,
+ (error) => {
+ if (token === generation) fail(error);
+ },
+ );
+ await next.processor.ready;
+ if (token !== generation) {
+ next.dispose();
+ return;
+ }
+ engine.markAll();
+ published = -1;
+ $("loading").hidden = true;
+ if (sizeKey() !== lastSize) resized();
+ else message("Ready to sculpt");
+ } catch (error) {
+ if (token === generation) fail(error);
+ } finally {
+ initializing = false;
+ updateUI();
+ if (renderer) invalidate();
+ }
+}
+function frameModel() {
+ cameraAspect = viewport.clientWidth / viewport.clientHeight;
+ const bounds = engine.bvh.root;
+ camera.target = bounds.min.map((v, k) => (v + bounds.max[k]) / 2);
+ const { right, up } = cameraBasis(camera.yaw, camera.pitch);
+ const half = bounds.max.map((v, k) => (v - bounds.min[k]) / 2);
+ const width = half.reduce((sum, v, k) => sum + v * Math.abs(right[k]), 0);
+ const height = half.reduce((sum, v, k) => sum + v * Math.abs(up[k]), 0);
+ camera.zoom = Math.max(
+ 0.2,
+ 1.45 *
+ Math.max(height, (width * viewport.clientHeight) / viewport.clientWidth),
+ );
+ invalidate();
+}
+function replace(mesh, name) {
+ engine = new Sculpt(mesh, 2 ** (mesh.level - 4) * 8 * 1048576);
+ saved = mesh.positions.slice();
+ modified = false;
+ $("document-name").textContent = name;
+ camera.yaw = 0;
+ camera.pitch = 0;
+ frameModel();
+ recreate();
+ updateUI();
+}
+function download(text, name, type) {
+ const url = URL.createObjectURL(new Blob([text], { type }));
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = name;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+}
+$("save").onclick = () => {
+ if (interactionBusy() || fileBusy) return;
+ download(saveProject(engine.mesh), "sculpt.yawn-sculpt", "application/json");
+ saved = engine.mesh.positions.slice();
+ modified = false;
+ updateUI();
+ message("Project downloaded");
+};
+$("export").onclick = () => {
+ if (interactionBusy() || fileBusy) return;
+ download(exportOBJ(engine.mesh), "sculpt.obj", "text/plain");
+ message("Model downloaded");
+};
+$("open").onclick = () => {
+ if (!interactionBusy() && !initializing) $("file").click();
+};
+$("file").onchange = async () => {
+ const file = $("file").files[0];
+ $("file").value = "";
+ if (!file || interactionBusy() || initializing) return;
+ fileBusy = true;
+ updateUI();
+ try {
+ if (file.size > 8 * 1048576)
+ throw new Error("Project exceeds the 8 MiB limit");
+ const mesh = loadProject(await file.text());
+ if (
+ modified &&
+ !confirm(
+ "Replace your current sculpt? Download it first if you want to keep it.",
+ )
+ )
+ return;
+ replace(mesh, file.name.replace(/\.yawn-sculpt$/, ""));
+ } catch (error) {
+ console.error(error);
+ message(
+ "Could not open this project. Choose a project downloaded from Sculpt.",
+ );
+ } finally {
+ fileBusy = false;
+ updateUI();
+ }
+};
+$("new").onclick = () => {
+ if (interactionBusy() || initializing) return;
+ $("new-warning").textContent = modified
+ ? "This replaces your unsaved sculpt. Save it first to keep it."
+ : "Choose how much detail you want.";
+ $("new-dialog").returnValue = "cancel";
+ $("new-dialog").showModal();
+};
+$("new-dialog").addEventListener("close", () => {
+ if ($("new-dialog").returnValue === "create")
+ replace(sphere(Number($("density").value)), "Untitled sphere");
+});
+$("help").onclick = () => $("help-dialog").showModal();
+$("undo").onclick = () => {
+ if (!interactionBusy() && engine.restore()) {
+ checkModified();
+ invalidate();
+ message("Undo");
+ }
+};
+$("redo").onclick = () => {
+ if (!interactionBusy() && engine.restore(true)) {
+ checkModified();
+ invalidate();
+ message("Redo");
+ }
+};
+$("frame").onclick = () => {
+ if (!interactionBusy()) frameModel();
+};
+function chooseBrush(name) {
+ if (interactionBusy()) return;
+ settings.brush = name;
+ document
+ .querySelectorAll("[data-brush]")
+ .forEach((b) =>
+ b.setAttribute("aria-pressed", String(b.dataset.brush === name)),
+ );
+ $("brush-title").textContent = name[0].toUpperCase() + name.slice(1);
+ $("invert").disabled = !["draw", "inflate"].includes(name);
+}
+document
+ .querySelectorAll("[data-brush]")
+ .forEach((b) => (b.onclick = () => chooseBrush(b.dataset.brush)));
+function settingsUI() {
+ for (const id of ["radius", "strength", "gain", "gamma"]) {
+ settings[id] = Number($(id).value);
+ $(id).style.setProperty(
+ "--slider-value",
+ `${(100 * (settings[id] - Number($(id).min))) / (Number($(id).max) - Number($(id).min))}%`,
+ );
+ $(id + "-value").textContent =
+ id === "radius"
+ ? `${Math.round((settings[id] / 0.6) * 100)}%`
+ : id === "strength"
+ ? `${Math.round(settings[id] * 100)}%`
+ : settings[id].toFixed(2) + (id === "gain" ? "×" : "");
+ }
+ for (const id of ["symmetry", "invert", "pressureRadius"])
+ settings[id] = $(id).checked;
+ $("curve-line").setAttribute(
+ "d",
+ Array.from(
+ { length: 41 },
+ (_, i) =>
+ `${i ? "L" : "M"}${i * 5} ${53 - 52 * pressureValue(i / 40, settings.gain, settings.gamma)}`,
+ ).join(" "),
+ );
+}
+for (const id of [
+ "radius",
+ "strength",
+ "gain",
+ "gamma",
+ "symmetry",
+ "invert",
+ "pressureRadius",
+])
+ $(id).addEventListener("input", settingsUI);
+$("reset-pressure").onclick = () => {
+ $("gain").value = "1";
+ $("gamma").value = "1";
+ $("pressureRadius").checked = false;
+ settingsUI();
+};
+document.querySelectorAll("[data-color]").forEach(
+ (b) =>
+ (b.onclick = () => {
+ camera.color = b.dataset.color.split(",").map(Number);
+ document
+ .querySelectorAll("[data-color]")
+ .forEach((x) => x.setAttribute("aria-pressed", String(x === b)));
+ invalidate();
+ }),
+);
+$("fullscreen").hidden = !document.fullscreenEnabled;
+$("fullscreen").onclick = async () => {
+ try {
+ if (document.fullscreenElement) await document.exitFullscreen();
+ else await document.documentElement.requestFullscreen();
+ } catch {
+ message("Full screen isn't available in this browser.");
+ }
+};
+document.addEventListener("fullscreenchange", () => {
+ $("fullscreen").textContent = document.fullscreenElement
+ ? "Exit full screen"
+ : "Full screen";
+});
+$("quality").onchange = () => {
+ pendingResize = true;
+ recreate();
+};
+$("retry").onclick = recreate;
+let resizeTimer;
+function resized() {
+ viewNavigation.checkViewport();
+ clearTimeout(resizeTimer);
+ resizeTimer = setTimeout(() => {
+ resizeTimer = 0;
+ if (sizeKey() !== lastSize) {
+ pendingResize = true;
+ recreate();
+ }
+ }, 200);
+ const unchanged = input.checkViewport();
+ if (sizeKey() !== lastSize) {
+ cursor(null);
+ if (unchanged) message("Updating viewport…");
+ }
+}
+new ResizeObserver(resized).observe(viewport);
+window.addEventListener("resize", resized);
+function watchDpr() {
+ const query = matchMedia(`(resolution: ${devicePixelRatio}dppx)`);
+ query.addEventListener(
+ "change",
+ () => {
+ resized();
+ watchDpr();
+ },
+ { once: true },
+ );
+}
+watchDpr();
+window.addEventListener("beforeunload", (e) => {
+ if (modified || engine.stroke) {
+ e.preventDefault();
+ e.returnValue = "";
+ }
+});
+document.addEventListener("visibilitychange", () => {
+ if (document.hidden) input.cancel();
+ const active = renderer,
+ token = generation;
+ if (!active) return;
+ (document.hidden ? active.core.pause() : active.core.play())
+ .then(() => {
+ if (token === generation && !document.hidden) invalidate();
+ })
+ .catch((error) => {
+ if (token === generation) fail(error);
+ });
+});
+window.addEventListener("keydown", (e) => {
+ if (e.key === "Escape" && !document.querySelector("dialog[open]")) {
+ viewNavigation.cancel();
+ input.cancel();
+ return;
+ }
+ if (
+ document.querySelector("dialog[open]") ||
+ e.target.closest?.(".panel,.tool-rail,#view-navigation") ||
+ /INPUT|SELECT|TEXTAREA/.test(e.target.tagName) ||
+ e.target.isContentEditable
+ )
+ return;
+ if (interactionBusy()) return;
+ const key = e.key.toLowerCase(),
+ mod = e.ctrlKey || e.metaKey;
+ if (mod && ["z", "s", "o", "y"].includes(key)) {
+ e.preventDefault();
+ $(
+ key === "s"
+ ? "save"
+ : key === "o"
+ ? "open"
+ : key === "y" || e.shiftKey
+ ? "redo"
+ : "undo",
+ ).click();
+ return;
+ }
+ if (mod || e.altKey) return;
+ if (/^[1-5]$/.test(key)) chooseBrush(brushes[Number(key) - 1]);
+ else if (key === "f") $("frame").click();
+ else if (key === "?") $("help").click();
+ else if (key === "[" || key === "]") {
+ $("radius").value = String(
+ clamp(settings.radius + (key === "]" ? 0.01 : -0.01), 0.04, 0.6),
+ );
+ settingsUI();
+ } else if (
+ ["arrowleft", "arrowright", "arrowup", "arrowdown", "+", "=", "-"].includes(
+ key,
+ )
+ ) {
+ e.preventDefault();
+ if (key === "arrowleft") camera.yaw -= 0.1;
+ else if (key === "arrowright") camera.yaw += 0.1;
+ else if (key === "arrowup")
+ camera.pitch = clamp(camera.pitch + 0.1, -Math.PI / 2, Math.PI / 2);
+ else if (key === "arrowdown")
+ camera.pitch = clamp(camera.pitch - 0.1, -Math.PI / 2, Math.PI / 2);
+ else
+ camera.zoom = clampZoom(
+ camera.zoom * (key === "-" ? 1.1 : 0.9),
+ viewport.getBoundingClientRect(),
+ );
+ invalidate();
+ }
+});
+settingsUI();
+frameModel();
+updateUI();
+recreate();
diff --git a/server/web/assets/sculpt/browser-check.js b/server/web/assets/sculpt/browser-check.js
new file mode 100644
index 0000000..9e9d8d8
--- /dev/null
+++ b/server/web/assets/sculpt/browser-check.js
@@ -0,0 +1,492 @@
+// Run explicitly in a fresh /view/sculpt page:
+// await (await import('/assets/sculpt/browser-check.js')).check()
+// Synthetic input checks logic, not physical pen feel. Hooks are always restored.
+import { Renderer } from "./renderer.js";
+import { Navigation } from "./navigation.js";
+
+export async function check() {
+ const $ = (id) => document.getElementById(id),
+ results = [];
+ const assert = (value, message) => {
+ if (!value) throw new Error(message);
+ };
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+ const wait = async (fn) => {
+ const start = performance.now();
+ while (!fn()) {
+ if (performance.now() - start > 15000)
+ throw new Error("Timed out waiting for UI");
+ await sleep(25);
+ }
+ };
+ const viewport = $("viewport"),
+ capture = viewport.setPointerCapture,
+ createURL = URL.createObjectURL,
+ publish = Renderer.prototype.publish;
+ const navRoot = $("view-navigation"),
+ navCapture = navRoot.setPointerCapture,
+ navRender = Navigation.prototype.render;
+ let nav;
+ navRoot.setPointerCapture = () => {};
+ Navigation.prototype.render = function () {
+ nav = this;
+ return navRender.call(this);
+ };
+ let blob,
+ engine,
+ camera,
+ processor,
+ publications = 0,
+ failNext = false;
+ viewport.setPointerCapture = () => {};
+ URL.createObjectURL = function (value) {
+ blob = value;
+ return createURL.call(this, value);
+ };
+ Renderer.prototype.publish = function (...args) {
+ engine = args[0];
+ camera = args[1];
+ processor = this.processor;
+ publications++;
+ if (failNext) {
+ failNext = false;
+ return Promise.reject(new Error("Synthetic upload failure"));
+ }
+ return publish.apply(this, args);
+ };
+ const saved = async () => {
+ await wait(() => !$("save").disabled);
+ $("save").click();
+ return JSON.parse(await blob.text());
+ };
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
+ const pointer = (type, x, y, pressure = 0.7, pointerType = "pen", id = 7) => {
+ const r = viewport.getBoundingClientRect();
+ viewport.querySelector("canvas").dispatchEvent(
+ new PointerEvent(type, {
+ bubbles: true,
+ pointerId: id,
+ pointerType,
+ clientX: r.left + r.width * x,
+ clientY: r.top + r.height * y,
+ pressure,
+ button: 0,
+ buttons: type === "pointerup" ? 0 : 1,
+ }),
+ );
+ };
+ const stroke = async (p) => {
+ pointer("pointerdown", 0.55, 0.44, p);
+ pointer("pointermove", 0.59, 0.51, p);
+ pointer("pointerup", 0.59, 0.51, 0);
+ await wait(() => !$("save").disabled);
+ await sleep(100);
+ };
+ try {
+ await wait(() => $("loading").hidden && $("error").hidden);
+ const initialCanvas = viewport.querySelector("canvas");
+ const bounds = JSON.stringify(viewport.getBoundingClientRect());
+ assert(
+ $("tools-panel").hidden && $("inspector").hidden,
+ "Panels must start closed",
+ );
+ const hover = (id, type = "pointerenter") =>
+ $(id).dispatchEvent(
+ new PointerEvent(type, { pointerType: "pen", buttons: 0 }),
+ );
+ hover("tools-toggle");
+ assert(!$("tools-panel").hidden, "Pen hover did not reveal brushes");
+ const palette = $("tools-panel").getBoundingClientRect();
+ assert(
+ palette.left >= 50 && palette.width <= 216,
+ "Brush popover overlaps rail or has oversized width",
+ );
+ assert(
+ document.querySelectorAll(".tool-rail [data-brush] svg").length === 5,
+ "Brush rail must have five icons",
+ );
+ assert(
+ $("radius").style.getPropertyValue("--slider-value").startsWith("32.14"),
+ "Basecoat size fill is not synchronized",
+ );
+ pointer("pointerdown", 0.8, 0.8, 1, "touch", 91);
+ pointer("pointermove", 0.82, 0.81, 1, "touch", 91);
+ pointer("pointerup", 0.82, 0.81, 0, "touch", 91);
+ assert(!$("tools-panel").hidden, "Rejected palm dismissed hover tools");
+ hover("tools-toggle", "pointerleave");
+ await sleep(100);
+ hover("tools-panel");
+ await sleep(300);
+ assert(!$("tools-panel").hidden, "Crossing panel gap closed tools");
+ hover("tools-panel", "pointerleave");
+ await sleep(300);
+ assert($("tools-panel").hidden, "Hover panel did not dismiss");
+ $("settings-toggle").click();
+ hover("settings-toggle", "pointerleave");
+ await sleep(300);
+ assert(!$("inspector").hidden, "Tapped panel did not stay open");
+ pointer("pointerdown", 0.2, 0.8, 1, "touch", 91);
+ pointer("pointerup", 0.2, 0.8, 0, "touch", 91);
+ assert(!$("inspector").hidden, "Rejected palm dismissed pinned settings");
+ $("pen-settings").open = true;
+ assert(
+ $("reset-pressure").getBoundingClientRect().bottom <=
+ $("pen-settings").nextElementSibling.getBoundingClientRect().top,
+ "Expanded pen controls overlap display disclosure",
+ );
+ $("gain").dispatchEvent(
+ new PointerEvent("pointerdown", { pointerType: "pen", bubbles: true }),
+ );
+ pointer("pointerdown", 0.2, 0.8, 1, "touch", 91);
+ assert(
+ !$("inspector").hidden,
+ "Rejected palm interrupted slider interaction",
+ );
+ pointer("pointerup", 0.2, 0.8, 0, "touch", 91);
+ $("gain").focus();
+ $("gain").dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
+ );
+ assert(
+ $("inspector").hidden && document.activeElement === $("settings-toggle"),
+ "Escape did not restore launcher focus",
+ );
+ $("pen-settings").open = false;
+ assert(
+ initialCanvas === viewport.querySelector("canvas") &&
+ bounds === JSON.stringify(viewport.getBoundingClientRect()),
+ "Menus resized/replaced canvas",
+ );
+ results.push(
+ "pen hover / gap grace / auto-hide / tap pin / palm-safe panels and slider / Escape focus / unchanged canvas",
+ );
+ $("new").click();
+ $("density").value = "4";
+ $("new-dialog").close("create");
+ await wait(
+ () =>
+ viewport.querySelector("canvas") !== initialCanvas &&
+ $("loading").hidden &&
+ !$("new").disabled,
+ );
+ await sleep(100);
+ const original = await saved();
+ await stroke(0);
+ assert(same(original, await saved()), "Zero pen pressure altered geometry");
+ results.push("zero pen pressure");
+ await stroke(0.8);
+ const edited = await saved();
+ assert(!same(original, edited), "Pen stroke did not deform mesh");
+ assert(!$("undo").disabled, "Undo unavailable");
+ $("undo").click();
+ assert(same(original, await saved()), "Undo not exact");
+ $("redo").click();
+ assert(same(edited, await saved()), "Redo not exact");
+ results.push("pen sculpt / exact undo / redo");
+ pointer("pointerdown", 0.48, 0.5, 0.9);
+ await sleep(100);
+ pointer("pointermove", 0.52, 0.55, 0.9);
+ viewport.dispatchEvent(new PointerEvent("pointercancel", { pointerId: 7 }));
+ assert(same(edited, await saved()), "Cancel did not restore mesh");
+ results.push("pointer cancellation");
+ for (const settling of [false, true]) {
+ const before = engine.mesh.positions.slice();
+ const history = [...engine.undoStack];
+ const redoHistory = [...engine.redoStack];
+ pointer("pointerdown", 0.48, 0.5, 0.9);
+ pointer("pointermove", 0.56, 0.55, 0.9);
+ await wait(() => engine.mesh.positions.some((v, i) => v !== before[i]));
+ $("frame").focus();
+ if (settling) pointer("pointerup", 0.56, 0.55, 0);
+ $("frame").dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key: "Escape",
+ bubbles: true,
+ cancelable: true,
+ }),
+ );
+ assert(
+ engine.mesh.positions.every((v, i) => v === before[i]),
+ "Focused navigation swallowed sculpt cancellation",
+ );
+ if (!settling) pointer("pointerup", 0.56, 0.55, 0);
+ await wait(() => !$("save").disabled);
+ assert(
+ engine.mesh.positions.every((v, i) => v === before[i]) &&
+ engine.undoStack.length === history.length &&
+ engine.undoStack.every((v, i) => v === history[i]) &&
+ engine.redoStack.length === redoHistory.length &&
+ engine.redoStack.every((v, i) => v === redoHistory[i]),
+ "Focused navigation cancellation changed geometry/history after settlement",
+ );
+ }
+ results.push(
+ "focused navigation Escape rolls back active and settling sculpt",
+ );
+ assert($("palm-rejection").checked, "Palm rejection must default on");
+ await sleep(100);
+ const beforePalm = structuredClone(camera);
+ pointer("pointerdown", 0.48, 0.5, 1, "touch", 1);
+ pointer("pointermove", 0.58, 0.54, 1, "touch", 1);
+ pointer("pointerup", 0.58, 0.54, 0, "touch", 1);
+ await sleep(100);
+ assert(
+ same(beforePalm, camera) && same(edited, await saved()),
+ "Palm changed canvas",
+ );
+ results.push("default palm rejection");
+ $("palm-rejection").checked = false;
+ $("palm-rejection").dispatchEvent(new Event("change"));
+ pointer("pointerdown", 0.48, 0.5, 1, "touch", 1);
+ await sleep(100);
+ pointer("pointerdown", 0.56, 0.52, 1, "touch", 2);
+ pointer("pointermove", 0.58, 0.54, 1, "touch", 2);
+ pointer("pointerup", 0.48, 0.5, 0, "touch", 1);
+ pointer("pointerup", 0.58, 0.54, 0, "touch", 2);
+ assert(same(edited, await saved()), "Pinch left sculpt marks");
+ await wait(() => camera.zoom !== beforePalm.zoom);
+ $("palm-rejection").checked = true;
+ $("palm-rejection").dispatchEvent(new Event("change"));
+ results.push("two-touch rollback/navigation");
+ $("quality").value = "economy";
+ $("quality").dispatchEvent(new Event("change"));
+ await wait(() => $("loading").hidden && !$("quality").disabled);
+ assert(same(edited, await saved()), "Quality changed geometry");
+ assert(!$("undo").disabled, "Quality lost history");
+ results.push("quality preserves mesh/history");
+ const oldCanvas = viewport.querySelector("canvas");
+ viewport.style.width = "85%";
+ await wait(
+ () =>
+ viewport.querySelector("canvas") !== oldCanvas && $("loading").hidden,
+ );
+ assert(same(edited, await saved()), "Resize changed geometry");
+ assert(!$("undo").disabled, "Resize lost history");
+ const smallCanvas = viewport.querySelector("canvas");
+ viewport.style.width = "";
+ await wait(
+ () =>
+ viewport.querySelector("canvas") !== smallCanvas && $("loading").hidden,
+ );
+ results.push("resize preserves mesh/history");
+ const positions = engine.mesh.positions.slice(),
+ undo = [...engine.undoStack],
+ redo = [...engine.redoStack],
+ bytes = engine.historyBytes;
+ pointer("pointerdown", 0.48, 0.5, 0.9);
+ pointer("pointermove", 0.56, 0.55, 0.9);
+ await wait(() => engine.mesh.positions.some((v, i) => v !== positions[i]));
+ const beforeResize = viewport.querySelector("canvas");
+ viewport.style.width = "90%";
+ // Release in this task, before ResizeObserver or the 200 ms debounce runs.
+ pointer("pointerup", 0.56, 0.55, 0);
+ const exact = () =>
+ engine.mesh.positions.every((v, i) => v === positions[i]) &&
+ engine.undoStack.length === undo.length &&
+ engine.undoStack.every((v, i) => v === undo[i]) &&
+ engine.redoStack.length === redo.length &&
+ engine.redoStack.every((v, i) => v === redo[i]) &&
+ engine.historyBytes === bytes;
+ assert(
+ exact(),
+ "Resize/release committed or retained provisional geometry",
+ );
+ pointer("pointerdown", 0.5, 0.5, 0.9);
+ assert(!engine.stroke, "Stroke started against a stale renderer size");
+ pointer("pointerup", 0.5, 0.5, 0);
+ await wait(
+ () =>
+ viewport.querySelector("canvas") !== beforeResize &&
+ $("loading").hidden,
+ );
+ assert(exact(), "Resize recreation changed history or geometry");
+ await stroke(0.8);
+ assert(
+ !same(edited, await saved()),
+ "New stroke failed after resize recovery",
+ );
+ $("undo").click();
+ assert(same(edited, await saved()), "Recovery stroke undo failed");
+ const resizedCanvas = viewport.querySelector("canvas");
+ viewport.style.width = "";
+ await wait(
+ () =>
+ viewport.querySelector("canvas") !== resizedCanvas &&
+ $("loading").hidden,
+ );
+ results.push(
+ "resize during stroke: immediate exact rollback / stale-start gate / recovery",
+ );
+ const dt = new DataTransfer();
+ dt.items.add(new File(['{"version":42}'], "invalid.yawn-sculpt"));
+ $("file").files = dt.files;
+ $("file").dispatchEvent(new Event("change"));
+ await wait(() => !$("open").disabled);
+ assert(same(edited, await saved()), "Invalid project replaced geometry");
+ results.push("invalid project preservation");
+ const native = JSON.stringify(edited),
+ valid = new DataTransfer();
+ valid.items.add(new File([native], "roundtrip.yawn-sculpt"));
+ $("file").files = valid.files;
+ $("file").dispatchEvent(new Event("change"));
+ await wait(() => !$("open").disabled && $("loading").hidden);
+ assert(same(edited, await saved()), "Project roundtrip changed geometry");
+ results.push("native project roundtrip");
+ $("export").click();
+ const obj = await blob.text();
+ assert(
+ obj.split("\n").filter((l) => l.startsWith("v ")).length === 2562,
+ "OBJ vertex count",
+ );
+ assert(
+ obj.split("\n").filter((l) => l.startsWith("f ")).length === 5120,
+ "OBJ face count",
+ );
+ results.push("OBJ export");
+ const navPointer = (mode, type, x = 100, y = 100) =>
+ document.querySelector(`[data-nav="${mode}"]`).dispatchEvent(
+ new PointerEvent(type, {
+ bubbles: true,
+ pointerType: "pen",
+ pointerId: 90,
+ button: 0,
+ clientX: x,
+ clientY: y,
+ }),
+ );
+ await wait(() => nav);
+ for (const mode of ["orbit", "pan", "zoom"]) {
+ const before = structuredClone(nav.host.camera);
+ navPointer(mode, "pointerdown");
+ assert(nav.busy, "Navigation did not capture gesture");
+ pointer("pointerdown", 0.5, 0.5);
+ assert(!engine.stroke, "Canvas sculpt started during navigation");
+ viewport.dispatchEvent(
+ new WheelEvent("wheel", {
+ deltaY: 200,
+ bubbles: true,
+ cancelable: true,
+ }),
+ );
+ assert(same(nav.host.camera, before), "Wheel competed with navigation");
+ navPointer(mode, "pointermove", 126, 119);
+ assert(!same(nav.host.camera, before), "Navigation did not move camera");
+ navRoot.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
+ );
+ assert(
+ !nav.busy && same(nav.host.camera, before),
+ "Escape did not restore camera",
+ );
+ navPointer(mode, "pointerdown");
+ navPointer(mode, "pointerup", 126, 119);
+ assert(
+ !nav.busy && !same(nav.host.camera, before),
+ "Navigation release did not finish",
+ );
+ await sleep(80);
+ }
+ const navBefore = structuredClone(nav.host.camera);
+ pointer("pointerdown", 0.5, 0.5);
+ navPointer("orbit", "pointerdown");
+ assert(!nav.busy, "Navigation started during sculpt");
+ pointer("pointerup", 0.5, 0.5, 0);
+ navPointer("orbit", "pointerdown");
+ assert(!nav.busy, "Navigation started during worker settlement");
+ await wait(() => !$("save").disabled);
+ $("undo").click();
+ assert(
+ same(nav.host.camera, navBefore),
+ "Rejected navigation changed camera",
+ );
+ const viewBefore = structuredClone(camera),
+ aspectBefore = viewport.clientWidth / viewport.clientHeight;
+ const shortZoom = viewBefore.zoom * Math.min(1, aspectBefore);
+ const resizeTo = async (width, height) => {
+ const previous = viewport.querySelector("canvas");
+ viewport.style.width = width;
+ viewport.style.height = height;
+ await wait(
+ () =>
+ viewport.querySelector("canvas") !== previous && $("loading").hidden,
+ );
+ await sleep(100);
+ };
+ await resizeTo("600px", "1000px");
+ assert(
+ Math.abs(camera.zoom * 0.6 - shortZoom) < 1e-9,
+ "Portrait changed apparent zoom",
+ );
+ assert(
+ same(camera.target, viewBefore.target) &&
+ camera.yaw === viewBefore.yaw &&
+ camera.pitch === viewBefore.pitch,
+ "Orientation reset camera pose",
+ );
+ await resizeTo("", "");
+ assert(
+ Math.abs(camera.zoom - viewBefore.zoom) < 1e-9,
+ "Orientation roundtrip changed zoom",
+ );
+ assert(same(edited, await saved()), "Navigation changed geometry");
+ document.querySelector('[data-brush="draw"]').click();
+ results.push(
+ "direct navigation / mutual exclusion / Escape rollback / orientation / geometry intact",
+ );
+ await sleep(300);
+ const idle = publications;
+ await sleep(500);
+ assert(publications === idle, "Idle repeated publications");
+ results.push("idle: zero publications over 500 ms");
+ failNext = true;
+ $("frame").click();
+ await wait(() => !$("error").hidden);
+ assert(!$("save").disabled, "Error disabled save");
+ assert(same(edited, await saved()), "Upload failure lost geometry");
+ $("retry").click();
+ await wait(() => $("loading").hidden && $("error").hidden);
+ assert(same(edited, await saved()), "Retry lost geometry");
+ results.push("upload failure / save / retry");
+ const recovered = engine.mesh.positions.slice();
+ pointer("pointerdown", 0.5, 0.5, 0.9);
+ await wait(() => engine.mesh.positions.some((v, i) => v !== recovered[i]));
+ // Publication captures the renderer's client after recreation; geometry can
+ // arrive first. Never inject into the previous, already-disposed worker.
+ await wait(() => processor && !processor.disposed && processor.job);
+ const oldProcessor = processor;
+ oldProcessor.worker.dispatchEvent(new Event("error"));
+ await wait(() => !$("error").hidden && !$("save").disabled);
+ assert(
+ same(edited, await saved()),
+ "Worker failure retained provisional geometry",
+ );
+ assert(oldProcessor.disposed, "Failed worker was not terminated");
+ $("retry").click();
+ await wait(
+ () =>
+ $("loading").hidden && $("error").hidden && processor !== oldProcessor,
+ );
+ await stroke(0.8);
+ assert(
+ !same(edited, await saved()),
+ "New worker failed to sculpt after retry",
+ );
+ $("undo").click();
+ assert(same(edited, await saved()), "Recovered worker undo failed");
+ results.push(
+ "sculpt worker failure / exact rollback / new worker / sculpt / undo",
+ );
+ return {
+ passed: results.length,
+ checks: results,
+ publications,
+ physicalPenVerified: false,
+ };
+ } finally {
+ viewport.setPointerCapture = capture;
+ URL.createObjectURL = createURL;
+ Renderer.prototype.publish = publish;
+ navRoot.setPointerCapture = navCapture;
+ Navigation.prototype.render = navRender;
+ }
+}
diff --git a/server/web/assets/sculpt/bvh.js b/server/web/assets/sculpt/bvh.js
new file mode 100644
index 0000000..64e3d99
--- /dev/null
+++ b/server/web/assets/sculpt/bvh.js
@@ -0,0 +1,169 @@
+import { add, mul, sub, dot, cross, unit, point } from "./math.js";
+
+export function triangleHit(mesh, f, origin, direction) {
+ const ids = mesh.indices.subarray(f * 3, f * 3 + 3);
+ const a = point(mesh.positions, ids[0]);
+ const e1 = sub(point(mesh.positions, ids[1]), a),
+ e2 = sub(point(mesh.positions, ids[2]), a);
+ const h = cross(direction, e2),
+ det = dot(e1, h);
+ if (Math.abs(det) < 1e-9) return null;
+ const s = sub(origin, a),
+ u = dot(s, h) / det;
+ if (u < 0 || u > 1) return null;
+ const q = cross(s, e1),
+ v = dot(direction, q) / det;
+ if (v < 0 || u + v > 1) return null;
+ const t = dot(e2, q) / det;
+ return t > 0
+ ? {
+ t,
+ position: add(origin, mul(direction, t)),
+ normal: unit(cross(e1, e2)),
+ face: f,
+ }
+ : null;
+}
+
+export class BVH {
+ constructor(mesh) {
+ this.mesh = mesh;
+ this.leaves = new Array(mesh.indices.length / 3);
+ const centers = Array.from({ length: this.leaves.length }, (_, f) =>
+ [0, 1, 2].map(
+ (k) =>
+ (mesh.positions[mesh.indices[f * 3] * 3 + k] +
+ mesh.positions[mesh.indices[f * 3 + 1] * 3 + k] +
+ mesh.positions[mesh.indices[f * 3 + 2] * 3 + k]) /
+ 3,
+ ),
+ );
+ const build = (faces, parent = null) => {
+ const node = { parent, min: [0, 0, 0], max: [0, 0, 0] };
+ if (faces.length <= 8) {
+ node.faces = faces;
+ for (const f of faces) this.leaves[f] = node;
+ } else {
+ const spans = [0, 1, 2].map((k) => {
+ let lo = Infinity,
+ hi = -Infinity;
+ for (const f of faces) {
+ lo = Math.min(lo, centers[f][k]);
+ hi = Math.max(hi, centers[f][k]);
+ }
+ return hi - lo;
+ });
+ const axis = spans.indexOf(Math.max(...spans));
+ faces.sort((a, b) => centers[a][axis] - centers[b][axis]);
+ const mid = faces.length >> 1;
+ node.left = build(faces.slice(0, mid), node);
+ node.right = build(faces.slice(mid), node);
+ }
+ this.bounds(node);
+ return node;
+ };
+ this.root = build(Array.from({ length: this.leaves.length }, (_, i) => i));
+ this.marks = new Uint32Array(mesh.positions.length / 3);
+ this.generation = 0;
+ }
+ bounds(node) {
+ for (let k = 0; k < 3; k++) {
+ let lo = Infinity,
+ hi = -Infinity;
+ if (node.faces)
+ for (const f of node.faces)
+ for (let j = 0; j < 3; j++) {
+ const v = this.mesh.positions[this.mesh.indices[f * 3 + j] * 3 + k];
+ lo = Math.min(lo, v);
+ hi = Math.max(hi, v);
+ }
+ else {
+ lo = Math.min(node.left.min[k], node.right.min[k]);
+ hi = Math.max(node.left.max[k], node.right.max[k]);
+ }
+ node.min[k] = lo;
+ node.max[k] = hi;
+ }
+ }
+ refit(faces) {
+ const dirty = new Set(faces.map((f) => this.leaves[f]));
+ for (const leaf of dirty) this.bounds(leaf);
+ let parents = new Set([...dirty].map((n) => n.parent).filter(Boolean));
+ while (parents.size) {
+ const next = new Set();
+ for (const n of parents) {
+ this.bounds(n);
+ if (n.parent) next.add(n.parent);
+ }
+ parents = next;
+ }
+ }
+ raycast(origin, direction) {
+ let best = null;
+ const entry = (n) => {
+ let near = 0,
+ far = best?.t ?? Infinity;
+ for (let k = 0; k < 3; k++) {
+ if (Math.abs(direction[k]) < 1e-12) {
+ if (origin[k] < n.min[k] || origin[k] > n.max[k]) return Infinity;
+ continue;
+ }
+ const a = (n.min[k] - origin[k]) / direction[k],
+ b = (n.max[k] - origin[k]) / direction[k];
+ near = Math.max(near, Math.min(a, b));
+ far = Math.min(far, Math.max(a, b));
+ if (near > far) return Infinity;
+ }
+ return near;
+ };
+ const visit = (n) => {
+ if (entry(n) === Infinity) return;
+ if (n.faces) {
+ for (const f of n.faces) {
+ const hit = triangleHit(this.mesh, f, origin, direction);
+ if (hit && (!best || hit.t < best.t)) best = hit;
+ }
+ } else if (entry(n.left) < entry(n.right)) {
+ visit(n.left);
+ visit(n.right);
+ } else {
+ visit(n.right);
+ visit(n.left);
+ }
+ };
+ visit(this.root);
+ return best;
+ }
+ within(center, radius) {
+ const result = [],
+ gen = ++this.generation,
+ r2 = radius * radius;
+ const visit = (n) => {
+ let d = 0;
+ for (let k = 0; k < 3; k++) {
+ const e = Math.max(n.min[k] - center[k], 0, center[k] - n.max[k]);
+ d += e * e;
+ }
+ if (d > r2) return;
+ if (n.faces)
+ for (const f of n.faces)
+ for (let j = 0; j < 3; j++) {
+ const v = this.mesh.indices[f * 3 + j];
+ if (this.marks[v] === gen) continue;
+ this.marks[v] = gen;
+ const p = this.mesh.positions;
+ const d2 =
+ (p[v * 3] - center[0]) ** 2 +
+ (p[v * 3 + 1] - center[1]) ** 2 +
+ (p[v * 3 + 2] - center[2]) ** 2;
+ if (d2 < r2) result.push(v);
+ }
+ else {
+ visit(n.left);
+ visit(n.right);
+ }
+ };
+ visit(this.root);
+ return result;
+ }
+}
diff --git a/server/web/assets/sculpt/engine.js b/server/web/assets/sculpt/engine.js
new file mode 100644
index 0000000..f375ed6
--- /dev/null
+++ b/server/web/assets/sculpt/engine.js
@@ -0,0 +1,269 @@
+import { sphere, updateNormals } from "./mesh.js";
+import { BVH } from "./bvh.js";
+import { point, sub, dot, clamp } from "./math.js";
+
+export const pressureValue = (pressure, gain = 1, gamma = 1) =>
+ clamp(gain * clamp(pressure, 0, 1) ** gamma, 0, 1);
+export const defaults = {
+ brush: "draw",
+ radius: 0.22,
+ strength: 0.45,
+ gain: 1,
+ gamma: 1,
+ symmetry: true,
+ invert: false,
+ pressureRadius: false,
+};
+
+export class Sculpt {
+ constructor(mesh = sphere(), historyCap = 16 * 1024 * 1024) {
+ this.mesh = mesh;
+ this.bvh = new BVH(mesh);
+ this.undoStack = [];
+ this.redoStack = [];
+ this.historyCap = historyCap;
+ this.revision = 0;
+ this.dirty = new Set();
+ this.markAll();
+ this.stroke = null;
+ }
+ markAll() {
+ for (let v = 0; v < this.mesh.positions.length / 3; v++) this.dirty.add(v);
+ this.revision++;
+ }
+ get historyBytes() {
+ return [...this.undoStack, ...this.redoStack].reduce(
+ (sum, e) =>
+ sum + e.ids.byteLength + e.before.byteLength + e.after.byteLength,
+ 0,
+ );
+ }
+ begin(settings) {
+ if (this.stroke) throw new Error("A stroke is already active.");
+ this.stroke = {
+ settings: { ...defaults, ...settings },
+ before: this.mesh.positions.slice(),
+ touched: new Set(),
+ grab: null,
+ };
+ }
+ changed(ids) {
+ if (!ids.length) return;
+ const { faces, vertices } = updateNormals(this.mesh, ids);
+ this.bvh.refit(faces);
+ for (const v of vertices) this.dirty.add(v);
+ this.revision++;
+ }
+ dab(hit, rawPressure = 1, drag = [0, 0, 0]) {
+ if (!this.stroke || !hit) return;
+ const { settings: s } = this.stroke;
+ const pressure = pressureValue(rawPressure, s.gain, s.gamma);
+ if (pressure === 0) return;
+ const radius = s.radius * (s.pressureRadius ? 0.25 + 0.75 * pressure : 1);
+ const p = this.mesh.positions,
+ n = this.mesh.normals;
+ // Merge reflected influence instead of applying two sequential dabs at the seam.
+ let affected = this.stroke.grab;
+ if (!affected) {
+ affected = new Map();
+ for (const mirror of s.symmetry ? [1, -1] : [1]) {
+ const center = [
+ hit.position[0] * mirror,
+ hit.position[1],
+ hit.position[2],
+ ];
+ const normal = [hit.normal[0] * mirror, hit.normal[1], hit.normal[2]];
+ for (const v of this.bvh.within(center, radius)) {
+ if (dot(point(n, v), normal) < 0.05) continue;
+ const delta = sub(point(p, v), center),
+ weight = (1 - dot(delta, delta) / (radius * radius)) ** 2;
+ if (!affected.has(v)) affected.set(v, []);
+ affected.get(v).push({ weight, center, normal, mirror });
+ }
+ }
+ if (s.brush === "grab") this.stroke.grab = affected;
+ }
+ const moved = [],
+ updates = [];
+ for (const [v, contributions] of affected) {
+ const position = point(p, v),
+ displacement = [0, 0, 0];
+ let total = 0,
+ envelope = 0;
+ for (const { weight, center, normal, mirror } of contributions) {
+ total += weight;
+ envelope = Math.max(envelope, weight);
+ let delta;
+ if (s.brush === "smooth") {
+ const neighbors = this.mesh.neighbors[v];
+ delta = [0, 1, 2].map((k) => {
+ let sum = 0;
+ for (const neighbor of neighbors) sum += p[neighbor * 3 + k];
+ return sum / neighbors.length - position[k];
+ });
+ } else if (s.brush === "flatten") {
+ const distance = dot(sub(position, center), normal);
+ delta = normal.map((x) => -x * distance);
+ } else if (s.brush === "grab") {
+ delta = drag.map((x, k) => x * (k === 0 ? mirror : 1));
+ } else {
+ const direction = s.brush === "inflate" ? point(n, v) : normal;
+ delta = direction.map((x) => x * radius * 0.12 * (s.invert ? -1 : 1));
+ }
+ for (let k = 0; k < 3; k++) displacement[k] += delta[k] * weight;
+ }
+ const base = s.brush === "grab" ? point(this.stroke.before, v) : position;
+ let next = base.map(
+ (x, k) =>
+ x + (displacement[k] * s.strength * pressure * envelope) / total,
+ );
+ if (s.symmetry && this.stroke.before[v * 3] === 0) next[0] = 0;
+ // Bounded document coordinates keep camera and project validation meaningful.
+ next = next.map((x) => clamp(x, -8, 8));
+ if (next.some((x, k) => Math.fround(x) !== position[k])) {
+ moved.push(v);
+ updates.push(...next);
+ this.stroke.touched.add(v);
+ }
+ }
+ // Jacobi update: smoothing reads only the pre-dab mesh, independent of iteration order.
+ for (let i = 0; i < moved.length; i++)
+ p.set(updates.slice(i * 3, i * 3 + 3), moved[i] * 3);
+ this.changed(moved);
+ }
+ end(cancel = false) {
+ if (!this.stroke) return;
+ const { before, touched } = this.stroke;
+ this.stroke = null;
+ const ids = Uint32Array.from(
+ [...touched].filter((v) =>
+ [0, 1, 2].some(
+ (k) => before[v * 3 + k] !== this.mesh.positions[v * 3 + k],
+ ),
+ ),
+ );
+ if (cancel) {
+ for (const v of ids)
+ this.mesh.positions.set(before.subarray(v * 3, v * 3 + 3), v * 3);
+ this.changed(Array.from(ids));
+ return;
+ }
+ if (!ids.length) return;
+ const entry = {
+ ids,
+ before: new Float32Array(ids.length * 3),
+ after: new Float32Array(ids.length * 3),
+ };
+ ids.forEach((v, i) => {
+ entry.before.set(before.subarray(v * 3, v * 3 + 3), i * 3);
+ entry.after.set(this.mesh.positions.subarray(v * 3, v * 3 + 3), i * 3);
+ });
+ this.redoStack = [];
+ this.undoStack.push(entry);
+ while (this.undoStack.length > 50 || this.historyBytes > this.historyCap)
+ this.undoStack.shift();
+ }
+ restore(redo = false) {
+ if (this.stroke) return false;
+ const source = redo ? this.redoStack : this.undoStack,
+ target = redo ? this.undoStack : this.redoStack,
+ entry = source.pop();
+ if (!entry) return false;
+ const values = redo ? entry.after : entry.before;
+ entry.ids.forEach((v, i) =>
+ this.mesh.positions.set(values.subarray(i * 3, i * 3 + 3), v * 3),
+ );
+ target.push(entry);
+ this.changed(Array.from(entry.ids));
+ return true;
+ }
+}
+
+// Screen-space arc-length sampler. Call break() when the ray misses the surface.
+export class StrokePath {
+ constructor(spacing, emit) {
+ this.spacing = spacing;
+ this.emit = emit;
+ this.break();
+ }
+ break() {
+ this.generation = (this.generation ?? 0) + 1;
+ this.last = null;
+ this.remaining = 0;
+ }
+ push(sample, budget = Infinity) {
+ const generation = this.generation;
+ if (!this.last) {
+ this.last = sample;
+ this.remaining = this.spacing;
+ this.emit(sample);
+ return true;
+ }
+ const a = this.last,
+ dx = sample.x - a.x,
+ dy = sample.y - a.y,
+ length = Math.hypot(dx, dy);
+ let distance = this.remaining;
+ let emitted = 0;
+ while (distance <= length && length > 0) {
+ const t = distance / length;
+ const current = {
+ x: a.x + dx * t,
+ y: a.y + dy * t,
+ pressure: a.pressure + (sample.pressure - a.pressure) * t,
+ };
+ this.emit(current);
+ if (this.generation !== generation) return true;
+ distance += this.spacing;
+ if (++emitted >= budget && distance <= length) {
+ this.last = current;
+ this.remaining = this.spacing;
+ return false;
+ }
+ }
+ this.remaining = distance - length;
+ this.last = sample;
+ return true;
+ }
+}
+
+export function saveProject(mesh) {
+ return JSON.stringify({
+ version: 1,
+ level: mesh.level,
+ positions: Array.from(mesh.positions),
+ });
+}
+export function loadProject(text) {
+ if (text.length > 8 * 1024 * 1024)
+ throw new Error("Project exceeds the 8 MiB limit.");
+ const data = JSON.parse(text);
+ if (
+ data?.version !== 1 ||
+ !Number.isInteger(data.level) ||
+ data.level < 4 ||
+ data.level > 6 ||
+ !Array.isArray(data.positions) ||
+ data.positions.length !== (10 * 4 ** data.level + 2) * 3 ||
+ !data.positions.every(
+ (v) => typeof v === "number" && Number.isFinite(v) && Math.abs(v) <= 8,
+ )
+ )
+ throw new Error("Invalid sculpt project.");
+ const mesh = sphere(data.level);
+ mesh.positions.set(data.positions);
+ updateNormals(mesh);
+ return mesh;
+}
+export function exportOBJ(mesh) {
+ const lines = ["# Yawn Sculpt — local mesh export"];
+ for (let i = 0; i < mesh.positions.length; i += 3)
+ lines.push(
+ `v ${mesh.positions[i]} ${mesh.positions[i + 1]} ${mesh.positions[i + 2]}`,
+ );
+ for (let i = 0; i < mesh.indices.length; i += 3)
+ lines.push(
+ `f ${mesh.indices[i] + 1} ${mesh.indices[i + 1] + 1} ${mesh.indices[i + 2] + 1}`,
+ );
+ return lines.join("\n");
+}
diff --git a/server/web/assets/sculpt/engine.test.js b/server/web/assets/sculpt/engine.test.js
new file mode 100644
index 0000000..630674e
--- /dev/null
+++ b/server/web/assets/sculpt/engine.test.js
@@ -0,0 +1,342 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { sphere, updateNormals } from "./mesh.js";
+import { triangleHit } from "./bvh.js";
+import {
+ Sculpt,
+ pressureValue,
+ StrokePath,
+ saveProject,
+ loadProject,
+ exportOBJ,
+} from "./engine.js";
+import { point, dot, unit, clampZoom } from "./math.js";
+
+const hit = { position: [0.19, 0.11, 0.97], normal: unit([0.19, 0.11, 0.97]) };
+const near = (a, b, epsilon = 1e-6) =>
+ assert.ok(Math.abs(a - b) < epsilon, `${a} != ${b}`);
+test("zoom limits use apparent scale, including portrait values outside landscape limits", () => {
+ const portrait = { width: 600, height: 1000 },
+ landscape = { width: 1000, height: 600 };
+ near(clampZoom(20, portrait), 20);
+ near(clampZoom(30, portrait), 14 / 0.6);
+ near(clampZoom(0.1, portrait), 0.2 / 0.6);
+ near(clampZoom(20, landscape), 14);
+ near(clampZoom(0.1, landscape), 0.2);
+});
+test("welded outward sphere has two faces per edge and expected topology", () => {
+ const m = sphere(4),
+ edges = new Map();
+ assert.equal(m.positions.length / 3, 2562);
+ assert.equal(m.indices.length / 3, 5120);
+ for (let f = 0; f < m.indices.length / 3; f++) {
+ const ids = m.indices.subarray(f * 3, f * 3 + 3);
+ assert.ok(dot(point(m.faceNormals, f), point(m.positions, ids[0])) > 0);
+ for (let k = 0; k < 3; k++) {
+ const a = ids[k],
+ b = ids[(k + 1) % 3],
+ key = [Math.min(a, b), Math.max(a, b)].join(":");
+ edges.set(key, (edges.get(key) || 0) + 1);
+ }
+ }
+ assert.ok([...edges.values()].every((n) => n === 2));
+});
+test("BVH agrees with brute-force picking and sphere queries after edits and undo", () => {
+ const s = new Sculpt(sphere(4));
+ const check = () => {
+ for (const origin of [
+ [0.19, 0.11, 4],
+ [-0.31, 0.42, 4],
+ [1.4, 0, 4],
+ [0.01, -0.99, 4],
+ ]) {
+ const direction = [0, 0, -1];
+ let expected = null;
+ for (let f = 0; f < s.mesh.indices.length / 3; f++) {
+ const h = triangleHit(s.mesh, f, origin, direction);
+ if (h && (!expected || h.t < expected.t)) expected = h;
+ }
+ const actual = s.bvh.raycast(origin, direction);
+ assert.equal(!!actual, !!expected);
+ if (actual) near(actual.t, expected.t);
+ }
+ const expected = [];
+ for (let v = 0; v < s.mesh.positions.length / 3; v++)
+ if (
+ Math.hypot(
+ ...point(s.mesh.positions, v).map((x, k) => x - hit.position[k]),
+ ) < 0.37
+ )
+ expected.push(v);
+ assert.deepEqual(
+ s.bvh.within(hit.position, 0.37).sort((a, b) => a - b),
+ expected,
+ );
+ };
+ check();
+ s.begin({ radius: 0.37 });
+ for (let i = 0; i < 12; i++) s.dab(hit, 0.7);
+ s.end();
+ check();
+ s.restore();
+ check();
+});
+test("local normals equal full recomputation including brush boundary", () => {
+ const s = new Sculpt(sphere(4));
+ s.begin({ radius: 0.29, symmetry: false });
+ s.dab(hit);
+ s.end();
+ const local = s.mesh.normals.slice();
+ updateNormals(s.mesh);
+ local.forEach((v, i) => near(v, s.mesh.normals[i]));
+});
+test("pressure zero is a no-op; nonlinear pressure has the independently expected displacement ratio", () => {
+ assert.equal(pressureValue(0, 2, 0.5), 0);
+ assert.equal(pressureValue(0.5, 1, 2), 0.25);
+ const a = new Sculpt(sphere(4)),
+ b = new Sculpt(sphere(4));
+ const before = a.mesh.positions.slice();
+ a.begin({ symmetry: false });
+ a.dab(hit, 0);
+ a.end();
+ assert.equal(a.undoStack.length, 0);
+ for (const [s, p] of [
+ [a, 0.5],
+ [b, 1],
+ ]) {
+ s.begin({ symmetry: false, gamma: 2 });
+ s.dab(hit, p);
+ s.end();
+ }
+ let da = 0,
+ db = 0;
+ before.forEach((x, i) => {
+ da += Math.abs(a.mesh.positions[i] - x);
+ db += Math.abs(b.mesh.positions[i] - x);
+ });
+ near(da / db, 0.25, 1e-4);
+});
+test("symmetry does not double strength on seam", () => {
+ const a = new Sculpt(sphere(4)),
+ b = new Sculpt(sphere(4));
+ for (const [s, symmetry] of [
+ [a, false],
+ [b, true],
+ ]) {
+ s.begin({ symmetry });
+ s.dab({ position: [0, 0, 1], normal: [0, 0, 1] });
+ s.end();
+ }
+ assert.deepEqual(a.mesh.positions, b.mesh.positions);
+});
+test("grab uses stroke origin, not repeated accumulated deltas; smooth is order independent", () => {
+ const a = new Sculpt(sphere(4)),
+ b = new Sculpt(sphere(4));
+ a.begin({ brush: "grab", symmetry: false });
+ b.begin({ brush: "grab", symmetry: false });
+ a.dab(hit, 1, [0.1, 0.04, 0]);
+ a.dab(hit, 1, [0.2, 0.08, 0]);
+ b.dab(hit, 1, [0.2, 0.08, 0]);
+ assert.deepEqual(a.mesh.positions, b.mesh.positions);
+ a.end();
+ b.end();
+ const original = b.bvh.within.bind(b.bvh);
+ b.bvh.within = (...args) => original(...args).reverse();
+ for (const s of [a, b]) {
+ s.begin({ brush: "smooth", symmetry: false });
+ s.dab(hit);
+ s.end();
+ }
+ assert.deepEqual(a.mesh.positions, b.mesh.positions);
+});
+test("exact history, cancel, redo truncation and byte cap", () => {
+ const s = new Sculpt(sphere(4), 12000),
+ original = s.mesh.positions.slice();
+ s.begin({});
+ s.dab(hit);
+ s.end();
+ const after = s.mesh.positions.slice();
+ assert.ok(s.historyBytes > 0);
+ s.restore();
+ assert.deepEqual(s.mesh.positions, original);
+ s.restore(true);
+ assert.deepEqual(s.mesh.positions, after);
+ s.begin({});
+ s.dab(hit);
+ s.end(true);
+ assert.deepEqual(s.mesh.positions, after);
+ assert.equal(s.undoStack.length, 1);
+ s.restore();
+ s.begin({ brush: "inflate" });
+ s.dab(hit);
+ s.end();
+ assert.equal(s.redoStack.length, 0);
+ for (let i = 0; i < 60; i++) {
+ s.begin({});
+ s.dab(hit);
+ s.end();
+ }
+ assert.ok(s.historyBytes <= 12000);
+ assert.ok(s.undoStack.length <= 50);
+});
+test("arc-length and interpolated pressure do not depend on event frequency", () => {
+ const run = (step) => {
+ const samples = [];
+ const path = new StrokePath(7, (p) => samples.push(p));
+ for (let x = 0; x <= 100; x += step)
+ path.push({ x, y: 0, pressure: x / 100 });
+ return samples;
+ };
+ const a = run(1),
+ b = run(25);
+ assert.equal(a.length, 15);
+ assert.equal(a.length, b.length);
+ a.forEach((p, i) => {
+ near(p.x, b[i].x);
+ near(p.pressure, b[i].pressure);
+ });
+});
+test("project roundtrip, malformed project rejection, OBJ indices", () => {
+ const s = new Sculpt(sphere(4));
+ s.begin({});
+ s.dab(hit);
+ s.end();
+ const mesh = loadProject(saveProject(s.mesh));
+ assert.deepEqual(mesh.positions, s.mesh.positions);
+ for (const data of [
+ { version: 2 },
+ { version: 1, level: 99, positions: [] },
+ { version: 1, level: 4, positions: ["x"] },
+ ])
+ assert.throws(() => loadProject(JSON.stringify(data)));
+ const obj = exportOBJ(mesh).split("\n");
+ assert.equal(obj.filter((l) => l.startsWith("v ")).length, 2562);
+ assert.equal(obj.filter((l) => l.startsWith("f ")).length, 5120);
+});
+test("directional symmetry and Grab strength preserve the seam and overlap envelope", () => {
+ const center = { position: [0, 0, 1], normal: [0, 0, 1] };
+ const a = new Sculpt(sphere(4)),
+ b = new Sculpt(sphere(4));
+ const before = a.mesh.positions.slice();
+ a.begin({ brush: "grab", symmetry: true, strength: 1 });
+ a.dab(center, 1, [0.2, 0, 0]);
+ a.end();
+ assert.deepEqual(a.mesh.positions, before);
+ for (const [s, symmetry] of [
+ [a, true],
+ [b, false],
+ ]) {
+ s.begin({ brush: "grab", symmetry, strength: 0.5 });
+ s.dab(center, 1, [0, 0.12, 0.08]);
+ s.end();
+ }
+ assert.deepEqual(a.mesh.positions, b.mesh.positions);
+ const s = new Sculpt(sphere(4));
+ s.dirty.clear();
+ const revision = s.revision;
+ s.begin({ brush: "grab", strength: 0 });
+ s.dab(hit, 1, [0.3, 0.1, 0.2]);
+ s.end();
+ assert.equal(s.revision, revision);
+ assert.equal(s.dirty.size, 0);
+ assert.equal(s.undoStack.length, 0);
+ const original = s.mesh.positions.slice(),
+ radius = 0.4,
+ drag = [0.13, 0.09, 0.05];
+ s.begin({ brush: "grab", strength: 0.5, symmetry: true, radius });
+ s.dab(hit, 0.8, drag);
+ s.end();
+ let overlaps = 0;
+ for (let v = 0; v < original.length / 3; v++) {
+ const p = point(original, v),
+ normal = point(sphereReference.normals, v);
+ const weights = [1, -1].map((m) => {
+ const c = [hit.position[0] * m, ...hit.position.slice(1)],
+ n = [hit.normal[0] * m, ...hit.normal.slice(1)];
+ const d2 = p.reduce((sum, x, k) => sum + (x - c[k]) ** 2, 0);
+ return d2 < radius ** 2 && dot(normal, n) >= 0.05
+ ? (1 - d2 / radius ** 2) ** 2
+ : 0;
+ });
+ if (weights.every((w) => w > 0) && Math.abs(weights[0] - weights[1]) > 1e-4)
+ overlaps++;
+ const total = weights[0] + weights[1];
+ if (!total) continue;
+ for (let k = 0; k < 3; k++) {
+ const blend = k === 0 ? weights[0] - weights[1] : total;
+ const expected =
+ k === 0 && p[0] === 0
+ ? 0
+ : p[k] + (drag[k] * 0.5 * 0.8 * Math.max(...weights) * blend) / total;
+ near(s.mesh.positions[v * 3 + k], expected);
+ }
+ }
+ assert.ok(overlaps > 0);
+});
+const sphereReference = sphere(4);
+test("breaking inside the sampler callback aborts interpolation and resets continuity", () => {
+ const samples = [];
+ let path;
+ path = new StrokePath(7, (p) => {
+ samples.push(Math.round(p.x));
+ if (p.x >= 14) path.break();
+ });
+ path.push({ x: 0, y: 0, pressure: 1 });
+ path.push({ x: 100, y: 0, pressure: 1 });
+ assert.deepEqual(samples, [0, 7, 14]);
+ assert.equal(path.last, null);
+ path.push({ x: 101, y: 0, pressure: 0.3 });
+ assert.deepEqual(samples, [0, 7, 14, 101]);
+});
+test("history count eviction retains exactly the latest fifty real commits", () => {
+ const s = new Sculpt(sphere(4), 64 * 1024 * 1024),
+ snapshots = [];
+ for (let i = 0; i < 55; i++) {
+ const previous = s.mesh.positions.slice();
+ const h = s.bvh.raycast([0.19, 0.11, 4], [0, 0, -1]);
+ s.begin({ brush: "grab", strength: 1, symmetry: false });
+ s.dab(h, 1, [0.001, 0.0005, 0]);
+ s.end();
+ assert.notDeepEqual(s.mesh.positions, previous);
+ snapshots.push(s.mesh.positions.slice());
+ assert.ok(s.historyBytes < 64 * 1024 * 1024);
+ }
+ assert.equal(s.undoStack.length, 50);
+ for (let i = 0; i < 50; i++) assert.equal(s.restore(), true);
+ assert.deepEqual(s.mesh.positions, snapshots[4]);
+ assert.equal(s.restore(), false);
+ for (let i = 0; i < 50; i++) assert.equal(s.restore(true), true);
+ assert.deepEqual(s.mesh.positions, snapshots[54]);
+});
+test("bounded-resolution benchmark (CPU only, not a device guarantee)", () => {
+ for (const level of [4, 5, 6]) {
+ const s = new Sculpt(sphere(level));
+ s.dirty.clear();
+ const times = [];
+ s.begin({ radius: 0.22 });
+ for (let i = 0; i < 35; i++) {
+ const start = performance.now();
+ const h = s.bvh.raycast([0.19, 0.11, 4], [0, 0, -1]);
+ s.dab(h, 0.65);
+ times.push(performance.now() - start);
+ }
+ s.end();
+ times.sort((a, b) => a - b);
+ let lo = Infinity,
+ hi = -1;
+ for (const v of s.dirty) {
+ lo = Math.min(lo, v);
+ hi = Math.max(hi, v);
+ }
+ console.log(
+ JSON.stringify({
+ level,
+ vertices: s.mesh.positions.length / 3,
+ p50ms: times[17].toFixed(2),
+ p95ms: times[33].toFixed(2),
+ uploadBytes: (hi - lo + 1) * 32,
+ historyBytes: s.historyBytes,
+ }),
+ );
+ }
+});
diff --git a/server/web/assets/sculpt/index.html b/server/web/assets/sculpt/index.html
new file mode 100644
index 0000000..09fead2
--- /dev/null
+++ b/server/web/assets/sculpt/index.html
@@ -0,0 +1,472 @@
+
+
+
+
+
+
+
+ Sculpt — Yawn
+
+
+
+
+
+
+
+
+
+
+
Preparing your clay.
+
A moment to get ready.
+
+
+
Your sculpt is safe.
+
+
+
+
+
+
+
+
+
+ Unsaved
+
+
+
+
+
+
+
diff --git a/server/web/assets/sculpt/input.js b/server/web/assets/sculpt/input.js
new file mode 100644
index 0000000..f74428a
--- /dev/null
+++ b/server/web/assets/sculpt/input.js
@@ -0,0 +1,406 @@
+import { cameraBasis, add, mul, clamp, clampZoom } from "./math.js";
+
+export class Input {
+ constructor(viewport, host) {
+ this.viewport = viewport;
+ this.host = host;
+ this.pointers = new Map();
+ this.active = null;
+ this.ending = false;
+ viewport.addEventListener("pointerdown", (e) => this.down(e));
+ viewport.addEventListener("pointermove", (e) => this.move(e));
+ viewport.addEventListener("pointerup", (e) => this.up(e));
+ viewport.addEventListener("pointercancel", (e) => {
+ const t = this.transaction;
+ if (
+ this.pointers.has(e.pointerId) ||
+ t?.pendingDown?.pointerId === e.pointerId ||
+ (t && !t.canceling && t.active.id === e.pointerId)
+ )
+ this.cancel();
+ });
+ viewport.addEventListener("lostpointercapture", (e) => {
+ if (
+ this.pointers.has(e.pointerId) ||
+ this.transaction?.pendingDown?.pointerId === e.pointerId
+ )
+ this.cancel();
+ });
+ viewport.addEventListener("contextmenu", (e) => e.preventDefault());
+ viewport.addEventListener(
+ "wheel",
+ (e) => {
+ e.preventDefault();
+ if (this.active || !host.ready()) return;
+ host.camera.zoom = clampZoom(
+ host.camera.zoom * Math.exp(e.deltaY * 0.001),
+ viewport.getBoundingClientRect(),
+ );
+ host.invalidate();
+ },
+ { passive: false },
+ );
+ viewport.addEventListener("pointerleave", () => {
+ if (!this.active) host.cursor(null);
+ });
+ window.addEventListener("blur", () => this.cancel());
+ }
+ get busy() {
+ return !!this.active || !!this.transaction;
+ }
+ rejectsTouch(e) {
+ return (
+ e.pointerType === "touch" &&
+ (this.host.palmRejection() || this.active?.type === "pen")
+ );
+ }
+ checkViewport(rect = this.viewport.getBoundingClientRect()) {
+ const owner = this.transaction?.pendingDown ?? this.active;
+ if (
+ rect.width <= 0 ||
+ rect.height <= 0 ||
+ (owner &&
+ (owner.canvas !== this.viewport.querySelector("canvas") ||
+ ["left", "top", "width", "height"].some(
+ (key) => rect[key] !== owner.rect[key],
+ )))
+ ) {
+ if (owner) {
+ this.cancel();
+ this.host.message(
+ "Viewport changed · current stroke canceled; completed work preserved",
+ );
+ }
+ return false;
+ }
+ return true;
+ }
+ sample(e, r = this.active?.rect ?? this.viewport.getBoundingClientRect()) {
+ return {
+ x: e.clientX - r.left,
+ y: e.clientY - r.top,
+ pressure: e.pointerType === "pen" ? e.pressure : 1,
+ };
+ }
+ world(
+ sample,
+ r = this.active?.rect ?? this.viewport.getBoundingClientRect(),
+ ) {
+ const c = this.host.camera,
+ b = cameraBasis(c.yaw, c.pitch);
+ return add(
+ c.target,
+ add(
+ mul(
+ b.right,
+ (((sample.x / r.width) * 2 - 1) * c.zoom * r.width) / r.height,
+ ),
+ mul(b.up, (1 - (sample.y / r.height) * 2) * c.zoom),
+ ),
+ );
+ }
+ pick(sample, rect) {
+ const b = cameraBasis(this.host.camera.yaw, this.host.camera.pitch);
+ return this.host
+ .engine()
+ .bvh.raycast(
+ add(this.world(sample, rect), mul(b.forward, 24)),
+ mul(b.forward, -1),
+ );
+ }
+ down(e) {
+ if (this.rejectsTouch(e)) return;
+ const canvas = this.viewport.querySelector("canvas");
+ if (e.target !== canvas) return;
+ if (!this.checkViewport()) return;
+ if (!this.host.ready() || this.ending) return;
+ if (this.pointers.has(e.pointerId) || this.pointers.size >= 2) return;
+ e.preventDefault();
+ this.viewport.focus({ preventScroll: true });
+ this.viewport.setPointerCapture(e.pointerId);
+ const rect = this.viewport.getBoundingClientRect();
+ if (e.pointerType === "pen" && this.active?.type === "touch") {
+ this.cancel();
+ if (this.transaction) {
+ this.transaction.pendingDown = {
+ pointerId: e.pointerId,
+ pointerType: e.pointerType,
+ clientX: e.clientX,
+ clientY: e.clientY,
+ pressure: e.pressure,
+ button: e.button,
+ shiftKey: e.shiftKey,
+ altKey: e.altKey,
+ rect,
+ canvas,
+ };
+ return;
+ }
+ }
+ this.start(e, rect, canvas);
+ }
+ start(e, rect, canvas) {
+ const sample = this.sample(e, rect);
+ this.pointers.set(e.pointerId, sample);
+ if (this.pointers.size > 1) {
+ if (this.active?.mode === "sculpt") {
+ this.cancelStroke();
+ }
+ this.active = {
+ mode: "gesture",
+ id: e.pointerId,
+ type: "touch",
+ rect,
+ canvas,
+ };
+ this.gesture = this.touchPair();
+ return;
+ }
+ const action =
+ e.button === 1
+ ? "pan"
+ : e.button === 2
+ ? "rotate"
+ : this.host.navigation();
+ const navigate = action !== "sculpt";
+ const hit = navigate ? null : this.pick(sample, rect);
+ this.active = {
+ id: e.pointerId,
+ type: e.pointerType,
+ mode: navigate || !hit ? "orbit" : "sculpt",
+ action,
+ start: sample,
+ last: sample,
+ hit,
+ rect,
+ canvas,
+ };
+ if (this.active.mode === "sculpt") {
+ const settings = { ...this.host.settings };
+ if (e.shiftKey) settings.brush = "smooth";
+ if (e.altKey) settings.invert = !settings.invert;
+ const engine = this.host.engine(),
+ client = this.host.processor();
+ engine.begin(settings);
+ const transaction = {
+ engine,
+ client,
+ active: this.active,
+ canceling: false,
+ };
+ this.transaction = transaction;
+ client
+ .begin(
+ {
+ positions: engine.mesh.positions.slice(),
+ settings,
+ camera: structuredClone(this.host.camera),
+ rect: {
+ left: rect.left,
+ top: rect.top,
+ width: rect.width,
+ height: rect.height,
+ },
+ hit,
+ sample,
+ },
+ (packet) => {
+ this.checkViewport();
+ if (this.transaction !== transaction || transaction.canceling)
+ return;
+ const { vertices, rows, touched } = packet;
+ for (let row = 0; row < rows.length; row++) {
+ const v = rows[row],
+ i = row * 8,
+ j = v * 3,
+ p = engine.mesh.positions,
+ n = engine.mesh.normals;
+ p[j] = vertices[i];
+ p[j + 1] = vertices[i + 1];
+ p[j + 2] = vertices[i + 2];
+ n[j] = vertices[i + 4];
+ n[j + 1] = vertices[i + 5];
+ n[j + 2] = vertices[i + 6];
+ engine.dirty.add(v);
+ }
+ for (const v of touched) engine.stroke.touched.add(v);
+ engine.revision++;
+ this.host.invalidate();
+ },
+ )
+ .then(
+ (result) => this.settle(transaction, result),
+ () => {
+ // StrokeClient reports worker failure once through the renderer's
+ // error handler; this continuation only settles document ownership.
+ this.settle(transaction, { canceled: true });
+ },
+ );
+ }
+ this.host.changed();
+ }
+ touchPair() {
+ const [a, b] = [...this.pointers.values()];
+ return a && b
+ ? {
+ x: (a.x + b.x) / 2,
+ y: (a.y + b.y) / 2,
+ distance: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)),
+ }
+ : null;
+ }
+ pan(dx, dy) {
+ const c = this.host.camera,
+ b = cameraBasis(c.yaw, c.pitch),
+ scale = (2 * c.zoom) / this.active.rect.height;
+ c.target = add(
+ c.target,
+ add(mul(b.right, -dx * scale), mul(b.up, dy * scale)),
+ );
+ }
+ move(e) {
+ if (this.host.blocked?.()) return;
+ if (this.rejectsTouch(e)) return;
+ if (!this.checkViewport()) return;
+ if (!this.active && e.target.tagName !== "CANVAS") {
+ this.host.cursor(null);
+ return;
+ }
+ const sample = this.sample(e);
+ this.host.cursor(sample);
+ this.host.pressure(e.pointerType === "pen" ? sample.pressure : null);
+ if (!this.active || (this.ending && this.active.mode === "sculpt")) return;
+ if (!this.pointers.has(e.pointerId)) return;
+ this.pointers.set(e.pointerId, sample);
+ if (this.active.mode === "gesture") {
+ const next = this.touchPair();
+ if (next && this.gesture) {
+ this.pan(next.x - this.gesture.x, next.y - this.gesture.y);
+ this.host.camera.zoom = clampZoom(
+ (this.host.camera.zoom * this.gesture.distance) / next.distance,
+ this.active.rect,
+ );
+ this.host.invalidate();
+ }
+ this.gesture = next;
+ return;
+ }
+ if (e.pointerId !== this.active.id) return;
+ if (this.active.mode === "sculpt") {
+ const samples = e.getCoalescedEvents?.() ?? [];
+ // Only Grab needs to observe the first effective pressure in the batch;
+ // writes overwrite a single slot, never enqueue samples.
+ if (this.host.engine().stroke.settings.brush === "grab")
+ for (const event of samples.length ? samples : [e])
+ this.transaction.client.write(this.sample(event));
+ else this.transaction.client.write(this.sample(samples.at(-1) ?? e));
+ } else {
+ const dx = sample.x - this.active.last.x,
+ dy = sample.y - this.active.last.y;
+ if (this.active.action === "pan") this.pan(dx, dy);
+ else if (this.active.action === "zoom")
+ this.host.camera.zoom = clampZoom(
+ this.host.camera.zoom * Math.exp(dy * 0.008),
+ this.active.rect,
+ );
+ else {
+ this.host.camera.yaw -= dx * 0.008;
+ this.host.camera.pitch = clamp(
+ this.host.camera.pitch + dy * 0.008,
+ -Math.PI / 2,
+ Math.PI / 2,
+ );
+ }
+ this.host.invalidate();
+ }
+ if (this.active) this.active.last = sample;
+ }
+ up(e) {
+ if (e.pointerType === "touch" && this.host.palmRejection()) return;
+ if (this.transaction?.pendingDown?.pointerId === e.pointerId) {
+ this.transaction.pendingDown = null;
+ this.release(e.pointerId);
+ return;
+ }
+ if (!this.checkViewport()) return;
+ this.pointers.delete(e.pointerId);
+ this.release(e.pointerId);
+ if (!this.active) return;
+ if (this.active.mode === "gesture") {
+ if (!this.pointers.size) {
+ this.active = null;
+ this.host.changed();
+ }
+ return;
+ }
+ if (e.pointerId !== this.active.id) return;
+ if (this.active.mode === "sculpt") {
+ this.ending = true;
+ this.transaction.client.end();
+ } else {
+ this.active = null;
+ this.host.changed();
+ }
+ }
+ settle(transaction, result) {
+ if (this.transaction !== transaction) return;
+ // Resize may precede the observer callback or this completion message.
+ this.checkViewport();
+ const engine = transaction.engine;
+ if (result.canceled || transaction.canceling) {
+ engine.end(true);
+ if (!transaction.canceling) {
+ const ids = [...this.pointers.keys()];
+ this.pointers.clear();
+ for (const id of ids) this.release(id);
+ }
+ this.host.invalidate();
+ } else {
+ // Repair main's face-normal cache and picking BVH only at the boundary.
+ engine.changed([...engine.stroke.touched]);
+ engine.end();
+ this.host.committed();
+ }
+ const pending = transaction.pendingDown;
+ this.transaction = null;
+ if (this.active === transaction.active) this.active = null;
+ this.ending = false;
+ if (result.message) this.host.message(result.message);
+ if (pending) {
+ if (
+ this.host.ready() &&
+ this.host.engine() === engine &&
+ this.host.processor() === transaction.client &&
+ this.viewport.hasPointerCapture(pending.pointerId)
+ )
+ this.start(pending, pending.rect, pending.canvas);
+ else this.release(pending.pointerId);
+ }
+ this.host.changed();
+ }
+ cancelStroke() {
+ const transaction = this.transaction;
+ if (!transaction || transaction.canceling) return;
+ transaction.canceling = true;
+ this.ending = true;
+ transaction.client.cancel();
+ transaction.engine.end(true);
+ this.host.invalidate();
+ }
+ cancel() {
+ const ids = [...this.pointers.keys()];
+ if (this.transaction?.pendingDown) {
+ ids.push(this.transaction.pendingDown.pointerId);
+ this.transaction.pendingDown = null;
+ }
+ this.cancelStroke();
+ this.pointers.clear();
+ this.active = null;
+ for (const id of ids) this.release(id);
+ this.host.changed();
+ }
+ release(id) {
+ if (this.viewport.hasPointerCapture(id))
+ this.viewport.releasePointerCapture(id);
+ }
+}
diff --git a/server/web/assets/sculpt/input.test.js b/server/web/assets/sculpt/input.test.js
new file mode 100644
index 0000000..4669530
--- /dev/null
+++ b/server/web/assets/sculpt/input.test.js
@@ -0,0 +1,456 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { Input } from "./input.js";
+import { Sculpt, defaults } from "./engine.js";
+import { sphere, updateNormals } from "./mesh.js";
+import { Renderer } from "./renderer.js";
+import { StrokeClient } from "./stroke-client.js";
+import { StrokeProcessor } from "./stroke-worker.js";
+import { Mailbox } from "./mailbox.js";
+
+// Exercise the real client protocol, with frame delivery under test control.
+// Structured clone/transfer and asynchronous messages match worker ownership.
+class ControlledWorker {
+ postMessage(data, transfer = []) {
+ const copy = structuredClone(data, { transfer });
+ queueMicrotask(() => {
+ if (this.dead) return;
+ if (copy.type === "init") {
+ this.processor = new StrokeProcessor(
+ sphere(copy.level),
+ new Mailbox(copy.row),
+ (message, transfers = []) => {
+ const value = structuredClone(message, { transfer: transfers });
+ queueMicrotask(() => {
+ if (!this.dead) this.onmessage({ data: value });
+ });
+ },
+ () => 0,
+ );
+ this.onmessage({ data: { type: "ready" } });
+ } else if (copy.type === "begin") this.processor.begin(copy);
+ else if (copy.type === "end") this.processor.end(copy.id, copy.version);
+ else if (copy.type === "cancel") this.processor.cancel(copy.id);
+ else if (copy.type === "ack") this.processor.acknowledge(copy.packet);
+ });
+ }
+ terminate() {
+ this.dead = true;
+ }
+}
+const flush = () => new Promise((resolve) => setImmediate(resolve));
+async function setup(t) {
+ const oldWorker = globalThis.Worker,
+ oldWindow = globalThis.window;
+ globalThis.Worker = ControlledWorker;
+ globalThis.window = { addEventListener() {} };
+ t.after(() => {
+ globalThis.Worker = oldWorker;
+ globalThis.window = oldWindow;
+ client.dispose();
+ });
+ const row = { buffer: new SharedArrayBuffer(64), descriptor: { offset: 0 } };
+ row.share = () => ({ buffer: row.buffer, descriptor: row.descriptor });
+ const client = new StrokeClient(row, 4, (error) => {
+ throw error;
+ });
+ await client.ready;
+ const engine = new Sculpt(sphere(4)),
+ listeners = new Map();
+ let commits = 0,
+ message = "";
+ const rect = { left: 0, top: 0, width: 800, height: 600 };
+ const captures = new Set();
+ const viewport = {
+ canvas: { tagName: "CANVAS" },
+ querySelector() {
+ return this.canvas;
+ },
+ addEventListener: (name, fn) => listeners.set(name, fn),
+ focus() {},
+ setPointerCapture(id) {
+ captures.add(id);
+ },
+ hasPointerCapture(id) {
+ return captures.has(id);
+ },
+ releasePointerCapture(id) {
+ captures.delete(id);
+ listeners.get("lostpointercapture")({ pointerId: id });
+ },
+ getBoundingClientRect: () => ({ ...rect }),
+ };
+ const host = {
+ engine: () => engine,
+ processor: () => client,
+ error: (e) => {
+ throw e;
+ },
+ camera: { yaw: 0, pitch: 0, zoom: 1.5, target: [0, 0, 0] },
+ settings: { ...defaults, symmetry: false },
+ ready: () => true,
+ navigation: () => "sculpt",
+ palmRejection: () => false,
+ schedule() {},
+ invalidate() {},
+ changed() {},
+ committed() {
+ commits++;
+ },
+ message(text) {
+ message = text;
+ },
+ cursor() {},
+ pressure() {},
+ };
+ const input = new Input(viewport, host);
+ const event = (x = 400, y = 300, type = "pen", pressure = 0.7, id = 1) => ({
+ clientX: x,
+ clientY: y,
+ pointerType: type,
+ pressure,
+ pointerId: id,
+ button: 0,
+ target: viewport.canvas,
+ preventDefault() {},
+ });
+ const frame = async () => {
+ await flush();
+ client.worker.processor.frame();
+ await flush();
+ };
+ const finish = async () => {
+ let count = 0;
+ while (input.transaction) {
+ await frame();
+ assert.ok(++count < 1000);
+ }
+ };
+ return {
+ input,
+ engine,
+ host,
+ client,
+ viewport,
+ captures,
+ event,
+ rect,
+ listeners,
+ frame,
+ finish,
+ get commits() {
+ return commits;
+ },
+ get message() {
+ return message;
+ },
+ };
+}
+
+test("palm rejection ignores touch before capture, focus, cursor or geometry; pen still works", async (t) => {
+ const a = await setup(t),
+ before = a.engine.mesh.positions.slice();
+ a.host.palmRejection = () => true;
+ a.viewport.focus =
+ a.host.cursor =
+ a.host.pressure =
+ () => {
+ throw Error("Rejected palm reached UI");
+ };
+ a.input.down(a.event(400, 300, "touch"));
+ a.input.move(a.event(460, 330, "touch"));
+ a.input.up(a.event(460, 330, "touch"));
+ assert.equal(a.captures.size, 0);
+ assert.equal(a.input.busy, false);
+ assert.deepEqual(a.engine.mesh.positions, before);
+ a.viewport.focus = a.host.cursor = a.host.pressure = () => {};
+ a.input.down(a.event());
+ a.input.up(a.event());
+ await a.finish();
+ assert.notDeepEqual(a.engine.mesh.positions, before);
+});
+test("pen navigation snapshots rotate/pan/zoom without sculpting", async (t) => {
+ const a = await setup(t),
+ before = a.engine.mesh.positions.slice();
+ a.host.palmRejection = () => true;
+ for (const mode of ["rotate", "pan", "zoom"]) {
+ a.host.camera = { yaw: 0, pitch: 0, zoom: 1.5, target: [0, 0, 0] };
+ a.host.navigation = () => mode;
+ a.input.down(a.event());
+ a.host.navigation = () => "sculpt";
+ a.input.move(a.event(440, 320));
+ a.input.up(a.event(440, 320));
+ const c = a.host.camera;
+ assert.equal(c.yaw, mode === "rotate" ? -0.32 : 0);
+ assert.equal(c.pitch, mode === "rotate" ? 0.16 : 0);
+ assert.deepEqual(c.target, mode === "pan" ? [-0.2, 0.1, 0] : [0, 0, 0]);
+ assert.equal(c.zoom, mode === "zoom" ? 1.5 * Math.exp(0.16) : 1.5);
+ assert.equal(a.input.busy, false);
+ }
+ assert.deepEqual(a.engine.mesh.positions, before);
+ assert.equal(a.engine.undoStack.length, 0);
+});
+test("zero pen is a no-op; release flushes newest mouse movement and commits once", async (t) => {
+ const a = await setup(t),
+ before = a.engine.mesh.positions.slice();
+ a.input.down(a.event(400, 300, "pen", 0));
+ a.input.up(a.event());
+ await a.finish();
+ assert.deepEqual(a.engine.mesh.positions, before);
+ assert.equal(a.engine.undoStack.length, 0);
+ a.input.down(a.event(400, 300, "mouse", 0));
+ a.input.move(a.event(520, 300, "mouse", 0));
+ a.input.up(a.event(520, 300, "mouse", 0));
+ assert.equal(a.engine.undoStack.length, 0);
+ await a.finish();
+ assert.notDeepEqual(a.engine.mesh.positions, before);
+ assert.equal(a.engine.undoStack.length, 1);
+ assert.equal(a.commits, 2);
+ a.input.up(a.event());
+ assert.equal(a.commits, 2);
+});
+test("hundreds of moves occupy one slot, no main-thread dabs or per-frame BVH maintenance", async (t) => {
+ const a = await setup(t);
+ let changed = 0;
+ const original = a.engine.changed.bind(a.engine);
+ a.engine.changed = (ids) => {
+ changed++;
+ original(ids);
+ };
+ a.engine.dab = () => assert.fail("main thread performed a dab");
+ a.input.down(a.event());
+ for (let i = 0; i < 1000; i++) a.input.move(a.event(400 + i / 10, 310));
+ assert.equal(a.input.queue, undefined);
+ assert.equal(a.client.mailbox.words.byteLength, 64);
+ await a.frame();
+ await a.frame();
+ assert.equal(changed, 0);
+ a.input.up(a.event());
+ await a.finish();
+ assert.equal(changed, 1);
+ assert.equal(a.engine.undoStack.length, 1);
+});
+test("cancel rejects late geometry and rolls back without another animation frame", async (t) => {
+ const a = await setup(t),
+ before = a.engine.mesh.positions.slice();
+ a.input.down(a.event());
+ await a.frame();
+ assert.notDeepEqual(a.engine.mesh.positions, before);
+ a.input.move(a.event(480, 310));
+ await flush();
+ a.client.worker.processor.frame(); // geometry message is now in flight
+ a.input.cancel();
+ assert.deepEqual(a.engine.mesh.positions, before);
+ assert.equal(a.input.busy, true);
+ await flush();
+ assert.equal(a.input.busy, false);
+ assert.deepEqual(a.engine.mesh.positions, before);
+ assert.equal(a.engine.undoStack.length, 0);
+});
+test("cancel wins over a completion that has not yet committed on main", async (t) => {
+ const a = await setup(t),
+ before = a.engine.mesh.positions.slice();
+ a.input.down(a.event());
+ await a.frame();
+ a.input.up(a.event());
+ await flush();
+ a.client.worker.processor.frame();
+ a.input.cancel();
+ await flush();
+ assert.deepEqual(a.engine.mesh.positions, before);
+ assert.equal(a.engine.undoStack.length, 0);
+ assert.equal(a.commits, 0);
+});
+test("touch takeover, pen palm rejection, capture loss and pen-over-touch handoff", async (t) => {
+ const a = await setup(t),
+ before = a.engine.mesh.positions.slice();
+ a.input.down(a.event(400, 300, "touch", 1, 1));
+ await a.frame();
+ a.input.down(a.event(450, 320, "touch", 1, 2));
+ assert.deepEqual(a.engine.mesh.positions, before);
+ assert.equal(a.input.active.mode, "gesture");
+ a.input.move(a.event(500, 340, "touch", 1, 2));
+ assert.notEqual(a.host.camera.zoom, 1.5);
+ a.input.up(a.event(400, 300, "touch", 0, 1));
+ a.input.up(a.event(500, 340, "touch", 0, 2));
+ await flush();
+ assert.equal(a.input.busy, false);
+ a.input.down(a.event(400, 300, "pen", 0.7, 3));
+ a.input.down(a.event(440, 310, "touch", 1, 4));
+ assert.equal(a.input.active.id, 3);
+ await a.frame();
+ a.listeners.get("lostpointercapture")({ pointerId: 3 });
+ await flush();
+ assert.equal(a.input.busy, false);
+ a.input.down(a.event(400, 300, "touch", 1, 5));
+ await a.frame();
+ a.input.down(a.event(400, 300, "pen", 0.8, 6));
+ await flush();
+ assert.equal(a.input.active.id, 6);
+ a.input.up(a.event(400, 300, "pen", 0, 6));
+ await a.finish();
+ assert.equal(a.engine.undoStack.length, 1);
+});
+test("resize before release, geometry delivery, or completion preserves existing undo and redo", async (t) => {
+ const a = await setup(t);
+ for (let i = 0; i < 2; i++) {
+ a.input.down(a.event(400 + i * 20, 300));
+ a.input.up(a.event());
+ await a.finish();
+ }
+ a.engine.restore();
+ const before = a.engine.mesh.positions.slice(),
+ undo = [...a.engine.undoStack],
+ redo = [...a.engine.redoStack];
+ for (const mode of ["release", "packet", "complete", "grab"]) {
+ a.host.settings.brush = mode === "grab" ? "grab" : "draw";
+ a.input.down(a.event());
+ a.input.move(a.event(440, 320));
+ await a.frame();
+ await a.frame();
+ assert.notDeepEqual(a.engine.mesh.positions, before);
+ if (mode === "complete") a.input.up(a.event());
+ a.rect.width = 650;
+ if (mode === "packet") {
+ a.input.move(a.event(470, 330));
+ } else a.input.up(a.event());
+ await a.finish();
+ assert.deepEqual(a.engine.mesh.positions, before);
+ assert.deepEqual(a.engine.undoStack, undo);
+ assert.deepEqual(a.engine.redoStack, redo);
+ a.rect.width = 800;
+ }
+});
+test("commits repair face-normal caches; undo/redo and adjacent strokes agree with full normals", async (t) => {
+ const a = await setup(t);
+ for (const x of [400, 430, 450]) {
+ a.input.down(a.event(x, 300));
+ a.input.move(a.event(x + 35, 330));
+ a.input.up(a.event());
+ await a.finish();
+ const expected = sphere(4);
+ expected.positions.set(a.engine.mesh.positions);
+ updateNormals(expected);
+ assert.deepEqual(a.engine.mesh.faceNormals, expected.faceNormals);
+ assert.deepEqual(a.engine.mesh.normals, expected.normals);
+ }
+ for (const redo of [false, true]) {
+ a.engine.restore(redo);
+ const expected = sphere(4);
+ expected.positions.set(a.engine.mesh.positions);
+ updateNormals(expected);
+ assert.deepEqual(a.engine.mesh.normals, expected.normals);
+ }
+});
+test("worker termination rolls back provisional geometry and late packets cannot revive it", async (t) => {
+ const a = await setup(t),
+ before = a.engine.mesh.positions.slice();
+ a.input.down(a.event());
+ await a.frame();
+ a.client.dispose();
+ await flush();
+ assert.deepEqual(a.engine.mesh.positions, before);
+ assert.equal(a.input.busy, false);
+ assert.equal(a.engine.undoStack.length, 0);
+});
+test("pending pen termination and stale canvas cannot revive a canceled contact", async (t) => {
+ const a = await setup(t),
+ before = a.engine.mesh.positions.slice();
+ for (const termination of [
+ "pointercancel",
+ "lostpointercapture",
+ "pointerup",
+ "resize",
+ "canvas",
+ "unready",
+ "continuation",
+ ]) {
+ a.input.down(a.event(400, 300, "touch", 1, 1));
+ await a.frame();
+ const pen = a.event(400, 300, "pen", 0.8, 2);
+ let prevented = false;
+ pen.preventDefault = () => {
+ prevented = true;
+ };
+ a.input.down(pen);
+ assert.ok(prevented);
+ assert.ok(a.captures.has(2));
+ const id = a.client.nextId;
+ if (termination === "pointerup") a.input.up(a.event(400, 300, "pen", 0, 2));
+ else if (termination === "resize") a.rect.left = 10;
+ else if (termination === "canvas")
+ a.viewport.canvas = { tagName: "CANVAS" };
+ else if (termination === "unready") a.host.ready = () => false;
+ else if (termination === "continuation") {
+ a.client.message({ type: "canceled", id: a.client.job.id });
+ a.listeners.get("pointercancel")({ pointerId: 2 });
+ } else a.listeners.get(termination)({ pointerId: 2 });
+ await flush();
+ assert.equal(a.client.nextId, id);
+ assert.equal(a.input.busy, false);
+ assert.equal(a.captures.size, 0);
+ assert.equal(a.engine.stroke, null);
+ assert.deepEqual(a.engine.mesh.positions, before);
+ assert.equal(a.engine.undoStack.length, 0);
+ a.rect.left = 0;
+ a.host.ready = () => true;
+ }
+ // A late old-touch cancellation cannot take ownership from the live pen.
+ a.input.down(a.event(400, 300, "touch", 1, 1));
+ await a.frame();
+ a.input.down(a.event(400, 300, "pen", 0.8, 2));
+ a.listeners.get("pointercancel")({ pointerId: 1 });
+ await flush();
+ assert.equal(a.input.active.id, 2);
+ a.input.up(a.event(400, 300, "pen", 0, 2));
+ await a.finish();
+ assert.equal(a.engine.undoStack.length, 1);
+ // DOM controls never acquire canvas capture or prevent touch defaults.
+ let prevented = false;
+ a.input.down({
+ ...a.event(),
+ pointerType: "touch",
+ target: { tagName: "BUTTON" },
+ preventDefault: () => {
+ prevented = true;
+ },
+ });
+ assert.equal(prevented, false);
+ assert.equal(a.captures.size, 0);
+});
+test("renderer staging remains immutable while upload is in flight", async () => {
+ const s = new Sculpt(sphere(4)),
+ r = new Renderer();
+ r.mesh = s.mesh;
+ r.canvas = { width: 800, height: 600 };
+ r.vertices = { view: new Float32Array((s.mesh.positions.length / 3) * 8) };
+ r.camera = { view: new Float32Array(20) };
+ const waits = [];
+ r.core = {
+ syncRows: () =>
+ new Promise((resolve, reject) => waits.push({ resolve, reject })),
+ };
+ const camera = {
+ yaw: 0,
+ pitch: 0,
+ zoom: 1.5,
+ target: [0, 0, 0],
+ color: [0.5, 0.3, 0.2],
+ };
+ const pending = r.publish(s, camera),
+ staged = r.vertices.view.slice();
+ s.begin({});
+ s.dab({ position: [0, 0, 1], normal: [0, 0, 1] });
+ s.end();
+ assert.equal(await r.publish(s, camera), false);
+ assert.deepEqual(r.vertices.view, staged);
+ waits.shift().resolve();
+ await Promise.resolve();
+ waits.shift().resolve();
+ await pending;
+ assert.ok(s.dirty.size > 0);
+ const failure = r.publish(s, camera);
+ waits.shift().reject(new Error("upload"));
+ await assert.rejects(failure, /upload/);
+ assert.equal(r.busy, false);
+});
diff --git a/server/web/assets/sculpt/latency-check.js b/server/web/assets/sculpt/latency-check.js
new file mode 100644
index 0000000..daaebb0
--- /dev/null
+++ b/server/web/assets/sculpt/latency-check.js
@@ -0,0 +1,148 @@
+// Explicit browser benchmark; never imported by the application.
+// await (await import('/assets/sculpt/latency-check.js')).check()
+import { Input } from "./input.js";
+import { Renderer } from "./renderer.js";
+import { StrokeClient } from "./stroke-client.js";
+import { Sculpt } from "./engine.js";
+
+export async function check() {
+ const viewport = document.getElementById("viewport");
+ const move = Input.prototype.move,
+ publish = Renderer.prototype.publish,
+ message = StrokeClient.prototype.message,
+ dab = Sculpt.prototype.dab;
+ const capture = viewport.setPointerCapture;
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+ const frame = () => new Promise(requestAnimationFrame);
+ const wait = async (predicate) => {
+ const start = performance.now();
+ while (!predicate()) {
+ if (performance.now() - start > 15000)
+ throw new Error("Latency check timed out");
+ await sleep(10);
+ }
+ };
+ let input,
+ durations = [],
+ uploads = [],
+ packets = [],
+ mainDabs = 0,
+ canceled = false;
+ Input.prototype.move = function (event) {
+ input = this;
+ const start = performance.now();
+ const result = move.call(this, event);
+ durations.push(performance.now() - start);
+ return result;
+ };
+ Sculpt.prototype.dab = function (...args) {
+ mainDabs++;
+ return dab.apply(this, args);
+ };
+ StrokeClient.prototype.message = function (data) {
+ const start = performance.now();
+ const result = message.call(this, data);
+ if (data.type === "geometry") packets.push(performance.now() - start);
+ return result;
+ };
+ Renderer.prototype.publish = async function (...args) {
+ const start = performance.now();
+ try {
+ return await publish.apply(this, args);
+ } finally {
+ uploads.push(performance.now() - start);
+ }
+ };
+ viewport.setPointerCapture = () => {};
+ const pointer = (type, x, y, pressure = 0.8) => {
+ const r = viewport.getBoundingClientRect();
+ return new PointerEvent(type, {
+ bubbles: true,
+ pointerId: 7,
+ pointerType: "pen",
+ clientX: r.left + r.width / 2 + x,
+ clientY: r.top + r.height / 2 + y,
+ pressure,
+ button: 0,
+ buttons: type === "pointerup" ? 0 : 1,
+ });
+ };
+ const send = (event) => viewport.querySelector("canvas").dispatchEvent(event);
+ const percentile = (values, p) =>
+ [...values].sort((a, b) => a - b)[
+ Math.min(values.length - 1, Math.floor(values.length * p))
+ ] ?? 0;
+ const results = [];
+ try {
+ for (const brush of ["draw", "grab"])
+ for (const perFrame of [1, 4, 16]) {
+ await wait(
+ () =>
+ document.getElementById("loading").hidden &&
+ !document.getElementById("new").disabled,
+ );
+ const old = viewport.querySelector("canvas");
+ document.getElementById("new").click();
+ document.getElementById("density").value = "5";
+ document.getElementById("new-dialog").close("create");
+ await wait(
+ () =>
+ old !== viewport.querySelector("canvas") &&
+ document.getElementById("loading").hidden,
+ );
+ document.querySelector(`[data-brush="${brush}"]`).click();
+ await sleep(300);
+ durations = [];
+ uploads = [];
+ packets = [];
+ mainDabs = 0;
+ canceled = false;
+ send(pointer("pointerdown", -80, -30));
+ for (let f = 0; f < 60; f++) {
+ await frame();
+ const samples = Array.from({ length: perFrame }, (_, i) => {
+ const t = (f + (i + 1) / perFrame) / 60;
+ return pointer(
+ "pointermove",
+ -80 + 160 * t,
+ -30 + 30 * Math.sin(t * Math.PI),
+ 0.3 + 0.6 * t,
+ );
+ });
+ const event = samples.at(-1);
+ Object.defineProperty(event, "getCoalescedEvents", {
+ value: () => samples,
+ });
+ send(event);
+ if (input && !input.busy) canceled = true;
+ }
+ const release = performance.now();
+ send(pointer("pointerup", 80, -30, 0));
+ await wait(() => !document.getElementById("save").disabled);
+ const releaseMs = performance.now() - release;
+ const undoEntries = input.host.engine().undoStack.length;
+ canceled ||= undoEntries !== 1;
+ if (canceled || mainDabs || input.queue !== undefined)
+ throw new Error("Worker input regression");
+ results.push({
+ brush,
+ samplesPerFrame: perFrame,
+ sharedInputBytes: input.host.processor().mailbox.words.byteLength,
+ mainDabs,
+ canceled,
+ releaseMs,
+ mainInputP95: percentile(durations, 0.95),
+ mainPacketP95: percentile(packets, 0.95),
+ uploadP95: percentile(uploads, 0.95),
+ undoEntries,
+ });
+ }
+ return results;
+ } finally {
+ Input.prototype.move = move;
+ StrokeClient.prototype.message = message;
+ Sculpt.prototype.dab = dab;
+ Renderer.prototype.publish = publish;
+ viewport.setPointerCapture = capture;
+ }
+}
diff --git a/server/web/assets/sculpt/mailbox.js b/server/web/assets/sculpt/mailbox.js
new file mode 100644
index 0000000..9cbdac2
--- /dev/null
+++ b/server/web/assets/sculpt/mailbox.js
@@ -0,0 +1,48 @@
+// One latest target in a core shared row. Versions are snapshot tokens, not a
+// sample count. The producer never touches the consumer's acknowledgement.
+export class Mailbox {
+ constructor({ buffer, descriptor }) {
+ this.words = new Int32Array(buffer, descriptor.offset, 16);
+ this.scratch = new ArrayBuffer(4);
+ this.bits = new Int32Array(this.scratch);
+ this.float = new Float32Array(this.scratch);
+ }
+ encode(value) {
+ this.float[0] = value;
+ return this.bits[0];
+ }
+ decode(value) {
+ this.bits[0] = value;
+ return this.float[0];
+ }
+ write(stroke, sample, seed) {
+ const version = (Atomics.load(this.words, 0) + 2) | 0;
+ Atomics.store(this.words, 0, version - 1);
+ Atomics.store(this.words, 2, stroke);
+ Atomics.store(this.words, 3, this.encode(sample.x));
+ Atomics.store(this.words, 4, this.encode(sample.y));
+ Atomics.store(this.words, 5, this.encode(sample.pressure));
+ Atomics.store(this.words, 6, this.encode(seed ?? 0));
+ Atomics.store(this.words, 7, seed === undefined ? 0 : 1);
+ Atomics.store(this.words, 0, version);
+ return version;
+ }
+ read(stroke) {
+ const a = Atomics.load(this.words, 0);
+ if (a & 1 || a === Atomics.load(this.words, 1)) return null;
+ const id = Atomics.load(this.words, 2);
+ const x = this.decode(Atomics.load(this.words, 3));
+ const y = this.decode(Atomics.load(this.words, 4));
+ const pressure = this.decode(Atomics.load(this.words, 5));
+ const seed = this.decode(Atomics.load(this.words, 6));
+ const hasSeed = Atomics.load(this.words, 7);
+ if (a !== Atomics.load(this.words, 0) || id !== stroke) return null;
+ return { version: a, x, y, pressure, seed: hasSeed ? seed : undefined };
+ }
+ acknowledge(version) {
+ Atomics.store(this.words, 1, version);
+ }
+ get processed() {
+ return Atomics.load(this.words, 1);
+ }
+}
diff --git a/server/web/assets/sculpt/math.js b/server/web/assets/sculpt/math.js
new file mode 100644
index 0000000..ddf7196
--- /dev/null
+++ b/server/web/assets/sculpt/math.js
@@ -0,0 +1,30 @@
+export const add = (a, b) => a.map((v, i) => v + b[i]);
+export const sub = (a, b) => a.map((v, i) => v - b[i]);
+export const mul = (a, s) => a.map((v) => v * s);
+export const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
+export const cross = (a, b) => [
+ a[1] * b[2] - a[2] * b[1],
+ a[2] * b[0] - a[0] * b[2],
+ a[0] * b[1] - a[1] * b[0],
+];
+export const unit = (a) => mul(a, 1 / (Math.hypot(...a) || 1));
+export const point = (p, i) => Array.from(p.subarray(i * 3, i * 3 + 3));
+export const clamp = (n, a, b) => Math.max(a, Math.min(b, n));
+
+// Bound apparent scale along the viewport's shorter side, in either orientation.
+export function clampZoom(zoom, rect) {
+ const scale = Math.min(1, rect.width / rect.height);
+ return clamp(zoom * scale, 0.2, 14) / scale;
+}
+
+// Orthographic camera: the same basis drives rendering, rays and grab planes.
+export function cameraBasis(yaw, pitch) {
+ const forward = [
+ Math.sin(yaw) * Math.cos(pitch),
+ Math.sin(pitch),
+ Math.cos(yaw) * Math.cos(pitch),
+ ];
+ // Analytic basis remains defined at the top/bottom cardinal views.
+ const right = [Math.cos(yaw), 0, -Math.sin(yaw)];
+ return { right, up: cross(forward, right), forward };
+}
diff --git a/server/web/assets/sculpt/menus.js b/server/web/assets/sculpt/menus.js
new file mode 100644
index 0000000..794e2d3
--- /dev/null
+++ b/server/web/assets/sculpt/menus.js
@@ -0,0 +1,109 @@
+// Panels overlay the canvas: revealing tools never resizes or replaces it.
+export class Menus {
+ constructor(canOpen, ignoreOutsidePointer) {
+ this.canOpen = canOpen;
+ this.items = [
+ ["tools-toggle", "tools-panel"],
+ ["settings-toggle", "inspector"],
+ ].map(([button, panel]) => ({
+ button: document.getElementById(button),
+ panel: document.getElementById(panel),
+ }));
+ for (const item of this.items) {
+ item.button.addEventListener("pointerenter", (e) => {
+ if (["pen", "mouse"].includes(e.pointerType) && e.buttons === 0)
+ this.open(item);
+ });
+ item.button.addEventListener("click", () => {
+ if (this.current === item && this.pinned) this.close();
+ else this.open(item, true);
+ });
+ item.button.addEventListener("keydown", (e) => {
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ this.open(item, true, true);
+ }
+ });
+ for (const el of [item.button, item.panel]) {
+ el.addEventListener("pointerenter", () => clearTimeout(this.timer));
+ el.addEventListener("pointerleave", () => {
+ clearTimeout(this.timer);
+ this.timer = setTimeout(() => {
+ if (!this.pinned) this.close(false);
+ }, 250);
+ });
+ }
+ item.panel.addEventListener("pointerdown", () => {
+ this.pinned = true;
+ clearTimeout(this.timer);
+ });
+ item.panel.addEventListener("focusin", () => {
+ this.pinned = true;
+ clearTimeout(this.timer);
+ });
+ item.panel.querySelector("[data-close]").onclick = () => this.close();
+ }
+ document.addEventListener(
+ "pointerdown",
+ (e) => {
+ if (
+ this.current &&
+ !ignoreOutsidePointer(e) &&
+ !this.current.panel.contains(e.target) &&
+ !this.current.button.contains(e.target)
+ )
+ this.close(false);
+ },
+ true,
+ );
+ document.addEventListener(
+ "keydown",
+ (e) => {
+ if (
+ e.key === "Escape" &&
+ this.current &&
+ !document.querySelector("dialog[open]")
+ ) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.close();
+ }
+ },
+ true,
+ );
+ window.addEventListener("resize", () => this.close(false));
+ }
+ open(item, pin = false, focus = false) {
+ if (!this.canOpen() || document.querySelector("dialog[open]")) return;
+ clearTimeout(this.timer);
+ if (this.current !== item) {
+ this.close(false);
+ this.current = item;
+ this.pinned = false;
+ }
+ this.pinned ||= pin;
+ item.panel.hidden = false;
+ const top = Math.max(
+ 12,
+ Math.min(
+ item.button.getBoundingClientRect().top,
+ window.innerHeight - item.panel.offsetHeight - 12,
+ ),
+ );
+ item.panel.style.top = `${top}px`;
+ item.panel.style.maxHeight = `calc(100dvh - ${top + 12}px)`;
+ item.button.setAttribute("aria-expanded", "true");
+ if (focus)
+ item.panel.querySelector("button:not(:disabled),input,select")?.focus();
+ }
+ close(restore = true) {
+ clearTimeout(this.timer);
+ if (!this.current) return;
+ const item = this.current;
+ this.current = null;
+ this.pinned = false;
+ item.panel.hidden = true;
+ item.button.setAttribute("aria-expanded", "false");
+ if (restore) item.button.focus();
+ }
+}
diff --git a/server/web/assets/sculpt/mesh.js b/server/web/assets/sculpt/mesh.js
new file mode 100644
index 0000000..0ae7183
--- /dev/null
+++ b/server/web/assets/sculpt/mesh.js
@@ -0,0 +1,142 @@
+import { unit } from "./math.js";
+
+export function sphere(level = 5) {
+ if (!Number.isInteger(level) || level < 4 || level > 6)
+ throw new Error("Choose a supported resolution.");
+ const t = (1 + Math.sqrt(5)) / 2;
+ const vertices = [
+ [-1, t, 0],
+ [1, t, 0],
+ [-1, -t, 0],
+ [1, -t, 0],
+ [0, -1, t],
+ [0, 1, t],
+ [0, -1, -t],
+ [0, 1, -t],
+ [t, 0, -1],
+ [t, 0, 1],
+ [-t, 0, -1],
+ [-t, 0, 1],
+ ].map(unit);
+ let faces = [
+ [0, 11, 5],
+ [0, 5, 1],
+ [0, 1, 7],
+ [0, 7, 10],
+ [0, 10, 11],
+ [1, 5, 9],
+ [5, 11, 4],
+ [11, 10, 2],
+ [10, 7, 6],
+ [7, 1, 8],
+ [3, 9, 4],
+ [3, 4, 2],
+ [3, 2, 6],
+ [3, 6, 8],
+ [3, 8, 9],
+ [4, 9, 5],
+ [2, 4, 11],
+ [6, 2, 10],
+ [8, 6, 7],
+ [9, 8, 1],
+ ];
+ for (let l = 0; l < level; l++) {
+ const cache = new Map();
+ const midpoint = (a, b) => {
+ const key = Math.min(a, b) * 100000 + Math.max(a, b);
+ if (!cache.has(key)) {
+ cache.set(key, vertices.length);
+ vertices.push(unit(vertices[a].map((v, i) => v + vertices[b][i])));
+ }
+ return cache.get(key);
+ };
+ faces = faces.flatMap(([a, b, c]) => {
+ const ab = midpoint(a, b),
+ bc = midpoint(b, c),
+ ca = midpoint(c, a);
+ return [
+ [a, ab, ca],
+ [b, bc, ab],
+ [c, ca, bc],
+ [ab, bc, ca],
+ ];
+ });
+ }
+ const positions = new Float32Array(vertices.flat());
+ const indices = new Uint32Array(faces.flat());
+ const incident = Array.from({ length: vertices.length }, () => []);
+ const neighbors = Array.from({ length: vertices.length }, () => new Set());
+ faces.forEach((face, f) =>
+ face.forEach((v) => {
+ incident[v].push(f);
+ for (const other of face) if (other !== v) neighbors[v].add(other);
+ }),
+ );
+ const mesh = {
+ level,
+ positions,
+ indices,
+ normals: new Float32Array(positions.length),
+ faceNormals: new Float32Array(indices.length),
+ incident: incident.map((a) => Uint32Array.from(a)),
+ neighbors: neighbors.map((a) => Uint32Array.from(a)),
+ };
+ mesh.faceMarks = new Uint32Array(faces.length);
+ mesh.vertexMarks = new Uint32Array(vertices.length);
+ mesh.generation = 0;
+ updateNormals(mesh);
+ return mesh;
+}
+
+// Include the unchanged incident faces when recomputing a boundary vertex normal.
+export function updateNormals(mesh, moved) {
+ const { positions: p, indices: idx, normals: n, faceNormals: fn } = mesh;
+ const faces = [],
+ vertices = [];
+ const gen = ++mesh.generation;
+ if (moved) {
+ for (const v of moved)
+ for (const f of mesh.incident[v]) {
+ if (mesh.faceMarks[f] !== gen) {
+ mesh.faceMarks[f] = gen;
+ faces.push(f);
+ }
+ }
+ } else for (let f = 0; f < idx.length / 3; f++) faces.push(f);
+ for (const f of faces) {
+ const a = idx[f * 3],
+ b = idx[f * 3 + 1],
+ c = idx[f * 3 + 2];
+ const ax = p[b * 3] - p[a * 3],
+ ay = p[b * 3 + 1] - p[a * 3 + 1],
+ az = p[b * 3 + 2] - p[a * 3 + 2];
+ const bx = p[c * 3] - p[a * 3],
+ by = p[c * 3 + 1] - p[a * 3 + 1],
+ bz = p[c * 3 + 2] - p[a * 3 + 2];
+ fn[f * 3] = ay * bz - az * by;
+ fn[f * 3 + 1] = az * bx - ax * bz;
+ fn[f * 3 + 2] = ax * by - ay * bx;
+ for (let j = 0; j < 3; j++) {
+ const v = idx[f * 3 + j];
+ if (mesh.vertexMarks[v] !== gen) {
+ mesh.vertexMarks[v] = gen;
+ vertices.push(v);
+ }
+ }
+ }
+ for (const v of vertices) {
+ let x = 0,
+ y = 0,
+ z = 0;
+ for (const f of mesh.incident[v]) {
+ x += fn[f * 3];
+ y += fn[f * 3 + 1];
+ z += fn[f * 3 + 2];
+ }
+ const length = Math.hypot(x, y, z) || 1;
+ n[v * 3] = x / length;
+ n[v * 3 + 1] = y / length;
+ n[v * 3 + 2] = z / length;
+ }
+ return { faces, vertices };
+}
diff --git a/server/web/assets/sculpt/navigation.js b/server/web/assets/sculpt/navigation.js
new file mode 100644
index 0000000..3d5c372
--- /dev/null
+++ b/server/web/assets/sculpt/navigation.js
@@ -0,0 +1,237 @@
+import { cameraBasis, clamp, clampZoom, dot } from "./math.js";
+
+export const axes = [
+ {
+ name: "X",
+ vector: [1, 0, 0],
+ yaw: Math.PI / 2,
+ pitch: 0,
+ color: "#ed5265",
+ },
+ {
+ name: "Y",
+ vector: [0, 1, 0],
+ yaw: 0,
+ pitch: Math.PI / 2,
+ color: "#91be38",
+ },
+ { name: "Z", vector: [0, 0, 1], yaw: 0, pitch: 0, color: "#438cdd" },
+ {
+ name: "−X",
+ vector: [-1, 0, 0],
+ yaw: -Math.PI / 2,
+ pitch: 0,
+ color: "#ed5265",
+ },
+ {
+ name: "−Y",
+ vector: [0, -1, 0],
+ yaw: 0,
+ pitch: -Math.PI / 2,
+ color: "#91be38",
+ },
+ { name: "−Z", vector: [0, 0, -1], yaw: Math.PI, pitch: 0, color: "#438cdd" },
+];
+
+export function dragView(camera, start, action, dx, dy, rect) {
+ if (action === "orbit") {
+ camera.yaw = start.yaw - dx * 0.008;
+ camera.pitch = clamp(start.pitch + dy * 0.008, -Math.PI / 2, Math.PI / 2);
+ } else if (action === "pan") {
+ const { right, up } = cameraBasis(start.yaw, start.pitch);
+ const scale = (2 * start.zoom) / rect.height;
+ camera.target = start.target.map(
+ (v, k) => v + (-dx * right[k] + dy * up[k]) * scale,
+ );
+ } else {
+ camera.zoom = clampZoom(start.zoom * Math.exp(dy * 0.008), rect);
+ }
+}
+
+// DOM-only navigation; the camera remains owned by the app, never by the gizmo.
+export class Navigation {
+ constructor(root, viewport, host) {
+ this.root = root;
+ this.viewport = viewport;
+ this.host = host;
+ this.gizmo = root.querySelector("#orbit-gizmo");
+ this.lines = root.querySelectorAll(".axis-line");
+ this.buttons = axes.map((axis, i) => {
+ const button = document.createElement("button");
+ button.className = "axis";
+ button.type = "button";
+ button.dataset.axis = i;
+ button.setAttribute("aria-label", `View from ${axis.name}`);
+ button.title = `View from ${axis.name}`;
+ button.textContent = i < 3 ? axis.name : "";
+ button.style.setProperty("--axis-color", axis.color);
+ button.addEventListener("click", (e) => {
+ if (e.detail === 0 && !this.busy && host.ready()) this.snap(i);
+ });
+ this.gizmo.append(button);
+ return button;
+ });
+ root.addEventListener("pointerdown", (e) => this.down(e));
+ root.addEventListener("pointermove", (e) => this.move(e));
+ root.addEventListener("pointerup", (e) => this.up(e));
+ for (const event of ["pointercancel", "lostpointercapture"])
+ root.addEventListener(event, (e) => {
+ if (this.active?.id === e.pointerId) this.cancel();
+ });
+ root.addEventListener("contextmenu", (e) => e.preventDefault());
+ root.addEventListener("keydown", (e) => this.key(e));
+ window.addEventListener("blur", () => this.cancel());
+ window.addEventListener("resize", () => this.checkViewport());
+ document.addEventListener("visibilitychange", () => {
+ if (document.hidden) this.cancel();
+ });
+ this.render();
+ }
+ get busy() {
+ return !!this.active;
+ }
+ down(e) {
+ const control = e.target.closest("[data-nav]");
+ if (!control || this.busy || !this.host.ready() || e.button !== 0) return;
+ e.preventDefault();
+ this.host.start();
+ const c = this.host.camera;
+ this.active = {
+ id: e.pointerId,
+ x: e.clientX,
+ y: e.clientY,
+ action: control.dataset.nav,
+ axis: e.target.closest("[data-axis]")?.dataset.axis,
+ moved: false,
+ rect: this.viewport.getBoundingClientRect(),
+ start: {
+ yaw: c.yaw,
+ pitch: c.pitch,
+ zoom: c.zoom,
+ target: c.target.slice(),
+ },
+ };
+ this.root.setPointerCapture(e.pointerId);
+ control.focus({ preventScroll: true });
+ this.host.changed();
+ }
+ checkViewport() {
+ if (!this.active) return true;
+ const rect = this.viewport.getBoundingClientRect();
+ if (
+ ["left", "top", "width", "height"].some(
+ (k) => rect[k] !== this.active.rect[k],
+ )
+ ) {
+ this.cancel();
+ return false;
+ }
+ return true;
+ }
+ move(e) {
+ if (this.active?.id !== e.pointerId || !this.checkViewport()) return;
+ const a = this.active,
+ dx = e.clientX - a.x,
+ dy = e.clientY - a.y;
+ a.moved ||= Math.hypot(dx, dy) >= 4;
+ if (a.moved) {
+ dragView(this.host.camera, a.start, a.action, dx, dy, a.rect);
+ this.host.invalidate();
+ }
+ }
+ up(e) {
+ if (this.active?.id !== e.pointerId) return;
+ this.move(e);
+ const a = this.active;
+ if (!a) return;
+ this.active = null;
+ if (this.root.hasPointerCapture(a.id))
+ this.root.releasePointerCapture(a.id);
+ if (!a.moved && a.axis !== undefined) this.snap(Number(a.axis));
+ this.host.changed();
+ }
+ cancel() {
+ const a = this.active;
+ if (!a) return;
+ this.active = null;
+ Object.assign(this.host.camera, a.start);
+ if (this.root.hasPointerCapture(a.id))
+ this.root.releasePointerCapture(a.id);
+ this.host.invalidate();
+ this.host.changed();
+ }
+ snap(i) {
+ // Clicking the already-facing endpoint flips to the opposite view, making
+ // the occluded endpoint reachable when two axes project to the same point.
+ if (
+ dot(
+ cameraBasis(this.host.camera.yaw, this.host.camera.pitch).forward,
+ axes[i].vector,
+ ) > 0.999999
+ )
+ i = (i + 3) % 6;
+ const { yaw, pitch } = axes[i];
+ Object.assign(this.host.camera, { yaw, pitch });
+ this.host.invalidate();
+ }
+ key(e) {
+ if (e.key === "Escape") {
+ if (!this.busy) return;
+ this.cancel();
+ e.preventDefault();
+ e.stopPropagation();
+ return;
+ }
+ const action = e.target.closest("[data-nav]")?.dataset.nav;
+ if (
+ !action ||
+ ![
+ "ArrowLeft",
+ "ArrowRight",
+ "ArrowUp",
+ "ArrowDown",
+ "+",
+ "=",
+ "-",
+ ].includes(e.key)
+ )
+ return;
+ e.preventDefault();
+ e.stopPropagation();
+ if (this.busy || !this.host.ready()) return;
+ const dx = e.key === "ArrowLeft" ? -12 : e.key === "ArrowRight" ? 12 : 0;
+ const dy = ["ArrowUp", "+", "="].includes(e.key)
+ ? -12
+ : ["ArrowDown", "-"].includes(e.key)
+ ? 12
+ : 0;
+ const c = this.host.camera;
+ dragView(
+ c,
+ { ...c, target: c.target.slice() },
+ action,
+ dx,
+ dy,
+ this.viewport.getBoundingClientRect(),
+ );
+ this.host.invalidate();
+ }
+ render() {
+ const basis = cameraBasis(this.host.camera.yaw, this.host.camera.pitch);
+ axes.forEach((axis, i) => {
+ const x = 52 + 34 * dot(axis.vector, basis.right),
+ y = 52 - 34 * dot(axis.vector, basis.up),
+ depth = dot(axis.vector, basis.forward);
+ const b = this.buttons[i];
+ b.style.left = `${x}px`;
+ b.style.top = `${y}px`;
+ b.style.zIndex = String(10 + Math.round(depth * 5));
+ b.classList.toggle("rear", i >= 3);
+ if (i < 3) {
+ this.lines[i].setAttribute("x2", x);
+ this.lines[i].setAttribute("y2", y);
+ this.lines[i].style.stroke = axis.color;
+ }
+ });
+ }
+}
diff --git a/server/web/assets/sculpt/navigation.test.js b/server/web/assets/sculpt/navigation.test.js
new file mode 100644
index 0000000..83ca6fe
--- /dev/null
+++ b/server/web/assets/sculpt/navigation.test.js
@@ -0,0 +1,146 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { Navigation, axes, dragView } from "./navigation.js";
+import { cameraBasis, dot } from "./math.js";
+const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-10, `${a} != ${b}`);
+test("all six cardinal cameras and pole departures have orthonormal finite bases", () => {
+ for (const a of axes) {
+ const b = cameraBasis(a.yaw, a.pitch);
+ a.vector.forEach((v, k) => near(b.forward[k], v));
+ near(dot(b.right, b.right), 1);
+ near(dot(b.up, b.up), 1);
+ near(dot(b.right, b.up), 0);
+ const c = {};
+ dragView(c, { yaw: a.yaw, pitch: a.pitch }, "orbit", 7, -3, {
+ width: 800,
+ height: 600,
+ });
+ assert.ok(cameraBasis(c.yaw, c.pitch).up.every(Number.isFinite));
+ }
+});
+test("drag uses snapshot totals, asymmetric pan, bounded zoom and no target drift", () => {
+ const start = { yaw: 0, pitch: 0, zoom: 2, target: [3, 4, 5] },
+ c = structuredClone(start),
+ rect = { width: 800, height: 400 };
+ dragView(c, start, "pan", 30, -10, rect);
+ assert.deepEqual(c.target, [2.7, 3.9, 5]);
+ dragView(c, start, "pan", 60, 20, rect);
+ assert.deepEqual(c.target, [2.4, 4.2, 5]);
+ dragView(c, start, "orbit", 25, -10, rect);
+ near(c.yaw, -0.2);
+ near(c.pitch, -0.08);
+ assert.deepEqual(c.target, [2.4, 4.2, 5]);
+ dragView(c, start, "zoom", 0, 50, rect);
+ near(c.zoom, 2 * Math.exp(0.4));
+ dragView(c, start, "zoom", 0, 1e5, rect);
+ near(c.zoom, 14);
+});
+function fixture() {
+ const n = Object.create(Navigation.prototype),
+ captures = new Set();
+ let ready = true;
+ const rect = { left: 0, top: 0, width: 800, height: 600 },
+ camera = { yaw: 0.3, pitch: 0.2, zoom: 2, target: [1, 2, 3] };
+ n.host = {
+ camera,
+ ready: () => ready,
+ start() {},
+ changed() {},
+ invalidate() {},
+ };
+ n.viewport = { getBoundingClientRect: () => ({ ...rect }) };
+ n.root = {
+ setPointerCapture: (id) => captures.add(id),
+ hasPointerCapture: (id) => captures.has(id),
+ releasePointerCapture: (id) => {
+ captures.delete(id);
+ if (n.active?.id === id) n.cancel();
+ },
+ };
+ const event = (x = 100, y = 100, id = 1, axis) => ({
+ pointerId: id,
+ clientX: x,
+ clientY: y,
+ button: 0,
+ preventDefault() {},
+ target: {
+ closest: (s) =>
+ s === "[data-nav]"
+ ? { dataset: { nav: "orbit" }, focus() {} }
+ : axis === undefined
+ ? null
+ : { dataset: { axis } },
+ },
+ });
+ return { n, camera, rect, captures, event, ready: (v) => (ready = v) };
+}
+test("navigation admission, single pointer, release coordinates, cancel and viewport rollback", () => {
+ const { n, camera, rect, captures, event, ready } = fixture(),
+ before = structuredClone(camera);
+ ready(false);
+ n.down(event());
+ assert.equal(n.busy, false);
+ ready(true);
+ n.down(event());
+ n.move(event(120, 110, 2));
+ assert.deepEqual(camera, before);
+ n.up(event(125, 115));
+ near(camera.yaw, 0.1);
+ near(camera.pitch, 0.32);
+ assert.equal(n.busy, false);
+ assert.equal(captures.size, 0);
+ const committed = structuredClone(camera);
+ n.down(event());
+ n.move(event(180, 140));
+ n.cancel();
+ assert.deepEqual(camera, committed);
+ n.down(event());
+ n.move(event(200, 160));
+ rect.width = 700;
+ n.up(event(210, 170));
+ assert.deepEqual(camera, committed);
+ assert.equal(n.busy, false);
+});
+test("Escape is consumed only by an active navigation gesture", () => {
+ const { n, camera, captures, event, ready } = fixture();
+ const before = structuredClone(camera);
+ let prevented = 0,
+ stopped = 0;
+ const escape = {
+ key: "Escape",
+ preventDefault: () => prevented++,
+ stopPropagation: () => stopped++,
+ };
+ n.key(escape);
+ ready(false);
+ n.key(escape);
+ assert.equal(prevented, 0);
+ assert.equal(stopped, 0);
+ assert.deepEqual(camera, before);
+ ready(true);
+ n.down(event());
+ n.move(event(180, 140));
+ n.key(escape);
+ n.up(event(200, 160));
+ assert.deepEqual(camera, before);
+ assert.equal(n.busy, false);
+ assert.equal(captures.size, 0);
+ assert.equal(prevented, 1);
+ assert.equal(stopped, 1);
+});
+test("axis clicks snap/flip without changing zoom or target; drags never snap", () => {
+ const { n, camera, event } = fixture();
+ n.down(event(100, 100, 1, "0"));
+ n.up(event(101, 101));
+ near(camera.yaw, Math.PI / 2);
+ near(camera.pitch, 0);
+ n.down(event(100, 100, 1, "0"));
+ n.up(event(100, 100));
+ near(camera.yaw, -Math.PI / 2);
+ assert.deepEqual(camera.target, [1, 2, 3]);
+ near(camera.zoom, 2);
+ n.down(event(100, 100, 1, "1"));
+ n.up(event(140, 115));
+ near(camera.yaw, -Math.PI / 2 - 0.32);
+ near(camera.pitch, 0.12);
+});
diff --git a/server/web/assets/sculpt/renderer.js b/server/web/assets/sculpt/renderer.js
new file mode 100644
index 0000000..cb08ed4
--- /dev/null
+++ b/server/web/assets/sculpt/renderer.js
@@ -0,0 +1,237 @@
+import { YawnCore } from "@yawn/core";
+import { cameraBasis } from "./math.js";
+
+function wire(v) {
+ if (Array.isArray(v)) return `(array ${v.map(wire).join(" ")})`;
+ if (v && typeof v === "object")
+ return `(object ${Object.entries(v)
+ .map(([k, x]) => `(field ${JSON.stringify(k)} ${wire(x)})`)
+ .join(" ")})`;
+ return JSON.stringify(v);
+}
+const shader = `
+struct Camera { right:vec4f, up:vec4f, forward:vec4f, center:vec4f, color:vec4f }
+@group(0) @binding(0) var camera:Camera;
+struct Out { @builtin(position) position:vec4f, @location(0) normal:vec3f }
+@vertex fn vertex(@location(0) p:vec3f, @location(1) n:vec3f)->Out {
+ let d=p-camera.center.xyz;
+ var o:Out;
+ o.position=vec4f(dot(d,camera.right.xyz)/camera.right.w,dot(d,camera.up.xyz)/camera.up.w,0.5-dot(d,camera.forward.xyz)/32.0,1.0);
+ o.normal=vec3f(dot(n,camera.right.xyz),dot(n,camera.up.xyz),dot(n,camera.forward.xyz));return o;
+}
+@fragment fn fragment(o:Out)->@location(0) vec4f {
+ let n=normalize(o.normal);
+ let key=max(dot(n,normalize(vec3f(-0.5,0.8,1.0))),0.0);
+ let fill=max(dot(n,normalize(vec3f(0.8,0.0,0.4))),0.0);
+ let rim=pow(1.0-max(n.z,0.0),3.0);
+ let spec=pow(max(dot(n,normalize(vec3f(-0.3,0.45,1.0))),0.0),48.0);
+ let color=camera.color.xyz*(0.24+key*0.65+fill*0.16)+vec3f(0.12,0.14,0.16)*rim+vec3f(0.12)*spec;
+ return vec4f(pow(color,vec3f(1.0/2.2)),1.0);
+}`;
+
+export class Renderer {
+ static async create(canvas, mesh, camera, quality = "balanced") {
+ const rect = canvas.getBoundingClientRect();
+ const pixels =
+ quality === "economy" ? 600000 : quality === "detail" ? 2000000 : 1000000;
+ const scale = Math.min(
+ devicePixelRatio,
+ quality === "economy" ? 1 : quality === "detail" ? 2 : 1.5,
+ Math.sqrt(pixels / (rect.width * rect.height)),
+ );
+ canvas.width = Math.max(1, Math.round(rect.width * scale));
+ canvas.height = Math.max(1, Math.round(rect.height * scale));
+ const r = new Renderer();
+ r.canvas = canvas;
+ r.mesh = mesh;
+ const bytes =
+ (mesh.positions.length / 3) * 32 +
+ Math.ceil(mesh.indices.length / 4) * 16 +
+ 80 +
+ 64 +
+ 256;
+ if (bytes > 8 * 1024 * 1024)
+ throw new Error("Mesh exceeds the render arena");
+ r.core = new YawnCore(canvas, { arenaBytes: 8 * 1024 * 1024 });
+ let timedOut = false;
+ const timeout = setTimeout(() => {
+ timedOut = true;
+ r.dispose();
+ }, 20000);
+ try {
+ await r.core.ready;
+ [r.vertices, r.indices, r.camera, r.sample] =
+ await r.core.createRowsBatch([
+ {
+ name: "sculptVertices",
+ rows: mesh.positions.length / 3,
+ stride: 32,
+ format: "f32",
+ },
+ {
+ name: "sculptIndices",
+ rows: Math.ceil(mesh.indices.length / 4),
+ stride: 16,
+ format: "u32",
+ },
+ { name: "sculptCamera", rows: 1, stride: 80, format: "f32" },
+ { name: "sculptSample", rows: 1, stride: 64, format: "i32" },
+ ]);
+ r.sample.view.fill(0);
+ r.indices.view.set(mesh.indices);
+ r.writeVertices(0, mesh.positions.length / 3);
+ r.writeCamera(camera);
+ const graph = {
+ id: "sculpt",
+ resources: {
+ buffers: [
+ {
+ id: "vertices",
+ array: "sculptVertices",
+ usage: ["vertex"],
+ sync: "manual",
+ },
+ {
+ id: "indices",
+ array: "sculptIndices",
+ usage: ["index"],
+ sync: "manual",
+ },
+ {
+ id: "camera",
+ array: "sculptCamera",
+ usage: ["uniform"],
+ sync: "manual",
+ },
+ ],
+ textures: [
+ {
+ id: "depth",
+ format: "depth24plus",
+ size: ["canvas", "canvas"],
+ usage: ["render"],
+ },
+ ],
+ },
+ pipelines: {
+ render: [
+ {
+ id: "clay",
+ code: shader,
+ vertex: {
+ buffers: [
+ {
+ arrayStride: 32,
+ attributes: [
+ { format: "float32x3", offset: 0, shaderLocation: 0 },
+ { format: "float32x3", offset: 16, shaderLocation: 1 },
+ ],
+ },
+ ],
+ },
+ fragment: { targets: [{ format: "canvas" }] },
+ primitive: {
+ topology: "triangle-list",
+ frontFace: "ccw",
+ cullMode: "back",
+ conservative: false,
+ },
+ depthStencil: {
+ format: "depth24plus",
+ depth_write_enabled: true,
+ depth_compare: "less",
+ },
+ },
+ ],
+ },
+ passes: [
+ {
+ id: "sculpt",
+ type: "render",
+ pipeline: "clay",
+ bindings: [{ binding: 0, resource: "camera" }],
+ color: [{ resource: "canvas", clear: [0.12, 0.14, 0.17, 1] }],
+ depth: { resource: "depth" },
+ vertexBuffers: [{ resource: "vertices" }],
+ indexBuffer: { resource: "indices" },
+ draw: { indices: mesh.indices.length, instances: 1 },
+ },
+ ],
+ };
+ await r.core.switchLoadout(
+ await r.core.compileGraph(`(yawn-graph 1 ${wire(graph)})`),
+ );
+ await r.core.setFps(quality === "economy" ? 30 : 60);
+ await r.core.play();
+ return r;
+ } catch (error) {
+ r.dispose();
+ if (timedOut) throw new Error("Renderer initialization timed out");
+ throw error;
+ } finally {
+ clearTimeout(timeout);
+ }
+ }
+ writeVertices(first, count) {
+ const p = this.mesh.positions,
+ n = this.mesh.normals,
+ v = this.vertices.view;
+ for (let i = first; i < first + count; i++) {
+ const a = i * 3,
+ b = i * 8;
+ v[b] = p[a];
+ v[b + 1] = p[a + 1];
+ v[b + 2] = p[a + 2];
+ v[b + 4] = n[a];
+ v[b + 5] = n[a + 1];
+ v[b + 6] = n[a + 2];
+ }
+ }
+ writeCamera(camera) {
+ const { right, up, forward } = cameraBasis(camera.yaw, camera.pitch);
+ const aspect = this.canvas.width / this.canvas.height;
+ this.camera.view.set([
+ ...right,
+ camera.zoom * aspect,
+ ...up,
+ camera.zoom,
+ ...forward,
+ 0,
+ ...camera.target,
+ 0,
+ ...camera.color,
+ 0,
+ ]);
+ }
+ // One acknowledged publication at a time: shared staging bytes are immutable in flight.
+ async publish(engine, camera) {
+ if (this.busy || this.disposed) return false;
+ this.busy = true;
+ const timeout = setTimeout(() => this.dispose(), 10000);
+ try {
+ let first = Infinity,
+ last = -1;
+ for (const v of engine.dirty) {
+ first = Math.min(first, v);
+ last = Math.max(last, v);
+ }
+ engine.dirty.clear();
+ if (last >= first) {
+ this.writeVertices(first, last - first + 1);
+ await this.core.syncRows("sculptVertices", first, last - first + 1);
+ }
+ if (this.disposed) return false;
+ this.writeCamera(camera);
+ await this.core.syncRows("sculptCamera", 0, 1);
+ return true;
+ } finally {
+ clearTimeout(timeout);
+ this.busy = false;
+ }
+ }
+ dispose() {
+ this.disposed = true;
+ this.processor?.dispose();
+ this.core?.dispose();
+ }
+}
diff --git a/server/web/assets/sculpt/stroke-client.js b/server/web/assets/sculpt/stroke-client.js
new file mode 100644
index 0000000..6378188
--- /dev/null
+++ b/server/web/assets/sculpt/stroke-client.js
@@ -0,0 +1,126 @@
+import { Mailbox } from "./mailbox.js";
+import { pressureValue } from "./engine.js";
+
+export class StrokeClient {
+ constructor(row, level, onError) {
+ this.mailbox = new Mailbox(row.share());
+ this.worker = new Worker(new URL("./stroke-worker.js", import.meta.url), {
+ type: "module",
+ name: "sculpt-samples",
+ });
+ this.nextId = 0;
+ this.onError = onError;
+ this.ready = new Promise((resolve, reject) => {
+ this.resolveReady = resolve;
+ this.rejectReady = reject;
+ });
+ this.timer = setTimeout(
+ () => this.fail(new Error("Sculpt worker initialization timed out")),
+ 20000,
+ );
+ this.worker.onmessage = ({ data }) => this.message(data);
+ this.worker.onerror = () => this.fail(new Error("Sculpt worker stopped"));
+ this.worker.onmessageerror = () =>
+ this.fail(new Error("Sculpt worker message failed"));
+ this.worker.postMessage({ type: "init", row: row.share(), level });
+ }
+ begin(data, apply) {
+ if (this.job || this.disposed)
+ throw new Error("Sculpt worker is unavailable");
+ const id = ++this.nextId;
+ const promise = new Promise((resolve, reject) => {
+ this.job = { id, settings: data.settings, apply, resolve, reject };
+ });
+ this.write(data.sample);
+ this.worker.postMessage(
+ { type: "begin", ...data, id, version: this.version },
+ [data.positions.buffer],
+ );
+ return promise;
+ }
+ write(sample) {
+ const j = this.job;
+ if (!j || j.canceling) return;
+ if (j.settings.brush === "grab") {
+ if (
+ pressureValue(sample.pressure, j.settings.gain, j.settings.gamma) ===
+ 0 &&
+ this.version !== undefined &&
+ j.hasSample
+ )
+ return;
+ if (
+ pressureValue(sample.pressure, j.settings.gain, j.settings.gamma) > 0 &&
+ j.seed === undefined
+ )
+ j.seed = sample.pressure;
+ }
+ this.version = this.mailbox.write(j.id, sample, j.seed);
+ j.hasSample = true;
+ }
+ end() {
+ if (!this.job || this.job.canceling) return;
+ this.worker.postMessage({
+ type: "end",
+ id: this.job.id,
+ version: this.version,
+ });
+ this.armTimeout();
+ }
+ cancel() {
+ if (!this.job) return;
+ this.job.canceling = true;
+ this.worker.postMessage({ type: "cancel", id: this.job.id });
+ this.armTimeout();
+ }
+ armTimeout() {
+ clearTimeout(this.timer);
+ this.timer = setTimeout(
+ () => this.fail(new Error("Sculpt worker did not settle")),
+ 10000,
+ );
+ }
+ message(data) {
+ if (this.disposed) return;
+ if (data.type === "ready") {
+ clearTimeout(this.timer);
+ this.resolveReady();
+ return;
+ }
+ if (data.type === "error") {
+ this.fail(new Error(data.message));
+ return;
+ }
+ const j = this.job;
+ if (data.type === "geometry") {
+ if (j?.id === data.id && !j.canceling) j.apply(data);
+ this.worker.postMessage({ type: "ack", packet: data.packet });
+ } else if (
+ j?.id === data.id &&
+ ["complete", "canceled"].includes(data.type)
+ ) {
+ clearTimeout(this.timer);
+ this.job = null;
+ j.resolve({
+ canceled: j.canceling || data.type === "canceled",
+ message: data.message,
+ });
+ }
+ }
+ fail(error) {
+ if (this.disposed) return;
+ this.rejectReady(error);
+ this.job?.reject(error);
+ this.job = null;
+ this.dispose();
+ this.onError(error);
+ }
+ dispose() {
+ this.disposed = true;
+ clearTimeout(this.timer);
+ this.worker.terminate();
+ this.rejectReady(new Error("Sculpt worker disposed"));
+ this.job?.resolve({ canceled: true });
+ this.job = null;
+ }
+}
diff --git a/server/web/assets/sculpt/stroke-worker.js b/server/web/assets/sculpt/stroke-worker.js
new file mode 100644
index 0000000..dfdd005
--- /dev/null
+++ b/server/web/assets/sculpt/stroke-worker.js
@@ -0,0 +1,238 @@
+import { Sculpt, StrokePath, pressureValue } from "./engine.js";
+import { sphere, updateNormals } from "./mesh.js";
+import { cameraBasis, add, mul, sub } from "./math.js";
+import { Mailbox } from "./mailbox.js";
+
+// Sole owner of brush-time geometry work. Exported for deterministic tests;
+// the browser entry below only supplies worker rAF and postMessage.
+export class StrokeProcessor {
+ constructor(mesh, mailbox, send, clock = () => performance.now()) {
+ this.engine = new Sculpt(mesh);
+ this.mailbox = mailbox;
+ this.send = send;
+ this.clock = clock;
+ this.packet = 0;
+ this.outstanding = 0;
+ this.job = null;
+ }
+ begin(data) {
+ if (this.job) throw new Error("Previous stroke has not settled");
+ const e = this.engine;
+ const moved = [];
+ for (let v = 0; v < data.positions.length / 3; v++) {
+ const i = v * 3,
+ p = e.mesh.positions;
+ if (
+ p[i] !== data.positions[i] ||
+ p[i + 1] !== data.positions[i + 1] ||
+ p[i + 2] !== data.positions[i + 2]
+ )
+ moved.push(v);
+ }
+ e.mesh.positions.set(data.positions);
+ if (moved.length) {
+ const { faces } = updateNormals(e.mesh, moved);
+ e.bvh.refit(faces);
+ }
+ e.dirty.clear();
+ e.begin(data.settings);
+ const basis = cameraBasis(data.camera.yaw, data.camera.pitch);
+ this.job = {
+ ...data,
+ basis,
+ target: { ...data.sample, version: data.version },
+ admittedAt: this.clock(),
+ ending: false,
+ attempts: 0,
+ };
+ this.path = new StrokePath(
+ Math.max(
+ 1,
+ ((data.settings.radius * data.rect.height) / (2 * data.camera.zoom)) *
+ 0.15,
+ ),
+ (sample) => {
+ this.job.attempts++;
+ const hit = e.bvh.raycast(
+ add(this.world(sample), mul(basis.forward, 24)),
+ mul(basis.forward, -1),
+ );
+ if (!hit) this.path.break();
+ else e.dab(hit, sample.pressure);
+ },
+ );
+ this.origin = this.world(data.sample);
+ }
+ world(sample) {
+ const { camera: c, rect: r, basis: b } = this.job;
+ return add(
+ c.target,
+ add(
+ mul(b.right, ((2 * sample.x - r.width) * c.zoom) / r.height),
+ mul(b.up, (1 - (2 * sample.y) / r.height) * c.zoom),
+ ),
+ );
+ }
+ end(id, version) {
+ if (this.job?.id === id) {
+ this.job.ending = true;
+ this.job.finalVersion = version;
+ }
+ }
+ cancel(id, message) {
+ if (this.job?.id === id) {
+ this.job = null;
+ this.engine.stroke = null;
+ this.engine.dirty.clear();
+ this.outstanding = 0;
+ }
+ this.send({ type: "canceled", id, message });
+ }
+ acknowledge(packet) {
+ if (packet === this.outstanding) this.outstanding = 0;
+ }
+ publish() {
+ const e = this.engine;
+ if (!this.job || this.outstanding || !e.dirty.size) return;
+ const rows = Uint32Array.from(e.dirty);
+ const vertices = new Float32Array(rows.length * 8);
+ for (let row = 0; row < rows.length; row++) {
+ const i = rows[row] * 3,
+ j = row * 8,
+ p = e.mesh.positions,
+ n = e.mesh.normals;
+ vertices[j] = p[i];
+ vertices[j + 1] = p[i + 1];
+ vertices[j + 2] = p[i + 2];
+ vertices[j + 4] = n[i];
+ vertices[j + 5] = n[i + 1];
+ vertices[j + 6] = n[i + 2];
+ }
+ const touched = Uint32Array.from(e.stroke.touched);
+ e.dirty.clear();
+ this.outstanding = ++this.packet;
+ this.send(
+ {
+ type: "geometry",
+ id: this.job.id,
+ packet: this.packet,
+ vertices,
+ rows,
+ touched,
+ },
+ [vertices.buffer, rows.buffer, touched.buffer],
+ );
+ }
+ frame() {
+ const j = this.job;
+ if (!j) return false;
+ const start = this.clock();
+ j.attempts = 0;
+ if (!j.target) {
+ j.target = this.mailbox.read(j.id);
+ j.admittedAt = start;
+ }
+ if (j.target) {
+ const sample = j.target;
+ if (j.settings.brush === "grab") {
+ // Influence is fixed by the first effective pressure, even if newer
+ // samples replace that seed before this worker gets a frame.
+ const seed =
+ sample.seed ??
+ (pressureValue(sample.pressure, j.settings.gain, j.settings.gamma) > 0
+ ? sample.pressure
+ : undefined);
+ if (this.engine.stroke.grab === null && seed !== undefined)
+ this.engine.dab(j.hit, seed, [0, 0, 0]);
+ this.engine.dab(
+ j.hit,
+ sample.pressure,
+ sub(this.world(sample), this.origin),
+ );
+ this.mailbox.acknowledge(sample.version);
+ j.target = null;
+ } else {
+ let iterations = 0;
+ while (
+ j.target &&
+ j.attempts < 64 &&
+ (iterations === 0 || this.clock() - start < 4)
+ ) {
+ iterations++;
+ if (this.path.push(sample, 1)) {
+ this.mailbox.acknowledge(sample.version);
+ j.target = null;
+ }
+ }
+ if (j.target && this.clock() - j.admittedAt > 250) {
+ this.cancel(
+ j.id,
+ "Stroke canceled because editing fell behind. Previous strokes preserved. Try a smaller brush.",
+ );
+ return false;
+ }
+ }
+ }
+ this.publish();
+ if (
+ j.ending &&
+ !j.target &&
+ this.mailbox.processed === j.finalVersion &&
+ !this.outstanding &&
+ !this.engine.dirty.size
+ ) {
+ this.engine.stroke = null;
+ this.job = null;
+ this.send({ type: "complete", id: j.id });
+ return false;
+ }
+ return true;
+ }
+}
+
+if (typeof self !== "undefined" && typeof document === "undefined") {
+ let processor,
+ frame = 0;
+ const schedule = () => {
+ if (!frame && processor?.job)
+ frame = requestAnimationFrame(() => {
+ frame = 0;
+ try {
+ if (processor.frame()) schedule();
+ } catch (error) {
+ self.postMessage({ type: "error", message: error.message });
+ }
+ });
+ };
+ self.onmessage = ({ data }) => {
+ try {
+ if (data.type === "init") {
+ if (typeof requestAnimationFrame !== "function")
+ throw new Error(
+ "Sculpt worker requires worker requestAnimationFrame support",
+ );
+ processor = new StrokeProcessor(
+ sphere(data.level),
+ new Mailbox(data.row),
+ (message, transfer = []) => self.postMessage(message, transfer),
+ );
+ self.postMessage({ type: "ready" });
+ } else if (data.type === "begin") {
+ processor.begin(data);
+ schedule();
+ } else if (data.type === "end") {
+ processor.end(data.id, data.version);
+ schedule();
+ } else if (data.type === "ack") {
+ processor.acknowledge(data.packet);
+ schedule();
+ } else if (data.type === "cancel") {
+ if (frame) cancelAnimationFrame(frame);
+ frame = 0;
+ processor.cancel(data.id);
+ }
+ } catch (error) {
+ self.postMessage({ type: "error", message: error.message });
+ }
+ };
+}
diff --git a/server/web/assets/sculpt/styles.css b/server/web/assets/sculpt/styles.css
new file mode 100644
index 0000000..3ee139d
--- /dev/null
+++ b/server/web/assets/sculpt/styles.css
@@ -0,0 +1,523 @@
+/* Basecoat owns controls; these rules own the sculpt workspace and density. */
+:root {
+ color-scheme: dark;
+ font-family:
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ sans-serif;
+ font-size: 14px;
+}
+body {
+ margin: 0;
+ height: 100dvh;
+ overflow: hidden;
+ overscroll-behavior: none;
+}
+[hidden] {
+ display: none !important;
+}
+#viewport {
+ position: fixed;
+ inset: 0;
+ overflow: hidden;
+ touch-action: none;
+ outline: none;
+ background: #20242b;
+}
+canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+ touch-action: none;
+}
+button,
+input,
+select,
+summary {
+ -webkit-tap-highlight-color: transparent;
+}
+.tool-rail {
+ position: fixed;
+ z-index: 4;
+ top: max(12px, env(safe-area-inset-top));
+ left: max(8px, env(safe-area-inset-left));
+ max-height: calc(100dvh - 24px - env(safe-area-inset-bottom));
+ overflow-y: auto;
+ scrollbar-width: none;
+ display: grid;
+ gap: 6px;
+}
+.tool-rail .brushes,
+.rail-group {
+ display: grid;
+ gap: 1px;
+ padding: 3px;
+ border-radius: 5px;
+ background: #22252bd9;
+}
+.icon-button.btn {
+ width: 38px;
+ height: 38px;
+ padding: 8px;
+ border: 0;
+ border-radius: 4px;
+ color: #d4d7db;
+ background: #22252bd9;
+ touch-action: none;
+}
+.tool-rail .icon-button.btn {
+ touch-action: pan-y;
+}
+.icon-button svg {
+ width: 22px;
+ height: 22px;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.6;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ pointer-events: none;
+}
+.icon-button.btn:hover {
+ background: #43484f;
+}
+.tool-rail .btn[aria-pressed="true"] {
+ color: #fff;
+ background: #386fa9;
+}
+.tool-rail > .btn {
+ margin-left: 3px;
+}
+#view-navigation {
+ position: fixed;
+ z-index: 4;
+ right: max(12px, env(safe-area-inset-right));
+ top: max(12px, env(safe-area-inset-top));
+ touch-action: none;
+ user-select: none;
+}
+#orbit-gizmo {
+ position: relative;
+ width: 104px;
+ height: 104px;
+ border-radius: 50%;
+ cursor: grab;
+ touch-action: none;
+}
+#orbit-gizmo:active {
+ cursor: grabbing;
+}
+#orbit-gizmo > svg {
+ position: absolute;
+ inset: 0;
+ width: 104px;
+ height: 104px;
+ pointer-events: none;
+}
+.axis-line {
+ stroke-width: 2;
+}
+.axis {
+ position: absolute;
+ width: 25px;
+ height: 25px;
+ padding: 0;
+ border: 1px solid var(--axis-color);
+ border-radius: 50%;
+ transform: translate(-50%, -50%);
+ background: var(--axis-color);
+ color: #15202c;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: grab;
+ touch-action: none;
+}
+.axis.rear {
+ background: #30343bec;
+}
+.axis:hover {
+ filter: brightness(1.3);
+}
+.axis:focus-visible,
+#orbit-gizmo:focus-visible {
+ outline: 2px solid white;
+ outline-offset: 3px;
+}
+.view-buttons {
+ display: flex;
+ justify-content: flex-end;
+ gap: 2px;
+}
+.view-buttons .btn {
+ width: 32px;
+ height: 32px;
+ padding: 7px;
+ border-radius: 50%;
+}
+.modified-label {
+ position: fixed;
+ z-index: 3;
+ top: max(8px, env(safe-area-inset-top));
+ font-size: 10px;
+ letter-spacing: 0.02em;
+ color: #b7bac1;
+ pointer-events: none;
+}
+.modified-label {
+ left: 62px;
+}
+.panel {
+ position: fixed;
+ z-index: 5;
+ top: max(12px, env(safe-area-inset-top));
+ padding: 10px;
+ background: var(--popover);
+ color: var(--popover-foreground);
+ border: 1px solid var(--border);
+ box-shadow: 0 8px 24px #0003;
+ max-height: calc(
+ 100dvh - max(24px, env(safe-area-inset-top)) -
+ max(12px, env(safe-area-inset-bottom))
+ );
+ overflow: auto;
+ overscroll-behavior: contain;
+ scrollbar-width: thin;
+ touch-action: pan-y;
+}
+.tools-panel {
+ left: max(58px, calc(env(safe-area-inset-left) + 50px));
+ width: min(216px, calc(100vw - 74px));
+ border-radius: 6px;
+}
+.settings-panel {
+ left: max(58px, calc(env(safe-area-inset-left) + 50px));
+ width: min(264px, calc(100vw - 74px));
+ border-radius: 6px;
+}
+.panel-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 28px;
+ margin-bottom: 6px;
+}
+.panel-heading h2 {
+ font-size: 12px;
+ font-weight: 600;
+}
+.panel .btn {
+ height: 30px;
+ font-size: 12px;
+ padding-inline: 8px;
+ border-radius: 5px;
+}
+.panel-heading .btn {
+ width: 28px;
+ height: 28px;
+ font-size: 18px;
+ color: var(--muted-foreground);
+}
+.brushes {
+ display: grid;
+ gap: 2px;
+}
+.brushes .btn {
+ justify-content: space-between;
+ font-weight: 400;
+}
+.brushes .btn span {
+ color: var(--muted-foreground);
+ font-size: 10px;
+}
+.panel [aria-pressed="true"] {
+ background: var(--accent);
+ color: var(--accent-foreground);
+ box-shadow: inset 2px 0 0 #dedee4;
+}
+.control-section {
+ border-top: 1px solid var(--border);
+ margin-top: 8px;
+ padding-top: 8px;
+}
+label {
+ display: block;
+ font-size: 11px;
+ line-height: 18px;
+}
+output {
+ float: right;
+ color: var(--muted-foreground);
+ font-size: 10px;
+ font-variant-numeric: tabular-nums;
+}
+.panel input[type="range"] {
+ display: block;
+ width: 100%;
+ height: 24px;
+ margin: 0 0 4px;
+ touch-action: none;
+}
+.toggle-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ min-height: 32px;
+ cursor: pointer;
+}
+.toggle-row small {
+ display: block;
+ color: var(--muted-foreground);
+ font-size: 10px;
+ line-height: 16px;
+}
+.toggle-row input {
+ flex-shrink: 0;
+}
+.history,
+.navigation,
+.project-actions,
+.settings-footer {
+ display: grid;
+ gap: 4px;
+}
+.history,
+.settings-footer {
+ grid-template-columns: 1fr 1fr;
+}
+.navigation {
+ grid-template-columns: repeat(3, 1fr);
+}
+.wide {
+ width: 100%;
+ margin-top: 4px;
+}
+.project-name {
+ color: var(--muted-foreground);
+ font-size: 11px;
+ margin: 4px 0 10px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.project-actions {
+ grid-template-columns: repeat(3, 1fr);
+}
+#save {
+ grid-column: 1/-1;
+}
+.palm-row {
+ margin-top: 12px;
+ padding-bottom: 10px;
+}
+details {
+ border-top: 1px solid var(--border);
+}
+/* Avoid transient overlapping controls during the library's height animation. */
+details::details-content {
+ transition: none;
+}
+summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 36px;
+ font-size: 12px;
+ cursor: pointer;
+ list-style: none;
+}
+summary::-webkit-details-marker {
+ display: none;
+}
+summary::after {
+ content: "+";
+ color: var(--muted-foreground);
+}
+details[open] > summary::after {
+ content: "−";
+}
+summary:focus-visible {
+ outline: 2px solid var(--ring);
+ outline-offset: 2px;
+ border-radius: 3px;
+}
+.disclosure-content {
+ padding: 4px 0 12px;
+}
+.settings-footer {
+ border-top: 1px solid var(--border);
+ padding-top: 8px;
+}
+.select {
+ width: 100%;
+}
+.panel .select {
+ height: 32px;
+ font-size: 12px;
+}
+.hint {
+ font-size: 11px;
+ line-height: 1.6;
+ color: var(--muted-foreground);
+}
+.pressure-meter {
+ height: 3px;
+ background: var(--muted);
+ margin: 8px 0;
+ border-radius: 2px;
+ overflow: hidden;
+}
+#pressure-fill {
+ width: 0;
+ height: 100%;
+ background: var(--foreground);
+}
+#curve {
+ display: block;
+ width: 100%;
+ height: 40px;
+ margin: 8px 0;
+}
+.curve-grid {
+ fill: none;
+ stroke: var(--border);
+}
+.curve-grid,
+#curve-line {
+ stroke-width: 1;
+}
+#curve-line {
+ fill: none;
+ stroke: var(--foreground);
+}
+.swatches {
+ display: flex;
+ gap: 8px;
+ margin-top: 10px;
+}
+.swatches .btn {
+ width: 32px;
+ height: 32px;
+ padding: 6px;
+ border: 1px solid transparent;
+}
+.swatches .btn::before {
+ content: "";
+ width: 18px;
+ height: 18px;
+ background: var(--swatch);
+ border-radius: 50%;
+}
+.swatches .btn[aria-pressed="true"] {
+ border-color: var(--muted-foreground);
+ box-shadow: none;
+}
+#cursor {
+ position: absolute;
+ border: 1px solid #d0efdc;
+ border-radius: 50%;
+ pointer-events: none;
+ transform: translate(-50%, -50%);
+}
+#cursor i {
+ position: absolute;
+ width: 3px;
+ height: 3px;
+ top: 50%;
+ left: 50%;
+ border-radius: 50%;
+ background: #d0efdc;
+ transform: translate(-50%, -50%);
+}
+.status-toast {
+ position: fixed;
+ z-index: 8;
+ bottom: max(12px, env(safe-area-inset-bottom));
+ left: 50%;
+ transform: translateX(-50%);
+ max-width: min(400px, calc(100vw - 32px));
+ padding: 6px 10px;
+ border-radius: 5px;
+ background: var(--popover);
+ border: 1px solid var(--border);
+ color: var(--popover-foreground);
+ font-size: 11px;
+ text-align: center;
+ pointer-events: none;
+ opacity: 0;
+}
+.status-toast.visible {
+ opacity: 1;
+}
+.canvas-message {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: min(340px, calc(100% - 48px));
+ padding: 20px;
+ color: var(--popover-foreground);
+ background: var(--popover);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+}
+.canvas-message h1 {
+ font-size: 16px;
+ font-weight: 600;
+}
+.canvas-message p {
+ margin: 10px 0 16px;
+ font-size: 12px;
+ color: var(--muted-foreground);
+}
+.dialog > form {
+ width: min(380px, calc(100vw - 32px));
+ overflow: auto;
+}
+.dialog label {
+ margin-bottom: 6px;
+}
+.dialog .hint {
+ margin-top: 8px;
+}
+.dialog dl {
+ font-size: 12px;
+ line-height: 1.6;
+}
+.dialog dt {
+ margin-top: 12px;
+ font-weight: 500;
+}
+.dialog dd {
+ color: var(--muted-foreground);
+}
+@media (any-pointer: coarse) {
+ .icon-button.btn {
+ width: 40px;
+ height: 40px;
+ }
+ .view-buttons .btn {
+ width: 34px;
+ height: 34px;
+ }
+ .panel .btn {
+ min-height: 36px;
+ }
+ .toggle-row,
+ summary {
+ min-height: 40px;
+ }
+ .panel input[type="range"] {
+ height: 32px;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .btn,
+ .dialog,
+ .dialog > * {
+ transition: none;
+ }
+}
diff --git a/server/web/assets/sculpt/vendor/basecoat/LICENSE.md b/server/web/assets/sculpt/vendor/basecoat/LICENSE.md
new file mode 100644
index 0000000..5ba264e
--- /dev/null
+++ b/server/web/assets/sculpt/vendor/basecoat/LICENSE.md
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Ronan Berder
+
+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.
\ No newline at end of file
diff --git a/server/web/assets/sculpt/vendor/basecoat/README.md b/server/web/assets/sculpt/vendor/basecoat/README.md
new file mode 100644
index 0000000..0c1df23
--- /dev/null
+++ b/server/web/assets/sculpt/vendor/basecoat/README.md
@@ -0,0 +1,12 @@
+# Basecoat 1.0.2
+
+`basecoat.min.css` is the unmodified `dist/basecoat.cdn.min.css` (Vega style)
+from the npm package `basecoat-css@1.0.2`, licensed under MIT (see LICENSE.md).
+Source: https://www.npmjs.com/package/basecoat-css/v/1.0.2
+Documentation: https://basecoatui.com/
+
+SHA-256: `8123677adb9bba43be3298e1543bcc5fc763e8cda3d32dc74c806046a3537ca0`
+
+Served locally through Go's embedded assets. No CDN, Tailwind build, or Basecoat
+JavaScript runtime is required. Native controls and the existing Menus controller
+own interactions. Do not format or modify the upstream stylesheet.
diff --git a/server/web/assets/sculpt/vendor/basecoat/basecoat.min.css b/server/web/assets/sculpt/vendor/basecoat/basecoat.min.css
new file mode 100644
index 0000000..dbaf046
--- /dev/null
+++ b/server/web/assets/sculpt/vendor/basecoat/basecoat.min.css
@@ -0,0 +1,2 @@
+/*! tailwindcss v4.3.1 | MIT License | https://tailwindcss.com */
+@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-content:"";--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-space-x-reverse:0;--tw-leading:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-duration:initial;--tw-ease:initial;--tw-font-weight:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-tracking:initial;--tw-outline-style:solid;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--font-sans:"Geist Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"Geist Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-widest:.1em;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-sm:calc(var(--radius) - 4px);--radius-md:calc(var(--radius) - 2px);--radius-lg:var(--radius);--radius-xl:calc(var(--radius) + 4px);--radius-2xl:1rem;--radius-4xl:2rem;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-xs:4px;--aspect-video:16 / 9;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-background:var(--background);--color-foreground:var(--foreground);--color-card:var(--card);--color-card-foreground:var(--card-foreground);--color-popover:var(--popover);--color-popover-foreground:var(--popover-foreground);--color-primary:var(--primary);--color-primary-foreground:var(--primary-foreground);--color-secondary:var(--secondary);--color-secondary-foreground:var(--secondary-foreground);--color-muted:var(--muted);--color-muted-foreground:var(--muted-foreground);--color-accent:var(--accent);--color-accent-foreground:var(--accent-foreground);--color-destructive:var(--destructive);--color-border:var(--border);--color-input:var(--input);--color-ring:var(--ring);--color-sidebar:var(--sidebar);--color-sidebar-foreground:var(--sidebar-foreground);--color-sidebar-accent:var(--sidebar-accent);--color-sidebar-accent-foreground:var(--sidebar-accent-foreground);--color-sidebar-border:var(--sidebar-border);--color-sidebar-ring:var(--sidebar-ring)}}@layer base{*,: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(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-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;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}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}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance: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}::-webkit-calendar-picker-indicator{line-height:1}:-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}*{border-color:var(--color-border);outline-color:var(--color-ring)}@supports (color:color-mix(in lab, red, red)){*{outline-color:color-mix(in oklab, var(--color-ring) 50%, transparent)}}html{scroll-behavior:smooth}body{overscroll-behavior:none;background-color:var(--color-background);color:var(--color-foreground)}}@layer components{.accordion{flex-direction:column;width:100%;display:flex}.accordion>details>summary{cursor:pointer;border-style:var(--tw-border-style);width:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-outline-style:none;border-width:1px;border-color:#0000;outline-style:none;flex:1;justify-content:space-between;align-items:flex-start;list-style-type:none;display:flex;position:relative}.accordion>details>summary>svg:last-child{pointer-events:none;transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0}.accordion>details[open]>summary>svg:last-child{rotate:180deg}.accordion>details[aria-disabled=true]>summary,.accordion>details>summary[aria-disabled=true]{pointer-events:none;opacity:.5}.accordion>details>:not(summary){overflow:hidden}.alert{width:100%;position:relative}.alert:has(>footer){padding-inline-end:4.5rem}.alert>:is(h2,h3,h4,h5,h6,strong,[data-title]){min-height:calc(var(--spacing) * 4)}.alert>section{justify-items:start;gap:var(--spacing);display:grid}.alert>section :where(p,ul,ol){margin:0}.alert>section :where(p,ul,ol):not(:last-child){margin-bottom:calc(var(--spacing) * 4)}.alert>section ul{list-style-type:disc;list-style-position:inside}.alert>section ol{list-style-type:decimal;list-style-position:inside}.alert>footer{position:absolute}.avatar{appearance:none;border-style:var(--tw-border-style);vertical-align:middle;--tw-outline-style:none;-webkit-user-select:none;user-select:none;background-color:#0000;border-width:0;outline-style:none;flex-shrink:0;padding:0;display:inline-flex;position:relative}.avatar:after{content:var(--tw-content);content:var(--tw-content);content:var(--tw-content);border-radius:inherit;content:var(--tw-content);border-style:var(--tw-border-style);content:var(--tw-content);border-width:1px;border-color:var(--color-border);content:var(--tw-content);mix-blend-mode:darken;position:absolute;inset:0}.avatar:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-ring)}@supports (color:color-mix(in lab, red, red)){.avatar:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-ring) 50%, transparent)}}.avatar:is(html.dark *):after{content:var(--tw-content);mix-blend-mode:lighten}.avatar>img{aspect-ratio:1;border-radius:inherit;object-fit:cover;width:100%;height:100%;position:absolute;inset:0}.avatar>span:not(.avatar-badge){border-radius:inherit;width:100%;height:100%;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));justify-content:center;align-items:center;display:flex}.avatar[data-size=sm]>span:not(.avatar-badge){font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.avatar>.avatar-badge{z-index:10;background-blend-mode:color;--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);-webkit-user-select:none;user-select:none;inset-inline-end:calc(var(--spacing) * 0);border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex;position:absolute;bottom:0}.avatar[data-size=sm]>.avatar-badge{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.avatar[data-size=sm]>.avatar-badge>svg{display:none}.avatar>.avatar-badge{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.avatar>.avatar-badge>svg{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.avatar[data-size=lg]>.avatar-badge{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.avatar[data-size=lg]>.avatar-badge>svg{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.avatar-group{display:flex}:where(.avatar-group>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * -2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * -2) * calc(1 - var(--tw-space-x-reverse)))}.avatar-group>.avatar,.avatar-group>[data-count]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-background)}.avatar-group>[data-count]{flex-shrink:0;justify-content:center;align-items:center;display:flex;position:relative}.badge{white-space:nowrap;flex-shrink:0;justify-content:center;align-items:center;width:fit-content;display:inline-flex;overflow:hidden}.badge:focus-visible{border-color:var(--color-ring);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-ring)}@supports (color:color-mix(in lab, red, red)){.badge:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-ring) 50%, transparent)}}.badge[aria-invalid=true]{border-color:var(--color-destructive);--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.badge[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 20%, transparent)}}.badge:is(html.dark *)[aria-invalid=true]{--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.badge:is(html.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 40%, transparent)}}.badge>svg{pointer-events:none}.breadcrumb>ol{overflow-wrap:break-word;flex-wrap:wrap;align-items:center;margin:0;padding:0;list-style-type:none;display:flex}.breadcrumb li{align-items:center;display:inline-flex}.breadcrumb a{border-radius:var(--radius-sm);--tw-outline-style:none;outline-style:none}.breadcrumb a:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-ring)}@supports (color:color-mix(in lab, red, red)){.breadcrumb a:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-ring) 50%, transparent)}}.breadcrumb [aria-current=page]{cursor:default}.breadcrumb li[aria-hidden=true]{-webkit-user-select:none;user-select:none}.breadcrumb li>span[aria-hidden=true]{justify-content:center;align-items:center;display:flex}.breadcrumb [data-rtl-flip]:where(:dir(rtl),[dir=rtl],[dir=rtl] *){rotate:180deg}.btn{white-space:nowrap;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-outline-style:none;-webkit-user-select:none;user-select:none;outline-style:none;flex-shrink:0;justify-content:center;align-items:center;display:inline-flex}.btn:disabled{pointer-events:none;opacity:.5}.btn svg{pointer-events:none;flex-shrink:0}.button-group{isolation:isolate;align-items:stretch;width:fit-content;display:inline-flex}.button-group:has(>:is(.dropdown-menu,.popover,.select)>[data-popover][aria-hidden=false]){z-index:50;position:relative}.button-group>.input{flex:1}.button-group>:focus-visible,.button-group>:is(.dropdown-menu,.popover,.select)>button:focus-visible{z-index:10;position:relative}.button-group>hr[role=separator]{border-style:var(--tw-border-style);border-width:0;flex-shrink:0;align-self:stretch;width:1px;height:auto;margin-block:1px;margin-inline:0;position:relative}.button-group:not([data-orientation=vertical])>:not(:first-child),.button-group:not([data-orientation=vertical])>:is(.dropdown-menu,.popover,.select):not(:first-child)>button{border-inline-start-style:var(--tw-border-style);border-inline-start-width:0;border-start-start-radius:0;border-end-start-radius:0}.button-group:not([data-orientation=vertical])>:not(:last-child),.button-group:not([data-orientation=vertical])>:is(.dropdown-menu,.popover,.select):not(:last-child)>button{border-start-end-radius:0;border-end-end-radius:0}.button-group[data-orientation=vertical]{flex-direction:column}.button-group[data-orientation=vertical]>hr[role=separator]{width:auto;height:1px;margin-block:0;margin-inline:1px}.button-group[data-orientation=vertical]>:not(:first-child),.button-group[data-orientation=vertical]>:is(.dropdown-menu,.popover,.select):not(:first-child)>button{border-top-style:var(--tw-border-style);border-top-width:0;border-top-left-radius:0;border-top-right-radius:0}.button-group[data-orientation=vertical]>:not(:last-child),.button-group[data-orientation=vertical]>:is(.dropdown-menu,.popover,.select):not(:last-child)>button{border-bottom-right-radius:0;border-bottom-left-radius:0}.button-group>:is(span,label,output){align-items:center;display:flex}.button-group>:is(span,label,output) svg{pointer-events:none}.card{flex-direction:column;display:flex}.card>header{grid-auto-rows:min-content;align-items:flex-start;display:grid;container:card-header/inline-size}.card>header:has(>[data-slot=card-action],>.card-action){grid-template-columns:1fr auto}.card>header:has(>p){grid-template-rows:auto auto}.card>header>:is(h2,h3,[data-title],.card-title){--tw-leading:1;line-height:1}.card>header>:is([data-slot=card-action],.card-action){grid-row:1/span 2;grid-column-start:2;place-self:flex-start flex-end}.card>header>menu.card-action{margin:0;padding:0;list-style-type:none}.card>footer{align-items:center;display:flex}.chart{aspect-ratio:var(--aspect-video);width:100%;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));justify-content:center;display:flex;position:relative}.chart>canvas{width:100%;height:100%;display:block}.chart-tooltip{pointer-events:none;z-index:50;min-width:calc(var(--spacing) * 32);--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);--tw-translate-y:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y);transition-property:left,top,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;--tw-ease:var(--ease-out);transition-duration:.15s;transition-timing-function:var(--ease-out);align-items:flex-start;display:grid;position:fixed}.chart-tooltip[hidden]{opacity:0}.chart-tooltip-items{gap:calc(var(--spacing) * 1.5);display:grid}.chart-tooltip-item{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;width:100%;display:flex}.chart-tooltip-indicator{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5);background-color:var(--chart-indicator-color);border-radius:2px;flex-shrink:0}.chart-legend-indicator{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2);background-color:var(--chart-indicator-color);border-radius:2px;flex-shrink:0}.chart-tooltip-label{--tw-leading:1;color:var(--color-muted-foreground);line-height:1}.chart-tooltip-value{font-family:var(--font-mono);--tw-leading:1;--tw-font-weight:var(--font-weight-medium);line-height:1;font-weight:var(--font-weight-medium);--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);margin-inline-start:auto}.chart-legend{justify-content:center;align-items:center;gap:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}.chart-legend-item{align-items:center;gap:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));display:flex}.chart-legend-item[aria-pressed=false]{opacity:.7}details::details-content{opacity:0;block-size:0;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));transition-behavior:allow-discrete;display:block}details[open]::details-content{block-size:auto;block-size:calc-size(auto, size);opacity:1}details summary{cursor:pointer;align-items:center;display:inline-flex}details>summary::-webkit-details-marker{display:none}.command-dialog{color:inherit;opacity:0;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));transition-behavior:allow-discrete;background-color:#0000;max-width:none;max-height:none;padding:0;inset-block:0}.command-dialog:is([open],:popover-open),.command-dialog:is([open],:popover-open)::backdrop{opacity:1}.command-dialog:is([open],:popover-open)>.command{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}@starting-style{.command-dialog:is([open],:popover-open),.command-dialog:is([open],:popover-open)::backdrop{opacity:0}.command-dialog:is([open],:popover-open)>.command{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}}.command-dialog::backdrop{opacity:0;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));transition-behavior:allow-discrete}.command-dialog>.command{z-index:50;--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);--tw-translate-y:0;width:100%;max-width:calc(100% - 2rem);max-height:min(640px,100% - 2rem);translate:var(--tw-translate-x) var(--tw-translate-y);--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-direction:column;display:flex;position:fixed;top:33.3333%;left:50%;overflow:hidden}@media (min-width:40rem){.command-dialog>.command{max-width:var(--container-sm)}}.command-dialog>.command>:is(button,form[method=dialog]){position:absolute}.command-dialog>.command>:is(button,form[method=dialog]>button){opacity:.7;transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.command-dialog>.command>:is(button,form[method=dialog]>button):hover{opacity:1}}.command-dialog>.command>:is(button,form[method=dialog]>button):focus-visible{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.command-dialog>.command>:is(button,form[method=dialog]>button):focus-visible{outline-offset:2px;outline:2px solid #0000}}.command-dialog>.command>:is(button,form[method=dialog]>button):disabled,.command-dialog>.command>:is(button,form[method=dialog]>button) svg{pointer-events:none}.command-dialog>.command>:is(button,form[method=dialog]>button) svg{flex-shrink:0}.command-dialog>.command>:is(button,form[method=dialog]>button)>svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.command{flex-direction:column;width:100%;display:flex;overflow:hidden}.command>header{align-items:center;display:flex}.command>header svg{flex-shrink:0}.command>header input{--tw-outline-style:none;background-color:#0000;outline-style:none;width:100%}@media (forced-colors:active){.command>header input{outline-offset:2px;outline:2px solid #0000}}.command>header input:disabled{cursor:not-allowed;opacity:.5}.command [role=menu]{--tw-outline-style:none;outline-style:none;overflow:hidden auto}@media (forced-colors:active){.command [role=menu]{outline-offset:2px;outline:2px solid #0000}}.command [role=menu]{scrollbar-width:none}.command [role=menu]::-webkit-scrollbar{display:none}.command [role=menuitem]{cursor:default;--tw-outline-style:none;outline-style:none;align-items:center;width:100%;display:flex;position:relative}@media (forced-colors:active){.command [role=menuitem]{outline-offset:2px;outline:2px solid #0000}}.command [role=menuitem]{-webkit-user-select:none;user-select:none}.command [role=menuitem]:disabled,.command [role=menuitem][aria-disabled=true]{pointer-events:none;opacity:.5}.command [role=menuitem][aria-hidden=true]{display:none}.command [role=menuitem][data-disabled=true]{pointer-events:none;opacity:.5}.command [role=menuitem] svg{pointer-events:none;flex-shrink:0}.command [role=menuitem].active,.command [role=menuitem][data-selected=true]{background-color:var(--color-muted);color:var(--color-foreground)}.command [role=group]{overflow:hidden}.command [role=group]:not(:has([role=menuitem]:not([aria-hidden=true]))){display:none}.command [role=heading]{display:flex}.command [role=separator]{border-style:var(--tw-border-style);border-width:0}.command [data-shortcut]{margin-inline-start:auto}.command [data-indicator]{opacity:0;margin-inline-start:auto}.command [role=menuitem][data-checked=true] [data-indicator],.command [role=menuitem][aria-selected=true] [data-indicator],.command [role=menuitem][data-selected=true] [data-indicator]{opacity:1}.command [role=menuitem]:has([data-shortcut]) [data-indicator]{display:none}.command [role=menu]:not(:has([role=menuitem]:not([aria-hidden=true]))):before{text-overflow:ellipsis;white-space:nowrap;justify-content:center;align-items:center;display:flex;overflow:hidden}.command [role=menu][data-empty]:not(:has([role=menuitem]:not([aria-hidden=true]))):before{--tw-content:attr(data-empty);content:var(--tw-content)}.command [role=menu]:not([data-empty]):not(:has([role=menuitem]:not([aria-hidden=true]))):before{--tw-content:"No results found";content:var(--tw-content)}.command:not([data-command-initialized]) [role=menuitem]:not([aria-disabled=true]):not(:disabled):hover{background-color:var(--color-muted);color:var(--color-foreground)}.command:has(>header input:not(:placeholder-shown)) [role=separator]{display:none}.combobox{flex-direction:column;width:fit-content;display:inline-flex;position:relative}.combobox>input[role=combobox]{--tw-outline-style:none;outline-style:none;width:100%;min-width:0}@media (forced-colors:active){.combobox>input[role=combobox]{outline-offset:2px;outline:2px solid #0000}}.combobox>input[role=combobox]::placeholder{color:var(--color-muted-foreground)}.combobox>input[role=combobox]:disabled{pointer-events:none;cursor:not-allowed;opacity:.5}.combobox .input-group input[role=combobox],.combobox [data-popover] input[role=combobox]{--tw-outline-style:none;outline-style:none;min-width:0}@media (forced-colors:active){.combobox .input-group input[role=combobox],.combobox [data-popover] input[role=combobox]{outline-offset:2px;outline:2px solid #0000}}:is(.combobox .input-group input[role=combobox],.combobox [data-popover] input[role=combobox])::placeholder{color:var(--color-muted-foreground)}:is(.combobox .input-group input[role=combobox],.combobox [data-popover] input[role=combobox]):disabled{pointer-events:none;cursor:not-allowed;opacity:.5}.combobox>svg,.combobox-trigger-icon{pointer-events:none;flex-shrink:0;position:absolute}.combobox:has(>.combobox-chips)>.combobox-trigger-icon{display:none}.combobox [data-clear]{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6);cursor:default;appearance:none;border-style:var(--tw-border-style);color:currentColor;background-color:#0000;border-width:0;flex-shrink:0;justify-content:center;align-items:center;padding:0;display:inline-flex}.combobox [data-clear]:disabled{pointer-events:none;opacity:.5}.combobox [data-clear][hidden],.combobox .input-group:has([data-clear]:not([hidden])) button[aria-haspopup=listbox]{display:none}.combobox [data-popover]{isolation:isolate;min-width:max(100%,9rem);overflow:hidden}.combobox [role=listbox]{--tw-outline-style:none;outline-style:none;overflow:hidden auto}@media (forced-colors:active){.combobox [role=listbox]{outline-offset:2px;outline:2px solid #0000}}.combobox [role=listbox] [role=group]:not(:has([role=option]:not([aria-hidden=true]))){display:none}.combobox [role=listbox]:not(:has([data-value]:not([aria-hidden=true]))):before{text-overflow:ellipsis;white-space:nowrap;justify-content:center;align-items:center;display:flex;overflow:hidden}.combobox [role=listbox][data-empty]:not(:has([data-value]:not([aria-hidden=true]))):before{--tw-content:attr(data-empty);content:var(--tw-content)}.combobox [role=listbox]:not([data-empty]):not(:has([data-value]:not([aria-hidden=true]))):before{--tw-content:"No results found";content:var(--tw-content)}.combobox [role=option]{cursor:default;text-overflow:ellipsis;white-space:nowrap;--tw-outline-style:none;outline-style:none;align-items:center;width:100%;display:flex;position:relative;overflow:hidden}@media (forced-colors:active){.combobox [role=option]{outline-offset:2px;outline:2px solid #0000}}.combobox [role=option]{-webkit-user-select:none;user-select:none}.combobox [role=option]:disabled,.combobox [role=option][aria-disabled=true]{pointer-events:none;opacity:.5}.combobox [role=option][aria-hidden=true]{display:none}.combobox [role=option] svg{pointer-events:none;flex-shrink:0}.combobox [role=option][aria-selected=true]{background-image:var(--check-icon);background-position:right .5rem center;background-repeat:no-repeat;background-size:.875rem}.combobox [role=option][aria-selected=true]:dir(rtl){background-position:.5rem}.combobox [role=separator]{border-style:var(--tw-border-style);border-width:0}.combobox [role=option].active,.combobox [role=option]:focus-visible,.combobox:not([data-combobox-initialized]) [role=option]:not([aria-disabled=true]):not(:disabled):hover{background-color:var(--color-accent);color:var(--color-accent-foreground)}.combobox-chips{flex-wrap:wrap;align-items:center;width:100%;min-width:0;display:flex}.combobox-chips>input[role=combobox]{min-width:calc(var(--spacing) * 16);--tw-outline-style:none;background-color:#0000;outline-style:none;flex:1}@media (forced-colors:active){.combobox-chips>input[role=combobox]{outline-offset:2px;outline:2px solid #0000}}.combobox-chips>input[role=combobox]::placeholder{color:var(--color-muted-foreground)}.combobox-chips>input[role=combobox]:disabled{pointer-events:none;cursor:not-allowed;opacity:.5}.combobox-chip{white-space:nowrap;flex-shrink:0;align-items:center;width:fit-content;display:inline-flex}.combobox-chip-remove{flex-shrink:0;justify-content:center;align-items:center;display:inline-flex}.dialog,.alert-dialog{color:inherit;opacity:0;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));transition-behavior:allow-discrete;background-color:#0000;padding:0}:is(.dialog,.alert-dialog)::backdrop{opacity:0;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));transition-behavior:allow-discrete}.dialog,.alert-dialog{max-width:none;max-height:none}:is(.dialog,.alert-dialog):is([open],:popover-open),:is(.dialog,.alert-dialog):is([open],:popover-open)::backdrop{opacity:1}:is(.dialog,.alert-dialog):is([open],:popover-open)>*{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}@starting-style{:is(.dialog,.alert-dialog):is([open],:popover-open),:is(.dialog,.alert-dialog):is([open],:popover-open)::backdrop{opacity:0}:is(.dialog,.alert-dialog):is([open],:popover-open)>*{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}}:is(.dialog,.alert-dialog)>*{z-index:50;--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);width:100%;max-height:calc(100% - 2rem);translate:var(--tw-translate-x) var(--tw-translate-y);--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y);--tw-outline-style:none;outline-style:none;flex-direction:column;display:flex;position:fixed;top:50%;left:50%}@media (forced-colors:active){:is(.dialog,.alert-dialog)>*{outline-offset:2px;outline:2px solid #0000}}:is(.dialog,.alert-dialog)>*{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.dialog>*>header{flex-direction:column;display:flex}.dialog>*>section{flex:1}.dialog>*>footer{flex-direction:column-reverse;display:flex}@media (min-width:40rem){.dialog>*>footer{flex-direction:row;justify-content:flex-end}}.dialog>*>:is(button,form[method=dialog]){position:absolute}.dialog>*>:is(button,form[method=dialog]>button){opacity:.7;transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.dialog>*>:is(button,form[method=dialog]>button):hover{opacity:1}}.dialog>*>:is(button,form[method=dialog]>button):focus-visible{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.dialog>*>:is(button,form[method=dialog]>button):focus-visible{outline-offset:2px;outline:2px solid #0000}}.dialog>*>:is(button,form[method=dialog]>button):disabled,.dialog>*>:is(button,form[method=dialog]>button) svg{pointer-events:none}.dialog>*>:is(button,form[method=dialog]>button) svg{flex-shrink:0}.dialog>*>:is(button,form[method=dialog]>button)>svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.alert-dialog>*>header{text-align:center;grid-template-rows:auto 1fr;place-items:center;display:grid}@media (min-width:40rem){.alert-dialog>*>header{text-align:start;place-items:start}}.alert-dialog>*>header:has(>figure){grid-template-rows:auto auto 1fr}@media (min-width:40rem){.alert-dialog>*>header:has(>figure){grid-template-rows:auto 1fr}}.alert-dialog>*>header>figure{flex-shrink:0;margin:0}@media (min-width:40rem){.alert-dialog>*>header>figure{grid-row:span 2/span 2}}.alert-dialog>*>header>figure svg{pointer-events:none;flex-shrink:0}.alert-dialog>*>header>figure:has(img){overflow:hidden}.alert-dialog>*>header>figure:has(img) img{object-fit:cover;width:100%;height:100%}@media (min-width:40rem){.alert-dialog>*>header:has(>figure)>:is(h2,p){grid-column-start:2}}.alert-dialog>*>footer{flex-direction:column-reverse;display:flex}@media (min-width:40rem){.alert-dialog>*>footer{flex-direction:row;justify-content:flex-end}.alert-dialog[data-size=sm]>*>header,.alert-dialog>[data-size=sm]>header{text-align:center;place-items:center}.alert-dialog[data-size=sm]>*>header:has(>figure),.alert-dialog>[data-size=sm]>header:has(>figure){grid-template-rows:auto auto 1fr}.alert-dialog[data-size=sm]>*>header:has(>figure)>:is(h2,p),.alert-dialog>[data-size=sm]>header:has(>figure)>:is(h2,p){grid-column-start:auto}}.alert-dialog[data-size=sm]>*>footer,.alert-dialog>[data-size=sm]>footer{grid-template-columns:repeat(2,minmax(0,1fr));display:grid}@media (min-width:40rem){.alert-dialog[data-size=sm]>*>footer,.alert-dialog>[data-size=sm]>footer{grid-template-columns:repeat(2,minmax(0,1fr));display:grid}}.drawer{width:auto;max-width:none;height:auto;max-height:none;color:inherit;opacity:0;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));transition-behavior:allow-discrete;--tw-duration:.5s;--tw-ease:cubic-bezier(.32,.72,0,1);background-color:#0000;margin:0;padding:0;transition-duration:.5s;transition-timing-function:cubic-bezier(.32,.72,0,1);position:fixed;inset:0;overflow:visible}.drawer::backdrop{opacity:0;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));transition-behavior:allow-discrete;--tw-duration:.5s;--tw-ease:cubic-bezier(.32,.72,0,1);transition-duration:.5s;transition-timing-function:cubic-bezier(.32,.72,0,1)}.drawer>*{z-index:50;--tw-outline-style:none;outline-style:none;flex-direction:column;height:auto;display:flex;position:fixed}@media (forced-colors:active){.drawer>*{outline-offset:2px;outline:2px solid #0000}}.drawer>*{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.5s;--tw-ease:cubic-bezier(.32,.72,0,1);will-change:transform;transition-duration:.5s;transition-timing-function:cubic-bezier(.32,.72,0,1)}.drawer:not([data-side])>*,.drawer[data-side=bottom]>*{margin-top:calc(var(--spacing) * 24);--tw-translate-y:100%;max-height:80vh;translate:var(--tw-translate-x) var(--tw-translate-y);inset-inline:0;bottom:0}.drawer[data-side=top]>*{margin-bottom:calc(var(--spacing) * 24);--tw-translate-y:-100%;max-height:80vh;translate:var(--tw-translate-x) var(--tw-translate-y);inset-inline:0;top:0}.drawer[data-side=left]>*{width:75%;max-width:var(--container-sm);--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y);inset-block:0;left:0}.drawer[data-side=right]>*{width:75%;max-width:var(--container-sm);--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y);inset-block:0;right:0}.drawer[data-closing],.drawer[data-closing]::backdrop{opacity:0}.drawer[data-closing]:not([data-side])>*,.drawer[data-closing][data-side=bottom]>*{--tw-translate-y:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.drawer[data-closing][data-side=top]>*{--tw-translate-y:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.drawer[data-closing][data-side=left]>*{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.drawer[data-closing][data-side=right]>*{--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.drawer:is([open],:popover-open):not([data-closing]),.drawer:is([open],:popover-open):not([data-closing])::backdrop{opacity:1}.drawer:is([open],:popover-open):not([data-closing])>*{--tw-translate-x:0;--tw-translate-y:0;translate:var(--tw-translate-x) var(--tw-translate-y)}@starting-style{.drawer:is([open],:popover-open):not([data-closing]),.drawer:is([open],:popover-open):not([data-closing])::backdrop{opacity:0}.drawer:is([open],:popover-open):not([data-closing]):not([data-side])>*,.drawer:is([open],:popover-open):not([data-closing])[data-side=bottom]>*{--tw-translate-y:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.drawer:is([open],:popover-open):not([data-closing])[data-side=top]>*{--tw-translate-y:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.drawer:is([open],:popover-open):not([data-closing])[data-side=left]>*{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.drawer:is([open],:popover-open):not([data-closing])[data-side=right]>*{--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}}.drawer>*>header{flex-direction:column;display:flex}.drawer>*>section{flex:1;min-height:0;overflow-y:auto}.drawer>*>footer{flex-direction:column;margin-top:auto;display:flex}.dropdown-menu{display:inline-flex;position:relative}.dropdown-menu>[data-popover]{min-width:anchor-size(width)}.dropdown-menu [role=menu]{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.dropdown-menu [role=menu]{outline-offset:2px;outline:2px solid #0000}}.dropdown-menu :is([role=menuitem],[role=menuitemcheckbox],[role=menuitemradio]){cursor:default;text-overflow:ellipsis;white-space:nowrap;--tw-outline-style:none;outline-style:none;align-items:center;width:100%;display:flex;position:relative;overflow:hidden}@media (forced-colors:active){.dropdown-menu :is([role=menuitem],[role=menuitemcheckbox],[role=menuitemradio]){outline-offset:2px;outline:2px solid #0000}}.dropdown-menu :is([role=menuitem],[role=menuitemcheckbox],[role=menuitemradio]){-webkit-user-select:none;user-select:none}.dropdown-menu :is([role=menuitem],[role=menuitemcheckbox],[role=menuitemradio]):disabled,.dropdown-menu :is([role=menuitem],[role=menuitemcheckbox],[role=menuitemradio])[aria-disabled=true]{pointer-events:none;opacity:.5}.dropdown-menu :is([role=menuitem],[role=menuitemcheckbox],[role=menuitemradio])[aria-hidden=true]{display:none}.dropdown-menu :is([role=menuitem],[role=menuitemcheckbox],[role=menuitemradio]) svg{pointer-events:none;flex-shrink:0}.dropdown-menu [role=menu] [role=heading]{display:flex}.dropdown-menu [role=separator]{border-style:var(--tw-border-style);border-width:0}.dropdown-menu :is([data-shortcut],kbd:last-child,.kbd:last-child){text-align:end;flex-shrink:0;margin-inline-start:auto}.dropdown-menu [data-shortcut],.dropdown-menu :is(kbd:last-child,.kbd:last-child):not([data-shortcut]){padding-inline-start:calc(var(--spacing) * 8)}.dropdown-menu [data-indicator]{pointer-events:none;visibility:hidden}.dropdown-menu :is([role=menuitemcheckbox],[role=menuitemradio])[aria-checked=true] [data-indicator]{visibility:visible}.dropdown-menu [data-rtl-flip]:where(:dir(rtl),[dir=rtl],[dir=rtl] *){rotate:180deg}.dropdown-menu:not([data-dropdown-menu-initialized]) :is([role=menuitem],[role=menuitemcheckbox],[role=menuitemradio]):not([aria-disabled=true]):not(:disabled):hover{background-color:var(--color-accent);color:var(--color-accent-foreground)}.empty{text-align:center;text-wrap:balance;flex-direction:column;flex:1;justify-content:center;align-items:center;width:100%;min-width:0;display:flex}.empty>header{max-width:var(--container-sm);flex-direction:column;align-items:center;display:flex}.empty figure{flex-shrink:0;justify-content:center;align-items:center;display:flex}.empty figure svg{pointer-events:none;flex-shrink:0}.empty figure:has(img){overflow:hidden}.empty figure:has(img) img{object-fit:cover;width:100%;height:100%}.empty>:is(section,footer){width:100%;max-width:var(--container-sm);text-wrap:balance;flex-direction:column;align-items:center;min-width:0;display:flex}.empty>header>p>a{text-underline-offset:4px;text-decoration-line:underline}.empty>header>p>a:hover{color:var(--color-primary)}.fieldset{flex-direction:column;display:flex}.fieldset>:is([role=group],[role=radiogroup]):not(.field),.field>:is(fieldset,[role=group],[role=radiogroup]):not(.field){flex-direction:column;width:100%;display:flex;container:field-group/inline-size}.field{flex-direction:column;width:100%;display:flex}.field>*{width:100%}.field>.sr-only{width:auto}.field[data-orientation=horizontal]{flex-direction:row;align-items:center}.field[data-orientation=horizontal]:has(>section){align-items:flex-start}.field[data-orientation=horizontal] p{text-wrap:balance}.field[data-orientation=horizontal]>*{width:auto}.field[data-orientation=horizontal]:has(>section)>[type=checkbox],.field[data-orientation=horizontal]:has(>section) [type=radio]{margin-top:1px}.field[data-orientation=horizontal]>label{flex:auto}.field[data-orientation=horizontal]:has(>section)>section{flex:1}.field[data-orientation=responsive]{flex-direction:column}@container field-group (min-width:28rem){.field[data-orientation=responsive]{flex-direction:row;align-items:center}.field[data-orientation=responsive]:has(>section){align-items:flex-start}.field[data-orientation=responsive]>*{width:auto}.field[data-orientation=responsive]:has(>section)>[type=checkbox],.field[data-orientation=responsive]:has(>section) [type=radio]{margin-top:1px}.field[data-orientation=responsive]>label{flex:auto}}.field section{--tw-leading:var(--leading-snug);line-height:var(--leading-snug);flex-direction:column;flex:1;display:flex}.field h2,.field h3{align-items:center;width:fit-content;display:flex}.field [role=alert]{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.field [role=alert] ul{gap:var(--spacing);flex-direction:column;margin-inline-start:calc(var(--spacing) * 4);list-style-type:disc;display:flex}.field>label,.field>section>label,label:has(>.field){width:fit-content}.field>label:has(>.field),.field>label:has(input[type=radio]),.field>label:has(input[type=checkbox]),.field>label:has(input[role=switch]),label:has(>.field){width:100%}.field>label:has(>.field),label:has(>.field){flex-direction:column}.fieldset legend+p,.field>p,.field section>p{--tw-leading:var(--leading-normal);line-height:var(--leading-normal);--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}:is(.fieldset legend+p,.field>p,.field section>p)>a{text-underline-offset:4px;text-decoration-line:underline}:is(.fieldset legend+p,.field>p,.field section>p)>a:hover{color:var(--color-primary)}:is(.field>p,.field section>p):last-child{margin-top:0}:is(.field>p,.field section>p):nth-last-child(2){margin-top:calc(var(--spacing) * -1)}.field-separator{position:relative}.field-separator>hr[role=separator]{inset-inline:0;position:absolute;top:50%}.field-separator>span{background-color:var(--color-background);width:fit-content;margin-inline:auto;display:block;position:relative}.field>input[type=checkbox]:not([role=switch]),.input[type=checkbox]:not([role=switch]){appearance:none;--tw-outline-style:none;outline-style:none;flex-shrink:0;position:relative}:is(.field>input[type=checkbox]:not([role=switch]),.input[type=checkbox]:not([role=switch])):disabled{cursor:not-allowed;opacity:.5}:is(.field>input[type=checkbox]:not([role=switch]),.input[type=checkbox]:not([role=switch])):checked:after{--tw-content:"";content:var(--tw-content);-webkit-mask-image:var(--check-icon);-webkit-mask-image:var(--check-icon);mask-image:var(--check-icon);background-color:currentColor;display:block;-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:.875rem;mask-size:.875rem;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.field>input[type=text]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=email],.field>input[type=password],.field>input[type=number],.field>input[type=file],.field>input[type=tel],.field>input[type=url],.field>input[type=search]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=date],.field>input[type=datetime-local],.field>input[type=month],.field>input[type=week],.field>input[type=time],.input[type=text],.input[type=email],.input[type=password],.input[type=number],.input[type=file],.input[type=tel],.input[type=url],.input[type=search],.input[type=date],.input[type=datetime-local],.input[type=month],.input[type=week],.input[type=time],.input:not([type]){--tw-outline-style:none;outline-style:none;width:100%;min-width:0}:is(.field>input[type=text]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=email],.field>input[type=password],.field>input[type=number],.field>input[type=file],.field>input[type=tel],.field>input[type=url],.field>input[type=search]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=date],.field>input[type=datetime-local],.field>input[type=month],.field>input[type=week],.field>input[type=time],.input[type=text],.input[type=email],.input[type=password],.input[type=number],.input[type=file],.input[type=tel],.input[type=url],.input[type=search],.input[type=date],.input[type=datetime-local],.input[type=month],.input[type=week],.input[type=time],.input:not([type]))::file-selector-button{display:inline-flex}:is(.field>input[type=text]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=email],.field>input[type=password],.field>input[type=number],.field>input[type=file],.field>input[type=tel],.field>input[type=url],.field>input[type=search]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=date],.field>input[type=datetime-local],.field>input[type=month],.field>input[type=week],.field>input[type=time],.input[type=text],.input[type=email],.input[type=password],.input[type=number],.input[type=file],.input[type=tel],.input[type=url],.input[type=search],.input[type=date],.input[type=datetime-local],.input[type=month],.input[type=week],.input[type=time],.input:not([type]))::file-selector-button{border-style:var(--tw-border-style);border-width:0}:is(.field>input[type=text]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=email],.field>input[type=password],.field>input[type=number],.field>input[type=file],.field>input[type=tel],.field>input[type=url],.field>input[type=search]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=date],.field>input[type=datetime-local],.field>input[type=month],.field>input[type=week],.field>input[type=time],.input[type=text],.input[type=email],.input[type=password],.input[type=number],.input[type=file],.input[type=tel],.input[type=url],.input[type=search],.input[type=date],.input[type=datetime-local],.input[type=month],.input[type=week],.input[type=time],.input:not([type]))::file-selector-button{background-color:#0000}:is(.field>input[type=text]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=email],.field>input[type=password],.field>input[type=number],.field>input[type=file],.field>input[type=tel],.field>input[type=url],.field>input[type=search]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=date],.field>input[type=datetime-local],.field>input[type=month],.field>input[type=week],.field>input[type=time],.input[type=text],.input[type=email],.input[type=password],.input[type=number],.input[type=file],.input[type=tel],.input[type=url],.input[type=search],.input[type=date],.input[type=datetime-local],.input[type=month],.input[type=week],.input[type=time],.input:not([type]))::file-selector-button{color:var(--color-foreground)}:is(.field>input[type=text]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=email],.field>input[type=password],.field>input[type=number],.field>input[type=file],.field>input[type=tel],.field>input[type=url],.field>input[type=search]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=date],.field>input[type=datetime-local],.field>input[type=month],.field>input[type=week],.field>input[type=time],.input[type=text],.input[type=email],.input[type=password],.input[type=number],.input[type=file],.input[type=tel],.input[type=url],.input[type=search],.input[type=date],.input[type=datetime-local],.input[type=month],.input[type=week],.input[type=time],.input:not([type]))::placeholder{color:var(--color-muted-foreground)}:is(.field>input[type=text]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=email],.field>input[type=password],.field>input[type=number],.field>input[type=file],.field>input[type=tel],.field>input[type=url],.field>input[type=search]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=date],.field>input[type=datetime-local],.field>input[type=month],.field>input[type=week],.field>input[type=time],.input[type=text],.input[type=email],.input[type=password],.input[type=number],.input[type=file],.input[type=tel],.input[type=url],.input[type=search],.input[type=date],.input[type=datetime-local],.input[type=month],.input[type=week],.input[type=time],.input:not([type])):disabled{pointer-events:none;cursor:not-allowed;opacity:.5}.item-group{flex-direction:column;width:100%;display:flex}.item-group>hr{border-style:var(--tw-border-style);border-width:0;border-top-style:var(--tw-border-style);border-top-width:1px;flex-shrink:0;width:100%}.item{border-style:var(--tw-border-style);width:100%;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.1s;--tw-outline-style:none;border-width:1px;outline-style:none;flex-wrap:wrap;align-items:center;transition-duration:.1s;display:flex}.item:focus-visible{border-color:var(--color-ring);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-ring)}@supports (color:color-mix(in lab, red, red)){.item:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-ring) 50%, transparent)}}@media (hover:hover){a.item:hover{background-color:var(--color-muted)}}.item>figure{flex-shrink:0;justify-content:center;align-items:center;display:flex}.item>figure svg{pointer-events:none}.item>figure:has(>img){overflow:hidden}.item>figure:has(>img) img{object-fit:cover;width:100%;height:100%}.item>section{flex-direction:column;flex:1;display:flex}.item>section+section{flex:none}.item>aside{align-items:center;display:flex}.item>:is(header,footer){flex-basis:100%;justify-content:space-between;align-items:center;display:flex}.item :is(h2,h3,h4){-webkit-line-clamp:1;-webkit-box-orient:vertical;align-items:center;width:fit-content;display:flex;overflow:hidden}.item p{-webkit-line-clamp:2;text-align:start;--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal);color:var(--color-muted-foreground);-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.item p>a{text-underline-offset:4px;text-decoration-line:underline}.item p>a:hover{color:var(--color-primary)}.kbd{pointer-events:none;-webkit-user-select:none;user-select:none;justify-content:center;align-items:center;display:inline-flex}.field>label,.field>section>label,.label{-webkit-user-select:none;user-select:none;align-items:center;display:flex}:is(.field>label,.field>section>label,.label):is(:where(.peer):disabled~*),:is(.field>label,.field>section>label,.label):has(>:disabled),:is(.field>label,.field>section>label,.label):has(+:disabled){pointer-events:none}.field>select,select.select{appearance:none;--tw-outline-style:none;-webkit-user-select:none;user-select:none;outline-style:none;min-width:0}:is(.field>select,select.select):disabled{cursor:not-allowed;opacity:.5}:is(.field>select,select.select) option,:is(.field>select,select.select) optgroup{color:canvastext;background-color:canvas}[data-popover]{visibility:visible;z-index:50;--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;width:max-content;min-width:100%;scale:var(--tw-scale-x) var(--tw-scale-y);opacity:1;--tw-outline-style:none;outline-style:none;position:absolute;overflow:hidden auto}@media (forced-colors:active){[data-popover]{outline-offset:2px;outline:2px solid #0000}}[data-popover]{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}[data-popover][aria-hidden=true]{visibility:hidden;--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y);opacity:0}[data-popover][aria-hidden=true]:not([data-side]),[data-popover][aria-hidden=true][data-side=bottom]{--tw-translate-y:calc(var(--spacing) * -2);translate:var(--tw-translate-x) var(--tw-translate-y)}[data-popover][aria-hidden=true][data-side=top]{--tw-translate-y:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y)}[data-popover][aria-hidden=true][data-side=left]{--tw-translate-x:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y)}[data-popover][aria-hidden=true][data-side=right]{--tw-translate-x:calc(var(--spacing) * -2);translate:var(--tw-translate-x) var(--tw-translate-y)}[data-popover][aria-hidden=true][data-side=inline-start]{--tw-translate-x:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y)}[data-popover][aria-hidden=true][data-side=inline-start]:dir(rtl){--tw-translate-x:calc(var(--spacing) * -2);translate:var(--tw-translate-x) var(--tw-translate-y)}[data-popover][aria-hidden=true][data-side=inline-end]{--tw-translate-x:calc(var(--spacing) * -2);translate:var(--tw-translate-x) var(--tw-translate-y)}[data-popover][aria-hidden=true][data-side=inline-end]:dir(rtl){--tw-translate-x:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y)}[data-popover]:not([data-side]),[data-popover][data-side=bottom]{margin-top:var(--spacing);top:100%}[data-popover][data-side=top]{margin-bottom:var(--spacing);bottom:100%}[data-popover][data-side=left]{margin-inline-end:var(--spacing);inset-inline-end:100%}[data-popover][data-side=right]{margin-inline-start:var(--spacing);inset-inline-start:100%}[data-popover][data-side=inline-start]{margin-inline-end:var(--spacing);inset-inline-end:100%}[data-popover][data-side=inline-end]{margin-inline-start:var(--spacing);inset-inline-start:100%}:is([data-popover]:not([data-side]),[data-popover][data-side=bottom],[data-popover][data-side=top]):not([data-align]),:is([data-popover]:not([data-side]),[data-popover][data-side=bottom],[data-popover][data-side=top])[data-align=start]{inset-inline-start:calc(var(--spacing) * 0)}:is([data-popover]:not([data-side]),[data-popover][data-side=bottom],[data-popover][data-side=top])[data-align=end]{inset-inline-end:calc(var(--spacing) * 0)}:is([data-popover]:not([data-side]),[data-popover][data-side=bottom],[data-popover][data-side=top])[data-align=center]{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);inset-inline-start:50%}:is([data-popover][data-side=left],[data-popover][data-side=right],[data-popover][data-side=inline-start],[data-popover][data-side=inline-end]):not([data-align]),:is([data-popover][data-side=left],[data-popover][data-side=right],[data-popover][data-side=inline-start],[data-popover][data-side=inline-end])[data-align=start]{top:0}:is([data-popover][data-side=left],[data-popover][data-side=right],[data-popover][data-side=inline-start],[data-popover][data-side=inline-end])[data-align=end]{bottom:0}:is([data-popover][data-side=left],[data-popover][data-side=right],[data-popover][data-side=inline-start],[data-popover][data-side=inline-end])[data-align=center]{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);top:50%}.popover{display:inline-flex;position:relative}.progress{align-items:center;width:100%;display:flex;overflow:hidden}.progress:dir(rtl){flex-direction:row-reverse}.progress>span{height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.field>input[type=radio],.input[type=radio]{appearance:none;border-style:var(--tw-border-style);--tw-outline-style:none;border-width:1px;outline-style:none;flex-shrink:0;position:relative}:is(.field>input[type=radio],.input[type=radio]):disabled{cursor:not-allowed;opacity:.5}:is(.field>input[type=radio],.input[type=radio]):checked:before{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);--tw-content:"";content:var(--tw-content);border-radius:3.40282e38px;position:absolute;top:50%;left:50%}.field>input[type=range],.input[type=range]{appearance:none;--tw-outline-style:none;outline-style:none;align-items:center;padding:0;display:flex}:is(.field>input[type=range],.input[type=range]):disabled{pointer-events:none;opacity:.5}.field>input[type=range],.input[type=range]{--slider-value:20%}:is(.field>input[type=range],.input[type=range])::-webkit-slider-thumb{appearance:none;flex-shrink:0;display:block}:is(.field>input[type=range],.input[type=range])::-webkit-slider-runnable-track{appearance:none;background:linear-gradient(to right, var(--primary) var(--slider-value), var(--muted) var(--slider-value));width:100%}:is(.field>input[type=range],.input[type=range]):dir(rtl)::-webkit-slider-runnable-track{background:linear-gradient(to left, var(--primary) var(--slider-value), var(--muted) var(--slider-value))}:is(.field>input[type=range],.input[type=range])::-moz-range-thumb{appearance:none;flex-shrink:0;display:block}:is(.field>input[type=range],.input[type=range])::-moz-range-track{appearance:none;background:linear-gradient(to right, var(--primary) var(--slider-value), var(--muted) var(--slider-value));width:100%}:is(.field>input[type=range],.input[type=range]):dir(rtl)::-moz-range-track{background:linear-gradient(to left, var(--primary) var(--slider-value), var(--muted) var(--slider-value))}:is(.field>input[type=range],.input[type=range])::-ms-thumb{appearance:none;flex-shrink:0;display:block}:is(.field>input[type=range],.input[type=range])::-ms-track{appearance:none;width:100%}:is(.field>input[type=range],.input[type=range])::-ms-fill-lower{background-color:var(--color-primary)}:is(.field>input[type=range],.input[type=range])::-ms-fill-upper{background-color:var(--color-muted)}.select:not(select){display:inline-flex;position:relative}.select:not(select)>button{--tw-font-weight:var(--font-weight-normal);width:fit-content;font-weight:var(--font-weight-normal);white-space:nowrap;--tw-outline-style:none;outline-style:none;justify-content:space-between;align-items:center;display:flex}@media (forced-colors:active){.select:not(select)>button{outline-offset:2px;outline:2px solid #0000}}.select:not(select)>button:disabled{pointer-events:none;cursor:not-allowed;opacity:.5}.select:not(select)>button svg{pointer-events:none;flex-shrink:0}.select:not(select)>button>span{text-overflow:ellipsis;white-space:nowrap;text-align:start;flex:1;display:flex;overflow:hidden}.select:not(select)>button>svg{flex-shrink:0}.select:not(select) [data-popover]{isolation:isolate;min-width:max(100%,9rem);overflow-x:hidden}.select:not(select) [role=listbox]{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.select:not(select) [role=listbox]{outline-offset:2px;outline:2px solid #0000}}.select:not(select) [role=listbox] [role=group]:not(:has([role=option]:not([aria-hidden=true]))){display:none}.select:not(select) [role=listbox]:not(:has([data-value]:not([aria-hidden=true]))):before{text-overflow:ellipsis;white-space:nowrap;justify-content:center;align-items:center;display:flex;overflow:hidden}.select:not(select) [role=listbox][data-empty]:not(:has([data-value]:not([aria-hidden=true]))):before{--tw-content:attr(data-empty);content:var(--tw-content)}.select:not(select) [role=listbox]:not([data-empty]):not(:has([data-value]:not([aria-hidden=true]))):before{--tw-content:"No results found";content:var(--tw-content)}.select:not(select) [role=option]{cursor:default;text-overflow:ellipsis;white-space:nowrap;--tw-outline-style:none;outline-style:none;align-items:center;width:100%;display:flex;position:relative;overflow:hidden}@media (forced-colors:active){.select:not(select) [role=option]{outline-offset:2px;outline:2px solid #0000}}.select:not(select) [role=option]{-webkit-user-select:none;user-select:none}.select:not(select) [role=option]:disabled,.select:not(select) [role=option][aria-disabled=true]{pointer-events:none;opacity:.5}.select:not(select) [role=option][aria-hidden=true]{display:none}.select:not(select) [role=option] svg{pointer-events:none;flex-shrink:0}.select:not(select) [role=option][aria-selected=true]{background-image:var(--check-icon);background-position:right .5rem center;background-repeat:no-repeat;background-size:.875rem}.select:not(select) [role=option][aria-selected=true]:dir(rtl){background-position:.5rem}.select:not(select) [role=separator]{border-style:var(--tw-border-style);border-width:0}.select:not(select):not([data-select-initialized]) [role=option]:not([aria-disabled=true]):not(:disabled):hover,.select:not(select) [role=option].active{background-color:var(--color-accent);color:var(--color-accent-foreground)}@media not all and (min-width:48rem){.sidebar:not([data-sidebar-initialized]){display:none}.sidebar:not([aria-hidden]),.sidebar[aria-hidden=false]{z-index:40;background-color:#00000080;position:fixed;inset:0}@supports (color:color-mix(in lab, red, red)){.sidebar:not([aria-hidden]),.sidebar[aria-hidden=false]{background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}}.sidebar nav{z-index:50;width:var(--sidebar-mobile-width);transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.3s;--tw-ease:var(--ease-in-out);transition-duration:.3s;transition-timing-function:var(--ease-in-out);flex-direction:column;display:flex;position:fixed;inset-block:0}@media (min-width:48rem){.sidebar nav{width:var(--sidebar-width)}}.sidebar+*{transition-property:margin;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.3s;--tw-ease:var(--ease-in-out);transition-duration:.3s;transition-timing-function:var(--ease-in-out)}:is(.sidebar:not([data-side]),.sidebar[data-side=left]) nav{border-right-style:var(--tw-border-style);border-right-width:1px;left:0}:is(.sidebar:not([data-side]),.sidebar[data-side=left])+*{position:relative}@media (min-width:48rem){:is(.sidebar:not([data-side]),.sidebar[data-side=left])+*{margin-left:var(--sidebar-width)}}:is(.sidebar:not([data-side]),.sidebar[data-side=left])[aria-hidden=true] nav{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}@media (min-width:48rem){:is(.sidebar:not([data-side]),.sidebar[data-side=left])[aria-hidden=true]+*{margin-left:0}}.sidebar[data-side=right] nav{border-left-style:var(--tw-border-style);border-left-width:1px;right:0}.sidebar[data-side=right]+*{position:relative}@media (min-width:48rem){.sidebar[data-side=right]+*{margin-right:var(--sidebar-width)}}.sidebar[data-side=right][aria-hidden=true] nav{--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}@media (min-width:48rem){.sidebar[data-side=right][aria-hidden=true]+*{margin-right:0}}.sidebar nav>header,.sidebar nav>footer{flex-direction:column;display:flex}.sidebar nav [role=separator]{width:auto}.sidebar nav>section{flex-direction:column;flex:1;min-height:0;display:flex;overflow-y:auto}.sidebar nav>section>[role=group]{flex-direction:column;width:100%;min-width:0;display:flex;position:relative}.sidebar nav>section h3{--tw-outline-style:none;outline-style:none;flex-shrink:0;align-items:center;display:flex}@media (forced-colors:active){.sidebar nav>section h3{outline-offset:2px;outline:2px solid #0000}}.sidebar nav>section h3{transition-property:margin,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;--tw-ease:linear;transition-duration:.2s;transition-timing-function:linear}.sidebar nav>section h3>svg{flex-shrink:0}.sidebar nav>section ul{flex-direction:column;width:100%;min-width:0;display:flex}.sidebar nav>section ul li{position:relative}.sidebar nav>section ul li>a,.sidebar nav>section ul li>button,.sidebar nav>section ul li>details>summary{text-align:left;--tw-outline-style:none;outline-style:none;align-items:center;width:100%;display:flex;overflow:hidden}@media (forced-colors:active){.sidebar nav>section ul li>a,.sidebar nav>section ul li>button,.sidebar nav>section ul li>details>summary{outline-offset:2px;outline:2px solid #0000}}.sidebar nav>section ul li>a,.sidebar nav>section ul li>button,.sidebar nav>section ul li>details>summary{transition-property:width,height,padding;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}:is(.sidebar nav>section ul li>a,.sidebar nav>section ul li>button,.sidebar nav>section ul li>details>summary):disabled,:is(.sidebar nav>section ul li>a,.sidebar nav>section ul li>button,.sidebar nav>section ul li>details>summary)[aria-disabled=true]{pointer-events:none;opacity:.5}:is(.sidebar nav>section ul li>a,.sidebar nav>section ul li>button,.sidebar nav>section ul li>details>summary)>span:last-child{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}:is(.sidebar nav>section ul li>a,.sidebar nav>section ul li>button,.sidebar nav>section ul li>details>summary)>svg{flex-shrink:0}.sidebar nav>section ul li>details:not([open])>summary:after{rotate:-90deg}.sidebar nav>section ul li>details:not([open])>summary:after:where(){rotate:90deg}.sidebar nav>section ul li>details>summary:after{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5);transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-ease:linear;--tw-content:"";content:var(--tw-content);-webkit-mask-image:var(--chevron-down-icon);-webkit-mask-image:var(--chevron-down-icon);mask-image:var(--chevron-down-icon);background-color:currentColor;margin-inline-start:auto;transition-timing-function:linear;display:block;-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:1rem;mask-size:1rem;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.sidebar nav>section ul ul{flex-direction:column;min-width:0;display:flex}.scrollbar{scrollbar-width:auto;scrollbar-color:var(--scrollbar-thumb) var(--scrollbar-track)}.scrollbar::-webkit-scrollbar{width:var(--scrollbar-width);height:var(--scrollbar-width)}.scrollbar::-webkit-scrollbar-track{background:var(--scrollbar-track)}.scrollbar::-webkit-scrollbar-thumb{background:var(--scrollbar-thumb);border-radius:var(--scrollbar-radius);background-clip:padding-box;border:1px solid #0000}.scrollbar::-webkit-scrollbar-corner{background:var(--scrollbar-track)}.scrollbar-sm{scrollbar-width:thin;scrollbar-color:var(--scrollbar-thumb) var(--scrollbar-track)}.scrollbar-sm::-webkit-scrollbar{width:var(--scrollbar-sm-width);height:var(--scrollbar-sm-width)}.scrollbar-sm::-webkit-scrollbar-track{background:var(--scrollbar-track)}.scrollbar-sm::-webkit-scrollbar-thumb{background:var(--scrollbar-thumb);border-radius:var(--scrollbar-radius);background-clip:padding-box;border:1px solid #0000}.scrollbar-sm::-webkit-scrollbar-corner{background:var(--scrollbar-track)}.skeleton{animation:var(--animate-pulse)}.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]{appearance:none;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-outline-style:none;outline-style:none;flex-shrink:0;align-items:center;display:inline-flex}:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]):disabled{cursor:not-allowed;opacity:.5}:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]):before{content:var(--tw-content);pointer-events:none;content:var(--tw-content);content:var(--tw-content);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);content:var(--tw-content);transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-content:"";content:var(--tw-content);display:block}.table{caption-side:bottom;width:100%}.table tfoot{border-top-style:var(--tw-border-style);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);border-top-width:1px}.table th{vertical-align:middle;--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);white-space:nowrap}.table th>[role=checkbox]{--tw-translate-y:2px;translate:var(--tw-translate-x) var(--tw-translate-y)}.table td{vertical-align:middle;white-space:nowrap}.table td>[role=checkbox]{--tw-translate-y:2px;translate:var(--tw-translate-x) var(--tw-translate-y)}.tabs{display:flex}.tabs:has(>[role=tablist][aria-orientation=horizontal]),.tabs:not(:has(>[role=tablist][aria-orientation=vertical])){flex-direction:column}.tabs:has(>[role=tablist][aria-orientation=vertical]){flex-direction:row}.tabs>[role=tablist]{width:fit-content;color:var(--color-muted-foreground);justify-content:center;align-items:center;display:inline-flex}.tabs>[role=tablist][aria-orientation=vertical]{flex-direction:column;height:fit-content}.tabs>[role=tablist]>[role=tab]{white-space:nowrap;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-outline-style:none;outline-style:none;flex:1;justify-content:center;align-items:center;display:inline-flex;position:relative}.tabs>[role=tablist]>[role=tab]:disabled,.tabs>[role=tablist]>[role=tab][aria-disabled=true]{pointer-events:none;opacity:.5}.tabs>[role=tablist]>[role=tab] svg{pointer-events:none;flex-shrink:0}.tabs>[role=tabpanel]{--tw-outline-style:none;outline-style:none;flex:1}.field>textarea,.textarea{field-sizing:content;min-height:calc(var(--spacing) * 16);--tw-outline-style:none;outline-style:none;width:100%;display:flex}:is(.field>textarea,.textarea)::placeholder{color:var(--color-muted-foreground)}:is(.field>textarea,.textarea):disabled{cursor:not-allowed;opacity:.5}.input-group{--tw-outline-style:none;outline-style:none;align-items:center;width:100%;min-width:0;display:flex;position:relative}.input-group[data-orientation=vertical],.input-group:has(>[data-align=block-start]),.input-group:has(>[data-align=block-end]){flex-direction:column;align-items:stretch;height:auto}.input-group>:is(input,textarea,select,[data-control]){appearance:none;border-style:var(--tw-border-style);min-width:0;color:var(--color-foreground);--tw-shadow:0 0 #0000;--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-outline-style:none;background-color:#0000;border-width:0;border-radius:0;outline-style:none;flex:1;display:block}.input-group>:is(input,textarea,select,[data-control])::placeholder{color:var(--color-muted-foreground)}.input-group>:is(input,textarea,select,[data-control]):focus-visible,.input-group>:is(input,textarea,select,[data-control])[aria-invalid=true]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.input-group>:is(input,select,[data-control]){height:100%}.input-group>[data-control]{width:100%}.input-group:is([data-orientation=vertical],:has(>[data-align=block-start]),:has(>[data-align=block-end]))>:is(input,select,[data-control]){flex:none;height:auto}.input-group[data-orientation=vertical]:has(>footer[data-align=end])>:is(input,select,[data-control]),.input-group:has(>[data-align=block-end])>:is(input,select,[data-control]){padding-top:calc(var(--spacing) * 3)}.input-group[data-orientation=vertical]:has(>header[data-align=start])>:is(input,select,[data-control]),.input-group:has(>[data-align=block-start])>:is(input,select,[data-control]){padding-bottom:calc(var(--spacing) * 3)}.input-group>textarea{field-sizing:content;min-height:calc(var(--spacing) * 20);resize:none;width:100%}.input-group>:is(span,kbd,svg,[role=group],.popover,.dropdown-menu,button,footer,header,hr[role=separator]){flex-shrink:0}.input-group>:is(span,[role=group],footer,header){align-items:center;display:inline-flex}.input-group>svg{pointer-events:none}.input-group>:is([data-align=start],[data-align=inline-start],[data-align=block-start]){order:-9999}.input-group>:is([data-align=end],[data-align=inline-end],[data-align=block-end]){order:9999}.input-group>:is([data-align=block-start],[data-align=block-end]){justify-content:flex-start;width:100%}.input-group>button:is([data-align=start],[data-align=inline-start]){margin-inline-start:var(--spacing)}.input-group>button:is([data-align=end],[data-align=inline-end]){margin-inline-end:var(--spacing)}.toaster{pointer-events:none;z-index:50;gap:calc(var(--spacing) * 4);width:100%;padding:calc(var(--spacing) * 4);flex-direction:column-reverse;display:flex;position:fixed;bottom:0}@media (min-width:40rem){.toaster{max-width:calc(var(--spacing) * 90)}}.toaster:not([data-align]),.toaster[data-align=end]{right:0}.toaster[data-align=start]{left:0}.toaster[data-align=center]{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);left:50%}.toaster .toast{pointer-events:auto;width:100%;transition-property:grid-template-rows,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.3s;--tw-ease:var(--ease-in-out);transition-duration:.3s;transition-timing-function:var(--ease-in-out);grid-template-rows:1fr;animation:.3s ease-in-out toast-up;display:grid}.toaster .toast .toast-content{align-items:center;gap:calc(var(--spacing) * 2.5);padding:calc(var(--spacing) * 3);font-size:13px;display:flex;overflow:hidden}.toaster .toast .toast-content svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4);flex-shrink:0}.toaster .toast .toast-content section h2{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.toaster .toast .toast-content section p{word-break:break-all;color:var(--color-muted-foreground)}.toaster .toast .toast-content footer{gap:calc(var(--spacing) * 2);flex-direction:column;margin-inline-start:auto;display:flex}.toaster .toast .toast-content footer [data-toast-action],.toaster .toast .toast-content footer [data-toast-cancel]{height:calc(var(--spacing) * 6);padding-inline:calc(var(--spacing) * 2.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.toaster .toast[aria-hidden=true]{border-style:var(--tw-border-style);opacity:0;border-width:0;grid-template-rows:0fr;padding:0;overflow:hidden}.toaster .toast[aria-hidden=true] .toast-content{border-style:var(--tw-border-style);border-width:0}[data-tooltip]{position:relative}[data-tooltip]:before{pointer-events:none;visibility:hidden;z-index:60;width:fit-content;max-width:var(--container-xs);transform-origin:var(--transform-origin);--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y);text-overflow:ellipsis;white-space:nowrap;opacity:0;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-content:attr(data-tooltip);content:var(--tw-content);position:absolute;overflow:hidden}[data-tooltip]:hover:before{visibility:visible;--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y);opacity:1}[data-tooltip]:focus-visible:not(:hover):before{display:none}:is([data-tooltip]:not([data-side]),[data-tooltip][data-side=top]):before{content:var(--tw-content);content:var(--tw-content);margin-bottom:calc(var(--spacing) * 1.5);content:var(--tw-content);--tw-translate-y:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y);bottom:100%}@media (hover:hover){:is([data-tooltip]:not([data-side]),[data-tooltip][data-side=top]):hover:before{content:var(--tw-content);--tw-translate-y:0;translate:var(--tw-translate-x) var(--tw-translate-y)}}[data-tooltip][data-side=bottom]:before{content:var(--tw-content);content:var(--tw-content);margin-top:calc(var(--spacing) * 1.5);content:var(--tw-content);--tw-translate-y:calc(var(--spacing) * -2);translate:var(--tw-translate-x) var(--tw-translate-y);top:100%}@media (hover:hover){[data-tooltip][data-side=bottom]:hover:before{content:var(--tw-content);--tw-translate-y:0;translate:var(--tw-translate-x) var(--tw-translate-y)}}:is([data-tooltip]:not([data-side]),[data-tooltip][data-side=top],[data-tooltip][data-side=bottom])[data-align=start]:before{content:var(--tw-content);left:0}:is([data-tooltip]:not([data-side]),[data-tooltip][data-side=top],[data-tooltip][data-side=bottom])[data-align=end]:before{content:var(--tw-content);right:0}:is(:is([data-tooltip]:not([data-side]),[data-tooltip][data-side=top],[data-tooltip][data-side=bottom]):not([data-align]),:is([data-tooltip]:not([data-side]),[data-tooltip][data-side=top],[data-tooltip][data-side=bottom])[data-align=center]):before{content:var(--tw-content);content:var(--tw-content);--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);left:50%}[data-tooltip][data-side=left]:before{content:var(--tw-content);content:var(--tw-content);margin-right:calc(var(--spacing) * 1.5);content:var(--tw-content);--tw-translate-x:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y);right:100%}@media (hover:hover){[data-tooltip][data-side=left]:hover:before{content:var(--tw-content);--tw-translate-x:0;translate:var(--tw-translate-x) var(--tw-translate-y)}}[data-tooltip][data-side=right]:before{content:var(--tw-content);content:var(--tw-content);margin-left:calc(var(--spacing) * 1.5);content:var(--tw-content);--tw-translate-x:calc(var(--spacing) * -2);translate:var(--tw-translate-x) var(--tw-translate-y);left:100%}@media (hover:hover){[data-tooltip][data-side=right]:hover:before{content:var(--tw-content);--tw-translate-x:0;translate:var(--tw-translate-x) var(--tw-translate-y)}}[data-tooltip][data-side=inline-start]:before{content:var(--tw-content);content:var(--tw-content);margin-right:calc(var(--spacing) * 1.5);content:var(--tw-content);--tw-translate-x:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y);right:100%}@media (hover:hover){[data-tooltip][data-side=inline-start]:hover:before{content:var(--tw-content);--tw-translate-x:0;translate:var(--tw-translate-x) var(--tw-translate-y)}}[data-tooltip][data-side=inline-start]:dir(rtl):before{content:var(--tw-content);content:var(--tw-content);content:var(--tw-content);content:var(--tw-content);margin-right:0;margin-left:calc(var(--spacing) * 1.5);content:var(--tw-content);--tw-translate-x:calc(var(--spacing) * -2);translate:var(--tw-translate-x) var(--tw-translate-y);left:100%;right:auto}@media (hover:hover){[data-tooltip][data-side=inline-start]:dir(rtl):hover:before{content:var(--tw-content);--tw-translate-x:0;translate:var(--tw-translate-x) var(--tw-translate-y)}}[data-tooltip][data-side=inline-end]:before{content:var(--tw-content);content:var(--tw-content);margin-left:calc(var(--spacing) * 1.5);content:var(--tw-content);--tw-translate-x:calc(var(--spacing) * -2);translate:var(--tw-translate-x) var(--tw-translate-y);left:100%}@media (hover:hover){[data-tooltip][data-side=inline-end]:hover:before{content:var(--tw-content);--tw-translate-x:0;translate:var(--tw-translate-x) var(--tw-translate-y)}}[data-tooltip][data-side=inline-end]:dir(rtl):before{content:var(--tw-content);content:var(--tw-content);content:var(--tw-content);margin-right:calc(var(--spacing) * 1.5);content:var(--tw-content);content:var(--tw-content);--tw-translate-x:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y);margin-left:0;left:auto;right:100%}@media (hover:hover){[data-tooltip][data-side=inline-end]:dir(rtl):hover:before{content:var(--tw-content);--tw-translate-x:0;translate:var(--tw-translate-x) var(--tw-translate-y)}}:is([data-tooltip][data-side=left],[data-tooltip][data-side=right],[data-tooltip][data-side=inline-start],[data-tooltip][data-side=inline-end])[data-align=start]:before{content:var(--tw-content);top:0}:is([data-tooltip][data-side=left],[data-tooltip][data-side=right],[data-tooltip][data-side=inline-start],[data-tooltip][data-side=inline-end])[data-align=end]:before{content:var(--tw-content);bottom:0}:is(:is([data-tooltip][data-side=left],[data-tooltip][data-side=right],[data-tooltip][data-side=inline-start],[data-tooltip][data-side=inline-end]):not([data-align]),:is([data-tooltip][data-side=left],[data-tooltip][data-side=right],[data-tooltip][data-side=inline-start],[data-tooltip][data-side=inline-end])[data-align=center]):before{content:var(--tw-content);content:var(--tw-content);--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);top:50%}.accordion>details:not(:last-child){border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.accordion>details>summary{border-radius:var(--radius-md);padding-block:calc(var(--spacing) * 4);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}@media (hover:hover){.accordion>details>summary:hover{text-decoration-line:underline}}.accordion>details>summary:focus-visible{border-color:var(--color-ring);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-ring)}@supports (color:color-mix(in lab, red, red)){.accordion>details>summary:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-ring) 50%, transparent)}}.accordion>details>summary>svg:last-child{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4);color:var(--color-muted-foreground);margin-inline-start:auto}.accordion>details>:not(summary){padding-top:0;padding-bottom:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.btn{border-radius:var(--radius-md);border-style:var(--tw-border-style);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);background-clip:padding-box;border-width:1px;border-color:#0000}.btn:focus-visible{border-color:var(--color-ring);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-ring)}@supports (color:color-mix(in lab, red, red)){.btn:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-ring) 50%, transparent)}}.btn:active:not([aria-haspopup]){--tw-translate-y:1px;translate:var(--tw-translate-x) var(--tw-translate-y)}.btn[aria-invalid=true]{border-color:var(--color-destructive);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.btn[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 20%, transparent)}}.btn:is(html.dark *)[aria-invalid=true]{border-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.btn:is(html.dark *)[aria-invalid=true]{border-color:color-mix(in oklab, var(--color-destructive) 50%, transparent)}}.btn:is(html.dark *)[aria-invalid=true]{--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.btn:is(html.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 40%, transparent)}}.btn svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.btn:not([data-size]),.btn[data-size=default]{height:calc(var(--spacing) * 9);gap:calc(var(--spacing) * 1.5);padding-inline:calc(var(--spacing) * 2.5)}:is(.btn:not([data-size]),.btn[data-size=default]):has([data-icon=inline-end]){padding-right:calc(var(--spacing) * 2)}:is(.btn:not([data-size]),.btn[data-size=default]):has([data-icon=inline-start]){padding-left:calc(var(--spacing) * 2)}.btn[data-size=xs]{height:calc(var(--spacing) * 6);gap:var(--spacing);border-radius:min(var(--radius-md), 8px);padding-inline:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.btn[data-size=xs]:has([data-icon=inline-end]){padding-right:calc(var(--spacing) * 1.5)}.btn[data-size=xs]:has([data-icon=inline-start]){padding-left:calc(var(--spacing) * 1.5)}.btn[data-size=xs] svg:not([class*=size-]){width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.btn[data-size=sm]{height:calc(var(--spacing) * 8);gap:var(--spacing);border-radius:min(var(--radius-md), 10px);padding-inline:calc(var(--spacing) * 2.5)}.btn[data-size=sm]:has([data-icon=inline-end]){padding-right:calc(var(--spacing) * 1.5)}.btn[data-size=sm]:has([data-icon=inline-start]){padding-left:calc(var(--spacing) * 1.5)}.btn[data-size=lg]{height:calc(var(--spacing) * 10);gap:calc(var(--spacing) * 1.5);padding-inline:calc(var(--spacing) * 2.5)}.btn[data-size=lg]:has([data-icon=inline-end]){padding-right:calc(var(--spacing) * 2)}.btn[data-size=lg]:has([data-icon=inline-start]){padding-left:calc(var(--spacing) * 2)}.btn[data-size=icon]{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.btn[data-size=icon-xs]{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6);border-radius:min(var(--radius-md), 8px)}.btn[data-size=icon-xs] svg:not([class*=size-]){width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.btn[data-size=icon-sm]{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8);border-radius:min(var(--radius-md), 10px)}.btn[data-size=icon-lg]{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.btn:not([data-variant]),.btn[data-variant=primary]{background-color:var(--color-primary);color:var(--color-primary-foreground)}@media (hover:hover){:is(.btn:not([data-variant]),.btn[data-variant=primary]):hover{background-color:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){:is(.btn:not([data-variant]),.btn[data-variant=primary]):hover{background-color:color-mix(in oklab, var(--color-primary) 80%, transparent)}}}.btn[data-variant=secondary]{background-color:var(--color-secondary);color:var(--color-secondary-foreground)}@media (hover:hover){.btn[data-variant=secondary]:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.btn[data-variant=secondary]:hover{background-color:color-mix(in oklch,var(--secondary),var(--foreground) 5%)}}}.btn[data-variant=secondary][aria-expanded=true]{background-color:var(--color-secondary);color:var(--color-secondary-foreground)}.btn[data-variant=outline]{border-color:var(--color-border);background-color:var(--color-background);--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}@media (hover:hover){.btn[data-variant=outline]:hover{background-color:var(--color-muted);color:var(--color-foreground)}}.btn[data-variant=outline][aria-expanded=true]{background-color:var(--color-muted);color:var(--color-foreground)}.btn[data-variant=outline]:is(html.dark *){border-color:var(--color-input);background-color:var(--color-input)}@supports (color:color-mix(in lab, red, red)){.btn[data-variant=outline]:is(html.dark *){background-color:color-mix(in oklab, var(--color-input) 30%, transparent)}}@media (hover:hover){.btn[data-variant=outline]:is(html.dark *):hover{background-color:var(--color-input)}@supports (color:color-mix(in lab, red, red)){.btn[data-variant=outline]:is(html.dark *):hover{background-color:color-mix(in oklab, var(--color-input) 50%, transparent)}}.btn[data-variant=ghost]:hover{background-color:var(--color-muted);color:var(--color-foreground)}}.btn[data-variant=ghost][aria-expanded=true]{background-color:var(--color-muted);color:var(--color-foreground)}@media (hover:hover){.btn[data-variant=ghost]:is(html.dark *):hover{background-color:var(--color-muted)}@supports (color:color-mix(in lab, red, red)){.btn[data-variant=ghost]:is(html.dark *):hover{background-color:color-mix(in oklab, var(--color-muted) 50%, transparent)}}}.btn[data-variant=link]{color:var(--color-primary);text-underline-offset:4px}@media (hover:hover){.btn[data-variant=link]:hover{text-decoration-line:underline}}.btn[data-variant=destructive]{background-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.btn[data-variant=destructive]{background-color:color-mix(in oklab, var(--color-destructive) 10%, transparent)}}.btn[data-variant=destructive]{color:var(--color-destructive)}@media (hover:hover){.btn[data-variant=destructive]:hover{background-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.btn[data-variant=destructive]:hover{background-color:color-mix(in oklab, var(--color-destructive) 20%, transparent)}}}.btn[data-variant=destructive]:focus-visible{border-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.btn[data-variant=destructive]:focus-visible{border-color:color-mix(in oklab, var(--color-destructive) 40%, transparent)}}.btn[data-variant=destructive]:focus-visible{--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.btn[data-variant=destructive]:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 20%, transparent)}}.btn[data-variant=destructive]:is(html.dark *){background-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.btn[data-variant=destructive]:is(html.dark *){background-color:color-mix(in oklab, var(--color-destructive) 20%, transparent)}}@media (hover:hover){.btn[data-variant=destructive]:is(html.dark *):hover{background-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.btn[data-variant=destructive]:is(html.dark *):hover{background-color:color-mix(in oklab, var(--color-destructive) 30%, transparent)}}}.btn[data-variant=destructive]:is(html.dark *):focus-visible{--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.btn[data-variant=destructive]:is(html.dark *):focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 40%, transparent)}}.button-group:has(>.button-group){gap:calc(var(--spacing) * 2)}.button-group:not([data-orientation=vertical])>:last-child,.button-group:not([data-orientation=vertical])>:is(.dropdown-menu,.popover,.select):last-child>button{border-start-end-radius:var(--radius-md)!important;border-end-end-radius:var(--radius-md)!important}.button-group[data-orientation=vertical]>:last-child,.button-group[data-orientation=vertical]>:is(.dropdown-menu,.popover,.select):last-child>button{border-bottom-right-radius:var(--radius-md)!important;border-bottom-left-radius:var(--radius-md)!important}.button-group>:is(span,label,output){gap:calc(var(--spacing) * 2);border-radius:var(--radius-md);border-style:var(--tw-border-style);background-color:var(--color-muted);padding-inline:calc(var(--spacing) * 2.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-width:1px}.button-group>:is(span,label,output) svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.button-group>hr[role=separator]{background-color:var(--color-input)}.tabs{gap:calc(var(--spacing) * 2)}.tabs>[role=tablist]{height:calc(var(--spacing) * 9);border-radius:var(--radius-lg);padding:3px}.tabs>[role=tablist]:not([data-variant=line]){background-color:var(--color-muted)}.tabs>[role=tablist][data-variant=line]{gap:var(--spacing);background-color:#0000;border-radius:0}.tabs>[role=tablist][aria-orientation=vertical]{height:fit-content}.tabs>[role=tablist]>[role=tab]{gap:calc(var(--spacing) * 1.5);border-radius:var(--radius-md);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2);padding-block:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-foreground);border-width:1px;border-color:#0000}@supports (color:color-mix(in lab, red, red)){.tabs>[role=tablist]>[role=tab]{color:color-mix(in oklab, var(--color-foreground) 60%, transparent)}}@media (hover:hover){.tabs>[role=tablist]>[role=tab]:hover{color:var(--color-foreground)}}.tabs>[role=tablist]>[role=tab]:focus-visible{border-color:var(--color-ring);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-ring)}@supports (color:color-mix(in lab, red, red)){.tabs>[role=tablist]>[role=tab]:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-ring) 50%, transparent)}}.tabs>[role=tablist]>[role=tab]:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px;outline-color:var(--color-ring)}.tabs>[role=tablist]>[role=tab]:is(html.dark *){color:var(--color-muted-foreground)}@media (hover:hover){.tabs>[role=tablist]>[role=tab]:is(html.dark *):hover{color:var(--color-foreground)}}.tabs>[role=tablist]>[role=tab] svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.tabs>[role=tablist]>[role=tab]:after{content:var(--tw-content);content:var(--tw-content);background-color:var(--color-foreground);content:var(--tw-content);opacity:0;content:var(--tw-content);transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));position:absolute}.tabs>[role=tablist][aria-orientation=vertical]>[role=tab]{justify-content:flex-start;width:100%}.tabs>[role=tablist][data-variant=line][aria-orientation=horizontal]>[role=tab]{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);background-color:#0000}.tabs>[role=tablist][data-variant=line][aria-orientation=horizontal]>[role=tab]:after{content:var(--tw-content);content:var(--tw-content);content:var(--tw-content);height:calc(var(--spacing) * .5);inset-inline:0;bottom:-5px}.tabs>[role=tablist][data-variant=line][aria-orientation=vertical]>[role=tab]{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);background-color:#0000}.tabs>[role=tablist][data-variant=line][aria-orientation=vertical]>[role=tab]:after{content:var(--tw-content);content:var(--tw-content);content:var(--tw-content);width:calc(var(--spacing) * .5);inset-block:0;inset-inline-end:-4px}.tabs>[role=tablist]:not([data-variant=line])>[role=tab][aria-selected=true]{background-color:var(--color-background);color:var(--color-foreground);--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.tabs>[role=tablist]:not([data-variant=line])>[role=tab][aria-selected=true]:is(html.dark *){border-color:var(--color-input);background-color:var(--color-input)}@supports (color:color-mix(in lab, red, red)){.tabs>[role=tablist]:not([data-variant=line])>[role=tab][aria-selected=true]:is(html.dark *){background-color:color-mix(in oklab, var(--color-input) 30%, transparent)}}.tabs>[role=tablist]:not([data-variant=line])>[role=tab][aria-selected=true]:is(html.dark *){color:var(--color-foreground)}.tabs>[role=tablist][data-variant=line]>[role=tab][aria-selected=true]{color:var(--color-foreground);--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);background-color:#0000}.tabs>[role=tablist][data-variant=line]>[role=tab][aria-selected=true]:after{content:var(--tw-content);opacity:1}.tabs>[role=tablist][data-variant=line]>[role=tab][aria-selected=true]:is(html.dark *){color:var(--color-foreground);background-color:#0000;border-color:#0000}.tabs>[role=tablist]>[role=tab]:has(>svg:first-child){padding-inline-start:calc(var(--spacing) * 1.5)}.tabs>[role=tablist]>[role=tab]:has(>svg:last-child){padding-inline-end:calc(var(--spacing) * 1.5)}.tabs>[role=tabpanel]{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.field>textarea,.textarea{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-input);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000}:is(.field>textarea,.textarea):focus-visible{border-color:var(--color-ring);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-ring)}@supports (color:color-mix(in lab, red, red)){:is(.field>textarea,.textarea):focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-ring) 50%, transparent)}}:is(.field>textarea,.textarea)[aria-invalid=true]{border-color:var(--color-destructive);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){:is(.field>textarea,.textarea)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 20%, transparent)}}@media (min-width:48rem){.field>textarea,.textarea{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}:is(.field>textarea,.textarea):is(html.dark *){background-color:var(--color-input)}@supports (color:color-mix(in lab, red, red)){:is(.field>textarea,.textarea):is(html.dark *){background-color:color-mix(in oklab, var(--color-input) 30%, transparent)}}:is(.field>textarea,.textarea):is(html.dark *)[aria-invalid=true]{border-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){:is(.field>textarea,.textarea):is(html.dark *)[aria-invalid=true]{border-color:color-mix(in oklab, var(--color-destructive) 50%, transparent)}}:is(.field>textarea,.textarea):is(html.dark *)[aria-invalid=true]{--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){:is(.field>textarea,.textarea):is(html.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 40%, transparent)}}.field>input[type=text]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=email],.field>input[type=password],.field>input[type=number],.field>input[type=file],.field>input[type=tel],.field>input[type=url],.field>input[type=search]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=date],.field>input[type=datetime-local],.field>input[type=month],.field>input[type=week],.field>input[type=time],.field>input:not([type]),.input[type=text],.input[type=email],.input[type=password],.input[type=number],.input[type=file],.input[type=tel],.input[type=url],.input[type=search],.input[type=date],.input[type=datetime-local],.input[type=month],.input[type=week],.input[type=time],.input:not([type]){height:calc(var(--spacing) * 9);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-input);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000}:is(.field>input[type=text]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=email],.field>input[type=password],.field>input[type=number],.field>input[type=file],.field>input[type=tel],.field>input[type=url],.field>input[type=search]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=date],.field>input[type=datetime-local],.field>input[type=month],.field>input[type=week],.field>input[type=time],.field>input:not([type]),.input[type=text],.input[type=email],.input[type=password],.input[type=number],.input[type=file],.input[type=tel],.input[type=url],.input[type=search],.input[type=date],.input[type=datetime-local],.input[type=month],.input[type=week],.input[type=time],.input:not([type]))::file-selector-button{height:calc(var(--spacing) * 7)}:is(.field>input[type=text]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=email],.field>input[type=password],.field>input[type=number],.field>input[type=file],.field>input[type=tel],.field>input[type=url],.field>input[type=search]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=date],.field>input[type=datetime-local],.field>input[type=month],.field>input[type=week],.field>input[type=time],.field>input:not([type]),.input[type=text],.input[type=email],.input[type=password],.input[type=number],.input[type=file],.input[type=tel],.input[type=url],.input[type=search],.input[type=date],.input[type=datetime-local],.input[type=month],.input[type=week],.input[type=time],.input:not([type]))::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}:is(.field>input[type=text]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=email],.field>input[type=password],.field>input[type=number],.field>input[type=file],.field>input[type=tel],.field>input[type=url],.field>input[type=search]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=date],.field>input[type=datetime-local],.field>input[type=month],.field>input[type=week],.field>input[type=time],.field>input:not([type]),.input[type=text],.input[type=email],.input[type=password],.input[type=number],.input[type=file],.input[type=tel],.input[type=url],.input[type=search],.input[type=date],.input[type=datetime-local],.input[type=month],.input[type=week],.input[type=time],.input:not([type]))::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}:is(.field>input[type=text]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=email],.field>input[type=password],.field>input[type=number],.field>input[type=file],.field>input[type=tel],.field>input[type=url],.field>input[type=search]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=date],.field>input[type=datetime-local],.field>input[type=month],.field>input[type=week],.field>input[type=time],.field>input:not([type]),.input[type=text],.input[type=email],.input[type=password],.input[type=number],.input[type=file],.input[type=tel],.input[type=url],.input[type=search],.input[type=date],.input[type=datetime-local],.input[type=month],.input[type=week],.input[type=time],.input:not([type])):focus-visible{border-color:var(--color-ring);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-ring)}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=text]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=email],.field>input[type=password],.field>input[type=number],.field>input[type=file],.field>input[type=tel],.field>input[type=url],.field>input[type=search]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=date],.field>input[type=datetime-local],.field>input[type=month],.field>input[type=week],.field>input[type=time],.field>input:not([type]),.input[type=text],.input[type=email],.input[type=password],.input[type=number],.input[type=file],.input[type=tel],.input[type=url],.input[type=search],.input[type=date],.input[type=datetime-local],.input[type=month],.input[type=week],.input[type=time],.input:not([type])):focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-ring) 50%, transparent)}}:is(.field>input[type=text]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=email],.field>input[type=password],.field>input[type=number],.field>input[type=file],.field>input[type=tel],.field>input[type=url],.field>input[type=search]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=date],.field>input[type=datetime-local],.field>input[type=month],.field>input[type=week],.field>input[type=time],.field>input:not([type]),.input[type=text],.input[type=email],.input[type=password],.input[type=number],.input[type=file],.input[type=tel],.input[type=url],.input[type=search],.input[type=date],.input[type=datetime-local],.input[type=month],.input[type=week],.input[type=time],.input:not([type]))[aria-invalid=true]{border-color:var(--color-destructive);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=text]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=email],.field>input[type=password],.field>input[type=number],.field>input[type=file],.field>input[type=tel],.field>input[type=url],.field>input[type=search]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=date],.field>input[type=datetime-local],.field>input[type=month],.field>input[type=week],.field>input[type=time],.field>input:not([type]),.input[type=text],.input[type=email],.input[type=password],.input[type=number],.input[type=file],.input[type=tel],.input[type=url],.input[type=search],.input[type=date],.input[type=datetime-local],.input[type=month],.input[type=week],.input[type=time],.input:not([type]))[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 20%, transparent)}}@media (min-width:48rem){.field>input[type=text]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=email],.field>input[type=password],.field>input[type=number],.field>input[type=file],.field>input[type=tel],.field>input[type=url],.field>input[type=search]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=date],.field>input[type=datetime-local],.field>input[type=month],.field>input[type=week],.field>input[type=time],.field>input:not([type]),.input[type=text],.input[type=email],.input[type=password],.input[type=number],.input[type=file],.input[type=tel],.input[type=url],.input[type=search],.input[type=date],.input[type=datetime-local],.input[type=month],.input[type=week],.input[type=time],.input:not([type]){font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}:is(.field>input[type=text]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=email],.field>input[type=password],.field>input[type=number],.field>input[type=file],.field>input[type=tel],.field>input[type=url],.field>input[type=search]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=date],.field>input[type=datetime-local],.field>input[type=month],.field>input[type=week],.field>input[type=time],.field>input:not([type]),.input[type=text],.input[type=email],.input[type=password],.input[type=number],.input[type=file],.input[type=tel],.input[type=url],.input[type=search],.input[type=date],.input[type=datetime-local],.input[type=month],.input[type=week],.input[type=time],.input:not([type])):is(html.dark *){background-color:var(--color-input)}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=text]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=email],.field>input[type=password],.field>input[type=number],.field>input[type=file],.field>input[type=tel],.field>input[type=url],.field>input[type=search]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=date],.field>input[type=datetime-local],.field>input[type=month],.field>input[type=week],.field>input[type=time],.field>input:not([type]),.input[type=text],.input[type=email],.input[type=password],.input[type=number],.input[type=file],.input[type=tel],.input[type=url],.input[type=search],.input[type=date],.input[type=datetime-local],.input[type=month],.input[type=week],.input[type=time],.input:not([type])):is(html.dark *){background-color:color-mix(in oklab, var(--color-input) 30%, transparent)}}:is(.field>input[type=text]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=email],.field>input[type=password],.field>input[type=number],.field>input[type=file],.field>input[type=tel],.field>input[type=url],.field>input[type=search]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=date],.field>input[type=datetime-local],.field>input[type=month],.field>input[type=week],.field>input[type=time],.field>input:not([type]),.input[type=text],.input[type=email],.input[type=password],.input[type=number],.input[type=file],.input[type=tel],.input[type=url],.input[type=search],.input[type=date],.input[type=datetime-local],.input[type=month],.input[type=week],.input[type=time],.input:not([type])):is(html.dark *)[aria-invalid=true]{border-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=text]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=email],.field>input[type=password],.field>input[type=number],.field>input[type=file],.field>input[type=tel],.field>input[type=url],.field>input[type=search]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=date],.field>input[type=datetime-local],.field>input[type=month],.field>input[type=week],.field>input[type=time],.field>input:not([type]),.input[type=text],.input[type=email],.input[type=password],.input[type=number],.input[type=file],.input[type=tel],.input[type=url],.input[type=search],.input[type=date],.input[type=datetime-local],.input[type=month],.input[type=week],.input[type=time],.input:not([type])):is(html.dark *)[aria-invalid=true]{border-color:color-mix(in oklab, var(--color-destructive) 50%, transparent)}}:is(.field>input[type=text]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=email],.field>input[type=password],.field>input[type=number],.field>input[type=file],.field>input[type=tel],.field>input[type=url],.field>input[type=search]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=date],.field>input[type=datetime-local],.field>input[type=month],.field>input[type=week],.field>input[type=time],.field>input:not([type]),.input[type=text],.input[type=email],.input[type=password],.input[type=number],.input[type=file],.input[type=tel],.input[type=url],.input[type=search],.input[type=date],.input[type=datetime-local],.input[type=month],.input[type=week],.input[type=time],.input:not([type])):is(html.dark *)[aria-invalid=true]{--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=text]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=email],.field>input[type=password],.field>input[type=number],.field>input[type=file],.field>input[type=tel],.field>input[type=url],.field>input[type=search]:not(:is(.command>header input,.dialog>*>header input)),.field>input[type=date],.field>input[type=datetime-local],.field>input[type=month],.field>input[type=week],.field>input[type=time],.field>input:not([type]),.input[type=text],.input[type=email],.input[type=password],.input[type=number],.input[type=file],.input[type=tel],.input[type=url],.input[type=search],.input[type=date],.input[type=datetime-local],.input[type=month],.input[type=week],.input[type=time],.input:not([type])):is(html.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 40%, transparent)}}.field>select,select.select{height:calc(var(--spacing) * 9);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-input);width:fit-content;padding-block:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;padding-inline-start:calc(var(--spacing) * 2.5);padding-inline-end:calc(var(--spacing) * 8)}:is(.field>select,select.select) ::selection{background-color:var(--color-primary)}:is(.field>select,select.select)::selection{background-color:var(--color-primary)}:is(.field>select,select.select) ::selection{color:var(--color-primary-foreground)}:is(.field>select,select.select)::selection{color:var(--color-primary-foreground)}:is(.field>select,select.select)::placeholder{color:var(--color-muted-foreground)}:is(.field>select,select.select):focus-visible{border-color:var(--color-ring);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-ring)}@supports (color:color-mix(in lab, red, red)){:is(.field>select,select.select):focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-ring) 50%, transparent)}}:is(.field>select,select.select)[aria-invalid=true]{border-color:var(--color-destructive);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){:is(.field>select,select.select)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 20%, transparent)}}:is(.field>select,select.select)[data-size=sm]{height:calc(var(--spacing) * 8)}:is(.field>select,select.select):is(html.dark *){background-color:var(--color-input)}@supports (color:color-mix(in lab, red, red)){:is(.field>select,select.select):is(html.dark *){background-color:color-mix(in oklab, var(--color-input) 30%, transparent)}}@media (hover:hover){:is(.field>select,select.select):is(html.dark *):hover{background-color:var(--color-input)}@supports (color:color-mix(in lab, red, red)){:is(.field>select,select.select):is(html.dark *):hover{background-color:color-mix(in oklab, var(--color-input) 50%, transparent)}}}:is(.field>select,select.select):is(html.dark *)[aria-invalid=true]{border-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){:is(.field>select,select.select):is(html.dark *)[aria-invalid=true]{border-color:color-mix(in oklab, var(--color-destructive) 50%, transparent)}}:is(.field>select,select.select):is(html.dark *)[aria-invalid=true]{--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){:is(.field>select,select.select):is(html.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 40%, transparent)}}.field>select,select.select{background-image:var(--chevron-down-icon-50);background-position:right .625rem center;background-repeat:no-repeat;background-size:1rem}:is(.field>select,select.select):dir(rtl){background-position:.625rem}.alert{gap:calc(var(--spacing) * .5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);text-align:start;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border-width:1px;display:grid}.alert:has(>svg){column-gap:calc(var(--spacing) * 2.5);grid-template-columns:auto 1fr}.alert>svg{--tw-translate-y:calc(var(--spacing) * .5);translate:var(--tw-translate-x) var(--tw-translate-y);color:currentColor;grid-row:span 2/span 2}.alert>svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.alert{background-color:var(--color-card);color:var(--color-card-foreground)}.alert[data-variant=destructive]{background-color:var(--color-card);color:var(--color-destructive)}.alert[data-variant=destructive]>svg{color:currentColor}.alert>:is(h2,h3,h4,h5,h6,strong,[data-title]),.alert[data-variant=destructive]>:is(h2,h3,h4,h5,h6,strong,[data-title]){--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.alert:has(>svg)>:is(h2,h3,h4,h5,h6,strong,[data-title]){grid-column-start:2}.alert>section{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));text-wrap:balance;color:var(--color-muted-foreground)}@media (min-width:48rem){.alert>section{text-wrap:pretty}}.alert>section p:not(:last-child){margin-bottom:calc(var(--spacing) * 4)}.alert[data-variant=destructive]>section{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));text-wrap:balance;color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.alert[data-variant=destructive]>section{color:color-mix(in oklab, var(--color-destructive) 90%, transparent)}}@media (min-width:48rem){.alert[data-variant=destructive]>section{text-wrap:pretty}}.alert[data-variant=destructive]>section p:not(:last-child){margin-bottom:calc(var(--spacing) * 4)}.alert>footer,.alert[data-variant=destructive]>footer{inset-inline-end:calc(var(--spacing) * 3);top:calc(var(--spacing) * 2.5)}.avatar{border-radius:3.40282e38px}.avatar:after{content:var(--tw-content);border-radius:inherit}.avatar{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.avatar[data-size=sm]{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.avatar[data-size=lg]{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.avatar>span:not(.avatar-badge){border-radius:inherit;background-color:var(--color-muted);color:var(--color-muted-foreground)}.avatar>img{border-radius:inherit}.avatar>.avatar-badge{background-color:var(--color-primary);color:var(--color-primary-foreground);--tw-ring-color:var(--color-background)}.avatar-group>[data-count]{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8);background-color:var(--color-muted);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-muted-foreground);border-radius:3.40282e38px}.avatar-group>[data-count]>svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.avatar-group:has(>.avatar[data-size=sm])>[data-count]{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.avatar-group:has(>.avatar[data-size=sm])>[data-count]>svg{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.avatar-group:has(>.avatar[data-size=lg])>[data-count]{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.avatar-group:has(>.avatar[data-size=lg])>[data-count]>svg{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.badge{height:calc(var(--spacing) * 5);gap:var(--spacing);border-radius:var(--radius-4xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-width:1px;border-color:#0000}.badge>svg{width:calc(var(--spacing) * 3)!important;height:calc(var(--spacing) * 3)!important}.badge:not([data-variant]),.badge[data-variant=primary]{background-color:var(--color-primary);color:var(--color-primary-foreground)}@media (hover:hover){:is(.badge:not([data-variant]),.badge[data-variant=primary]):is(a):hover{background-color:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){:is(.badge:not([data-variant]),.badge[data-variant=primary]):is(a):hover{background-color:color-mix(in oklab, var(--color-primary) 80%, transparent)}}}.badge[data-variant=secondary]{background-color:var(--color-secondary);color:var(--color-secondary-foreground)}@media (hover:hover){.badge[data-variant=secondary]:is(a):hover{background-color:var(--color-secondary)}@supports (color:color-mix(in lab, red, red)){.badge[data-variant=secondary]:is(a):hover{background-color:color-mix(in oklab, var(--color-secondary) 80%, transparent)}}}.badge[data-variant=outline]{border-color:var(--color-border);color:var(--color-foreground)}@media (hover:hover){.badge[data-variant=outline]:is(a):hover{background-color:var(--color-muted);color:var(--color-muted-foreground)}}.badge[data-variant=destructive]{background-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.badge[data-variant=destructive]{background-color:color-mix(in oklab, var(--color-destructive) 10%, transparent)}}.badge[data-variant=destructive]{color:var(--color-destructive)}.badge[data-variant=destructive]:focus-visible{--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.badge[data-variant=destructive]:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 20%, transparent)}}.badge[data-variant=destructive]:is(html.dark *){background-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.badge[data-variant=destructive]:is(html.dark *){background-color:color-mix(in oklab, var(--color-destructive) 20%, transparent)}}.badge[data-variant=destructive]:is(html.dark *):focus-visible{--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.badge[data-variant=destructive]:is(html.dark *):focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 40%, transparent)}}@media (hover:hover){.badge[data-variant=destructive]:is(a):hover{background-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.badge[data-variant=destructive]:is(a):hover{background-color:color-mix(in oklab, var(--color-destructive) 20%, transparent)}}.badge[data-variant=ghost]:hover{background-color:var(--color-muted);color:var(--color-muted-foreground)}.badge[data-variant=ghost]:is(html.dark *):hover{background-color:var(--color-muted)}@supports (color:color-mix(in lab, red, red)){.badge[data-variant=ghost]:is(html.dark *):hover{background-color:color-mix(in oklab, var(--color-muted) 50%, transparent)}}}.badge[data-variant=link]{color:var(--color-primary);text-underline-offset:4px}@media (hover:hover){.badge[data-variant=link]:hover{text-decoration-line:underline}}.breadcrumb>ol{gap:calc(var(--spacing) * 1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-muted-foreground)}@media (min-width:40rem){.breadcrumb>ol{gap:calc(var(--spacing) * 2.5)}}.breadcrumb li{gap:calc(var(--spacing) * 1.5)}.breadcrumb a{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.breadcrumb a:hover{color:var(--color-foreground)}}.breadcrumb [aria-current=page]{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal);color:var(--color-foreground)}.breadcrumb li[aria-hidden=true]>svg{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.breadcrumb li>span[aria-hidden=true]{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.breadcrumb li>span[aria-hidden=true]>svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.command{background-color:var(--color-popover);padding:var(--spacing);color:var(--color-popover-foreground);border-radius:var(--radius-xl)!important}.command-dialog>.command{background-color:var(--color-popover);color:var(--color-popover-foreground);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-foreground);border-radius:var(--radius-xl)!important}@supports (color:color-mix(in lab, red, red)){.command-dialog>.command{--tw-ring-color:color-mix(in oklab, var(--color-foreground) 10%, transparent)}}.command-dialog>.command{--tw-duration:.1s;transition-duration:.1s}.command>header{margin:var(--spacing);height:calc(var(--spacing) * 8);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-input);gap:0;margin-bottom:0}@supports (color:color-mix(in lab, red, red)){.command>header{border-color:color-mix(in oklab, var(--color-input) 30%, transparent)}}.command>header{background-color:var(--color-input)}@supports (color:color-mix(in lab, red, red)){.command>header{background-color:color-mix(in oklab, var(--color-input) 30%, transparent)}}.command>header{padding-inline:calc(var(--spacing) * 2)}.command>header svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4);opacity:.5}.command>header input{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));padding-inline-start:calc(var(--spacing) * 1.5)}.command>header input::placeholder{color:var(--color-muted-foreground)}.command [role=menu]{max-height:calc(var(--spacing) * 72);scroll-padding-block:var(--spacing)}.command [role=menu]:not(:has([role=menuitem]:not([aria-hidden=true]))):before{padding-block:calc(var(--spacing) * 6);text-align:center;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.command [role=group]{padding:var(--spacing);color:var(--color-foreground)}.command [role=heading]{padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-muted-foreground)}.command [role=separator]{margin-inline:calc(var(--spacing) * -1);background-color:var(--color-border);width:auto;height:1px}.command [role=menuitem]{gap:calc(var(--spacing) * 2);border-radius:var(--radius-sm);padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}:where(.command-dialog) :is(.command [role=menuitem]){border-radius:var(--radius-lg)}.command [role=menuitem] svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.command [role=menuitem].active{background-color:var(--color-muted);color:var(--color-foreground)}.command [role=menuitem].active svg{color:var(--color-foreground)}.command [data-shortcut]{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest);color:var(--color-muted-foreground)}.command [role=menuitem].active [data-shortcut]{color:var(--color-foreground)}.combobox>input[role=combobox]{height:calc(var(--spacing) * 9);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-input);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;padding-inline-start:calc(var(--spacing) * 2.5);padding-inline-end:calc(var(--spacing) * 8)}.combobox>input[role=combobox]:focus-visible{border-color:var(--color-ring);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-ring)}@supports (color:color-mix(in lab, red, red)){.combobox>input[role=combobox]:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-ring) 50%, transparent)}}.combobox>input[role=combobox][aria-invalid=true]{border-color:var(--color-destructive);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.combobox>input[role=combobox][aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 20%, transparent)}}.combobox>input[role=combobox]:is(html.dark *){background-color:var(--color-input)}@supports (color:color-mix(in lab, red, red)){.combobox>input[role=combobox]:is(html.dark *){background-color:color-mix(in oklab, var(--color-input) 30%, transparent)}}.combobox>input[role=combobox]:is(html.dark *)[aria-invalid=true]{border-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.combobox>input[role=combobox]:is(html.dark *)[aria-invalid=true]{border-color:color-mix(in oklab, var(--color-destructive) 50%, transparent)}}.combobox>input[role=combobox]:is(html.dark *)[aria-invalid=true]{--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.combobox>input[role=combobox]:is(html.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 40%, transparent)}}.combobox>svg,.combobox-trigger-icon{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4);--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);color:var(--color-muted-foreground);inset-inline-end:calc(var(--spacing) * 2.5);top:50%}.combobox [data-popover]{max-height:calc(var(--spacing) * 72);border-radius:var(--radius-md);background-color:var(--color-popover);color:var(--color-popover-foreground);--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:color-mix(in oklab, var(--color-foreground) 10%, transparent);--tw-duration:.1s;gap:0;padding:0;transition-duration:.1s}.combobox [role=listbox]{max-height:min(18rem, var(--available-height,18rem));scroll-padding-block:var(--spacing);padding:var(--spacing);overflow-y:auto}.combobox [role=listbox]:not(:has([data-value]:not([aria-hidden=true]))){padding:0}.combobox [role=listbox]:not(:has([data-value]:not([aria-hidden=true]))):before{padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:center;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-muted-foreground)}.combobox [role=heading]{padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--color-muted-foreground)}.combobox [role=option]{gap:calc(var(--spacing) * 2);border-radius:var(--radius-sm);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));padding-inline-start:calc(var(--spacing) * 2);padding-inline-end:calc(var(--spacing) * 8)}.combobox [role=option] svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.combobox [role=option].active,.combobox [role=option]:focus-visible{background-color:var(--color-accent);color:var(--color-accent-foreground)}:is(.combobox [role=option].active,.combobox [role=option]:focus-visible) svg{color:var(--color-accent-foreground)}.combobox [role=group]{scroll-margin-block:var(--spacing);padding:var(--spacing)}.combobox [role=separator]{margin-inline:calc(var(--spacing) * -1);margin-block:var(--spacing);background-color:var(--color-border);height:1px}.combobox-chips{min-height:calc(var(--spacing) * 9);gap:calc(var(--spacing) * 1.5);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-input);padding-inline:calc(var(--spacing) * 1.5);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;background-clip:padding-box}.combobox-chips:focus-within{border-color:var(--color-ring);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-ring)}@supports (color:color-mix(in lab, red, red)){.combobox-chips:focus-within{--tw-ring-color:color-mix(in oklab, var(--color-ring) 50%, transparent)}}.combobox-chips:has([aria-invalid=true]){border-color:var(--color-destructive);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.combobox-chips:has([aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--color-destructive) 20%, transparent)}}.combobox-chips:is(html.dark *){background-color:var(--color-input)}@supports (color:color-mix(in lab, red, red)){.combobox-chips:is(html.dark *){background-color:color-mix(in oklab, var(--color-input) 30%, transparent)}}.combobox-chips:is(html.dark *):has([aria-invalid=true]){border-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.combobox-chips:is(html.dark *):has([aria-invalid=true]){border-color:color-mix(in oklab, var(--color-destructive) 50%, transparent)}}.combobox-chips:is(html.dark *):has([aria-invalid=true]){--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.combobox-chips:is(html.dark *):has([aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--color-destructive) 40%, transparent)}}.combobox-chip{height:calc(var(--spacing) * 5.5);gap:var(--spacing);border-radius:var(--radius-sm);background-color:var(--color-muted);padding-inline:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-foreground)}.combobox-chip:has(>.combobox-chip-remove){padding-inline-end:0}.combobox-chip-remove{opacity:.5;margin-inline-start:calc(var(--spacing) * -1)}@media (hover:hover){.combobox-chip-remove:hover{opacity:1}}.combobox-chip-remove svg{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.combobox [data-popover]>.input-group{margin:var(--spacing);height:calc(var(--spacing) * 8);border-color:var(--color-input);width:auto;margin-bottom:0}@supports (color:color-mix(in lab, red, red)){.combobox [data-popover]>.input-group{border-color:color-mix(in oklab, var(--color-input) 30%, transparent)}}.combobox [data-popover]>.input-group{background-color:var(--color-input)}@supports (color:color-mix(in lab, red, red)){.combobox [data-popover]>.input-group{background-color:color-mix(in oklab, var(--color-input) 30%, transparent)}}.combobox [data-popover]>.input-group{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.combobox [data-popover]>.input-group:focus-within{border-color:inherit;--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.combobox [data-popover]>input[role=combobox]{margin-bottom:var(--spacing);width:100%}.select:not(select)>button{height:calc(var(--spacing) * 9);gap:calc(var(--spacing) * 1.5);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-input);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;padding-inline-start:calc(var(--spacing) * 2.5);padding-inline-end:calc(var(--spacing) * 2)}.select:not(select)>button:focus-visible{border-color:var(--color-ring);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-ring)}@supports (color:color-mix(in lab, red, red)){.select:not(select)>button:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-ring) 50%, transparent)}}.select:not(select)>button[aria-invalid=true]{border-color:var(--color-destructive);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.select:not(select)>button[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 20%, transparent)}}.select:not(select)>button[data-size=sm]{height:calc(var(--spacing) * 8)}.select:not(select)>button:is(html.dark *){background-color:var(--color-input)}@supports (color:color-mix(in lab, red, red)){.select:not(select)>button:is(html.dark *){background-color:color-mix(in oklab, var(--color-input) 30%, transparent)}}@media (hover:hover){.select:not(select)>button:is(html.dark *):hover{background-color:var(--color-input)}@supports (color:color-mix(in lab, red, red)){.select:not(select)>button:is(html.dark *):hover{background-color:color-mix(in oklab, var(--color-input) 50%, transparent)}}}.select:not(select)>button:is(html.dark *)[aria-invalid=true]{border-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.select:not(select)>button:is(html.dark *)[aria-invalid=true]{border-color:color-mix(in oklab, var(--color-destructive) 50%, transparent)}}.select:not(select)>button:is(html.dark *)[aria-invalid=true]{--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.select:not(select)>button:is(html.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 40%, transparent)}}.select:not(select)>button svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.select:not(select)>button>span{gap:calc(var(--spacing) * 1.5)}.select:not(select)>button>svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4);color:var(--color-muted-foreground)}.select:not(select) [data-popover]{border-radius:var(--radius-md);background-color:var(--color-popover);padding:var(--spacing);color:var(--color-popover-foreground);--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:color-mix(in oklab, var(--color-foreground) 10%, transparent);--tw-duration:.1s;transition-duration:.1s}.select:not(select) [role=listbox]{scroll-padding-block:var(--spacing)}.select:not(select) [role=listbox]:not(:has([data-value]:not([aria-hidden=true]))):before{padding-block:calc(var(--spacing) * 6);text-align:center;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.select:not(select) [role=heading]{padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--color-muted-foreground)}.select:not(select) [role=option]{gap:calc(var(--spacing) * 2);border-radius:var(--radius-sm);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));padding-inline-start:calc(var(--spacing) * 2);padding-inline-end:calc(var(--spacing) * 8)}.select:not(select) [role=option] svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.select:not(select) [role=option].active,.select:not(select) [role=option]:focus-visible{background-color:var(--color-muted);color:var(--color-foreground)}:is(.select:not(select) [role=option].active,.select:not(select) [role=option]:focus-visible) svg{color:var(--color-foreground)}.select:not(select) [role=group]{scroll-margin-block:var(--spacing);padding:var(--spacing)}.select:not(select) [role=separator]{margin-inline:calc(var(--spacing) * -1);margin-block:var(--spacing);background-color:var(--color-border);height:1px}.dialog::backdrop,.alert-dialog::backdrop,.command-dialog::backdrop{background-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.dialog::backdrop,.alert-dialog::backdrop,.command-dialog::backdrop{background-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.dialog::backdrop,.alert-dialog::backdrop,.command-dialog::backdrop{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);--tw-duration:.1s;transition-duration:.1s}.alert-dialog>*{max-width:var(--container-xs);gap:calc(var(--spacing) * 6);border-radius:var(--radius-xl);background-color:var(--color-popover);padding:calc(var(--spacing) * 6);color:var(--color-popover-foreground);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-foreground)}@supports (color:color-mix(in lab, red, red)){.alert-dialog>*{--tw-ring-color:color-mix(in oklab, var(--color-foreground) 10%, transparent)}}.alert-dialog>*{--tw-duration:.1s;transition-duration:.1s}@media (min-width:40rem){.alert-dialog>*{max-width:var(--container-lg)}.alert-dialog[data-size=sm]>*,.alert-dialog>[data-size=sm]{max-width:var(--container-xs)}}.dialog>*{gap:calc(var(--spacing) * 6);border-radius:var(--radius-xl);background-color:var(--color-popover);max-width:calc(100% - 2rem);padding:calc(var(--spacing) * 6);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-popover-foreground);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-foreground)}@supports (color:color-mix(in lab, red, red)){.dialog>*{--tw-ring-color:color-mix(in oklab, var(--color-foreground) 10%, transparent)}}.dialog>*{--tw-duration:.1s;transition-duration:.1s}@media (min-width:40rem){.dialog>*{max-width:var(--container-md)}}.dialog>*>:is(button,form[method=dialog]){inset-inline-end:calc(var(--spacing) * 4);top:calc(var(--spacing) * 4)}.dialog>*>header{gap:calc(var(--spacing) * 2)}.alert-dialog>*>header{column-gap:calc(var(--spacing) * 6);row-gap:calc(var(--spacing) * 1.5)}.alert-dialog>*>header>figure{margin-bottom:calc(var(--spacing) * 2);width:calc(var(--spacing) * 16);height:calc(var(--spacing) * 16);border-radius:var(--radius-md);background-color:var(--color-muted);justify-content:center;align-items:center;display:inline-flex}.alert-dialog>*>header>figure>svg:not([class*=size-]){width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.alert-dialog>*>footer,.dialog>*>footer{gap:calc(var(--spacing) * 2)}.dialog>*>header>h2,.dialog>*>[data-title]{--tw-leading:1;--tw-font-weight:var(--font-weight-medium);line-height:1;font-weight:var(--font-weight-medium)}.alert-dialog>*>header>h2,.alert-dialog>*>[data-title]{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.dialog>*>header>p,.dialog>*>[data-description]{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:var(--leading-normal);line-height:var(--leading-normal);color:var(--color-muted-foreground)}:is(.dialog>*>header>p,.dialog>*>[data-description]) a{text-underline-offset:3px;text-decoration-line:underline}:is(.dialog>*>header>p,.dialog>*>[data-description]) a:hover{color:var(--color-foreground)}.alert-dialog>*>header>p,.alert-dialog>*>[data-description]{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));text-wrap:balance;color:var(--color-muted-foreground)}@media (min-width:48rem){.alert-dialog>*>header>p,.alert-dialog>*>[data-description]{text-wrap:pretty}}:is(.alert-dialog>*>header>p,.alert-dialog>*>[data-description]) a{text-underline-offset:3px;text-decoration-line:underline}:is(.alert-dialog>*>header>p,.alert-dialog>*>[data-description]) a:hover{color:var(--color-foreground)}.drawer::backdrop{background-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.drawer::backdrop{background-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.drawer::backdrop{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.drawer>*{background-color:var(--color-popover);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-popover-foreground)}.drawer:not([data-side])>*,.drawer[data-side=bottom]>*{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl);border-top-style:var(--tw-border-style);border-top-width:1px}.drawer[data-side=top]>*{border-bottom-right-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl);border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.drawer[data-side=left]>*{border-top-right-radius:var(--radius-xl);border-bottom-right-radius:var(--radius-xl);border-right-style:var(--tw-border-style);border-right-width:1px}.drawer[data-side=right]>*{border-top-left-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl);border-left-style:var(--tw-border-style);border-left-width:1px}.drawer>*>header{gap:calc(var(--spacing) * .5);padding:calc(var(--spacing) * 4);text-align:start}@media (min-width:48rem){.drawer>*>header{gap:calc(var(--spacing) * 1.5)}}.drawer:not([data-side])>*>header,.drawer[data-side=bottom]>*>header,.drawer[data-side=top]>*>header{text-align:center}.drawer>*>footer{gap:calc(var(--spacing) * 2);padding:calc(var(--spacing) * 4)}.drawer>*>header>h2,.drawer>*>[data-title]{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height));--tw-leading:1;--tw-font-weight:var(--font-weight-semibold);line-height:1;font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.drawer>*>header>p,.drawer>*>[data-description]{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-muted-foreground)}.dropdown-menu>[data-popover]{border-radius:var(--radius-md);background-color:var(--color-popover);padding:var(--spacing);color:var(--color-popover-foreground);--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:color-mix(in oklab, var(--color-foreground) 10%, transparent);--tw-duration:.1s;gap:0;transition-duration:.1s;overflow:hidden auto}.dropdown-menu :is([role=menuitem],[role=menuitemcheckbox],[role=menuitemradio]){gap:calc(var(--spacing) * 2);border-radius:var(--radius-sm);padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.dropdown-menu :is([role=menuitem],[role=menuitemcheckbox],[role=menuitemradio]):focus-visible{background-color:var(--color-accent);color:var(--color-accent-foreground)}.dropdown-menu :is([role=menuitem],[role=menuitemcheckbox],[role=menuitemradio]) svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.dropdown-menu :is([role=menuitem],[role=menuitemcheckbox],[role=menuitemradio]).active{background-color:var(--color-muted);color:var(--color-foreground)}.dropdown-menu [data-variant=destructive]{color:var(--color-destructive)}.dropdown-menu [data-variant=destructive]:is(:focus-visible,.active){background-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.dropdown-menu [data-variant=destructive]:is(:focus-visible,.active){background-color:color-mix(in oklab, var(--color-destructive) 10%, transparent)}}.dropdown-menu [data-variant=destructive]:is(:focus-visible,.active){color:var(--color-destructive)}.dropdown-menu [data-variant=destructive]:is(:focus-visible,.active):is(html.dark *){background-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.dropdown-menu [data-variant=destructive]:is(:focus-visible,.active):is(html.dark *){background-color:color-mix(in oklab, var(--color-destructive) 20%, transparent)}}.dropdown-menu [data-variant=destructive] svg{color:var(--color-destructive)}.dropdown-menu :is([role=menuitemcheckbox],[role=menuitemradio]){padding-block:calc(var(--spacing) * 1.5);padding-inline-start:calc(var(--spacing) * 2);padding-inline-end:calc(var(--spacing) * 8)}.dropdown-menu :is([role=menuitem],[role=menuitemcheckbox],[role=menuitemradio])[data-inset],.dropdown-menu [role=heading][data-inset]{padding-inline-start:calc(var(--spacing) * 8)}.dropdown-menu [data-indicator]{justify-content:center;align-items:center;display:flex;position:absolute;inset-inline-end:calc(var(--spacing) * 2)}.dropdown-menu [role=heading]{padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-muted-foreground)}.dropdown-menu [role=separator]{margin-inline:calc(var(--spacing) * -1);margin-block:var(--spacing);background-color:var(--color-border);height:1px}.dropdown-menu :is([data-shortcut],kbd:last-child,.kbd:last-child){font-family:var(--font-sans);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest);color:var(--color-muted-foreground)}.dropdown-menu :is([role=menuitem],[role=menuitemcheckbox],[role=menuitemradio]):is(:focus-visible,.active) :is([data-shortcut],kbd:last-child,.kbd:last-child){color:var(--color-accent-foreground)}[data-popover]{gap:calc(var(--spacing) * 4);border-radius:var(--radius-md);background-color:var(--color-popover);padding:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-popover-foreground);--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:color-mix(in oklab, var(--color-foreground) 10%, transparent);--tw-duration:.1s;flex-direction:column;transition-duration:.1s;display:flex}.popover>[data-popover]>header,.popover>[data-popover]>[data-header]{gap:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));flex-direction:column;display:flex}.popover>[data-popover]>header>:is(h1,h2,h3,h4,h5,h6),.popover>[data-popover]>:is(h1,h2,h3,h4,h5,h6,[data-title]){--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.popover>[data-popover]>header>p,.popover>[data-popover]>[data-description]{color:var(--color-muted-foreground)}.card{border-radius:var(--radius-xl);background-color:var(--color-card);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-card-foreground);--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:color-mix(in oklab, var(--color-foreground) 10%, transparent);overflow:hidden}.card:has(>img:first-child){padding-top:0}.card>img:first-child{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl)}.card>img:last-child{border-bottom-right-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.card{gap:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6)}.card[data-size=sm]{gap:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 4)}.card>header{gap:var(--spacing);border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl);padding-inline:calc(var(--spacing) * 6)}.card>header.border-b{padding-bottom:calc(var(--spacing) * 6)}.card[data-size=sm]>header{padding-inline:calc(var(--spacing) * 4)}.card[data-size=sm]>header.border-b{padding-bottom:calc(var(--spacing) * 4)}.card>header>:is(h2,h3,[data-title],.card-title){font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-leading:var(--leading-normal);line-height:var(--leading-normal);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.card[data-size=sm]>header>:is(h2,h3,[data-title],.card-title){font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.card>header>:is(p,[data-description],.card-description){font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-muted-foreground)}.card>section{padding-inline:calc(var(--spacing) * 6)}.card[data-size=sm]>section{padding-inline:calc(var(--spacing) * 4)}.card>footer{border-bottom-right-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl);padding-inline:calc(var(--spacing) * 6)}.card>footer.border-t{padding-top:calc(var(--spacing) * 6)}.card[data-size=sm]>footer{padding-inline:calc(var(--spacing) * 4)}.card[data-size=sm]>footer.border-t{padding-top:calc(var(--spacing) * 4)}.chart{--chart-bar-radius:6px}.chart-tooltip{gap:calc(var(--spacing) * 1.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-border)}@supports (color:color-mix(in lab, red, red)){.chart-tooltip{border-color:color-mix(in oklab, var(--color-border) 50%, transparent)}}.chart-tooltip{background-color:var(--color-background);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--color-foreground);--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.chart-tooltip-title{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.chart-legend-item{color:var(--color-foreground)}.chart-legend-item[aria-pressed=false]{color:var(--color-muted-foreground)}.field>input[type=checkbox]:not([role=switch]),.input[type=checkbox]:not([role=switch]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-input);--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:4px;justify-content:center;align-items:center;display:flex}:is(.field>input[type=checkbox]:not([role=switch]),.input[type=checkbox]:not([role=switch])):checked{border-color:var(--color-primary);background-color:var(--color-primary);color:var(--color-primary-foreground)}:is(.field>input[type=checkbox]:not([role=switch]),.input[type=checkbox]:not([role=switch])):focus-visible{border-color:var(--color-ring);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-ring)}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=checkbox]:not([role=switch]),.input[type=checkbox]:not([role=switch])):focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-ring) 50%, transparent)}}:where([data-disabled=true]) :is(.field>input[type=checkbox]:not([role=switch]),.input[type=checkbox]:not([role=switch])){opacity:.5}:is(.field>input[type=checkbox]:not([role=switch]),.input[type=checkbox]:not([role=switch]))[aria-invalid=true]{border-color:var(--color-destructive);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=checkbox]:not([role=switch]),.input[type=checkbox]:not([role=switch]))[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 20%, transparent)}}:is(.field>input[type=checkbox]:not([role=switch]),.input[type=checkbox]:not([role=switch]))[aria-invalid=true]:checked{border-color:var(--color-primary)}:is(.field>input[type=checkbox]:not([role=switch]),.input[type=checkbox]:not([role=switch])):is(html.dark *){background-color:var(--color-input)}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=checkbox]:not([role=switch]),.input[type=checkbox]:not([role=switch])):is(html.dark *){background-color:color-mix(in oklab, var(--color-input) 30%, transparent)}}:is(.field>input[type=checkbox]:not([role=switch]),.input[type=checkbox]:not([role=switch])):is(html.dark *):checked{background-color:var(--color-primary)}:is(.field>input[type=checkbox]:not([role=switch]),.input[type=checkbox]:not([role=switch])):is(html.dark *)[aria-invalid=true]{border-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=checkbox]:not([role=switch]),.input[type=checkbox]:not([role=switch])):is(html.dark *)[aria-invalid=true]{border-color:color-mix(in oklab, var(--color-destructive) 50%, transparent)}}:is(.field>input[type=checkbox]:not([role=switch]),.input[type=checkbox]:not([role=switch])):is(html.dark *)[aria-invalid=true]{--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=checkbox]:not([role=switch]),.input[type=checkbox]:not([role=switch])):is(html.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 40%, transparent)}}:is(.field>input[type=checkbox]:not([role=switch]),.input[type=checkbox]:not([role=switch])):checked:after{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}[role=radiogroup]{gap:calc(var(--spacing) * 3);display:grid}.field>input[type=radio],.input[type=radio]{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4);border-color:var(--color-input);border-radius:3.40282e38px;display:flex}:is(.field>input[type=radio],.input[type=radio]):checked{border-color:var(--color-primary);background-color:var(--color-primary);color:var(--color-primary-foreground)}:is(.field>input[type=radio],.input[type=radio]):focus-visible{border-color:var(--color-ring);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-ring)}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=radio],.input[type=radio]):focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-ring) 50%, transparent)}}:is(.field>input[type=radio],.input[type=radio])[aria-invalid=true]{border-color:var(--color-destructive);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=radio],.input[type=radio])[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 20%, transparent)}}:is(.field>input[type=radio],.input[type=radio])[aria-invalid=true]:checked{border-color:var(--color-primary)}:is(.field>input[type=radio],.input[type=radio]):is(html.dark *){background-color:var(--color-input)}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=radio],.input[type=radio]):is(html.dark *){background-color:color-mix(in oklab, var(--color-input) 30%, transparent)}}:is(.field>input[type=radio],.input[type=radio]):is(html.dark *):checked{background-color:var(--color-primary)}:is(.field>input[type=radio],.input[type=radio]):is(html.dark *)[aria-invalid=true]{border-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=radio],.input[type=radio]):is(html.dark *)[aria-invalid=true]{border-color:color-mix(in oklab, var(--color-destructive) 50%, transparent)}}:is(.field>input[type=radio],.input[type=radio]):is(html.dark *)[aria-invalid=true]{--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=radio],.input[type=radio]):is(html.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 40%, transparent)}}:is(.field>input[type=radio],.input[type=radio]):checked:before{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2);background-color:var(--color-primary-foreground)}.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]{border-style:var(--tw-border-style);--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-width:1px;border-color:#0000;border-radius:3.40282e38px}:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]):not(:checked){background-color:var(--color-input)}:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]):checked{background-color:var(--color-primary)}:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]):focus-visible{border-color:var(--color-ring);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-ring)}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]):focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-ring) 50%, transparent)}}:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch])[aria-invalid=true]{border-color:var(--color-destructive);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch])[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 20%, transparent)}}:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]):is(html.dark *):not(:checked){background-color:var(--color-input)}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]):is(html.dark *):not(:checked){background-color:color-mix(in oklab, var(--color-input) 80%, transparent)}}:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]):is(html.dark *)[aria-invalid=true]{border-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]):is(html.dark *)[aria-invalid=true]{border-color:color-mix(in oklab, var(--color-destructive) 50%, transparent)}}:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]):is(html.dark *)[aria-invalid=true]{--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]):is(html.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--color-destructive) 40%, transparent)}}:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]):not([data-size=sm]){width:32px;height:18.4px}:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch])[data-size=sm]{width:24px;height:14px}:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]):before{background-color:var(--color-background);border-radius:3.40282e38px}html.dark :is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]):not(:checked):before{background-color:var(--color-foreground)}html.dark :is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]):checked:before{background-color:var(--color-primary-foreground)}:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]):not([data-size=sm]):before{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch])[data-size=sm]:before{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]):checked:not([data-size=sm]):before,:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]):checked[data-size=sm]:before{--tw-translate-x:calc(100% - 2px);translate:var(--tw-translate-x) var(--tw-translate-y)}:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]):dir(rtl):checked:not([data-size=sm]):before{--tw-translate-x:calc(calc(100% - 2px) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]):dir(rtl):checked[data-size=sm]:before{--tw-translate-x:calc(calc(100% - 2px) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}:is(.field>input[type=checkbox][role=switch],.input[type=checkbox][role=switch]):not(:checked):before{--tw-translate-x:0;translate:var(--tw-translate-x) var(--tw-translate-y)}.fieldset{gap:calc(var(--spacing) * 6)}.fieldset:has(>[data-slot=checkbox-group]),.fieldset:has(>[data-slot=radio-group]){gap:calc(var(--spacing) * 3)}.fieldset>legend{margin-bottom:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.fieldset>legend[data-variant=label]{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.fieldset>legend[data-variant=legend]{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.fieldset>:is([role=group],[role=radiogroup]):not(.field),.field>:is(fieldset,[role=group],[role=radiogroup]):not(.field){gap:calc(var(--spacing) * 7)}:is(.fieldset>:is([role=group],[role=radiogroup]):not(.field),.field>:is(fieldset,[role=group],[role=radiogroup]):not(.field))[role=radiogroup],:is(.fieldset>:is([role=group],[role=radiogroup]):not(.field),.field>:is(fieldset,[role=group],[role=radiogroup]):not(.field)):has(>.field>input[type=checkbox]:not([role=switch])),:is(.fieldset>:is([role=group],[role=radiogroup]):not(.field),.field>:is(fieldset,[role=group],[role=radiogroup]):not(.field)):has(>.field>input[type=radio]),:is(.fieldset>:is([role=group],[role=radiogroup]):not(.field),.field>:is(fieldset,[role=group],[role=radiogroup]):not(.field))[data-slot=checkbox-group],:is(.fieldset>:is([role=group],[role=radiogroup]):not(.field),.field>:is(fieldset,[role=group],[role=radiogroup]):not(.field))[data-slot=radio-group]{gap:calc(var(--spacing) * 3)}:is(.fieldset,.field)>:is([role=group],[role=radiogroup]):not(.field)>:is([role=group],[role=radiogroup]):not(.field),.field>fieldset:not(.field)>:is([role=group],[role=radiogroup]):not(.field){gap:calc(var(--spacing) * 4)}.field{gap:calc(var(--spacing) * 3)}.field[data-invalid],.field[data-invalid=true]{color:var(--color-destructive)}.field>section{gap:var(--spacing)}.field>label,.field>section>label,label:has(>.field){gap:calc(var(--spacing) * 2);--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}:where([data-disabled=true]) :is(.field>label,.field>section>label,label:has(>.field)){opacity:.5}:is(.field>label,.field>section>label,label:has(>.field)):has(:checked){border-color:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){:is(.field>label,.field>section>label,label:has(>.field)):has(:checked){border-color:color-mix(in oklab, var(--color-primary) 30%, transparent)}}:is(.field>label,.field>section>label,label:has(>.field)):has(:checked){background-color:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){:is(.field>label,.field>section>label,label:has(>.field)):has(:checked){background-color:color-mix(in oklab, var(--color-primary) 5%, transparent)}}:is(.field>label,.field>section>label,label:has(>.field)):is(html.dark *):has(:checked){border-color:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){:is(.field>label,.field>section>label,label:has(>.field)):is(html.dark *):has(:checked){border-color:color-mix(in oklab, var(--color-primary) 20%, transparent)}}:is(.field>label,.field>section>label,label:has(>.field)):is(html.dark *):has(:checked){background-color:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){:is(.field>label,.field>section>label,label:has(>.field)):is(html.dark *):has(:checked){background-color:color-mix(in oklab, var(--color-primary) 10%, transparent)}}.field>label:has(input[type=radio]),.field>label:has(input[type=checkbox]),.field>label:has(input[role=switch]),.field>label:has(>.field),label:has(>.field){border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px}.field>label:has(input[type=radio]),.field>label:has(input[type=checkbox]),.field>label:has(input[role=switch]),:is(.field>label,label:has(>.field))>.field{padding:calc(var(--spacing) * 3)}.field h2,.field h3{gap:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:var(--leading-snug);line-height:var(--leading-snug);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}:where([data-disabled=true]) :is(.field h2,.field h3){opacity:.5}.fieldset legend+p,.field>p,.field section>p{text-align:start;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-muted-foreground)}[data-variant=legend]+:is(.fieldset legend+p,.field>p,.field section>p){margin-top:calc(var(--spacing) * -1.5)}.fieldset>legend[data-variant=label]{margin-bottom:0}.fieldset>legend:not([data-variant])+p,.fieldset>legend[data-variant=legend]+p{margin-top:calc(var(--spacing) * -1.5)}.field-separator{margin-block:calc(var(--spacing) * -2);height:calc(var(--spacing) * 5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}:where([data-variant=outline]) .field-separator{margin-bottom:calc(var(--spacing) * -2)}.field-separator>span{padding-inline:calc(var(--spacing) * 2);color:var(--color-muted-foreground)}.field [role=alert]{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-destructive)}.input-group{height:calc(var(--spacing) * 9);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-input);--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.input-group:focus-within{border-color:var(--color-ring);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-ring)}@supports (color:color-mix(in lab, red, red)){.input-group:focus-within{--tw-ring-color:color-mix(in oklab, var(--color-ring) 50%, transparent)}}.input-group:has([aria-invalid=true]){border-color:var(--color-destructive);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.input-group:has([aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--color-destructive) 20%, transparent)}}.input-group:has(>textarea){height:auto}.input-group:is(html.dark *){background-color:var(--color-input)}@supports (color:color-mix(in lab, red, red)){.input-group:is(html.dark *){background-color:color-mix(in oklab, var(--color-input) 30%, transparent)}}.input-group:is(html.dark *):has([aria-invalid=true]){--tw-ring-color:var(--color-destructive)}@supports (color:color-mix(in lab, red, red)){.input-group:is(html.dark *):has([aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--color-destructive) 40%, transparent)}}.input-group:has(>:is(input,textarea,select,[data-control]):disabled){opacity:.5}.input-group>:is(input,select,[data-control]){padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.input-group:not([data-orientation=vertical]):has(>:is([data-align=start],[data-align=inline-start]))>:is(input,select,[data-control]){padding-inline-start:calc(var(--spacing) * 1.5)}.input-group:not([data-orientation=vertical]):has(>:is([data-align=end],[data-align=inline-end]))>:is(input,select,[data-control]){padding-inline-end:calc(var(--spacing) * 1.5)}.input-group>textarea{padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.input-group>:is(span,[role=group],header,footer),.input-group>:is(svg,kbd,.popover,.dropdown-menu){gap:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-muted-foreground);-webkit-user-select:none;user-select:none}:is(.input-group>:is(span,[role=group],header,footer),.input-group>:is(svg,kbd,.popover,.dropdown-menu))>svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.input-group>:is([data-align=start],[data-align=inline-start]){padding-inline-start:calc(var(--spacing) * 2)}.input-group>:is([data-align=end],[data-align=inline-end]){padding-inline-end:calc(var(--spacing) * 2)}.input-group>:is([data-align=start],[data-align=inline-start]):has(>button){margin-inline-start:calc(var(--spacing) * -1)}.input-group>:is([data-align=end],[data-align=inline-end]):has(>button){margin-inline-end:calc(var(--spacing) * -1)}.input-group>:is([data-align=start],[data-align=inline-start]):has(>kbd){margin-inline-start:calc(var(--spacing) * -.5)}.input-group>:is([data-align=end],[data-align=inline-end]):has(>kbd){margin-inline-end:calc(var(--spacing) * -.5)}.input-group>[data-align=block-start]{padding-inline:calc(var(--spacing) * 2.5);padding-top:calc(var(--spacing) * 2)}.input-group>[data-align=block-end]{padding-inline:calc(var(--spacing) * 2.5);padding-bottom:calc(var(--spacing) * 2)}.input-group>[data-align=block-start].border-b{padding-bottom:calc(var(--spacing) * 2)}.input-group>[data-align=block-end].border-t{padding-top:calc(var(--spacing) * 2)}.input-group[data-orientation=vertical]>:is(header,footer,[role=group],[data-align]){padding-inline:calc(var(--spacing) * 2.5)}.input-group[data-orientation=vertical]>:is(header[data-align=start],[data-align=block-start]){padding-inline:calc(var(--spacing) * 2.5);padding-top:calc(var(--spacing) * 2)}.input-group[data-orientation=vertical]>:is(footer[data-align=end],[data-align=block-end]){padding-inline:calc(var(--spacing) * 2.5);padding-bottom:calc(var(--spacing) * 2)}.input-group[data-orientation=vertical]:has(>input)>:is(header[data-align=start],[data-align=block-start]){padding-top:calc(var(--spacing) * 2)}.input-group[data-orientation=vertical]:has(>input)>:is(footer[data-align=end],[data-align=block-end]),.input-group[data-orientation=vertical]>:is(header[data-align=start],[data-align=block-start]).border-b{padding-bottom:calc(var(--spacing) * 2)}.input-group[data-orientation=vertical]>:is(footer[data-align=end],[data-align=block-end]).border-t{padding-top:calc(var(--spacing) * 2)}.input-group>:is(kbd,span>kbd,[role=group]>kbd,header>kbd,footer>kbd){border-radius:calc(var(--radius) - 5px)}.input-group>:is(button,[role=group]>button,header>button,footer>button){gap:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.input-group>:is(button,[role=group]>button,header>button,footer>button)>svg:not([class*=size-]){width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.input-group>:is(button,[role=group]>button,header>button,footer>button):has(>svg):not(:has(>:not(svg))){width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6);border-radius:calc(var(--radius) - 5px);padding:0}.empty{gap:calc(var(--spacing) * 4);border-radius:var(--radius-lg);--tw-border-style:dashed;padding:calc(var(--spacing) * 12);border-style:dashed}.empty>header{gap:calc(var(--spacing) * 2)}.empty figure{margin-bottom:calc(var(--spacing) * 2)}.empty figure:has(>svg:only-child){width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10);border-radius:var(--radius-lg);background-color:var(--color-muted);color:var(--color-foreground);flex-shrink:0;justify-content:center;align-items:center;display:flex}.empty figure:has(>svg:only-child) svg:not([class*=size-]){width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.empty :is(h2,h3,h4){font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.empty>header>p{font-size:var(--text-sm);line-height:var(--leading-relaxed);color:var(--color-muted-foreground)}.empty>:is(section,footer){gap:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.item{border-radius:var(--radius-md);border-style:var(--tw-border-style);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border-width:1px}.item:not([data-variant]),.item[data-variant=default]{border-color:#0000}.item[data-variant=outline]{border-color:var(--color-border)}.item[data-variant=muted]{background-color:var(--color-muted);border-color:#0000}@supports (color:color-mix(in lab, red, red)){.item[data-variant=muted]{background-color:color-mix(in oklab, var(--color-muted) 50%, transparent)}}.item:not([data-size]),.item[data-size=default]{gap:calc(var(--spacing) * 3.5);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3.5)}.item[data-size=sm]{gap:calc(var(--spacing) * 2.5);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2.5)}.item[data-size=xs]{gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 2)}.item>figure{gap:calc(var(--spacing) * 2)}.item:has(p)>figure{--tw-translate-y:calc(var(--spacing) * .5);translate:var(--tw-translate-x) var(--tw-translate-y);align-self:flex-start}.item>figure:has(>svg:only-child) svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.item>figure:has(>img){width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10);border-radius:var(--radius-sm);overflow:hidden}.item[data-size=sm]>figure:has(>img){width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.item[data-size=xs]>figure:has(>img){width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.item>section{gap:var(--spacing)}.item[data-size=xs]>section{gap:0}.item :is(h2,h3,h4){gap:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:var(--leading-snug);line-height:var(--leading-snug);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);text-underline-offset:4px}.item p{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.item[data-size=xs] p{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.item>aside,.item>:is(header,footer){gap:calc(var(--spacing) * 2)}.item-group{gap:calc(var(--spacing) * 4)}.item-group:has(>.item[data-size=sm]){gap:calc(var(--spacing) * 2.5)}.item-group:has(>.item[data-size=xs]){gap:calc(var(--spacing) * 2)}.item-group>hr{margin-block:calc(var(--spacing) * 2)}.kbd{height:calc(var(--spacing) * 5);width:fit-content;min-width:calc(var(--spacing) * 5);gap:var(--spacing);border-radius:var(--radius-sm);background-color:var(--color-muted);padding-inline:var(--spacing);font-family:var(--font-sans);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-muted-foreground)}:where([data-slot=tooltip-content]) .kbd{background-color:var(--color-background)}@supports (color:color-mix(in lab, red, red)){:where([data-slot=tooltip-content]) .kbd{background-color:color-mix(in oklab, var(--color-background) 20%, transparent)}}:where([data-slot=tooltip-content]) .kbd{color:var(--color-background)}:where([data-slot=tooltip-content]) .kbd:is(html.dark *){background-color:var(--color-background)}@supports (color:color-mix(in lab, red, red)){:where([data-slot=tooltip-content]) .kbd:is(html.dark *){background-color:color-mix(in oklab, var(--color-background) 10%, transparent)}}.kbd svg:not([class*=size-]){width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.field>label,.label{gap:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:1;--tw-font-weight:var(--font-weight-medium);line-height:1;font-weight:var(--font-weight-medium)}:is(.field>label,.label):is(:where(.peer):disabled~*),:where([data-disabled=true]) :is(.field>label,.label){opacity:.5}.field>section>label{gap:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:var(--leading-snug);line-height:var(--leading-snug);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.field>section>label:is(:where(.peer):disabled~*),:where([data-disabled=true]) :is(.field>section>label){opacity:.5}.sidebar nav{background-color:var(--color-sidebar);color:var(--color-sidebar-foreground)}.sidebar nav>header,.sidebar nav>footer{gap:calc(var(--spacing) * 2);padding:calc(var(--spacing) * 2)}.sidebar nav>section{gap:calc(var(--spacing) * 2)}.sidebar nav [role=separator]{margin-inline:calc(var(--spacing) * 2);border-color:var(--color-sidebar-border)}.sidebar nav>section>[role=group]{padding:calc(var(--spacing) * 2)}.sidebar nav h3{height:calc(var(--spacing) * 8);border-radius:var(--radius-md);padding-inline:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-sidebar-foreground)}@supports (color:color-mix(in lab, red, red)){.sidebar nav h3{color:color-mix(in oklab, var(--color-sidebar-foreground) 70%, transparent)}}.sidebar nav h3{--tw-ring-color:var(--color-sidebar-ring);transition-property:margin,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;--tw-ease:linear;transition-duration:.2s;transition-timing-function:linear}.sidebar nav h3:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.sidebar nav h3>svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.sidebar nav>section>[role=group]>ul{gap:var(--spacing)}.sidebar nav>section>[role=group]{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.sidebar nav li>:is(a,button),.sidebar nav li>details>summary{gap:calc(var(--spacing) * 2);border-radius:var(--radius-md);padding:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-ring-color:var(--color-sidebar-ring)}@media (hover:hover){:is(.sidebar nav li>:is(a,button),.sidebar nav li>details>summary):hover{background-color:var(--color-sidebar-accent);color:var(--color-sidebar-accent-foreground)}}:is(.sidebar nav li>:is(a,button),.sidebar nav li>details>summary):focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){:is(.sidebar nav li>:is(a,button),.sidebar nav li>details>summary):focus-visible{outline-offset:2px;outline:2px solid #0000}}:is(.sidebar nav li>:is(a,button),.sidebar nav li>details>summary):active{background-color:var(--color-sidebar-accent);color:var(--color-sidebar-accent-foreground)}@media (hover:hover){:is(.sidebar nav li>:is(a,button),.sidebar nav li>details>summary)[data-open]:hover{background-color:var(--color-sidebar-accent);color:var(--color-sidebar-accent-foreground)}}:is(.sidebar nav li>:is(a,button),.sidebar nav li>details>summary)>svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4);flex-shrink:0}.sidebar nav li>:is(a,button):is([aria-current=page],[data-active=true]),.sidebar nav li>details>summary:is([aria-current=page],[data-active=true]){background-color:var(--color-sidebar-accent);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-sidebar-accent-foreground)}@media (hover:hover){:is(.sidebar nav li>:is(a,button):not([data-variant]),.sidebar nav li>details>summary:not([data-variant]),.sidebar nav li>:is(a,button)[data-variant=default],.sidebar nav li>details>summary[data-variant=default]):hover{background-color:var(--color-sidebar-accent);color:var(--color-sidebar-accent-foreground)}}.sidebar nav li>:is(a,button)[data-variant=outline],.sidebar nav li>details>summary[data-variant=outline]{background-color:var(--color-background);--tw-shadow:0 0 0 1px var(--tw-shadow-color,var(--sidebar-border));box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}@media (hover:hover){:is(.sidebar nav li>:is(a,button)[data-variant=outline],.sidebar nav li>details>summary[data-variant=outline]):hover{background-color:var(--color-sidebar-accent);color:var(--color-sidebar-accent-foreground);--tw-shadow:0 0 0 1px var(--tw-shadow-color,var(--sidebar-accent));box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.sidebar nav li>:is(a,button):not([data-size]),.sidebar nav li>details>summary:not([data-size]),.sidebar nav li>:is(a,button)[data-size=default],.sidebar nav li>details>summary[data-size=default]{height:calc(var(--spacing) * 8);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.sidebar nav li>:is(a,button)[data-size=sm],.sidebar nav li>details>summary[data-size=sm]{height:calc(var(--spacing) * 7);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.sidebar nav li>:is(a,button)[data-size=lg],.sidebar nav li>details>summary[data-size=lg]{height:calc(var(--spacing) * 12);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.sidebar nav li>details>summary:after{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.sidebar nav li>details::details-content{padding-inline:calc(var(--spacing) * 3.5)}.sidebar nav ul ul{--tw-translate-x:1px;translate:var(--tw-translate-x) var(--tw-translate-y);gap:var(--spacing);border-inline-start-style:var(--tw-border-style);border-inline-start-width:1px;border-color:var(--color-sidebar-border);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5)}.sidebar nav ul ul:where(:dir(rtl),[dir=rtl],[dir=rtl] *){--tw-translate-x:-1px;translate:var(--tw-translate-x) var(--tw-translate-y)}.sidebar nav ul ul>li>:is(a,button),.sidebar nav ul ul>li>details>summary{height:calc(var(--spacing) * 7);gap:calc(var(--spacing) * 2);border-radius:var(--radius-md);padding-inline:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-sidebar-foreground);--tw-ring-color:var(--color-sidebar-ring)}@media (hover:hover){:is(.sidebar nav ul ul>li>:is(a,button),.sidebar nav ul ul>li>details>summary):hover{background-color:var(--color-sidebar-accent);color:var(--color-sidebar-accent-foreground)}}:is(.sidebar nav ul ul>li>:is(a,button),.sidebar nav ul ul>li>details>summary):focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}:is(.sidebar nav ul ul>li>:is(a,button),.sidebar nav ul ul>li>details>summary):active{background-color:var(--color-sidebar-accent);color:var(--color-sidebar-accent-foreground)}:is(.sidebar nav ul ul>li>:is(a,button),.sidebar nav ul ul>li>details>summary)>svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.progress{height:calc(var(--spacing) * 1.5);background-color:var(--color-muted);border-radius:3.40282e38px}.progress>span{background-color:var(--color-primary)}.skeleton{border-radius:var(--radius-md);background-color:var(--color-muted)}.table-container{width:100%;position:relative;overflow-x:auto}.table{caption-side:bottom;width:100%;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.table thead tr{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.table tbody tr:last-child{border-style:var(--tw-border-style);border-width:0}.table tfoot{border-top-style:var(--tw-border-style);background-color:var(--color-muted);border-top-width:1px}@supports (color:color-mix(in lab, red, red)){.table tfoot{background-color:color-mix(in oklab, var(--color-muted) 50%, transparent)}}.table tfoot{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.table tfoot>tr:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.table tr{border-bottom-style:var(--tw-border-style);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-bottom-width:1px}@media (hover:hover){.table tr:hover{background-color:var(--color-muted)}@supports (color:color-mix(in lab, red, red)){.table tr:hover{background-color:color-mix(in oklab, var(--color-muted) 50%, transparent)}}}.table tr[data-state=selected]{background-color:var(--color-muted)}.table th{height:calc(var(--spacing) * 10);padding-inline:calc(var(--spacing) * 2);text-align:start;vertical-align:middle;--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);white-space:nowrap;color:var(--color-foreground)}.table th:has([role=checkbox]){padding-inline-end:0}.table td{padding:calc(var(--spacing) * 2);vertical-align:middle;white-space:nowrap}.table td:has([role=checkbox]){padding-inline-end:0}.table caption{margin-top:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-muted-foreground)}[data-tooltip]:before{align-items:center;gap:calc(var(--spacing) * 1.5);border-radius:var(--radius-md);background-color:var(--color-foreground);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--color-background);display:inline-flex}[data-tooltip]:before:has(){padding-inline-end:calc(var(--spacing) * 1.5)}[data-tooltip][data-arrow]:after{z-index:60;width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5);background-color:var(--color-foreground);border-radius:2px;position:absolute;rotate:45deg}.toast-content{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);background-color:var(--color-popover);color:var(--color-popover-foreground);--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-width:1px}.field>input[type=range],.input[type=range]{height:calc(var(--spacing) * 5);--slider-track-height:.375rem;--slider-thumb-size:1rem}:is(.field>input[type=range],.input[type=range])::-webkit-slider-runnable-track{height:calc(var(--spacing) * 1.5);background-color:var(--color-muted);border-radius:3.40282e38px}:is(.field>input[type=range],.input[type=range])::-moz-range-track{height:calc(var(--spacing) * 1.5);background-color:var(--color-muted);border-radius:3.40282e38px}:is(.field>input[type=range],.input[type=range])::-ms-track{height:calc(var(--spacing) * 1.5);background-color:var(--color-muted);border-radius:3.40282e38px}:is(.field>input[type=range],.input[type=range])::-ms-fill-lower{border-radius:inherit}:is(.field>input[type=range],.input[type=range])::-ms-fill-upper{border-radius:inherit}:is(.field>input[type=range],.input[type=range])::-webkit-slider-thumb{margin-top:calc(calc((var(--slider-thumb-size) - var(--slider-track-height)) / 2) * -1);width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-primary);background-color:var(--color-white);--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-ring);border-radius:3.40282e38px}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=range],.input[type=range])::-webkit-slider-thumb{--tw-ring-color:color-mix(in oklab, var(--color-ring) 50%, transparent)}}:is(.field>input[type=range],.input[type=range])::-webkit-slider-thumb{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){:is(.field>input[type=range],.input[type=range])::-webkit-slider-thumb:hover{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}:is(.field>input[type=range],.input[type=range])::-webkit-slider-thumb:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}:is(.field>input[type=range],.input[type=range])::-webkit-slider-thumb:focus-visible{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){:is(.field>input[type=range],.input[type=range])::-webkit-slider-thumb:focus-visible{outline-offset:2px;outline:2px solid #0000}}:is(.field>input[type=range],.input[type=range])::-moz-range-thumb{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-primary);background-color:var(--color-white);--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-ring);border-radius:3.40282e38px}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=range],.input[type=range])::-moz-range-thumb{--tw-ring-color:color-mix(in oklab, var(--color-ring) 50%, transparent)}}:is(.field>input[type=range],.input[type=range])::-moz-range-thumb{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){:is(.field>input[type=range],.input[type=range])::-moz-range-thumb:hover{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}:is(.field>input[type=range],.input[type=range])::-moz-range-thumb:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}:is(.field>input[type=range],.input[type=range])::-moz-range-thumb:focus-visible{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){:is(.field>input[type=range],.input[type=range])::-moz-range-thumb:focus-visible{outline-offset:2px;outline:2px solid #0000}}:is(.field>input[type=range],.input[type=range])::-ms-thumb{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-primary);background-color:var(--color-white);--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-ring);border-radius:3.40282e38px}@supports (color:color-mix(in lab, red, red)){:is(.field>input[type=range],.input[type=range])::-ms-thumb{--tw-ring-color:color-mix(in oklab, var(--color-ring) 50%, transparent)}}:is(.field>input[type=range],.input[type=range])::-ms-thumb{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){:is(.field>input[type=range],.input[type=range])::-ms-thumb:hover{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}:is(.field>input[type=range],.input[type=range])::-ms-thumb:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}:is(.field>input[type=range],.input[type=range])::-ms-thumb:focus-visible{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){:is(.field>input[type=range],.input[type=range])::-ms-thumb:focus-visible{outline-offset:2px;outline:2px solid #0000}}}@layer utilities;:root{--radius:.625rem;--background:oklch(100% 0 0);--foreground:oklch(14.5% 0 0);--card:oklch(100% 0 0);--card-foreground:oklch(14.5% 0 0);--popover:oklch(100% 0 0);--popover-foreground:oklch(14.5% 0 0);--primary:oklch(20.5% 0 0);--primary-foreground:oklch(98.5% 0 0);--secondary:oklch(97% 0 0);--secondary-foreground:oklch(20.5% 0 0);--muted:oklch(97% 0 0);--muted-foreground:oklch(55.6% 0 0);--accent:oklch(97% 0 0);--accent-foreground:oklch(20.5% 0 0);--destructive:oklch(57.7% .245 27.325);--border:oklch(92.2% 0 0);--input:oklch(92.2% 0 0);--ring:oklch(70.8% 0 0);--chart-1:#93c5fd;--chart-2:#3b82f6;--chart-3:#2563eb;--chart-4:#1d4ed8;--chart-5:#1e40af;--sidebar:oklch(98.5% 0 0);--sidebar-foreground:oklch(14.5% 0 0);--sidebar-primary:oklch(20.5% 0 0);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(97% 0 0);--sidebar-accent-foreground:oklch(20.5% 0 0);--sidebar-border:oklch(92.2% 0 0);--sidebar-ring:oklch(70.8% 0 0);--sidebar-width:16rem;--sidebar-mobile-width:18rem;--scrollbar-track:transparent;--scrollbar-thumb:var(--border);--scrollbar-width:.625rem;--scrollbar-sm-width:.375rem;--scrollbar-radius:9999px;--chevron-down-icon:url("data:image/svg+xml;utf8,");--chevron-down-icon-50:url("data:image/svg+xml;utf8,");--check-icon:url("data:image/svg+xml;utf8,")}.dark{--background:oklch(14.5% 0 0);--foreground:oklch(98.5% 0 0);--card:oklch(20.5% 0 0);--card-foreground:oklch(98.5% 0 0);--popover:oklch(20.5% 0 0);--popover-foreground:oklch(98.5% 0 0);--primary:oklch(92.2% 0 0);--primary-foreground:oklch(20.5% 0 0);--secondary:oklch(26.9% 0 0);--secondary-foreground:oklch(98.5% 0 0);--muted:oklch(26.9% 0 0);--muted-foreground:oklch(70.8% 0 0);--accent:oklch(37.1% 0 0);--accent-foreground:oklch(98.5% 0 0);--destructive:oklch(70.4% .191 22.216);--border:oklch(100% 0 0/.1);--input:oklch(100% 0 0/.15);--ring:oklch(55.6% 0 0);--chart-1:#93c5fd;--chart-2:#3b82f6;--chart-3:#2563eb;--chart-4:#1d4ed8;--chart-5:#1e40af;--sidebar:oklch(20.5% 0 0);--sidebar-foreground:oklch(98.5% 0 0);--sidebar-primary:oklch(48.8% .243 264.376);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(26.9% 0 0);--sidebar-accent-foreground:oklch(98.5% 0 0);--sidebar-border:oklch(100% 0 0/.1);--sidebar-ring:oklch(43.9% 0 0);--chevron-down-icon:url("data:image/svg+xml;utf8,");--chevron-down-icon-50:url("data:image/svg+xml;utf8,");--check-icon:url("data:image/svg+xml;utf8,");color-scheme:dark}@keyframes toast-up{0%{opacity:0;transform:translateY(100%)}}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-leading{syntax:"*";inherits:false}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}}
\ No newline at end of file
diff --git a/server/web/assets/sculpt/worker.test.js b/server/web/assets/sculpt/worker.test.js
new file mode 100644
index 0000000..6d23762
--- /dev/null
+++ b/server/web/assets/sculpt/worker.test.js
@@ -0,0 +1,217 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { Worker } from "node:worker_threads";
+import { Mailbox } from "./mailbox.js";
+import { StrokeProcessor } from "./stroke-worker.js";
+import { Sculpt, defaults, StrokePath } from "./engine.js";
+import { sphere } from "./mesh.js";
+
+const row = () => ({
+ buffer: new SharedArrayBuffer(128),
+ descriptor: { offset: 64 },
+});
+test("mailbox overwrites one slot, keeps a newer publication pending, wraps and rejects torn reads", (t) => {
+ const m = new Mailbox(row()),
+ p = { x: 12, y: -8, pressure: 0.5 };
+ const first = m.write(7, p);
+ assert.deepEqual(m.read(7), { ...p, seed: undefined, version: first });
+ const second = m.write(7, { x: 20, y: 3, pressure: 0.75 });
+ m.acknowledge(first);
+ assert.equal(m.read(7).version, second);
+ m.acknowledge(second);
+ assert.equal(m.read(7), null);
+ Atomics.store(m.words, 0, 0x7ffffffe);
+ const wrapped = m.write(7, p);
+ assert.equal(wrapped, -2147483648);
+ assert.equal(m.read(7).x, 12);
+ const load = Atomics.load;
+ let changed = false;
+ t.mock.method(Atomics, "load", (array, index) => {
+ const v = load(array, index);
+ if (array === m.words && index === 5 && !changed) {
+ changed = true;
+ m.write(7, { x: 99, y: 100, pressure: 0.2 });
+ }
+ return v;
+ });
+ assert.equal(m.read(7), null);
+ Atomics.store(m.words, 0, 11);
+ assert.equal(m.read(7), null);
+});
+test("actual concurrent SAB writer and reader never observe mixed coordinates", async (t) => {
+ const shared = row(),
+ m = new Mailbox(shared),
+ control = new Int32Array(shared.buffer, 0, 1);
+ const code = `import {parentPort,workerData} from 'node:worker_threads';
+ const {Mailbox}=await import(workerData.module);
+ const m=new Mailbox(workerData.row),control=new Int32Array(workerData.row.buffer,0,1);
+ let reads=0,last=0;
+ parentPort.postMessage('ready');
+ while(!Atomics.load(control,0)||last!==100000){
+ const p=m.read(7);if(!p)continue;
+ if(p.y!==-p.x||p.pressure!==p.x*2||p.seed!==p.x*3)throw new Error('torn tuple');
+ last=p.x;reads++;m.acknowledge(p.version);
+ }parentPort.postMessage({reads,last});`;
+ const worker = new Worker(
+ new URL("data:text/javascript," + encodeURIComponent(code)),
+ {
+ workerData: {
+ module: new URL("./mailbox.js", import.meta.url).href,
+ row: shared,
+ },
+ },
+ );
+ t.after(() => worker.terminate());
+ await new Promise((resolve, reject) => {
+ worker.once("message", resolve);
+ worker.once("error", reject);
+ });
+ const result = new Promise((resolve, reject) => {
+ worker.once("message", resolve);
+ worker.once("error", reject);
+ });
+ for (let i = 1; i <= 100000; i++) {
+ m.write(7, { x: i, y: -i, pressure: i * 2 }, i * 3);
+ if (i % 1000 === 0) await new Promise((resolve) => setImmediate(resolve));
+ }
+ Atomics.store(control, 0, 1);
+ const out = await result;
+ assert.ok(out.reads > 0);
+ assert.equal(out.last, 100000);
+});
+function setup(settings = {}, sample = { x: 400, y: 300, pressure: 0.7 }) {
+ const mailbox = new Mailbox(row()),
+ messages = [],
+ mesh = sphere(4);
+ let now = 0;
+ const processor = new StrokeProcessor(
+ mesh,
+ mailbox,
+ (m) => messages.push(m),
+ () => now,
+ );
+ const version = mailbox.write(1, sample);
+ processor.begin({
+ id: 1,
+ version,
+ sample,
+ positions: mesh.positions.slice(),
+ settings: { ...defaults, symmetry: false, ...settings },
+ camera: { yaw: 0, pitch: 0, zoom: 1.5, target: [0, 0, 0] },
+ rect: { width: 800, height: 600 },
+ hit: { position: [0, 0, 1], normal: [0, 0, 1] },
+ });
+ const ack = () => {
+ for (const m of messages)
+ if (m.type === "geometry") processor.acknowledge(m.packet);
+ };
+ return {
+ processor,
+ mailbox,
+ messages,
+ ack,
+ advance: (ms) => {
+ now += ms;
+ },
+ };
+}
+test("latest frame target equals feeding only that target, intermediate samples create no hidden queue", () => {
+ const a = setup(),
+ b = setup();
+ a.processor.frame();
+ b.processor.frame();
+ a.ack();
+ b.ack();
+ for (let i = 0; i < 10000; i++)
+ a.mailbox.write(1, {
+ x: 410 + i / 100,
+ y: 300 + Math.sin(i) * 30,
+ pressure: 0.4,
+ });
+ const end = { x: 470, y: 310, pressure: 0.8 };
+ const version = a.mailbox.write(1, end);
+ b.mailbox.write(1, end);
+ a.processor.frame();
+ b.processor.frame();
+ assert.deepEqual(
+ a.processor.engine.mesh.positions,
+ b.processor.engine.mesh.positions,
+ );
+ assert.equal(a.mailbox.processed, version);
+ assert.equal(a.processor.job.target, null);
+});
+test("Grab seeds pressure-radius from first effective pressure and ignores a zero-pressure tail", () => {
+ const a = setup(
+ { brush: "grab", pressureRadius: true },
+ { x: 400, y: 300, pressure: 0 },
+ );
+ a.processor.frame();
+ const version = a.mailbox.write(1, { x: 460, y: 310, pressure: 0.8 }, 0.25);
+ a.processor.frame();
+ const expected = new Sculpt(sphere(4));
+ expected.begin({ brush: "grab", pressureRadius: true, symmetry: false });
+ expected.dab({ position: [0, 0, 1], normal: [0, 0, 1] }, 0.25, [0, 0, 0]);
+ expected.dab(
+ { position: [0, 0, 1], normal: [0, 0, 1] },
+ Math.fround(0.8),
+ [0.3, -0.05, 0],
+ );
+ assert.deepEqual(a.processor.engine.mesh.positions, expected.mesh.positions);
+ assert.equal(a.mailbox.processed, version);
+});
+test("one immutable output in flight, new dirty work survives acknowledgement, end waits for applied output", () => {
+ const a = setup();
+ a.processor.frame();
+ const first = a.messages.find((m) => m.type === "geometry"),
+ bytes = first.vertices.slice();
+ const version = a.mailbox.write(1, { x: 470, y: 310, pressure: 0.9 });
+ a.processor.end(1, version);
+ a.processor.frame();
+ assert.deepEqual(first.vertices, bytes);
+ assert.equal(a.messages.filter((m) => m.type === "geometry").length, 1);
+ assert.ok(a.processor.engine.dirty.size);
+ assert.ok(!a.messages.some((m) => m.type === "complete"));
+ a.processor.acknowledge(first.packet);
+ a.processor.frame();
+ const second = a.messages.filter((m) => m.type === "geometry").at(-1);
+ assert.notEqual(second.packet, first.packet);
+ a.processor.acknowledge(first.packet);
+ assert.equal(a.processor.outstanding, second.packet);
+ a.processor.acknowledge(second.packet);
+ a.processor.frame();
+ assert.equal(a.messages.at(-1).type, "complete");
+});
+test("worker bounds real work; unfinished segment age is not reset by release or newer samples", () => {
+ const a = setup();
+ a.processor.frame();
+ a.ack();
+ const dab = a.processor.engine.dab.bind(a.processor.engine);
+ let calls = 0;
+ a.processor.engine.dab = (...args) => {
+ calls++;
+ a.advance(5);
+ dab(...args);
+ };
+ a.mailbox.write(1, { x: 590, y: 300, pressure: 0.8 });
+ a.processor.frame();
+ assert.equal(calls, 1);
+ assert.ok(a.processor.job.target);
+ a.advance(250);
+ const latest = a.mailbox.write(1, { x: 610, y: 305, pressure: 0.9 });
+ a.processor.end(1, latest);
+ a.processor.frame();
+ assert.equal(a.processor.job, null);
+ assert.equal(a.messages.at(-1).type, "canceled");
+});
+test("resuming a segment preserves independently computed positions and pressure", () => {
+ const emitted = [],
+ path = new StrokePath(5, (p) => emitted.push(p));
+ path.push({ x: 0, y: 0, pressure: 0.2 });
+ while (!path.push({ x: 24, y: 18, pressure: 0.8 }, 1)) {}
+ assert.equal(emitted.length, 7);
+ emitted.forEach((p, i) => {
+ assert.ok(Math.abs(p.x - 4 * i) < 1e-10);
+ assert.ok(Math.abs(p.y - 3 * i) < 1e-10);
+ assert.ok(Math.abs(p.pressure - (0.2 + 0.1 * i)) < 1e-10);
+ });
+});