From 347b676cf113127bc13493bcf69932a59fd13e1e Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 28 Sep 2026 08:53:50 +0000 Subject: [PATCH] Add core-powered web sculpting studio with tablet controls Add pressure-aware worker sculpting, bounded shared input, project files and history, and compact icon navigation with an XYZ orbit gizmo. Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4b4-202c-73f1-984d-9a55c88f9c83 Co-authored-by: Heaust Azure --- server/app.go | 4 +- server/web/assets/sculpt/README.md | 50 ++ server/web/assets/sculpt/app.js | 573 ++++++++++++++++++ server/web/assets/sculpt/browser-check.js | 492 +++++++++++++++ server/web/assets/sculpt/bvh.js | 169 ++++++ server/web/assets/sculpt/engine.js | 269 ++++++++ server/web/assets/sculpt/engine.test.js | 342 +++++++++++ server/web/assets/sculpt/index.html | 472 +++++++++++++++ server/web/assets/sculpt/input.js | 406 +++++++++++++ server/web/assets/sculpt/input.test.js | 456 ++++++++++++++ server/web/assets/sculpt/latency-check.js | 148 +++++ server/web/assets/sculpt/mailbox.js | 48 ++ server/web/assets/sculpt/math.js | 30 + server/web/assets/sculpt/menus.js | 109 ++++ server/web/assets/sculpt/mesh.js | 142 +++++ server/web/assets/sculpt/navigation.js | 237 ++++++++ server/web/assets/sculpt/navigation.test.js | 146 +++++ server/web/assets/sculpt/renderer.js | 237 ++++++++ server/web/assets/sculpt/stroke-client.js | 126 ++++ server/web/assets/sculpt/stroke-worker.js | 238 ++++++++ server/web/assets/sculpt/styles.css | 523 ++++++++++++++++ .../assets/sculpt/vendor/basecoat/LICENSE.md | 21 + .../assets/sculpt/vendor/basecoat/README.md | 12 + .../sculpt/vendor/basecoat/basecoat.min.css | 2 + server/web/assets/sculpt/worker.test.js | 217 +++++++ 25 files changed, 5468 insertions(+), 1 deletion(-) create mode 100644 server/web/assets/sculpt/README.md create mode 100644 server/web/assets/sculpt/app.js create mode 100644 server/web/assets/sculpt/browser-check.js create mode 100644 server/web/assets/sculpt/bvh.js create mode 100644 server/web/assets/sculpt/engine.js create mode 100644 server/web/assets/sculpt/engine.test.js create mode 100644 server/web/assets/sculpt/index.html create mode 100644 server/web/assets/sculpt/input.js create mode 100644 server/web/assets/sculpt/input.test.js create mode 100644 server/web/assets/sculpt/latency-check.js create mode 100644 server/web/assets/sculpt/mailbox.js create mode 100644 server/web/assets/sculpt/math.js create mode 100644 server/web/assets/sculpt/menus.js create mode 100644 server/web/assets/sculpt/mesh.js create mode 100644 server/web/assets/sculpt/navigation.js create mode 100644 server/web/assets/sculpt/navigation.test.js create mode 100644 server/web/assets/sculpt/renderer.js create mode 100644 server/web/assets/sculpt/stroke-client.js create mode 100644 server/web/assets/sculpt/stroke-worker.js create mode 100644 server/web/assets/sculpt/styles.css create mode 100644 server/web/assets/sculpt/vendor/basecoat/LICENSE.md create mode 100644 server/web/assets/sculpt/vendor/basecoat/README.md create mode 100644 server/web/assets/sculpt/vendor/basecoat/basecoat.min.css create mode 100644 server/web/assets/sculpt/worker.test.js 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.

+
+ +
+ + +
+
+ +
+
+ + + +
+
+ + + + +
+ + +
+
+

Fresh clay

+

Choose how much detail you want.

+
+
+ +

Simple feels lighter. Fine allows smaller details.

+
+
+ +
+
+
+ +
+
+

Make something.

+

Press lightly for a soft touch, harder for a deeper stroke.

+
+
+
+
Find your tools
+
+ Choose a brush in the left toolbar. The sliders button opens brush + settings; the gear opens projects and settings. +
+
Move around
+
+ Drag the colored axis gizmo to orbit. Click an axis for a + straight-on view. Drag the pan or magnifier icons to move or zoom. + Arrow keys work when these controls are focused. Escape cancels a + drag. The scene uses Y as its up axis. +
+
Rest your hand
+
+ Palm rejection is on. Turn it off in Settings to draw with a + finger or move with two fingers. +
+
Keep your work
+
+ Download your project before leaving. Open it here to keep + sculpting later. +
+
With a mouse
+
Right drag to rotate, middle drag to pan, scroll to zoom.
+
+
+
+
+
+ + 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); + }); +});