diff --git a/.vscode/settings.json b/.vscode/settings.json
index 15c0d1c61c..07a68e5252 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -59,6 +59,7 @@
"gopls": {
"analyses": {
"QF1003": false
- }
+ },
+ "directoryFilters": ["-tsunami/frontend/scaffold"]
}
}
diff --git a/Taskfile.yml b/Taskfile.yml
index 5e25f0967b..cdf3860b25 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -438,3 +438,90 @@ tasks:
ignore_error: true
- cmd: '{{.RMRF}} "dist"'
ignore_error: true
+
+ tsunami:demo:todo:
+ desc: Run the tsunami todo demo application
+ cmd: go run demo/todo/*.go
+ dir: tsunami
+ env:
+ TSUNAMI_LISTENADDR: "localhost:12026"
+
+ tsunami:frontend:dev:
+ desc: Run the tsunami frontend vite dev server
+ cmd: npm run dev
+ dir: tsunami/frontend
+
+ tsunami:frontend:build:
+ desc: Build the tsunami frontend
+ cmd: yarn build
+ dir: tsunami/frontend
+
+ tsunami:frontend:devbuild:
+ desc: Build the tsunami frontend in development mode (with source maps and symbols)
+ cmd: yarn build:dev
+ dir: tsunami/frontend
+
+ tsunami:scaffold:
+ desc: Build scaffold for tsunami frontend development
+ deps:
+ - tsunami:frontend:build
+ cmds:
+ - task: tsunami:scaffold:internal
+
+ tsunami:devscaffold:
+ desc: Build scaffold for tsunami frontend development (with source maps and symbols)
+ deps:
+ - tsunami:frontend:devbuild
+ cmds:
+ - task: tsunami:scaffold:internal
+
+ tsunami:scaffold:internal:
+ desc: Internal task to create scaffold directory structure
+ dir: tsunami/frontend
+ internal: true
+ cmds:
+ - cmd: "{{.RMRF}} scaffold"
+ ignore_error: true
+ - mkdir scaffold
+ - cd scaffold && npm --no-workspaces init -y --init-license Apache-2.0
+ - cd scaffold && npm pkg set name=tsunami-scaffold
+ - cd scaffold && npm pkg delete author
+ - cd scaffold && npm pkg set author.name="Command Line Inc"
+ - cd scaffold && npm pkg set author.email="info@commandline.dev"
+ - cd scaffold && npm --no-workspaces install tailwindcss @tailwindcss/cli
+ - cp -r dist scaffold/
+ - cp ../templates/app-main.go.tmpl scaffold/app-main.go
+ - cp ../templates/tailwind.css scaffold/
+ - cp ../templates/gitignore.tmpl scaffold/.gitignore
+
+ tsunami:build:
+ desc: Build the tsunami binary.
+ cmds:
+ - cmd: "{{.RM}} bin/tsunami*"
+ ignore_error: true
+ - mkdir -p bin
+ - cd tsunami && go build -ldflags "-X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.TsunamiVersion={{.VERSION}}" -o ../bin/tsunami{{exeExt}} cmd/main-tsunami.go
+ sources:
+ - "tsunami/**/*.go"
+ - "tsunami/go.mod"
+ - "tsunami/go.sum"
+ generates:
+ - "bin/tsunami{{exeExt}}"
+
+ tsunami:clean:
+ desc: Clean tsunami frontend build artifacts
+ dir: tsunami/frontend
+ cmds:
+ - cmd: "{{.RMRF}} dist"
+ ignore_error: true
+ - cmd: "{{.RMRF}} scaffold"
+ ignore_error: true
+
+ godoc:
+ desc: Start the Go documentation server for the root module
+ cmd: $(go env GOPATH)/bin/pkgsite -http=:6060
+
+ tsunami:godoc:
+ desc: Start the Go documentation server for the tsunami module
+ cmd: $(go env GOPATH)/bin/pkgsite -http=:6060
+ dir: tsunami
diff --git a/frontend/app/view/vdom/vdom.scss b/frontend/app/view/vdom/vdom.scss
deleted file mode 100644
index b8cd5f3003..0000000000
--- a/frontend/app/view/vdom/vdom.scss
+++ /dev/null
@@ -1,8 +0,0 @@
-// Copyright 2024, Command Line Inc.
-// SPDX-License-Identifier: Apache-2.0
-
-.view-vdom {
- overflow: auto;
- width: 100%;
- min-height: 100%;
-}
diff --git a/frontend/app/view/vdom/vdom.tsx b/frontend/app/view/vdom/vdom.tsx
index 4634defd87..eacdb79424 100644
--- a/frontend/app/view/vdom/vdom.tsx
+++ b/frontend/app/view/vdom/vdom.tsx
@@ -16,7 +16,6 @@ import {
validateAndWrapCss,
validateAndWrapReactStyle,
} from "@/app/view/vdom/vdom-utils";
-import "./vdom.scss";
const TextTag = "#text";
const FragmentTag = "#fragment";
@@ -506,7 +505,7 @@ function VDomView({ blockId, model }: VDomViewProps) {
model.viewRef = viewRef;
const vdomClass = "vdom-" + blockId;
return (
-
+
{contextActive ? : null}
);
diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts
index 3c09656649..0d70582e48 100644
--- a/frontend/types/gotypes.d.ts
+++ b/frontend/types/gotypes.d.ts
@@ -837,6 +837,7 @@ declare global {
"debug:panictype"?: string;
"block:view"?: string;
"ai:backendtype"?: string;
+ "ai:local"?: boolean;
"wsh:cmd"?: string;
"wsh:haderror"?: boolean;
"conn:conntype"?: string;
diff --git a/go.mod b/go.mod
index a1b73f762c..dc51de7d71 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/wavetermdev/waveterm
-go 1.24.2
+go 1.24.6
require (
github.com/alexflint/go-filemutex v1.3.0
diff --git a/package.json b/package.json
index 915c737405..a1b2f62779 100644
--- a/package.json
+++ b/package.json
@@ -172,6 +172,7 @@
},
"packageManager": "yarn@4.6.0",
"workspaces": [
- "docs"
+ "docs",
+ "tsunami/frontend"
]
}
diff --git a/public/logos/wave-logo-256.png b/public/logos/wave-logo-256.png
new file mode 100644
index 0000000000..d360280f1d
Binary files /dev/null and b/public/logos/wave-logo-256.png differ
diff --git a/tsunami/.gitignore b/tsunami/.gitignore
new file mode 100644
index 0000000000..6dd29b7f8d
--- /dev/null
+++ b/tsunami/.gitignore
@@ -0,0 +1 @@
+bin/
\ No newline at end of file
diff --git a/tsunami/app/atom.go b/tsunami/app/atom.go
new file mode 100644
index 0000000000..2800f7f65f
--- /dev/null
+++ b/tsunami/app/atom.go
@@ -0,0 +1,123 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package app
+
+import (
+ "log"
+ "reflect"
+ "runtime"
+
+ "github.com/wavetermdev/waveterm/tsunami/engine"
+ "github.com/wavetermdev/waveterm/tsunami/util"
+)
+
+// logInvalidAtomSet logs an error when an atom is being set during component render
+func logInvalidAtomSet(atomName string) {
+ _, file, line, ok := runtime.Caller(2)
+ if ok {
+ log.Printf("invalid Set of atom '%s' in component render function at %s:%d", atomName, file, line)
+ } else {
+ log.Printf("invalid Set of atom '%s' in component render function", atomName)
+ }
+}
+
+// sameRef returns true if oldVal and newVal share the same underlying reference
+// (pointer, map, or slice). Nil values return false.
+func sameRef[T any](oldVal, newVal T) bool {
+ vOld := reflect.ValueOf(oldVal)
+ vNew := reflect.ValueOf(newVal)
+
+ if !vOld.IsValid() || !vNew.IsValid() {
+ return false
+ }
+
+ switch vNew.Kind() {
+ case reflect.Ptr:
+ // direct comparison works for *T
+ return any(oldVal) == any(newVal)
+
+ case reflect.Map, reflect.Slice:
+ if vOld.Kind() != vNew.Kind() || vOld.IsZero() || vNew.IsZero() {
+ return false
+ }
+ return vOld.Pointer() == vNew.Pointer()
+ }
+
+ // primitives, structs, etc. → not a reference type
+ return false
+}
+
+// logMutationWarning logs a warning when mutation is detected
+func logMutationWarning(atomName string) {
+ _, file, line, ok := runtime.Caller(2)
+ if ok {
+ log.Printf("WARNING: atom '%s' appears to be mutated instead of copied at %s:%d - use app.DeepCopy to create a copy before mutating", atomName, file, line)
+ } else {
+ log.Printf("WARNING: atom '%s' appears to be mutated instead of copied - use app.DeepCopy to create a copy before mutating", atomName)
+ }
+}
+
+// Atom[T] represents a typed atom implementation
+type Atom[T any] struct {
+ name string
+ client *engine.ClientImpl
+}
+
+// AtomName implements the vdom.Atom interface
+func (a Atom[T]) AtomName() string {
+ return a.name
+}
+
+// Get returns the current value of the atom. When called during component render,
+// it automatically registers the component as a dependency for this atom, ensuring
+// the component re-renders when the atom value changes.
+func (a Atom[T]) Get() T {
+ vc := engine.GetGlobalRenderContext()
+ if vc != nil {
+ vc.UsedAtoms[a.name] = true
+ }
+ val := a.client.Root.GetAtomVal(a.name)
+ typedVal := util.GetTypedAtomValue[T](val, a.name)
+ return typedVal
+}
+
+// Set updates the atom's value to the provided new value and triggers re-rendering
+// of any components that depend on this atom. This method cannot be called during
+// render cycles - use effects or event handlers instead.
+func (a Atom[T]) Set(newVal T) {
+ vc := engine.GetGlobalRenderContext()
+ if vc != nil {
+ logInvalidAtomSet(a.name)
+ return
+ }
+
+ // Check for potential mutation bugs with reference types
+ currentVal := a.client.Root.GetAtomVal(a.name)
+ currentTyped := util.GetTypedAtomValue[T](currentVal, a.name)
+ if sameRef(currentTyped, newVal) {
+ logMutationWarning(a.name)
+ }
+
+ if err := a.client.Root.SetAtomVal(a.name, newVal); err != nil {
+ log.Printf("Failed to set atom value for %s: %v", a.name, err)
+ return
+ }
+ a.client.Root.AtomAddRenderWork(a.name)
+}
+
+// SetFn updates the atom's value by applying the provided function to the current value.
+// The function receives a copy of the current atom value, which can be safely mutated
+// without affecting the original data. The return value from the function becomes the
+// new atom value. This method cannot be called during render cycles.
+func (a Atom[T]) SetFn(fn func(T) T) {
+ vc := engine.GetGlobalRenderContext()
+ if vc != nil {
+ logInvalidAtomSet(a.name)
+ return
+ }
+ currentVal := a.Get()
+ copiedVal := DeepCopy(currentVal)
+ newVal := fn(copiedVal)
+ a.Set(newVal)
+}
diff --git a/tsunami/app/defaultclient.go b/tsunami/app/defaultclient.go
new file mode 100644
index 0000000000..dde0d6f264
--- /dev/null
+++ b/tsunami/app/defaultclient.go
@@ -0,0 +1,110 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package app
+
+import (
+ "encoding/json"
+ "io/fs"
+ "net/http"
+
+ "github.com/wavetermdev/waveterm/tsunami/engine"
+ "github.com/wavetermdev/waveterm/tsunami/vdom"
+)
+
+func DefineComponent[P any](name string, renderFn func(props P) any) vdom.Component[P] {
+ return engine.DefineComponentEx(engine.GetDefaultClient(), name, renderFn)
+}
+
+func SetGlobalEventHandler(handler func(event vdom.VDomEvent)) {
+ engine.GetDefaultClient().SetGlobalEventHandler(handler)
+}
+
+// RegisterSetupFn registers a single setup function that is called before the app starts running.
+// Only one setup function is allowed, so calling this will replace any previously registered
+// setup function.
+func RegisterSetupFn(fn func()) {
+ engine.GetDefaultClient().RegisterSetupFn(fn)
+}
+
+// SendAsyncInitiation notifies the frontend that the backend has updated state
+// and requires a re-render. Normally the frontend calls the backend in response
+// to events, but when the backend changes state independently (e.g., from a
+// background process), this function gives the frontend a "nudge" to update.
+func SendAsyncInitiation() error {
+ return engine.GetDefaultClient().SendAsyncInitiation()
+}
+
+func ConfigAtom[T any](name string, defaultValue T) Atom[T] {
+ fullName := "$config." + name
+ client := engine.GetDefaultClient()
+ atom := engine.MakeAtomImpl(defaultValue)
+ client.Root.RegisterAtom(fullName, atom)
+ return Atom[T]{name: fullName, client: client}
+}
+
+func DataAtom[T any](name string, defaultValue T) Atom[T] {
+ fullName := "$data." + name
+ client := engine.GetDefaultClient()
+ atom := engine.MakeAtomImpl(defaultValue)
+ client.Root.RegisterAtom(fullName, atom)
+ return Atom[T]{name: fullName, client: client}
+}
+
+func SharedAtom[T any](name string, defaultValue T) Atom[T] {
+ fullName := "$shared." + name
+ client := engine.GetDefaultClient()
+ atom := engine.MakeAtomImpl(defaultValue)
+ client.Root.RegisterAtom(fullName, atom)
+ return Atom[T]{name: fullName, client: client}
+}
+
+// HandleDynFunc registers a dynamic HTTP handler function with the internal http.ServeMux.
+// The pattern MUST start with "/dyn/" to be valid. This allows registration of dynamic
+// routes that can be handled at runtime.
+func HandleDynFunc(pattern string, fn func(http.ResponseWriter, *http.Request)) {
+ engine.GetDefaultClient().HandleDynFunc(pattern, fn)
+}
+
+// RunMain is used internally by generated code and should not be called directly.
+func RunMain() {
+ engine.GetDefaultClient().RunMain()
+}
+
+// RegisterEmbeds is used internally by generated code and should not be called directly.
+func RegisterEmbeds(assetsFilesystem fs.FS, staticFilesystem fs.FS, manifest []byte) {
+ client := engine.GetDefaultClient()
+ client.AssetsFS = assetsFilesystem
+ client.StaticFS = staticFilesystem
+ client.ManifestFileBytes = manifest
+}
+
+// DeepCopy creates a deep copy of the input value using JSON marshal/unmarshal.
+// Panics on JSON errors.
+func DeepCopy[T any](v T) T {
+ data, err := json.Marshal(v)
+ if err != nil {
+ panic(err)
+ }
+ var result T
+ err = json.Unmarshal(data, &result)
+ if err != nil {
+ panic(err)
+ }
+ return result
+}
+
+// QueueRefOp queues a reference operation to be executed on the DOM element.
+// Operations include actions like "focus", "scrollIntoView", etc.
+// If the ref is nil or not current, the operation is ignored.
+// This function must be called within a component context.
+func QueueRefOp(ref *vdom.VDomRef, op vdom.VDomRefOperation) {
+ if ref == nil || !ref.HasCurrent {
+ return
+ }
+ if op.RefId == "" {
+ op.RefId = ref.RefId
+ }
+ client := engine.GetDefaultClient()
+ client.Root.QueueRefOp(op)
+}
diff --git a/tsunami/app/hooks.go b/tsunami/app/hooks.go
new file mode 100644
index 0000000000..4be83edd24
--- /dev/null
+++ b/tsunami/app/hooks.go
@@ -0,0 +1,198 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package app
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/wavetermdev/waveterm/tsunami/engine"
+ "github.com/wavetermdev/waveterm/tsunami/util"
+ "github.com/wavetermdev/waveterm/tsunami/vdom"
+)
+
+// UseVDomRef provides a reference to a DOM element in the VDOM tree.
+// It returns a VDomRef that can be attached to elements for direct DOM access.
+// The ref will not be current on the first render - refs are set and become
+// current after client-side mounting.
+// This hook must be called within a component context.
+func UseVDomRef() *vdom.VDomRef {
+ rc := engine.GetGlobalRenderContext()
+ val := engine.UseVDomRef(rc)
+ refVal, ok := val.(*vdom.VDomRef)
+ if !ok {
+ panic("UseVDomRef hook value is not a ref (possible out of order or conditional hooks)")
+ }
+ return refVal
+}
+
+// UseRef is the tsunami analog to React's useRef hook.
+// It provides a mutable ref object that persists across re-renders.
+// Unlike UseVDomRef, this is not tied to DOM elements but holds arbitrary values.
+// This hook must be called within a component context.
+func UseRef[T any](val T) *vdom.VDomSimpleRef[T] {
+ rc := engine.GetGlobalRenderContext()
+ refVal := engine.UseRef(rc, &vdom.VDomSimpleRef[T]{Current: val})
+ typedRef, ok := refVal.(*vdom.VDomSimpleRef[T])
+ if !ok {
+ panic("UseRef hook value is not a ref (possible out of order or conditional hooks)")
+ }
+ return typedRef
+}
+
+// UseId returns the underlying component's unique identifier (UUID).
+// The ID persists across re-renders but is recreated when the component
+// is recreated, following React component lifecycle.
+// This hook must be called within a component context.
+func UseId() string {
+ rc := engine.GetGlobalRenderContext()
+ if rc == nil {
+ panic("UseId must be called within a component (no context)")
+ }
+ return engine.UseId(rc)
+}
+
+// UseRenderTs returns the timestamp of the current render.
+// This hook must be called within a component context.
+func UseRenderTs() int64 {
+ rc := engine.GetGlobalRenderContext()
+ if rc == nil {
+ panic("UseRenderTs must be called within a component (no context)")
+ }
+ return engine.UseRenderTs(rc)
+}
+
+// UseResync returns whether the current render is a resync operation.
+// Resyncs happen on initial app loads or full refreshes, as opposed to
+// incremental renders which happen otherwise.
+// This hook must be called within a component context.
+func UseResync() bool {
+ rc := engine.GetGlobalRenderContext()
+ if rc == nil {
+ panic("UseResync must be called within a component (no context)")
+ }
+ return engine.UseResync(rc)
+}
+
+// UseEffect is the tsunami analog to React's useEffect hook.
+// It queues effects to run after the render cycle completes.
+// The function can return a cleanup function that runs before the next effect
+// or when the component unmounts. Dependencies use shallow comparison, just like React.
+// This hook must be called within a component context.
+func UseEffect(fn func() func(), deps []any) {
+ // note UseEffect never actually runs anything, it just queues the effect to run later
+ rc := engine.GetGlobalRenderContext()
+ if rc == nil {
+ panic("UseEffect must be called within a component (no context)")
+ }
+ engine.UseEffect(rc, fn, deps)
+}
+
+// UseSetAppTitle sets the application title for the current component.
+// This hook must be called within a component context.
+func UseSetAppTitle(title string) {
+ rc := engine.GetGlobalRenderContext()
+ if rc == nil {
+ panic("UseSetAppTitle must be called within a component (no context)")
+ }
+ engine.UseSetAppTitle(rc, title)
+}
+
+// UseLocal creates a component-local atom that is automatically cleaned up when the component unmounts.
+// The atom is created with a unique name based on the component's wave ID and hook index.
+// This hook must be called within a component context.
+func UseLocal[T any](initialVal T) Atom[T] {
+ rc := engine.GetGlobalRenderContext()
+ if rc == nil {
+ panic("UseLocal must be called within a component (no context)")
+ }
+ atomName := engine.UseLocal(rc, initialVal)
+ return Atom[T]{
+ name: atomName,
+ client: engine.GetDefaultClient(),
+ }
+}
+
+// UseGoRoutine manages a goroutine lifecycle within a component.
+// It spawns a new goroutine with the provided function when dependencies change,
+// and automatically cancels the context on dependency changes or component unmount.
+// This hook must be called within a component context.
+func UseGoRoutine(fn func(ctx context.Context), deps []any) {
+ rc := engine.GetGlobalRenderContext()
+ if rc == nil {
+ panic("UseGoRoutine must be called within a component (no context)")
+ }
+
+ // Use UseRef to store the cancel function
+ cancelRef := UseRef[context.CancelFunc](nil)
+
+ UseEffect(func() func() {
+ // Cancel any existing goroutine
+ if cancelRef.Current != nil {
+ cancelRef.Current()
+ }
+
+ // Create new context and start goroutine
+ ctx, cancel := context.WithCancel(context.Background())
+ cancelRef.Current = cancel
+
+ componentName := "unknown"
+ if rc.Comp != nil && rc.Comp.Elem != nil {
+ componentName = rc.Comp.Elem.Tag
+ }
+
+ go func() {
+ defer func() {
+ util.PanicHandler(fmt.Sprintf("UseGoRoutine in component '%s'", componentName), recover())
+ }()
+ fn(ctx)
+ }()
+
+ // Return cleanup function that cancels the context
+ return func() {
+ if cancel != nil {
+ cancel()
+ }
+ }
+ }, deps)
+}
+
+// UseTicker manages a ticker lifecycle within a component.
+// It creates a ticker that calls the provided function at regular intervals.
+// The ticker is automatically stopped on dependency changes or component unmount.
+// This hook must be called within a component context.
+func UseTicker(interval time.Duration, tickFn func(), deps []any) {
+ UseGoRoutine(func(ctx context.Context) {
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ tickFn()
+ }
+ }
+ }, deps)
+}
+
+// UseAfter manages a timeout lifecycle within a component.
+// It creates a timer that calls the provided function after the specified duration.
+// The timer is automatically canceled on dependency changes or component unmount.
+// This hook must be called within a component context.
+func UseAfter(duration time.Duration, timeoutFn func(), deps []any) {
+ UseGoRoutine(func(ctx context.Context) {
+ timer := time.NewTimer(duration)
+ defer timer.Stop()
+
+ select {
+ case <-ctx.Done():
+ return
+ case <-timer.C:
+ timeoutFn()
+ }
+ }, deps)
+}
diff --git a/tsunami/build/build.go b/tsunami/build/build.go
new file mode 100644
index 0000000000..7ac516760e
--- /dev/null
+++ b/tsunami/build/build.go
@@ -0,0 +1,775 @@
+package build
+
+import (
+ "bufio"
+ "fmt"
+ "go/parser"
+ "go/token"
+ "io"
+ "log"
+ "os"
+ "os/exec"
+ "os/signal"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/wavetermdev/waveterm/tsunami/util"
+ "golang.org/x/mod/modfile"
+)
+
+const MinSupportedGoMinorVersion = 22
+const TsunamiUIImportPath = "github.com/wavetermdev/waveterm/tsunami/ui"
+
+type BuildOpts struct {
+ Dir string
+ Verbose bool
+ Open bool
+ KeepTemp bool
+ OutputFile string
+ ScaffoldPath string
+ SdkReplacePath string
+}
+
+type BuildEnv struct {
+ GoVersion string
+ TempDir string
+ cleanupOnce *sync.Once
+}
+
+func findGoExecutable() (string, error) {
+ // First try the standard PATH lookup
+ if goPath, err := exec.LookPath("go"); err == nil {
+ return goPath, nil
+ }
+
+ // Define platform-specific paths to check
+ var pathsToCheck []string
+
+ if runtime.GOOS == "windows" {
+ pathsToCheck = []string{
+ `c:\go\bin\go.exe`,
+ `c:\program files\go\bin\go.exe`,
+ }
+ } else {
+ // Unix-like systems (macOS, Linux, etc.)
+ pathsToCheck = []string{
+ "/opt/homebrew/bin/go", // Homebrew on Apple Silicon
+ "/usr/local/bin/go", // Traditional Homebrew or manual install
+ "/usr/local/go/bin/go", // Official Go installation
+ "/usr/bin/go", // System package manager
+ }
+ }
+
+ // Check each path
+ for _, path := range pathsToCheck {
+ if _, err := os.Stat(path); err == nil {
+ // File exists, check if it's executable
+ if info, err := os.Stat(path); err == nil && !info.IsDir() {
+ return path, nil
+ }
+ }
+ }
+
+ return "", fmt.Errorf("go command not found in PATH or common installation locations")
+}
+
+func verifyEnvironment(verbose bool) (*BuildEnv, error) {
+ // Find Go executable using enhanced search
+ goPath, err := findGoExecutable()
+ if err != nil {
+ return nil, fmt.Errorf("go command not found: %w", err)
+ }
+
+ // Run go version command
+ cmd := exec.Command(goPath, "version")
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("failed to run 'go version': %w", err)
+ }
+
+ // Parse go version output and check for 1.22+
+ versionStr := strings.TrimSpace(string(output))
+ if verbose {
+ log.Printf("Found %s", versionStr)
+ }
+
+ // Extract version like "go1.22.0" from output
+ versionRegex := regexp.MustCompile(`go(1\.\d+)`)
+ matches := versionRegex.FindStringSubmatch(versionStr)
+ if len(matches) < 2 {
+ return nil, fmt.Errorf("unable to parse go version from: %s", versionStr)
+ }
+
+ goVersion := matches[1]
+
+ // Check if version is 1.22+
+ minorRegex := regexp.MustCompile(`1\.(\d+)`)
+ minorMatches := minorRegex.FindStringSubmatch(goVersion)
+ if len(minorMatches) < 2 {
+ return nil, fmt.Errorf("unable to parse minor version from: %s", goVersion)
+ }
+
+ minor, err := strconv.Atoi(minorMatches[1])
+ if err != nil || minor < MinSupportedGoMinorVersion {
+ return nil, fmt.Errorf("go version 1.%d or higher required, found: %s", MinSupportedGoMinorVersion, versionStr)
+ }
+
+ // Check if npx is in PATH
+ _, err = exec.LookPath("npx")
+ if err != nil {
+ return nil, fmt.Errorf("npx command not found in PATH: %w", err)
+ }
+
+ if verbose {
+ log.Printf("Found npx in PATH")
+ }
+
+ // Check Tailwind CSS version
+ tailwindCmd := exec.Command("npx", "@tailwindcss/cli")
+ tailwindOutput, err := tailwindCmd.CombinedOutput()
+ if err != nil {
+ return nil, fmt.Errorf("failed to run 'npx @tailwindcss/cli': %w", err)
+ }
+
+ tailwindStr := strings.TrimSpace(string(tailwindOutput))
+ lines := strings.Split(tailwindStr, "\n")
+ if len(lines) == 0 {
+ return nil, fmt.Errorf("no output from tailwindcss command")
+ }
+
+ firstLine := lines[0]
+ if verbose {
+ log.Printf("Found %s", firstLine)
+ }
+
+ // Check for v4 (format: "≈ tailwindcss v4.1.12")
+ tailwindRegex := regexp.MustCompile(`tailwindcss v(\d+)`)
+ tailwindMatches := tailwindRegex.FindStringSubmatch(firstLine)
+ if len(tailwindMatches) < 2 {
+ return nil, fmt.Errorf("unable to parse tailwindcss version from: %s", firstLine)
+ }
+
+ majorVersion, err := strconv.Atoi(tailwindMatches[1])
+ if err != nil || majorVersion != 4 {
+ return nil, fmt.Errorf("tailwindcss v4 required, found: %s", firstLine)
+ }
+
+ return &BuildEnv{
+ GoVersion: goVersion,
+ cleanupOnce: &sync.Once{},
+ }, nil
+}
+
+func createGoMod(tempDir, appDirName, goVersion string, opts BuildOpts, verbose bool) error {
+ modulePath := fmt.Sprintf("tsunami/app/%s", appDirName)
+
+ // Check if go.mod already exists in original directory
+ originalGoModPath := filepath.Join(opts.Dir, "go.mod")
+ var modFile *modfile.File
+ var err error
+
+ if _, err := os.Stat(originalGoModPath); err == nil {
+ // go.mod exists, copy and parse it
+ if verbose {
+ log.Printf("Found existing go.mod, copying from %s", originalGoModPath)
+ }
+
+ // Copy existing go.mod to temp directory
+ tempGoModPath := filepath.Join(tempDir, "go.mod")
+ if err := copyFile(originalGoModPath, tempGoModPath); err != nil {
+ return fmt.Errorf("failed to copy existing go.mod: %w", err)
+ }
+
+ // Also copy go.sum if it exists
+ originalGoSumPath := filepath.Join(opts.Dir, "go.sum")
+ if _, err := os.Stat(originalGoSumPath); err == nil {
+ tempGoSumPath := filepath.Join(tempDir, "go.sum")
+ if err := copyFile(originalGoSumPath, tempGoSumPath); err != nil {
+ return fmt.Errorf("failed to copy existing go.sum: %w", err)
+ }
+ if verbose {
+ log.Printf("Found and copied existing go.sum from %s", originalGoSumPath)
+ }
+ }
+
+ // Parse the existing go.mod
+ goModContent, err := os.ReadFile(tempGoModPath)
+ if err != nil {
+ return fmt.Errorf("failed to read copied go.mod: %w", err)
+ }
+
+ modFile, err = modfile.Parse("go.mod", goModContent, nil)
+ if err != nil {
+ return fmt.Errorf("failed to parse existing go.mod: %w", err)
+ }
+ } else if os.IsNotExist(err) {
+ // go.mod doesn't exist, create new one
+ if verbose {
+ log.Printf("No existing go.mod found, creating new one")
+ }
+
+ modFile = &modfile.File{}
+ if err := modFile.AddModuleStmt(modulePath); err != nil {
+ return fmt.Errorf("failed to add module statement: %w", err)
+ }
+
+ if err := modFile.AddGoStmt(goVersion); err != nil {
+ return fmt.Errorf("failed to add go version: %w", err)
+ }
+
+ // Add requirement for tsunami SDK
+ if err := modFile.AddRequire("github.com/wavetermdev/waveterm/tsunami", "v0.0.0"); err != nil {
+ return fmt.Errorf("failed to add require directive: %w", err)
+ }
+ } else {
+ return fmt.Errorf("error checking for existing go.mod: %w", err)
+ }
+
+ // Add replace directive for tsunami SDK
+ if err := modFile.AddReplace("github.com/wavetermdev/waveterm/tsunami", "", opts.SdkReplacePath, ""); err != nil {
+ return fmt.Errorf("failed to add replace directive: %w", err)
+ }
+
+ // Format and write the file
+ modFile.Cleanup()
+ goModContent, err := modFile.Format()
+ if err != nil {
+ return fmt.Errorf("failed to format go.mod: %w", err)
+ }
+
+ goModPath := filepath.Join(tempDir, "go.mod")
+ if err := os.WriteFile(goModPath, goModContent, 0644); err != nil {
+ return fmt.Errorf("failed to write go.mod file: %w", err)
+ }
+
+ if verbose {
+ log.Printf("Created go.mod with module path: %s", modulePath)
+ log.Printf("Added require: github.com/wavetermdev/waveterm/tsunami v0.0.0")
+ log.Printf("Added replace directive: github.com/wavetermdev/waveterm/tsunami => %s", opts.SdkReplacePath)
+ }
+
+ // Run go mod tidy to clean up dependencies
+ tidyCmd := exec.Command("go", "mod", "tidy")
+ tidyCmd.Dir = tempDir
+
+ if verbose {
+ log.Printf("Running go mod tidy")
+ tidyCmd.Stdout = os.Stdout
+ tidyCmd.Stderr = os.Stderr
+ }
+
+ if err := tidyCmd.Run(); err != nil {
+ return fmt.Errorf("failed to run go mod tidy: %w", err)
+ }
+
+ if verbose {
+ log.Printf("Successfully ran go mod tidy")
+ }
+
+ return nil
+}
+
+func verifyTsunamiDir(dir string) error {
+ if dir == "" {
+ return fmt.Errorf("directory path cannot be empty")
+ }
+
+ // Check if directory exists
+ info, err := os.Stat(dir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return fmt.Errorf("directory %q does not exist", dir)
+ }
+ return fmt.Errorf("error accessing directory %q: %w", dir, err)
+ }
+
+ if !info.IsDir() {
+ return fmt.Errorf("%q is not a directory", dir)
+ }
+
+ // Check for app.go file
+ appGoPath := filepath.Join(dir, "app.go")
+ if err := CheckFileExists(appGoPath); err != nil {
+ return fmt.Errorf("app.go check failed in directory %q: %w", dir, err)
+ }
+
+ // Check static directory if it exists
+ staticPath := filepath.Join(dir, "static")
+ if err := IsDirOrNotFound(staticPath); err != nil {
+ return fmt.Errorf("static directory check failed in %q: %w", dir, err)
+ }
+
+ // Check that dist doesn't exist
+ distPath := filepath.Join(dir, "dist")
+ if err := FileMustNotExist(distPath); err != nil {
+ return fmt.Errorf("dist check failed in %q: %w", dir, err)
+ }
+
+ return nil
+}
+
+func verifyScaffoldPath(scaffoldPath string) error {
+ if scaffoldPath == "" {
+ return fmt.Errorf("scaffoldPath cannot be empty")
+ }
+
+ // Check if directory exists
+ info, err := os.Stat(scaffoldPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return fmt.Errorf("scaffoldPath directory %q does not exist", scaffoldPath)
+ }
+ return fmt.Errorf("error accessing scaffoldPath directory %q: %w", scaffoldPath, err)
+ }
+
+ if !info.IsDir() {
+ return fmt.Errorf("scaffoldPath %q is not a directory", scaffoldPath)
+ }
+
+ // Check for dist directory
+ distPath := filepath.Join(scaffoldPath, "dist")
+ if err := IsDirOrNotFound(distPath); err != nil {
+ return fmt.Errorf("dist directory check failed in scaffoldPath %q: %w", scaffoldPath, err)
+ }
+ info, err = os.Stat(distPath)
+ if err != nil || !info.IsDir() {
+ return fmt.Errorf("dist directory must exist in scaffoldPath %q", scaffoldPath)
+ }
+
+ // Check for app-main.go file
+ appMainPath := filepath.Join(scaffoldPath, "app-main.go")
+ if err := CheckFileExists(appMainPath); err != nil {
+ return fmt.Errorf("app-main.go check failed in scaffoldPath %q: %w", scaffoldPath, err)
+ }
+
+ // Check for tailwind.css file
+ tailwindPath := filepath.Join(scaffoldPath, "tailwind.css")
+ if err := CheckFileExists(tailwindPath); err != nil {
+ return fmt.Errorf("tailwind.css check failed in scaffoldPath %q: %w", scaffoldPath, err)
+ }
+
+ // Check for package.json file
+ packageJsonPath := filepath.Join(scaffoldPath, "package.json")
+ if err := CheckFileExists(packageJsonPath); err != nil {
+ return fmt.Errorf("package.json check failed in scaffoldPath %q: %w", scaffoldPath, err)
+ }
+
+ // Check for node_modules directory
+ nodeModulesPath := filepath.Join(scaffoldPath, "node_modules")
+ if err := IsDirOrNotFound(nodeModulesPath); err != nil {
+ return fmt.Errorf("node_modules directory check failed in scaffoldPath %q: %w", scaffoldPath, err)
+ }
+ info, err = os.Stat(nodeModulesPath)
+ if err != nil || !info.IsDir() {
+ return fmt.Errorf("node_modules directory must exist in scaffoldPath %q", scaffoldPath)
+ }
+
+ return nil
+}
+
+func buildImportsMap(dir string) (map[string]bool, error) {
+ imports := make(map[string]bool)
+
+ files, err := filepath.Glob(filepath.Join(dir, "*.go"))
+ if err != nil {
+ return nil, fmt.Errorf("failed to list go files: %w", err)
+ }
+
+ fset := token.NewFileSet()
+ for _, file := range files {
+ node, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly)
+ if err != nil {
+ continue // Skip files that can't be parsed
+ }
+
+ for _, imp := range node.Imports {
+ // Remove quotes from import path
+ importPath := strings.Trim(imp.Path.Value, `"`)
+ imports[importPath] = true
+ }
+ }
+
+ return imports, nil
+}
+
+func (be *BuildEnv) cleanupTempDir(keepTemp bool, verbose bool) {
+ if be == nil || be.cleanupOnce == nil {
+ return
+ }
+
+ be.cleanupOnce.Do(func() {
+ if keepTemp || be.TempDir == "" {
+ log.Printf("NOT cleaning tempdir\n")
+ return
+ }
+ if err := os.RemoveAll(be.TempDir); err != nil {
+ log.Printf("Failed to remove temp directory %s: %v", be.TempDir, err)
+ } else if verbose {
+ log.Printf("Removed temp directory: %s", be.TempDir)
+ }
+ })
+}
+
+func setupSignalCleanup(buildEnv *BuildEnv, keepTemp, verbose bool) {
+ if keepTemp {
+ return
+ }
+ sigChan := make(chan os.Signal, 1)
+ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
+ go func() {
+ defer signal.Stop(sigChan)
+ sig := <-sigChan
+ if verbose {
+ log.Printf("Received signal %v, cleaning up temp directory", sig)
+ }
+ buildEnv.cleanupTempDir(keepTemp, verbose)
+ os.Exit(1)
+ }()
+}
+
+func TsunamiBuild(opts BuildOpts) error {
+ buildEnv, err := tsunamiBuildInternal(opts)
+ defer buildEnv.cleanupTempDir(opts.KeepTemp, opts.Verbose)
+ if err != nil {
+ return err
+ }
+ setupSignalCleanup(buildEnv, opts.KeepTemp, opts.Verbose)
+ return nil
+}
+
+func tsunamiBuildInternal(opts BuildOpts) (*BuildEnv, error) {
+ buildEnv, err := verifyEnvironment(opts.Verbose)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := verifyTsunamiDir(opts.Dir); err != nil {
+ return nil, err
+ }
+
+ if err := verifyScaffoldPath(opts.ScaffoldPath); err != nil {
+ return nil, err
+ }
+
+ // Create temporary directory
+ tempDir, err := os.MkdirTemp("", "tsunami-build-*")
+ if err != nil {
+ return nil, fmt.Errorf("failed to create temp directory: %w", err)
+ }
+
+ buildEnv.TempDir = tempDir
+
+ log.Printf("Building tsunami app from %s\n", opts.Dir)
+
+ if opts.Verbose {
+ log.Printf("Temp dir: %s\n", tempDir)
+ }
+
+ // Copy all *.go files from the root directory
+ goCount, err := copyGoFiles(opts.Dir, tempDir)
+ if err != nil {
+ return buildEnv, fmt.Errorf("failed to copy go files: %w", err)
+ }
+
+ // Copy static directory
+ staticSrcDir := filepath.Join(opts.Dir, "static")
+ staticDestDir := filepath.Join(tempDir, "static")
+ staticCount, err := copyDirRecursive(staticSrcDir, staticDestDir, true)
+ if err != nil {
+ return buildEnv, fmt.Errorf("failed to copy static directory: %w", err)
+ }
+
+ // Copy scaffold directory contents selectively
+ scaffoldCount, err := copyScaffoldSelective(opts.ScaffoldPath, tempDir)
+ if err != nil {
+ return buildEnv, fmt.Errorf("failed to copy scaffold directory: %w", err)
+ }
+
+ if opts.Verbose {
+ log.Printf("Copied %d go files, %d static files, %d scaffold files\n", goCount, staticCount, scaffoldCount)
+ }
+
+ // Copy app-main.go from scaffold to main-app.go in temp dir
+ appMainSrc := filepath.Join(tempDir, "app-main.go")
+ appMainDest := filepath.Join(tempDir, "main-app.go")
+ if err := os.Rename(appMainSrc, appMainDest); err != nil {
+ return buildEnv, fmt.Errorf("failed to rename app-main.go to main-app.go: %w", err)
+ }
+
+ // Create go.mod file
+ appDirName := filepath.Base(opts.Dir)
+ if err := createGoMod(tempDir, appDirName, buildEnv.GoVersion, opts, opts.Verbose); err != nil {
+ return buildEnv, fmt.Errorf("failed to create go.mod: %w", err)
+ }
+
+ // Build imports map from Go files
+ imports, err := buildImportsMap(tempDir)
+ if err != nil {
+ return buildEnv, fmt.Errorf("failed to build imports map: %w", err)
+ }
+
+ // Create symlink to SDK ui directory only if UI package is imported
+ if imports[TsunamiUIImportPath] {
+ uiLinkPath := filepath.Join(tempDir, "ui")
+ uiTargetPath := filepath.Join(opts.SdkReplacePath, "ui")
+ if err := os.Symlink(uiTargetPath, uiLinkPath); err != nil {
+ return buildEnv, fmt.Errorf("failed to create ui symlink: %w", err)
+ }
+ if opts.Verbose {
+ log.Printf("Created UI symlink: %s -> %s", uiLinkPath, uiTargetPath)
+ }
+ } else if opts.Verbose {
+ log.Printf("Skipping UI symlink creation - no UI package imports found")
+ }
+
+ // Generate Tailwind CSS
+ if err := generateAppTailwindCss(tempDir, opts.Verbose); err != nil {
+ return buildEnv, fmt.Errorf("failed to generate tailwind css: %w", err)
+ }
+
+ // Build the Go application
+ if err := runGoBuild(tempDir, opts); err != nil {
+ return buildEnv, fmt.Errorf("failed to build application: %w", err)
+ }
+
+ // Move generated files back to original directory
+ if err := moveFilesBack(tempDir, opts.Dir, opts.Verbose); err != nil {
+ return buildEnv, fmt.Errorf("failed to move files back: %w", err)
+ }
+
+ return buildEnv, nil
+}
+
+func moveFilesBack(tempDir, originalDir string, verbose bool) error {
+ // Move go.mod back to original directory
+ goModSrc := filepath.Join(tempDir, "go.mod")
+ goModDest := filepath.Join(originalDir, "go.mod")
+ if err := copyFile(goModSrc, goModDest); err != nil {
+ return fmt.Errorf("failed to copy go.mod back: %w", err)
+ }
+ if verbose {
+ log.Printf("Moved go.mod back to %s", goModDest)
+ }
+
+ // Move go.sum back to original directory (only if it exists)
+ goSumSrc := filepath.Join(tempDir, "go.sum")
+ if _, err := os.Stat(goSumSrc); err == nil {
+ goSumDest := filepath.Join(originalDir, "go.sum")
+ if err := copyFile(goSumSrc, goSumDest); err != nil {
+ return fmt.Errorf("failed to copy go.sum back: %w", err)
+ }
+ if verbose {
+ log.Printf("Moved go.sum back to %s", goSumDest)
+ }
+ }
+
+ // Ensure static directory exists in original directory
+ staticDir := filepath.Join(originalDir, "static")
+ if err := os.MkdirAll(staticDir, 0755); err != nil {
+ return fmt.Errorf("failed to create static directory: %w", err)
+ }
+ if verbose {
+ log.Printf("Ensured static directory exists at %s", staticDir)
+ }
+
+ // Move tw.css back to original directory
+ twCssSrc := filepath.Join(tempDir, "static", "tw.css")
+ twCssDest := filepath.Join(originalDir, "static", "tw.css")
+ if err := copyFile(twCssSrc, twCssDest); err != nil {
+ return fmt.Errorf("failed to copy tw.css back: %w", err)
+ }
+ if verbose {
+ log.Printf("Moved tw.css back to %s", twCssDest)
+ }
+
+ return nil
+}
+
+func runGoBuild(tempDir string, opts BuildOpts) error {
+ var outputPath string
+ if opts.OutputFile != "" {
+ // Convert to absolute path resolved against current working directory
+ var err error
+ outputPath, err = filepath.Abs(opts.OutputFile)
+ if err != nil {
+ return fmt.Errorf("failed to resolve output path: %w", err)
+ }
+ } else {
+ binDir := filepath.Join(tempDir, "bin")
+ if err := os.MkdirAll(binDir, 0755); err != nil {
+ return fmt.Errorf("failed to create bin directory: %w", err)
+ }
+ outputPath = "bin/app"
+ }
+
+ goFiles, err := listGoFilesInDir(tempDir)
+ if err != nil {
+ return fmt.Errorf("failed to list go files: %w", err)
+ }
+
+ if len(goFiles) == 0 {
+ return fmt.Errorf("no .go files found in %s", tempDir)
+ }
+
+ // Build command with explicit go files
+ args := append([]string{"build", "-o", outputPath}, goFiles...)
+ buildCmd := exec.Command("go", args...)
+ buildCmd.Dir = tempDir
+
+ if opts.Verbose {
+ log.Printf("Running: %s", strings.Join(buildCmd.Args, " "))
+ buildCmd.Stdout = os.Stdout
+ buildCmd.Stderr = os.Stderr
+ }
+
+ if err := buildCmd.Run(); err != nil {
+ return fmt.Errorf("failed to build application: %w", err)
+ }
+
+ if opts.Verbose {
+ if opts.OutputFile != "" {
+ log.Printf("Application built successfully at %s", outputPath)
+ } else {
+ log.Printf("Application built successfully at %s", filepath.Join(tempDir, "bin", "app"))
+ }
+ }
+
+ return nil
+}
+
+func generateAppTailwindCss(tempDir string, verbose bool) error {
+ // tailwind.css is already in tempDir from scaffold copy
+ tailwindOutput := filepath.Join(tempDir, "static", "tw.css")
+
+ tailwindCmd := exec.Command("npx", "@tailwindcss/cli",
+ "-i", "./tailwind.css",
+ "-o", tailwindOutput)
+ tailwindCmd.Dir = tempDir
+
+ if verbose {
+ log.Printf("Running: %s", strings.Join(tailwindCmd.Args, " "))
+ }
+
+ if err := tailwindCmd.Run(); err != nil {
+ return fmt.Errorf("failed to run tailwind command: %w", err)
+ }
+
+ if verbose {
+ log.Printf("Tailwind CSS generated successfully")
+ }
+
+ return nil
+}
+
+func copyGoFiles(srcDir, destDir string) (int, error) {
+ entries, err := os.ReadDir(srcDir)
+ if err != nil {
+ return 0, err
+ }
+
+ fileCount := 0
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+
+ if strings.HasSuffix(entry.Name(), ".go") {
+ srcPath := filepath.Join(srcDir, entry.Name())
+ destPath := filepath.Join(destDir, entry.Name())
+
+ if err := copyFile(srcPath, destPath); err != nil {
+ return 0, fmt.Errorf("failed to copy %s: %w", entry.Name(), err)
+ }
+ fileCount++
+ }
+ }
+
+ return fileCount, nil
+}
+
+func TsunamiRun(opts BuildOpts) error {
+ buildEnv, err := tsunamiBuildInternal(opts)
+ defer buildEnv.cleanupTempDir(opts.KeepTemp, opts.Verbose)
+ if err != nil {
+ return err
+ }
+ setupSignalCleanup(buildEnv, opts.KeepTemp, opts.Verbose)
+
+ // Run the built application
+ appPath := filepath.Join(buildEnv.TempDir, "bin", "app")
+ runCmd := exec.Command(appPath)
+ runCmd.Dir = buildEnv.TempDir
+
+ log.Printf("Running tsunami app from %s", opts.Dir)
+
+ runCmd.Stdin = os.Stdin
+
+ if opts.Open {
+ // If --open flag is set, we need to capture stderr to parse the listening message
+ stderr, err := runCmd.StderrPipe()
+ if err != nil {
+ return fmt.Errorf("failed to create stderr pipe: %w", err)
+ }
+ runCmd.Stdout = os.Stdout
+
+ if err := runCmd.Start(); err != nil {
+ return fmt.Errorf("failed to start application: %w", err)
+ }
+
+ // Monitor stderr for the listening message
+ go monitorAndOpenBrowser(stderr, opts.Verbose)
+
+ if err := runCmd.Wait(); err != nil {
+ return fmt.Errorf("application exited with error: %w", err)
+ }
+ } else {
+ // Normal execution without browser opening
+ if opts.Verbose {
+ log.Printf("Executing: %s", appPath)
+ runCmd.Stdout = os.Stdout
+ runCmd.Stderr = os.Stderr
+ }
+
+ if err := runCmd.Start(); err != nil {
+ return fmt.Errorf("failed to start application: %w", err)
+ }
+
+ if err := runCmd.Wait(); err != nil {
+ return fmt.Errorf("application exited with error: %w", err)
+ }
+ }
+
+ return nil
+}
+
+func monitorAndOpenBrowser(r io.ReadCloser, verbose bool) {
+ defer r.Close()
+
+ scanner := bufio.NewScanner(r)
+ urlRegex := regexp.MustCompile(`\[tsunami\] listening at (http://[^\s]+)`)
+ browserOpened := false
+ if verbose {
+ log.Printf("monitoring for browser open\n")
+ }
+
+ for scanner.Scan() {
+ line := scanner.Text()
+ fmt.Println(line)
+
+ if !browserOpened && len(urlRegex.FindStringSubmatch(line)) > 1 {
+ matches := urlRegex.FindStringSubmatch(line)
+ url := matches[1]
+ if verbose {
+ log.Printf("Opening browser to %s", url)
+ }
+ go util.OpenBrowser(url, 100*time.Millisecond)
+ browserOpened = true
+ }
+ }
+}
diff --git a/tsunami/build/buildutil.go b/tsunami/build/buildutil.go
new file mode 100644
index 0000000000..3ad35233e0
--- /dev/null
+++ b/tsunami/build/buildutil.go
@@ -0,0 +1,228 @@
+package build
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+)
+
+func IsDirOrNotFound(path string) error {
+ info, err := os.Stat(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil // Not found is OK
+ }
+ return err // Other errors are not OK
+ }
+
+ if !info.IsDir() {
+ return fmt.Errorf("%q exists but is not a directory", path)
+ }
+
+ return nil // It's a directory, which is OK
+}
+
+func CheckFileExists(path string) error {
+ info, err := os.Stat(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return fmt.Errorf("file %q not found", path)
+ }
+ return fmt.Errorf("error accessing file %q: %w", path, err)
+ }
+
+ if info.IsDir() {
+ return fmt.Errorf("%q is a directory, not a file", path)
+ }
+
+ return nil
+}
+
+func FileMustNotExist(path string) error {
+ if _, err := os.Stat(path); err == nil {
+ return fmt.Errorf("%q must not exist", path)
+ } else if !os.IsNotExist(err) {
+ return err // Other errors are not OK
+ }
+ return nil // Not found is OK
+}
+
+func copyDirRecursive(srcDir, destDir string, forceCreateDestDir bool) (int, error) {
+ // Check if source directory exists
+ srcInfo, err := os.Stat(srcDir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ if forceCreateDestDir {
+ // Create destination directory even if source doesn't exist
+ if err := os.MkdirAll(destDir, 0755); err != nil {
+ return 0, fmt.Errorf("failed to create destination directory %s: %w", destDir, err)
+ }
+ }
+ return 0, nil // Source doesn't exist, return 0 files copied
+ }
+ return 0, fmt.Errorf("error accessing source directory %s: %w", srcDir, err)
+ }
+
+ // Check if source is actually a directory
+ if !srcInfo.IsDir() {
+ return 0, fmt.Errorf("source %s is not a directory", srcDir)
+ }
+
+ fileCount := 0
+ err = filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+
+ // Calculate destination path
+ relPath, err := filepath.Rel(srcDir, path)
+ if err != nil {
+ return err
+ }
+ destPath := filepath.Join(destDir, relPath)
+
+ if info.IsDir() {
+ // Create directory
+ if err := os.MkdirAll(destPath, info.Mode()); err != nil {
+ return err
+ }
+ } else {
+ // Copy file
+ if err := copyFile(path, destPath); err != nil {
+ return err
+ }
+ fileCount++
+ }
+
+ return nil
+ })
+
+ return fileCount, err
+}
+
+func copyFile(srcPath, destPath string) error {
+ // Get source file info for mode
+ srcInfo, err := os.Stat(srcPath)
+ if err != nil {
+ return err
+ }
+
+ // Create destination directory if it doesn't exist
+ destDir := filepath.Dir(destPath)
+ if err := os.MkdirAll(destDir, 0755); err != nil {
+ return err
+ }
+
+ srcFile, err := os.Open(srcPath)
+ if err != nil {
+ return err
+ }
+ defer srcFile.Close()
+
+ destFile, err := os.Create(destPath)
+ if err != nil {
+ return err
+ }
+ defer destFile.Close()
+
+ _, err = io.Copy(destFile, srcFile)
+ if err != nil {
+ return err
+ }
+
+ // Set the same mode as source file
+ return os.Chmod(destPath, srcInfo.Mode())
+}
+
+func listGoFilesInDir(dirPath string) ([]string, error) {
+ entries, err := os.ReadDir(dirPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read directory %s: %w", dirPath, err)
+ }
+
+ var goFiles []string
+ for _, entry := range entries {
+ if !entry.IsDir() && filepath.Ext(entry.Name()) == ".go" {
+ goFiles = append(goFiles, entry.Name())
+ }
+ }
+
+ return goFiles, nil
+}
+
+func copyScaffoldSelective(scaffoldPath, destDir string) (int, error) {
+ fileCount := 0
+
+ // Create symlinks for node_modules directory
+ symlinkItems := []string{"node_modules"}
+ for _, item := range symlinkItems {
+ srcPath := filepath.Join(scaffoldPath, item)
+ destPath := filepath.Join(destDir, item)
+
+ // Check if source exists
+ if _, err := os.Stat(srcPath); err != nil {
+ if os.IsNotExist(err) {
+ continue // Skip if doesn't exist
+ }
+ return 0, fmt.Errorf("error checking %s: %w", item, err)
+ }
+
+ // Create symlink
+ if err := os.Symlink(srcPath, destPath); err != nil {
+ return 0, fmt.Errorf("failed to create symlink for %s: %w", item, err)
+ }
+ fileCount++
+ }
+
+ // Copy package files instead of symlinking
+ packageFiles := []string{"package.json", "package-lock.json"}
+ for _, fileName := range packageFiles {
+ srcPath := filepath.Join(scaffoldPath, fileName)
+ destPath := filepath.Join(destDir, fileName)
+
+ // Check if source exists
+ if _, err := os.Stat(srcPath); err != nil {
+ if os.IsNotExist(err) {
+ continue // Skip if doesn't exist
+ }
+ return 0, fmt.Errorf("error checking %s: %w", fileName, err)
+ }
+
+ // Copy file
+ if err := copyFile(srcPath, destPath); err != nil {
+ return 0, fmt.Errorf("failed to copy %s: %w", fileName, err)
+ }
+ fileCount++
+ }
+
+ // Copy dist directory that needs to be fully copied for go embed
+ distSrcPath := filepath.Join(scaffoldPath, "dist")
+ distDestPath := filepath.Join(destDir, "dist")
+ dirCount, err := copyDirRecursive(distSrcPath, distDestPath, false)
+ if err != nil {
+ return 0, fmt.Errorf("failed to copy dist directory: %w", err)
+ }
+ fileCount += dirCount
+
+ // Copy files by pattern (*.go, *.md, *.json, tailwind.css)
+ patterns := []string{"*.go", "*.md", "*.json", "tailwind.css"}
+ for _, pattern := range patterns {
+ matches, err := filepath.Glob(filepath.Join(scaffoldPath, pattern))
+ if err != nil {
+ return 0, fmt.Errorf("failed to glob pattern %s: %w", pattern, err)
+ }
+
+ for _, srcPath := range matches {
+ fileName := filepath.Base(srcPath)
+ destPath := filepath.Join(destDir, fileName)
+
+ if err := copyFile(srcPath, destPath); err != nil {
+ return 0, fmt.Errorf("failed to copy %s: %w", fileName, err)
+ }
+ fileCount++
+ }
+ }
+
+ return fileCount, nil
+}
diff --git a/tsunami/cmd/main-tsunami.go b/tsunami/cmd/main-tsunami.go
new file mode 100644
index 0000000000..1ca4ffefd8
--- /dev/null
+++ b/tsunami/cmd/main-tsunami.go
@@ -0,0 +1,126 @@
+package main
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+ "github.com/wavetermdev/waveterm/tsunami/build"
+ "github.com/wavetermdev/waveterm/tsunami/tsunamibase"
+)
+
+const (
+ EnvTsunamiScaffoldPath = "TSUNAMI_SCAFFOLDPATH"
+ EnvTsunamiSdkReplacePath = "TSUNAMI_SDKREPLACEPATH"
+)
+
+// these are set at build time
+var TsunamiVersion = "0.0.0"
+var BuildTime = "0"
+
+var rootCmd = &cobra.Command{
+ Use: "tsunami",
+ Short: "Tsunami - A VDOM-based UI framework",
+ Long: `Tsunami is a VDOM-based UI framework for building modern applications.`,
+}
+
+var versionCmd = &cobra.Command{
+ Use: "version",
+ Short: "Print Tsunami version",
+ Long: `Print Tsunami version`,
+ Run: func(cmd *cobra.Command, args []string) {
+ fmt.Println("v" + tsunamibase.TsunamiVersion)
+ },
+}
+
+func validateEnvironmentVars(opts *build.BuildOpts) error {
+ scaffoldPath := os.Getenv(EnvTsunamiScaffoldPath)
+ if scaffoldPath == "" {
+ return fmt.Errorf("%s environment variable must be set", EnvTsunamiScaffoldPath)
+ }
+
+ sdkReplacePath := os.Getenv(EnvTsunamiSdkReplacePath)
+ if sdkReplacePath == "" {
+ return fmt.Errorf("%s environment variable must be set", EnvTsunamiSdkReplacePath)
+ }
+
+ opts.ScaffoldPath = scaffoldPath
+ opts.SdkReplacePath = sdkReplacePath
+ return nil
+}
+
+var buildCmd = &cobra.Command{
+ Use: "build [directory]",
+ Short: "Build a Tsunami application",
+ Long: `Build a Tsunami application from the specified directory.`,
+ Args: cobra.ExactArgs(1),
+ SilenceUsage: true,
+ Run: func(cmd *cobra.Command, args []string) {
+ verbose, _ := cmd.Flags().GetBool("verbose")
+ keepTemp, _ := cmd.Flags().GetBool("keeptemp")
+ output, _ := cmd.Flags().GetString("output")
+ opts := build.BuildOpts{
+ Dir: args[0],
+ Verbose: verbose,
+ KeepTemp: keepTemp,
+ OutputFile: output,
+ }
+ if err := validateEnvironmentVars(&opts); err != nil {
+ fmt.Println(err)
+ os.Exit(1)
+ }
+ if err := build.TsunamiBuild(opts); err != nil {
+ fmt.Println(err)
+ os.Exit(1)
+ }
+ },
+}
+
+var runCmd = &cobra.Command{
+ Use: "run [directory]",
+ Short: "Build and run a Tsunami application",
+ Long: `Build and run a Tsunami application from the specified directory.`,
+ Args: cobra.ExactArgs(1),
+ SilenceUsage: true,
+ Run: func(cmd *cobra.Command, args []string) {
+ verbose, _ := cmd.Flags().GetBool("verbose")
+ open, _ := cmd.Flags().GetBool("open")
+ keepTemp, _ := cmd.Flags().GetBool("keeptemp")
+ opts := build.BuildOpts{
+ Dir: args[0],
+ Verbose: verbose,
+ Open: open,
+ KeepTemp: keepTemp,
+ }
+ if err := validateEnvironmentVars(&opts); err != nil {
+ fmt.Println(err)
+ os.Exit(1)
+ }
+ if err := build.TsunamiRun(opts); err != nil {
+ fmt.Println(err)
+ os.Exit(1)
+ }
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(versionCmd)
+
+ buildCmd.Flags().BoolP("verbose", "v", false, "Enable verbose output")
+ buildCmd.Flags().Bool("keeptemp", false, "Keep temporary build directory")
+ buildCmd.Flags().StringP("output", "o", "", "Output file path for the built application")
+ rootCmd.AddCommand(buildCmd)
+
+ runCmd.Flags().BoolP("verbose", "v", false, "Enable verbose output")
+ runCmd.Flags().Bool("open", false, "Open the application in the browser after starting")
+ runCmd.Flags().Bool("keeptemp", false, "Keep temporary build directory")
+ rootCmd.AddCommand(runCmd)
+}
+
+func main() {
+ tsunamibase.TsunamiVersion = TsunamiVersion
+ if err := rootCmd.Execute(); err != nil {
+ fmt.Println(err)
+ os.Exit(1)
+ }
+}
diff --git a/tsunami/demo/.gitignore b/tsunami/demo/.gitignore
new file mode 100644
index 0000000000..b59f7e3a95
--- /dev/null
+++ b/tsunami/demo/.gitignore
@@ -0,0 +1 @@
+test/
\ No newline at end of file
diff --git a/tsunami/demo/cpuchart/app.go b/tsunami/demo/cpuchart/app.go
new file mode 100644
index 0000000000..3d6802e3ef
--- /dev/null
+++ b/tsunami/demo/cpuchart/app.go
@@ -0,0 +1,360 @@
+package main
+
+import (
+ "log"
+ "time"
+
+ "github.com/shirou/gopsutil/v4/cpu"
+ "github.com/wavetermdev/waveterm/tsunami/app"
+ "github.com/wavetermdev/waveterm/tsunami/vdom"
+)
+
+// Global atoms for config and data
+var (
+ dataPointCountAtom = app.ConfigAtom("dataPointCount", 60)
+ cpuDataAtom = app.DataAtom("cpuData", func() []CPUDataPoint {
+ // Initialize with empty data points to maintain consistent chart size
+ dataPointCount := 60 // Default value for initialization
+ initialData := make([]CPUDataPoint, dataPointCount)
+ for i := range initialData {
+ initialData[i] = CPUDataPoint{
+ Time: 0,
+ CPUUsage: nil, // Use nil to represent empty slots
+ Timestamp: "",
+ }
+ }
+ return initialData
+ }())
+)
+
+type CPUDataPoint struct {
+ Time int64 `json:"time"` // Unix timestamp in seconds
+ CPUUsage *float64 `json:"cpuUsage"` // CPU usage percentage (nil for empty slots)
+ Timestamp string `json:"timestamp"` // Human readable timestamp
+}
+
+type StatsPanelProps struct {
+ Data []CPUDataPoint `json:"data"`
+}
+
+func collectCPUUsage() (float64, error) {
+ percentages, err := cpu.Percent(time.Second, false)
+ if err != nil {
+ return 0, err
+ }
+ if len(percentages) == 0 {
+ return 0, nil
+ }
+ return percentages[0], nil
+}
+
+func generateCPUDataPoint() CPUDataPoint {
+ now := time.Now()
+ cpuUsage, err := collectCPUUsage()
+ if err != nil {
+ log.Printf("Error collecting CPU usage: %v", err)
+ cpuUsage = 0
+ }
+
+ dataPoint := CPUDataPoint{
+ Time: now.Unix(),
+ CPUUsage: &cpuUsage, // Convert to pointer
+ Timestamp: now.Format("15:04:05"),
+ }
+
+ log.Printf("CPU Usage: %.2f%% at %s", cpuUsage, dataPoint.Timestamp)
+ return dataPoint
+}
+
+var StatsPanel = app.DefineComponent("StatsPanel", func(props StatsPanelProps) any {
+ var currentUsage float64
+ var avgUsage float64
+ var maxUsage float64
+ var validCount int
+
+ if len(props.Data) > 0 {
+ lastPoint := props.Data[len(props.Data)-1]
+ if lastPoint.CPUUsage != nil {
+ currentUsage = *lastPoint.CPUUsage
+ }
+
+ // Calculate average and max from non-nil values
+ total := 0.0
+ for _, point := range props.Data {
+ if point.CPUUsage != nil {
+ total += *point.CPUUsage
+ validCount++
+ if *point.CPUUsage > maxUsage {
+ maxUsage = *point.CPUUsage
+ }
+ }
+ }
+ if validCount > 0 {
+ avgUsage = total / float64(validCount)
+ }
+ }
+
+ return vdom.H("div", map[string]any{
+ "className": "bg-gray-800 rounded-lg p-4 mb-6",
+ },
+ vdom.H("h3", map[string]any{
+ "className": "text-lg font-semibold text-white mb-3",
+ }, "CPU Statistics"),
+ vdom.H("div", map[string]any{
+ "className": "grid grid-cols-3 gap-4",
+ },
+ // Current Usage
+ vdom.H("div", map[string]any{
+ "className": "bg-gray-700 rounded p-3",
+ },
+ vdom.H("div", map[string]any{
+ "className": "text-sm text-gray-400 mb-1",
+ }, "Current"),
+ vdom.H("div", map[string]any{
+ "className": "text-2xl font-bold text-blue-400",
+ }, vdom.H("span", nil, int(currentUsage+0.5), "%")),
+ ),
+ // Average Usage
+ vdom.H("div", map[string]any{
+ "className": "bg-gray-700 rounded p-3",
+ },
+ vdom.H("div", map[string]any{
+ "className": "text-sm text-gray-400 mb-1",
+ }, "Average"),
+ vdom.H("div", map[string]any{
+ "className": "text-2xl font-bold text-green-400",
+ }, vdom.H("span", nil, int(avgUsage+0.5), "%")),
+ ),
+ // Max Usage
+ vdom.H("div", map[string]any{
+ "className": "bg-gray-700 rounded p-3",
+ },
+ vdom.H("div", map[string]any{
+ "className": "text-sm text-gray-400 mb-1",
+ }, "Peak"),
+ vdom.H("div", map[string]any{
+ "className": "text-2xl font-bold text-red-400",
+ }, vdom.H("span", nil, int(maxUsage+0.5), "%")),
+ ),
+ ),
+ )
+},
+)
+
+var App = app.DefineComponent("App", func(_ struct{}) any {
+ app.UseSetAppTitle("CPU Usage Monitor")
+
+ // Use UseTicker for continuous CPU data collection - automatically cleaned up on unmount
+ app.UseTicker(time.Second, func() {
+ // Collect new CPU data point and shift the data window
+ newPoint := generateCPUDataPoint()
+ cpuDataAtom.SetFn(func(data []CPUDataPoint) []CPUDataPoint {
+ currentDataPointCount := dataPointCountAtom.Get()
+
+ // Ensure we have the right size array
+ if len(data) != currentDataPointCount {
+ // Resize array if config changed
+ resized := make([]CPUDataPoint, currentDataPointCount)
+ copyCount := currentDataPointCount
+ if len(data) < copyCount {
+ copyCount = len(data)
+ }
+ if copyCount > 0 {
+ copy(resized[currentDataPointCount-copyCount:], data[len(data)-copyCount:])
+ }
+ data = resized
+ }
+
+ // Append new point and keep only the last currentDataPointCount elements
+ data = append(data, newPoint)
+ if len(data) > currentDataPointCount {
+ data = data[len(data)-currentDataPointCount:]
+ }
+ return data
+ })
+ }, []any{})
+
+ handleClear := func() {
+ // Reset with empty data points based on current config
+ currentDataPointCount := dataPointCountAtom.Get()
+ initialData := make([]CPUDataPoint, currentDataPointCount)
+ for i := range initialData {
+ initialData[i] = CPUDataPoint{
+ Time: 0,
+ CPUUsage: nil,
+ Timestamp: "",
+ }
+ }
+ cpuDataAtom.Set(initialData)
+ }
+
+ // Read atom values once for rendering
+ cpuData := cpuDataAtom.Get()
+ dataPointCount := dataPointCountAtom.Get()
+
+ return vdom.H("div", map[string]any{
+ "className": "min-h-screen bg-gray-900 text-white p-6",
+ },
+ vdom.H("div", map[string]any{
+ "className": "max-w-6xl mx-auto",
+ },
+ // Header
+ vdom.H("div", map[string]any{
+ "className": "mb-8",
+ },
+ vdom.H("h1", map[string]any{
+ "className": "text-3xl font-bold text-white mb-2",
+ }, "Real-Time CPU Usage Monitor"),
+ vdom.H("p", map[string]any{
+ "className": "text-gray-400",
+ }, "Live CPU usage data collected using gopsutil, displaying 60 seconds of history"),
+ ),
+
+ // Controls
+ vdom.H("div", map[string]any{
+ "className": "bg-gray-800 rounded-lg p-4 mb-6",
+ },
+ vdom.H("div", map[string]any{
+ "className": "flex items-center gap-4 flex-wrap",
+ },
+ // Clear button
+ vdom.H("button", map[string]any{
+ "className": "px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-md text-sm font-medium transition-colors cursor-pointer",
+ "onClick": handleClear,
+ }, "Clear Data"),
+
+ // Status indicator
+ vdom.H("div", map[string]any{
+ "className": "flex items-center gap-2",
+ },
+ vdom.H("div", map[string]any{
+ "className": "w-2 h-2 rounded-full bg-green-500",
+ }),
+ vdom.H("span", map[string]any{
+ "className": "text-sm text-gray-400",
+ }, "Live Monitoring"),
+ vdom.H("span", map[string]any{
+ "className": "text-sm text-gray-500 ml-2",
+ }, "(", len(cpuData), "/", dataPointCount, " data points)"),
+ ),
+ ),
+ ),
+
+ // Statistics Panel
+ StatsPanel(StatsPanelProps{
+ Data: cpuData,
+ }),
+
+ // Main chart
+ vdom.H("div", map[string]any{
+ "className": "bg-gray-800 rounded-lg p-6 mb-6",
+ },
+ vdom.H("h2", map[string]any{
+ "className": "text-xl font-semibold text-white mb-4",
+ }, "CPU Usage Over Time"),
+ vdom.H("div", map[string]any{
+ "className": "w-full h-96",
+ },
+ vdom.H("recharts:ResponsiveContainer", map[string]any{
+ "width": "100%",
+ "height": "100%",
+ },
+ vdom.H("recharts:LineChart", map[string]any{
+ "data": cpuData,
+ "isAnimationActive": false,
+ },
+ vdom.H("recharts:CartesianGrid", map[string]any{
+ "strokeDasharray": "3 3",
+ "stroke": "#374151",
+ }),
+ vdom.H("recharts:XAxis", map[string]any{
+ "dataKey": "timestamp",
+ "stroke": "#9CA3AF",
+ "fontSize": 12,
+ }),
+ vdom.H("recharts:YAxis", map[string]any{
+ "domain": []int{0, 100},
+ "stroke": "#9CA3AF",
+ "fontSize": 12,
+ }),
+ vdom.H("recharts:Tooltip", map[string]any{
+ "labelStyle": map[string]any{
+ "color": "#374151",
+ },
+ "contentStyle": map[string]any{
+ "backgroundColor": "#1F2937",
+ "border": "1px solid #374151",
+ "borderRadius": "6px",
+ "color": "#F3F4F6",
+ },
+ }),
+ vdom.H("recharts:Line", map[string]any{
+ "type": "monotone",
+ "dataKey": "cpuUsage",
+ "stroke": "#3B82F6",
+ "strokeWidth": 2,
+ "dot": false,
+ "name": "CPU Usage (%)",
+ "isAnimationActive": false,
+ }),
+ ),
+ ),
+ ),
+ ),
+
+ // Info section
+ vdom.H("div", map[string]any{
+ "className": "bg-blue-900 bg-opacity-50 border border-blue-700 rounded-lg p-4",
+ },
+ vdom.H("h3", map[string]any{
+ "className": "text-lg font-semibold text-blue-200 mb-2",
+ }, "Real-Time CPU Monitoring Features"),
+ vdom.H("ul", map[string]any{
+ "className": "space-y-2 text-blue-100",
+ },
+ vdom.H("li", map[string]any{
+ "className": "flex items-start gap-2",
+ },
+ vdom.H("span", map[string]any{
+ "className": "text-blue-400 mt-1",
+ }, "•"),
+ "Live CPU usage data collected using github.com/shirou/gopsutil/v4",
+ ),
+ vdom.H("li", map[string]any{
+ "className": "flex items-start gap-2",
+ },
+ vdom.H("span", map[string]any{
+ "className": "text-blue-400 mt-1",
+ }, "•"),
+ "Continuous monitoring with 1-second update intervals",
+ ),
+ vdom.H("li", map[string]any{
+ "className": "flex items-start gap-2",
+ },
+ vdom.H("span", map[string]any{
+ "className": "text-blue-400 mt-1",
+ }, "•"),
+ "Rolling window of 60 seconds of historical data",
+ ),
+ vdom.H("li", map[string]any{
+ "className": "flex items-start gap-2",
+ },
+ vdom.H("span", map[string]any{
+ "className": "text-blue-400 mt-1",
+ }, "•"),
+ "Real-time statistics: current, average, and peak usage",
+ ),
+ vdom.H("li", map[string]any{
+ "className": "flex items-start gap-2",
+ },
+ vdom.H("span", map[string]any{
+ "className": "text-blue-400 mt-1",
+ }, "•"),
+ "Dark theme optimized for Wave Terminal",
+ ),
+ ),
+ ),
+ ),
+ )
+},
+)
diff --git a/tsunami/demo/cpuchart/go.mod b/tsunami/demo/cpuchart/go.mod
new file mode 100644
index 0000000000..0140d12180
--- /dev/null
+++ b/tsunami/demo/cpuchart/go.mod
@@ -0,0 +1,23 @@
+module tsunami/app/cpuchart
+
+go 1.24.6
+
+require (
+ github.com/shirou/gopsutil/v4 v4.25.8
+ github.com/wavetermdev/waveterm/tsunami v0.0.0
+)
+
+require (
+ github.com/ebitengine/purego v0.8.4 // indirect
+ github.com/go-ole/go-ole v1.2.6 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
+ github.com/outrigdev/goid v0.3.0 // indirect
+ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
+ github.com/tklauser/go-sysconf v0.3.15 // indirect
+ github.com/tklauser/numcpus v0.10.0 // indirect
+ github.com/yusufpapurcu/wmi v1.2.4 // indirect
+ golang.org/x/sys v0.35.0 // indirect
+)
+
+replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami
diff --git a/tsunami/demo/cpuchart/go.sum b/tsunami/demo/cpuchart/go.sum
new file mode 100644
index 0000000000..4d3c872cfc
--- /dev/null
+++ b/tsunami/demo/cpuchart/go.sum
@@ -0,0 +1,36 @@
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
+github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
+github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
+github.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=
+github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
+github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
+github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970=
+github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
+github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
+github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
+github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
+github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
+github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/tsunami/demo/cpuchart/static/tw.css b/tsunami/demo/cpuchart/static/tw.css
new file mode 100644
index 0000000000..e7b155870f
--- /dev/null
+++ b/tsunami/demo/cpuchart/static/tw.css
@@ -0,0 +1,1276 @@
+/*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */
+@layer properties;
+@layer theme, base, components, utilities;
+@layer theme {
+ :root, :host {
+ --font-sans: "Inter", sans-serif;
+ --font-mono: "Hack", monospace;
+ --color-red-100: oklch(93.6% 0.032 17.717);
+ --color-red-400: oklch(70.4% 0.191 22.216);
+ --color-red-500: oklch(63.7% 0.237 25.331);
+ --color-red-800: oklch(44.4% 0.177 26.899);
+ --color-green-400: oklch(79.2% 0.209 151.711);
+ --color-green-500: oklch(72.3% 0.219 149.579);
+ --color-blue-100: oklch(93.2% 0.032 255.585);
+ --color-blue-200: oklch(88.2% 0.059 254.128);
+ --color-blue-400: oklch(70.7% 0.165 254.624);
+ --color-blue-700: oklch(48.8% 0.243 264.376);
+ --color-blue-900: oklch(37.9% 0.146 265.522);
+ --color-gray-400: oklch(70.7% 0.022 261.325);
+ --color-gray-500: oklch(55.1% 0.027 264.364);
+ --color-gray-600: oklch(44.6% 0.03 256.802);
+ --color-gray-700: oklch(37.3% 0.034 259.733);
+ --color-gray-800: oklch(27.8% 0.033 256.848);
+ --color-gray-900: oklch(21% 0.034 264.665);
+ --color-white: #fff;
+ --spacing: 0.25rem;
+ --container-6xl: 72rem;
+ --text-sm: 0.875rem;
+ --text-sm--line-height: calc(1.25 / 0.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);
+ --text-xl: 1.25rem;
+ --text-xl--line-height: calc(1.75 / 1.25);
+ --text-2xl: 1.5rem;
+ --text-2xl--line-height: calc(2 / 1.5);
+ --text-3xl: 1.875rem;
+ --text-3xl--line-height: calc(2.25 / 1.875);
+ --font-weight-medium: 500;
+ --font-weight-semibold: 600;
+ --font-weight-bold: 700;
+ --leading-relaxed: 1.625;
+ --radius-md: 0.375rem;
+ --radius-lg: 0.5rem;
+ --ease-in: cubic-bezier(0.4, 0, 1, 1);
+ --ease-out: cubic-bezier(0, 0, 0.2, 1);
+ --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
+ --default-transition-duration: 150ms;
+ --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ --default-font-family: var(--font-sans);
+ --default-mono-font-family: var(--font-mono);
+ --radius: 8px;
+ --color-background: rgb(34, 34, 34);
+ --color-primary: rgb(247, 247, 247);
+ --color-secondary: rgba(215, 218, 224, 0.7);
+ --color-muted: rgba(215, 218, 224, 0.5);
+ --color-accent-300: rgb(110, 231, 133);
+ --color-panel: rgba(255, 255, 255, 0.12);
+ --color-border: rgba(255, 255, 255, 0.16);
+ --color-accent: rgb(88, 193, 66);
+ }
+}
+@layer base {
+ *, ::after, ::before, ::backdrop, ::file-selector-button {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ border: 0 solid;
+ }
+ html, :host {
+ line-height: 1.5;
+ -webkit-text-size-adjust: 100%;
+ tab-size: 4;
+ 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;
+ 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 {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+ }
+ sub {
+ bottom: -0.25em;
+ }
+ sup {
+ top: -0.5em;
+ }
+ table {
+ text-indent: 0;
+ border-color: inherit;
+ border-collapse: collapse;
+ }
+ :-moz-focusring {
+ outline: auto;
+ }
+ progress {
+ vertical-align: baseline;
+ }
+ summary {
+ display: list-item;
+ }
+ ol, ul, menu {
+ list-style: none;
+ }
+ img, svg, video, canvas, audio, iframe, embed, object {
+ display: block;
+ vertical-align: middle;
+ }
+ img, video {
+ max-width: 100%;
+ height: auto;
+ }
+ button, input, select, optgroup, textarea, ::file-selector-button {
+ font: inherit;
+ font-feature-settings: inherit;
+ font-variation-settings: inherit;
+ letter-spacing: inherit;
+ color: inherit;
+ border-radius: 0;
+ background-color: transparent;
+ opacity: 1;
+ }
+ :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)) {
+ 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, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-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"]), ::file-selector-button {
+ appearance: button;
+ }
+ ::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
+ height: auto;
+ }
+ [hidden]:where(:not([hidden="until-found"])) {
+ display: none !important;
+ }
+}
+@layer utilities {
+ .collapse {
+ visibility: collapse;
+ }
+ .invisible {
+ visibility: hidden;
+ }
+ .visible {
+ visibility: visible;
+ }
+ .sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border-width: 0;
+ }
+ .not-sr-only {
+ position: static;
+ width: auto;
+ height: auto;
+ padding: 0;
+ margin: 0;
+ overflow: visible;
+ clip-path: none;
+ white-space: normal;
+ }
+ .absolute {
+ position: absolute;
+ }
+ .fixed {
+ position: fixed;
+ }
+ .relative {
+ position: relative;
+ }
+ .static {
+ position: static;
+ }
+ .sticky {
+ position: sticky;
+ }
+ .isolate {
+ isolation: isolate;
+ }
+ .isolation-auto {
+ isolation: auto;
+ }
+ .container {
+ width: 100%;
+ @media (width >= 40rem) {
+ max-width: 40rem;
+ }
+ @media (width >= 48rem) {
+ max-width: 48rem;
+ }
+ @media (width >= 64rem) {
+ max-width: 64rem;
+ }
+ @media (width >= 80rem) {
+ max-width: 80rem;
+ }
+ @media (width >= 96rem) {
+ max-width: 96rem;
+ }
+ }
+ .mx-auto {
+ margin-inline: auto;
+ }
+ .my-6 {
+ margin-block: calc(var(--spacing) * 6);
+ }
+ .mt-1 {
+ margin-top: calc(var(--spacing) * 1);
+ }
+ .mt-3 {
+ margin-top: calc(var(--spacing) * 3);
+ }
+ .mt-4 {
+ margin-top: calc(var(--spacing) * 4);
+ }
+ .mt-5 {
+ margin-top: calc(var(--spacing) * 5);
+ }
+ .mt-6 {
+ margin-top: calc(var(--spacing) * 6);
+ }
+ .mb-1 {
+ margin-bottom: calc(var(--spacing) * 1);
+ }
+ .mb-2 {
+ margin-bottom: calc(var(--spacing) * 2);
+ }
+ .mb-3 {
+ margin-bottom: calc(var(--spacing) * 3);
+ }
+ .mb-4 {
+ margin-bottom: calc(var(--spacing) * 4);
+ }
+ .mb-6 {
+ margin-bottom: calc(var(--spacing) * 6);
+ }
+ .mb-8 {
+ margin-bottom: calc(var(--spacing) * 8);
+ }
+ .ml-2 {
+ margin-left: calc(var(--spacing) * 2);
+ }
+ .ml-4 {
+ margin-left: calc(var(--spacing) * 4);
+ }
+ .block {
+ display: block;
+ }
+ .contents {
+ display: contents;
+ }
+ .flex {
+ display: flex;
+ }
+ .flow-root {
+ display: flow-root;
+ }
+ .grid {
+ display: grid;
+ }
+ .hidden {
+ display: none;
+ }
+ .inline {
+ display: inline;
+ }
+ .inline-block {
+ display: inline-block;
+ }
+ .inline-flex {
+ display: inline-flex;
+ }
+ .inline-grid {
+ display: inline-grid;
+ }
+ .inline-table {
+ display: inline-table;
+ }
+ .list-item {
+ display: list-item;
+ }
+ .table {
+ display: table;
+ }
+ .table-caption {
+ display: table-caption;
+ }
+ .table-cell {
+ display: table-cell;
+ }
+ .table-column {
+ display: table-column;
+ }
+ .table-column-group {
+ display: table-column-group;
+ }
+ .table-footer-group {
+ display: table-footer-group;
+ }
+ .table-header-group {
+ display: table-header-group;
+ }
+ .table-row {
+ display: table-row;
+ }
+ .table-row-group {
+ display: table-row-group;
+ }
+ .h-2 {
+ height: calc(var(--spacing) * 2);
+ }
+ .h-96 {
+ height: calc(var(--spacing) * 96);
+ }
+ .min-h-full {
+ min-height: 100%;
+ }
+ .min-h-screen {
+ min-height: 100vh;
+ }
+ .w-2 {
+ width: calc(var(--spacing) * 2);
+ }
+ .w-full {
+ width: 100%;
+ }
+ .max-w-6xl {
+ max-width: var(--container-6xl);
+ }
+ .max-w-none {
+ max-width: none;
+ }
+ .min-w-full {
+ min-width: 100%;
+ }
+ .shrink {
+ flex-shrink: 1;
+ }
+ .grow {
+ flex-grow: 1;
+ }
+ .border-collapse {
+ border-collapse: collapse;
+ }
+ .translate-none {
+ translate: none;
+ }
+ .scale-3d {
+ scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z);
+ }
+ .transform {
+ transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
+ }
+ .cursor-pointer {
+ cursor: pointer;
+ }
+ .touch-pinch-zoom {
+ --tw-pinch-zoom: pinch-zoom;
+ touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,);
+ }
+ .resize {
+ resize: both;
+ }
+ .list-inside {
+ list-style-position: inside;
+ }
+ .list-decimal {
+ list-style-type: decimal;
+ }
+ .list-disc {
+ list-style-type: disc;
+ }
+ .grid-cols-3 {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+ .flex-wrap {
+ flex-wrap: wrap;
+ }
+ .items-center {
+ align-items: center;
+ }
+ .items-start {
+ align-items: flex-start;
+ }
+ .gap-2 {
+ gap: calc(var(--spacing) * 2);
+ }
+ .gap-4 {
+ gap: calc(var(--spacing) * 4);
+ }
+ .space-y-1 {
+ :where(& > :not(:last-child)) {
+ --tw-space-y-reverse: 0;
+ margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));
+ margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));
+ }
+ }
+ .space-y-2 {
+ :where(& > :not(:last-child)) {
+ --tw-space-y-reverse: 0;
+ margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));
+ margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
+ }
+ }
+ .space-y-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-space-y-reverse: 1;
+ }
+ }
+ .space-x-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-space-x-reverse: 1;
+ }
+ }
+ .divide-x {
+ :where(& > :not(:last-child)) {
+ --tw-divide-x-reverse: 0;
+ border-inline-style: var(--tw-border-style);
+ border-inline-start-width: calc(1px * var(--tw-divide-x-reverse));
+ border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse)));
+ }
+ }
+ .divide-y {
+ :where(& > :not(:last-child)) {
+ --tw-divide-y-reverse: 0;
+ border-bottom-style: var(--tw-border-style);
+ border-top-style: var(--tw-border-style);
+ border-top-width: calc(1px * var(--tw-divide-y-reverse));
+ border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
+ }
+ }
+ .divide-y-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-divide-y-reverse: 1;
+ }
+ }
+ .truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .overflow-auto {
+ overflow: auto;
+ }
+ .overflow-x-auto {
+ overflow-x: auto;
+ }
+ .rounded {
+ border-radius: var(--radius);
+ }
+ .rounded-full {
+ border-radius: calc(infinity * 1px);
+ }
+ .rounded-lg {
+ border-radius: var(--radius-lg);
+ }
+ .rounded-md {
+ border-radius: var(--radius-md);
+ }
+ .rounded-s {
+ border-start-start-radius: var(--radius);
+ border-end-start-radius: var(--radius);
+ }
+ .rounded-ss {
+ border-start-start-radius: var(--radius);
+ }
+ .rounded-e {
+ border-start-end-radius: var(--radius);
+ border-end-end-radius: var(--radius);
+ }
+ .rounded-se {
+ border-start-end-radius: var(--radius);
+ }
+ .rounded-ee {
+ border-end-end-radius: var(--radius);
+ }
+ .rounded-es {
+ border-end-start-radius: var(--radius);
+ }
+ .rounded-t {
+ border-top-left-radius: var(--radius);
+ border-top-right-radius: var(--radius);
+ }
+ .rounded-l {
+ border-top-left-radius: var(--radius);
+ border-bottom-left-radius: var(--radius);
+ }
+ .rounded-tl {
+ border-top-left-radius: var(--radius);
+ }
+ .rounded-r {
+ border-top-right-radius: var(--radius);
+ border-bottom-right-radius: var(--radius);
+ }
+ .rounded-tr {
+ border-top-right-radius: var(--radius);
+ }
+ .rounded-b {
+ border-bottom-right-radius: var(--radius);
+ border-bottom-left-radius: var(--radius);
+ }
+ .rounded-br {
+ border-bottom-right-radius: var(--radius);
+ }
+ .rounded-bl {
+ border-bottom-left-radius: var(--radius);
+ }
+ .border {
+ border-style: var(--tw-border-style);
+ border-width: 1px;
+ }
+ .border-x {
+ border-inline-style: var(--tw-border-style);
+ border-inline-width: 1px;
+ }
+ .border-y {
+ border-block-style: var(--tw-border-style);
+ border-block-width: 1px;
+ }
+ .border-s {
+ border-inline-start-style: var(--tw-border-style);
+ border-inline-start-width: 1px;
+ }
+ .border-e {
+ border-inline-end-style: var(--tw-border-style);
+ border-inline-end-width: 1px;
+ }
+ .border-t {
+ border-top-style: var(--tw-border-style);
+ border-top-width: 1px;
+ }
+ .border-r {
+ border-right-style: var(--tw-border-style);
+ border-right-width: 1px;
+ }
+ .border-b {
+ border-bottom-style: var(--tw-border-style);
+ border-bottom-width: 1px;
+ }
+ .border-l {
+ border-left-style: var(--tw-border-style);
+ border-left-width: 1px;
+ }
+ .border-l-4 {
+ border-left-style: var(--tw-border-style);
+ border-left-width: 4px;
+ }
+ .border-blue-700 {
+ border-color: var(--color-blue-700);
+ }
+ .border-border {
+ border-color: var(--color-border);
+ }
+ .border-red-500 {
+ border-color: var(--color-red-500);
+ }
+ .bg-background {
+ background-color: var(--color-background);
+ }
+ .bg-blue-900 {
+ background-color: var(--color-blue-900);
+ }
+ .bg-gray-600 {
+ background-color: var(--color-gray-600);
+ }
+ .bg-gray-700 {
+ background-color: var(--color-gray-700);
+ }
+ .bg-gray-800 {
+ background-color: var(--color-gray-800);
+ }
+ .bg-gray-900 {
+ background-color: var(--color-gray-900);
+ }
+ .bg-green-500 {
+ background-color: var(--color-green-500);
+ }
+ .bg-panel {
+ background-color: var(--color-panel);
+ }
+ .bg-red-100 {
+ background-color: var(--color-red-100);
+ }
+ .bg-repeat {
+ background-repeat: repeat;
+ }
+ .mask-no-clip {
+ mask-clip: no-clip;
+ }
+ .mask-repeat {
+ mask-repeat: repeat;
+ }
+ .p-3 {
+ padding: calc(var(--spacing) * 3);
+ }
+ .p-4 {
+ padding: calc(var(--spacing) * 4);
+ }
+ .p-6 {
+ padding: calc(var(--spacing) * 6);
+ }
+ .px-1 {
+ padding-inline: calc(var(--spacing) * 1);
+ }
+ .px-4 {
+ padding-inline: calc(var(--spacing) * 4);
+ }
+ .py-0\.5 {
+ padding-block: calc(var(--spacing) * 0.5);
+ }
+ .py-2 {
+ padding-block: calc(var(--spacing) * 2);
+ }
+ .pl-4 {
+ padding-left: calc(var(--spacing) * 4);
+ }
+ .text-left {
+ text-align: left;
+ }
+ .font-mono {
+ font-family: var(--font-mono);
+ }
+ .text-2xl {
+ font-size: var(--text-2xl);
+ line-height: var(--tw-leading, var(--text-2xl--line-height));
+ }
+ .text-3xl {
+ font-size: var(--text-3xl);
+ line-height: var(--tw-leading, var(--text-3xl--line-height));
+ }
+ .text-base {
+ font-size: var(--text-base);
+ line-height: var(--tw-leading, var(--text-base--line-height));
+ }
+ .text-lg {
+ font-size: var(--text-lg);
+ line-height: var(--tw-leading, var(--text-lg--line-height));
+ }
+ .text-sm {
+ font-size: var(--text-sm);
+ line-height: var(--tw-leading, var(--text-sm--line-height));
+ }
+ .text-xl {
+ font-size: var(--text-xl);
+ line-height: var(--tw-leading, var(--text-xl--line-height));
+ }
+ .leading-relaxed {
+ --tw-leading: var(--leading-relaxed);
+ line-height: var(--leading-relaxed);
+ }
+ .font-bold {
+ --tw-font-weight: var(--font-weight-bold);
+ font-weight: var(--font-weight-bold);
+ }
+ .font-medium {
+ --tw-font-weight: var(--font-weight-medium);
+ font-weight: var(--font-weight-medium);
+ }
+ .font-semibold {
+ --tw-font-weight: var(--font-weight-semibold);
+ font-weight: var(--font-weight-semibold);
+ }
+ .text-wrap {
+ text-wrap: wrap;
+ }
+ .text-clip {
+ text-overflow: clip;
+ }
+ .text-ellipsis {
+ text-overflow: ellipsis;
+ }
+ .text-accent {
+ color: var(--color-accent);
+ }
+ .text-blue-100 {
+ color: var(--color-blue-100);
+ }
+ .text-blue-200 {
+ color: var(--color-blue-200);
+ }
+ .text-blue-400 {
+ color: var(--color-blue-400);
+ }
+ .text-gray-400 {
+ color: var(--color-gray-400);
+ }
+ .text-gray-500 {
+ color: var(--color-gray-500);
+ }
+ .text-green-400 {
+ color: var(--color-green-400);
+ }
+ .text-muted {
+ color: var(--color-muted);
+ }
+ .text-primary {
+ color: var(--color-primary);
+ }
+ .text-red-400 {
+ color: var(--color-red-400);
+ }
+ .text-red-800 {
+ color: var(--color-red-800);
+ }
+ .text-secondary {
+ color: var(--color-secondary);
+ }
+ .text-white {
+ color: var(--color-white);
+ }
+ .capitalize {
+ text-transform: capitalize;
+ }
+ .lowercase {
+ text-transform: lowercase;
+ }
+ .normal-case {
+ text-transform: none;
+ }
+ .uppercase {
+ text-transform: uppercase;
+ }
+ .italic {
+ font-style: italic;
+ }
+ .not-italic {
+ font-style: normal;
+ }
+ .diagonal-fractions {
+ --tw-numeric-fraction: diagonal-fractions;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .lining-nums {
+ --tw-numeric-figure: lining-nums;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .oldstyle-nums {
+ --tw-numeric-figure: oldstyle-nums;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .ordinal {
+ --tw-ordinal: ordinal;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .proportional-nums {
+ --tw-numeric-spacing: proportional-nums;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .slashed-zero {
+ --tw-slashed-zero: slashed-zero;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .stacked-fractions {
+ --tw-numeric-fraction: stacked-fractions;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .tabular-nums {
+ --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,);
+ }
+ .normal-nums {
+ font-variant-numeric: normal;
+ }
+ .line-through {
+ text-decoration-line: line-through;
+ }
+ .no-underline {
+ text-decoration-line: none;
+ }
+ .overline {
+ text-decoration-line: overline;
+ }
+ .underline {
+ text-decoration-line: underline;
+ }
+ .antialiased {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+ .subpixel-antialiased {
+ -webkit-font-smoothing: auto;
+ -moz-osx-font-smoothing: auto;
+ }
+ .shadow {
+ --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
+ }
+ .inset-ring {
+ --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-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);
+ }
+ .blur {
+ --tw-blur: blur(8px);
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .drop-shadow {
+ --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06)));
+ --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06));
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .invert {
+ --tw-invert: invert(100%);
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .filter {
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .backdrop-blur {
+ --tw-backdrop-blur: blur(8px);
+ -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,);
+ }
+ .backdrop-grayscale {
+ --tw-backdrop-grayscale: grayscale(100%);
+ -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,);
+ }
+ .backdrop-invert {
+ --tw-backdrop-invert: invert(100%);
+ -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,);
+ }
+ .backdrop-sepia {
+ --tw-backdrop-sepia: sepia(100%);
+ -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,);
+ }
+ .backdrop-filter {
+ -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,);
+ }
+ .transition-colors {
+ 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));
+ }
+ .ease-in {
+ --tw-ease: var(--ease-in);
+ transition-timing-function: var(--ease-in);
+ }
+ .ease-in-out {
+ --tw-ease: var(--ease-in-out);
+ transition-timing-function: var(--ease-in-out);
+ }
+ .ease-out {
+ --tw-ease: var(--ease-out);
+ transition-timing-function: var(--ease-out);
+ }
+ .divide-x-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-divide-x-reverse: 1;
+ }
+ }
+ .ring-inset {
+ --tw-ring-inset: inset;
+ }
+ .hover\:bg-gray-700 {
+ &:hover {
+ @media (hover: hover) {
+ background-color: var(--color-gray-700);
+ }
+ }
+ }
+ .hover\:text-accent-300 {
+ &:hover {
+ @media (hover: hover) {
+ color: var(--color-accent-300);
+ }
+ }
+ }
+}
+@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-rotate-x {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-rotate-y {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-rotate-z {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-skew-x {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-skew-y {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-pan-x {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-pan-y {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-pinch-zoom {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-space-y-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-space-x-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-divide-x-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-border-style {
+ syntax: "*";
+ inherits: false;
+ initial-value: solid;
+}
+@property --tw-divide-y-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-leading {
+ 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-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: 0px;
+}
+@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-blur {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-brightness {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-contrast {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-grayscale {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-hue-rotate {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-invert {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-opacity {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-saturate {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-sepia {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow-color {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow-alpha {
+ syntax: "";
+ inherits: false;
+ initial-value: 100%;
+}
+@property --tw-drop-shadow-size {
+ syntax: "*";
+ inherits: false;
+}
+@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;
+}
+@property --tw-ease {
+ syntax: "*";
+ inherits: false;
+}
+@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-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-scale-z: 1;
+ --tw-rotate-x: initial;
+ --tw-rotate-y: initial;
+ --tw-rotate-z: initial;
+ --tw-skew-x: initial;
+ --tw-skew-y: initial;
+ --tw-pan-x: initial;
+ --tw-pan-y: initial;
+ --tw-pinch-zoom: initial;
+ --tw-space-y-reverse: 0;
+ --tw-space-x-reverse: 0;
+ --tw-divide-x-reverse: 0;
+ --tw-border-style: solid;
+ --tw-divide-y-reverse: 0;
+ --tw-leading: 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-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-blur: initial;
+ --tw-brightness: initial;
+ --tw-contrast: initial;
+ --tw-grayscale: initial;
+ --tw-hue-rotate: initial;
+ --tw-invert: initial;
+ --tw-opacity: initial;
+ --tw-saturate: initial;
+ --tw-sepia: initial;
+ --tw-drop-shadow: initial;
+ --tw-drop-shadow-color: initial;
+ --tw-drop-shadow-alpha: 100%;
+ --tw-drop-shadow-size: initial;
+ --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;
+ --tw-ease: initial;
+ }
+ }
+}
diff --git a/tsunami/demo/githubaction/app.go b/tsunami/demo/githubaction/app.go
new file mode 100644
index 0000000000..2a8ef1a7e2
--- /dev/null
+++ b/tsunami/demo/githubaction/app.go
@@ -0,0 +1,422 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "sort"
+ "strconv"
+ "time"
+
+ "github.com/wavetermdev/waveterm/tsunami/app"
+ "github.com/wavetermdev/waveterm/tsunami/vdom"
+)
+
+// Global atoms for config and data
+var (
+ pollIntervalAtom = app.ConfigAtom("pollInterval", 5)
+ repositoryAtom = app.ConfigAtom("repository", "wavetermdev/waveterm")
+ workflowAtom = app.ConfigAtom("workflow", "build-helper.yml")
+ maxWorkflowRunsAtom = app.ConfigAtom("maxWorkflowRuns", 10)
+ workflowRunsAtom = app.DataAtom("workflowRuns", []WorkflowRun{})
+ lastErrorAtom = app.DataAtom("lastError", "")
+ isLoadingAtom = app.DataAtom("isLoading", true)
+ lastRefreshTimeAtom = app.DataAtom("lastRefreshTime", time.Time{})
+)
+
+type WorkflowRun struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Status string `json:"status"`
+ Conclusion string `json:"conclusion"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ HTMLURL string `json:"html_url"`
+ RunNumber int `json:"run_number"`
+}
+
+type GitHubResponse struct {
+ TotalCount int `json:"total_count"`
+ WorkflowRuns []WorkflowRun `json:"workflow_runs"`
+}
+
+func fetchWorkflowRuns(repository, workflow string, maxRuns int) ([]WorkflowRun, error) {
+ apiKey := os.Getenv("GITHUB_APIKEY")
+ if apiKey == "" {
+ return nil, fmt.Errorf("GITHUB_APIKEY environment variable not set")
+ }
+
+ url := fmt.Sprintf("https://api.github.com/repos/%s/actions/workflows/%s/runs?per_page=%d", repository, workflow, maxRuns)
+
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Authorization", "Bearer "+apiKey)
+ req.Header.Set("Accept", "application/vnd.github.v3+json")
+ req.Header.Set("User-Agent", "WaveTerminal-GitHubMonitor")
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to make request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body))
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response: %w", err)
+ }
+
+ var response GitHubResponse
+ if err := json.Unmarshal(body, &response); err != nil {
+ return nil, fmt.Errorf("failed to parse response: %w", err)
+ }
+
+ return response.WorkflowRuns, nil
+}
+
+func getStatusIcon(status, conclusion string) string {
+ switch status {
+ case "in_progress", "queued", "pending":
+ return "🔄"
+ case "completed":
+ switch conclusion {
+ case "success":
+ return "✅"
+ case "failure":
+ return "❌"
+ case "cancelled":
+ return "🚫"
+ case "skipped":
+ return "⏭️"
+ default:
+ return "❓"
+ }
+ default:
+ return "❓"
+ }
+}
+
+func getStatusColor(status, conclusion string) string {
+ switch status {
+ case "in_progress", "queued", "pending":
+ return "text-yellow-400"
+ case "completed":
+ switch conclusion {
+ case "success":
+ return "text-green-400"
+ case "failure":
+ return "text-red-400"
+ case "cancelled":
+ return "text-gray-400"
+ case "skipped":
+ return "text-blue-400"
+ default:
+ return "text-gray-400"
+ }
+ default:
+ return "text-gray-400"
+ }
+}
+
+func formatDuration(start, end time.Time, isRunning bool) string {
+ if isRunning {
+ duration := time.Since(start)
+ return fmt.Sprintf("%v (running)", duration.Round(time.Second))
+ }
+ if end.IsZero() {
+ return "Unknown"
+ }
+ duration := end.Sub(start)
+ return duration.Round(time.Second).String()
+}
+
+func getDisplayStatus(status, conclusion string) string {
+ switch status {
+ case "in_progress":
+ return "Running"
+ case "queued":
+ return "Queued"
+ case "pending":
+ return "Pending"
+ case "completed":
+ switch conclusion {
+ case "success":
+ return "Success"
+ case "failure":
+ return "Failed"
+ case "cancelled":
+ return "Cancelled"
+ case "skipped":
+ return "Skipped"
+ default:
+ return "Completed"
+ }
+ default:
+ return status
+ }
+}
+
+type WorkflowRunItemProps struct {
+ Run WorkflowRun `json:"run"`
+}
+
+var WorkflowRunItem = app.DefineComponent("WorkflowRunItem",
+ func(props WorkflowRunItemProps) any {
+ run := props.Run
+ isRunning := run.Status == "in_progress" || run.Status == "queued" || run.Status == "pending"
+
+ return vdom.H("div", map[string]any{
+ "className": "bg-gray-800 rounded-lg p-4 border border-gray-700 hover:border-gray-600 transition-colors",
+ },
+ vdom.H("div", map[string]any{
+ "className": "flex items-start justify-between",
+ },
+ vdom.H("div", map[string]any{
+ "className": "flex-1 min-w-0",
+ },
+ vdom.H("div", map[string]any{
+ "className": "flex items-center gap-3 mb-2",
+ },
+ vdom.H("span", map[string]any{
+ "className": "text-2xl",
+ }, getStatusIcon(run.Status, run.Conclusion)),
+ vdom.H("a", map[string]any{
+ "href": run.HTMLURL,
+ "target": "_blank",
+ "className": "font-semibold text-blue-400 hover:text-blue-300 cursor-pointer",
+ }, run.Name),
+ vdom.H("span", map[string]any{
+ "className": "text-sm text-gray-300",
+ }, "#", run.RunNumber),
+ ),
+ vdom.H("div", map[string]any{
+ "className": "flex items-center gap-4 text-sm",
+ },
+ vdom.H("span", map[string]any{
+ "className": vdom.Classes("font-medium", getStatusColor(run.Status, run.Conclusion)),
+ }, getDisplayStatus(run.Status, run.Conclusion)),
+ vdom.H("span", map[string]any{
+ "className": "text-gray-400",
+ }, "Duration: ", formatDuration(run.CreatedAt, run.UpdatedAt, isRunning)),
+ vdom.H("span", map[string]any{
+ "className": "text-gray-300",
+ }, "Started: ", run.CreatedAt.Format("15:04:05")),
+ ),
+ ),
+ ),
+ )
+ },
+)
+
+var App = app.DefineComponent("App",
+ func(_ struct{}) any {
+ app.UseSetAppTitle("GitHub Actions Monitor")
+
+ fetchData := func() {
+ currentMaxRuns := maxWorkflowRunsAtom.Get()
+ runs, err := fetchWorkflowRuns(repositoryAtom.Get(), workflowAtom.Get(), currentMaxRuns)
+ if err != nil {
+ log.Printf("Error fetching workflow runs: %v", err)
+ lastErrorAtom.Set(err.Error())
+ } else {
+ sort.Slice(runs, func(i, j int) bool {
+ return runs[i].CreatedAt.After(runs[j].CreatedAt)
+ })
+ workflowRunsAtom.Set(runs)
+ lastErrorAtom.Set("")
+ }
+ lastRefreshTimeAtom.Set(time.Now())
+ isLoadingAtom.Set(false)
+ }
+
+ // Initial fetch on mount
+ app.UseEffect(func() func() {
+ fetchData()
+ return nil
+ }, []any{})
+
+ // Automatic polling with UseTicker - automatically cleaned up on unmount
+ app.UseTicker(time.Duration(pollIntervalAtom.Get())*time.Second, func() {
+ fetchData()
+ }, []any{pollIntervalAtom.Get()})
+
+ handleRefresh := func() {
+ isLoadingAtom.Set(true)
+ go func() {
+ fetchData()
+ }()
+ }
+
+ workflowRuns := workflowRunsAtom.Get()
+ lastError := lastErrorAtom.Get()
+ isLoading := isLoadingAtom.Get()
+ lastRefreshTime := lastRefreshTimeAtom.Get()
+ pollInterval := pollIntervalAtom.Get()
+ repository := repositoryAtom.Get()
+ workflow := workflowAtom.Get()
+ maxWorkflowRuns := maxWorkflowRunsAtom.Get()
+
+ return vdom.H("div", map[string]any{
+ "className": "min-h-screen bg-gray-900 text-white p-6",
+ },
+ vdom.H("div", map[string]any{
+ "className": "max-w-6xl mx-auto",
+ },
+ vdom.H("div", map[string]any{
+ "className": "mb-8",
+ },
+ vdom.H("h1", map[string]any{
+ "className": "text-3xl font-bold text-white mb-2",
+ }, "GitHub Actions Monitor"),
+ vdom.H("p", map[string]any{
+ "className": "text-gray-400",
+ }, "Monitoring ", repositoryAtom.Get(), " ", workflowAtom.Get(), " workflow"),
+ ),
+
+ vdom.H("div", map[string]any{
+ "className": "bg-gray-800 rounded-lg p-4 mb-6",
+ },
+ vdom.H("div", map[string]any{
+ "className": "flex items-center justify-between",
+ },
+ vdom.H("div", map[string]any{
+ "className": "flex items-center gap-4",
+ },
+ vdom.H("button", map[string]any{
+ "className": vdom.Classes(
+ "px-4 py-2 rounded-md text-sm font-medium transition-colors cursor-pointer",
+ vdom.IfElse(isLoadingAtom.Get(), "bg-gray-600 text-gray-400", "bg-blue-600 hover:bg-blue-700 text-white"),
+ ),
+ "onClick": vdom.If(!isLoadingAtom.Get(), handleRefresh),
+ "disabled": isLoadingAtom.Get(),
+ }, vdom.IfElse(isLoadingAtom.Get(), "Refreshing...", "Refresh")),
+
+ vdom.H("div", map[string]any{
+ "className": "flex items-center gap-2",
+ },
+ vdom.H("div", map[string]any{
+ "className": vdom.Classes("w-2 h-2 rounded-full", vdom.IfElse(lastError == "", "bg-green-500", "bg-red-500")),
+ }),
+ vdom.H("span", map[string]any{
+ "className": "text-sm text-gray-400",
+ }, vdom.IfElse(lastError == "", "Connected", "Error")),
+ vdom.H("span", map[string]any{
+ "className": "text-sm text-gray-300 ml-2",
+ }, "Poll interval: ", pollInterval, "s"),
+ vdom.If(!lastRefreshTime.IsZero(),
+ vdom.H("span", map[string]any{
+ "className": "text-sm text-gray-300 ml-4",
+ }, "Last refresh: ", lastRefreshTime.Format("15:04:05")),
+ ),
+ ),
+ ),
+ vdom.H("div", map[string]any{
+ "className": "text-sm text-gray-300",
+ }, "Last ", maxWorkflowRuns, " workflow runs"),
+ ),
+ ),
+
+ vdom.If(lastError != "",
+ vdom.H("div", map[string]any{
+ "className": "bg-red-900 bg-opacity-50 border border-red-700 rounded-lg p-4 mb-6",
+ },
+ vdom.H("div", map[string]any{
+ "className": "flex items-center gap-2 text-red-200",
+ },
+ vdom.H("span", nil, "❌"),
+ vdom.H("strong", nil, "Error:"),
+ ),
+ vdom.H("p", map[string]any{
+ "className": "text-red-100 mt-1",
+ }, lastError),
+ ),
+ ),
+
+ vdom.H("div", map[string]any{
+ "className": "space-y-4",
+ },
+ vdom.If(isLoading && len(workflowRuns) == 0,
+ vdom.H("div", map[string]any{
+ "className": "text-center py-8 text-gray-400",
+ }, "Loading workflow runs..."),
+ ),
+ vdom.If(len(workflowRuns) > 0,
+ vdom.ForEach(workflowRuns, func(run WorkflowRun, idx int) any {
+ return WorkflowRunItem(WorkflowRunItemProps{
+ Run: run,
+ }).WithKey(strconv.FormatInt(run.ID, 10))
+ }),
+ ),
+ vdom.If(!isLoading && len(workflowRuns) == 0 && lastError == "",
+ vdom.H("div", map[string]any{
+ "className": "text-center py-8 text-gray-400",
+ }, "No workflow runs found"),
+ ),
+ ),
+
+ vdom.H("div", map[string]any{
+ "className": "mt-8 bg-blue-900 bg-opacity-50 border border-blue-700 rounded-lg p-4",
+ },
+ vdom.H("h3", map[string]any{
+ "className": "text-lg font-semibold text-blue-200 mb-2",
+ }, "GitHub Actions Monitor Features"),
+ vdom.H("ul", map[string]any{
+ "className": "space-y-2 text-blue-100",
+ },
+ vdom.H("li", map[string]any{
+ "className": "flex items-start gap-2",
+ },
+ vdom.H("span", map[string]any{
+ "className": "text-blue-400 mt-1",
+ }, "•"),
+ "Monitors ", repository, " ", workflow, " workflow",
+ ),
+ vdom.H("li", map[string]any{
+ "className": "flex items-start gap-2",
+ },
+ vdom.H("span", map[string]any{
+ "className": "text-blue-400 mt-1",
+ }, "•"),
+ "Polls GitHub API every 5 seconds for real-time updates",
+ ),
+ vdom.H("li", map[string]any{
+ "className": "flex items-start gap-2",
+ },
+ vdom.H("span", map[string]any{
+ "className": "text-blue-400 mt-1",
+ }, "•"),
+ "Shows status icons: ✅ Success, ❌ Failure, 🔄 Running",
+ ),
+ vdom.H("li", map[string]any{
+ "className": "flex items-start gap-2",
+ },
+ vdom.H("span", map[string]any{
+ "className": "text-blue-400 mt-1",
+ }, "•"),
+ "Clickable workflow names open in GitHub (new tab)",
+ ),
+ vdom.H("li", map[string]any{
+ "className": "flex items-start gap-2",
+ },
+ vdom.H("span", map[string]any{
+ "className": "text-blue-400 mt-1",
+ }, "•"),
+ "Live duration tracking for running jobs",
+ ),
+ ),
+ ),
+ ),
+ )
+ },
+)
diff --git a/tsunami/demo/githubaction/go.mod b/tsunami/demo/githubaction/go.mod
new file mode 100644
index 0000000000..cf51505f61
--- /dev/null
+++ b/tsunami/demo/githubaction/go.mod
@@ -0,0 +1,12 @@
+module tsunami/app/githubaction
+
+go 1.24.6
+
+require github.com/wavetermdev/waveterm/tsunami v0.0.0
+
+require (
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/outrigdev/goid v0.3.0 // indirect
+)
+
+replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami
diff --git a/tsunami/demo/githubaction/go.sum b/tsunami/demo/githubaction/go.sum
new file mode 100644
index 0000000000..4c44991dfc
--- /dev/null
+++ b/tsunami/demo/githubaction/go.sum
@@ -0,0 +1,4 @@
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=
+github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=
diff --git a/tsunami/demo/githubaction/static/tw.css b/tsunami/demo/githubaction/static/tw.css
new file mode 100644
index 0000000000..2e3d9c16ac
--- /dev/null
+++ b/tsunami/demo/githubaction/static/tw.css
@@ -0,0 +1,1333 @@
+/*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */
+@layer properties;
+@layer theme, base, components, utilities;
+@layer theme {
+ :root, :host {
+ --font-sans: "Inter", sans-serif;
+ --font-mono: "Hack", monospace;
+ --color-red-100: oklch(93.6% 0.032 17.717);
+ --color-red-200: oklch(88.5% 0.062 18.334);
+ --color-red-400: oklch(70.4% 0.191 22.216);
+ --color-red-500: oklch(63.7% 0.237 25.331);
+ --color-red-700: oklch(50.5% 0.213 27.518);
+ --color-red-800: oklch(44.4% 0.177 26.899);
+ --color-red-900: oklch(39.6% 0.141 25.723);
+ --color-yellow-400: oklch(85.2% 0.199 91.936);
+ --color-green-400: oklch(79.2% 0.209 151.711);
+ --color-green-500: oklch(72.3% 0.219 149.579);
+ --color-blue-100: oklch(93.2% 0.032 255.585);
+ --color-blue-200: oklch(88.2% 0.059 254.128);
+ --color-blue-300: oklch(80.9% 0.105 251.813);
+ --color-blue-400: oklch(70.7% 0.165 254.624);
+ --color-blue-600: oklch(54.6% 0.245 262.881);
+ --color-blue-700: oklch(48.8% 0.243 264.376);
+ --color-blue-900: oklch(37.9% 0.146 265.522);
+ --color-gray-300: oklch(87.2% 0.01 258.338);
+ --color-gray-400: oklch(70.7% 0.022 261.325);
+ --color-gray-600: oklch(44.6% 0.03 256.802);
+ --color-gray-700: oklch(37.3% 0.034 259.733);
+ --color-gray-800: oklch(27.8% 0.033 256.848);
+ --color-gray-900: oklch(21% 0.034 264.665);
+ --color-white: #fff;
+ --spacing: 0.25rem;
+ --container-6xl: 72rem;
+ --text-sm: 0.875rem;
+ --text-sm--line-height: calc(1.25 / 0.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);
+ --text-xl: 1.25rem;
+ --text-xl--line-height: calc(1.75 / 1.25);
+ --text-2xl: 1.5rem;
+ --text-2xl--line-height: calc(2 / 1.5);
+ --text-3xl: 1.875rem;
+ --text-3xl--line-height: calc(2.25 / 1.875);
+ --font-weight-medium: 500;
+ --font-weight-semibold: 600;
+ --font-weight-bold: 700;
+ --leading-relaxed: 1.625;
+ --radius-md: 0.375rem;
+ --radius-lg: 0.5rem;
+ --ease-in: cubic-bezier(0.4, 0, 1, 1);
+ --ease-out: cubic-bezier(0, 0, 0.2, 1);
+ --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
+ --default-transition-duration: 150ms;
+ --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ --default-font-family: var(--font-sans);
+ --default-mono-font-family: var(--font-mono);
+ --radius: 8px;
+ --color-background: rgb(34, 34, 34);
+ --color-primary: rgb(247, 247, 247);
+ --color-secondary: rgba(215, 218, 224, 0.7);
+ --color-muted: rgba(215, 218, 224, 0.5);
+ --color-accent-300: rgb(110, 231, 133);
+ --color-panel: rgba(255, 255, 255, 0.12);
+ --color-border: rgba(255, 255, 255, 0.16);
+ --color-accent: rgb(88, 193, 66);
+ }
+}
+@layer base {
+ *, ::after, ::before, ::backdrop, ::file-selector-button {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ border: 0 solid;
+ }
+ html, :host {
+ line-height: 1.5;
+ -webkit-text-size-adjust: 100%;
+ tab-size: 4;
+ 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;
+ 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 {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+ }
+ sub {
+ bottom: -0.25em;
+ }
+ sup {
+ top: -0.5em;
+ }
+ table {
+ text-indent: 0;
+ border-color: inherit;
+ border-collapse: collapse;
+ }
+ :-moz-focusring {
+ outline: auto;
+ }
+ progress {
+ vertical-align: baseline;
+ }
+ summary {
+ display: list-item;
+ }
+ ol, ul, menu {
+ list-style: none;
+ }
+ img, svg, video, canvas, audio, iframe, embed, object {
+ display: block;
+ vertical-align: middle;
+ }
+ img, video {
+ max-width: 100%;
+ height: auto;
+ }
+ button, input, select, optgroup, textarea, ::file-selector-button {
+ font: inherit;
+ font-feature-settings: inherit;
+ font-variation-settings: inherit;
+ letter-spacing: inherit;
+ color: inherit;
+ border-radius: 0;
+ background-color: transparent;
+ opacity: 1;
+ }
+ :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)) {
+ 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, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-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"]), ::file-selector-button {
+ appearance: button;
+ }
+ ::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
+ height: auto;
+ }
+ [hidden]:where(:not([hidden="until-found"])) {
+ display: none !important;
+ }
+}
+@layer utilities {
+ .collapse {
+ visibility: collapse;
+ }
+ .invisible {
+ visibility: hidden;
+ }
+ .visible {
+ visibility: visible;
+ }
+ .sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border-width: 0;
+ }
+ .not-sr-only {
+ position: static;
+ width: auto;
+ height: auto;
+ padding: 0;
+ margin: 0;
+ overflow: visible;
+ clip-path: none;
+ white-space: normal;
+ }
+ .absolute {
+ position: absolute;
+ }
+ .fixed {
+ position: fixed;
+ }
+ .relative {
+ position: relative;
+ }
+ .static {
+ position: static;
+ }
+ .sticky {
+ position: sticky;
+ }
+ .isolate {
+ isolation: isolate;
+ }
+ .isolation-auto {
+ isolation: auto;
+ }
+ .container {
+ width: 100%;
+ @media (width >= 40rem) {
+ max-width: 40rem;
+ }
+ @media (width >= 48rem) {
+ max-width: 48rem;
+ }
+ @media (width >= 64rem) {
+ max-width: 64rem;
+ }
+ @media (width >= 80rem) {
+ max-width: 80rem;
+ }
+ @media (width >= 96rem) {
+ max-width: 96rem;
+ }
+ }
+ .mx-auto {
+ margin-inline: auto;
+ }
+ .my-6 {
+ margin-block: calc(var(--spacing) * 6);
+ }
+ .mt-1 {
+ margin-top: calc(var(--spacing) * 1);
+ }
+ .mt-3 {
+ margin-top: calc(var(--spacing) * 3);
+ }
+ .mt-4 {
+ margin-top: calc(var(--spacing) * 4);
+ }
+ .mt-5 {
+ margin-top: calc(var(--spacing) * 5);
+ }
+ .mt-6 {
+ margin-top: calc(var(--spacing) * 6);
+ }
+ .mt-8 {
+ margin-top: calc(var(--spacing) * 8);
+ }
+ .mb-2 {
+ margin-bottom: calc(var(--spacing) * 2);
+ }
+ .mb-3 {
+ margin-bottom: calc(var(--spacing) * 3);
+ }
+ .mb-4 {
+ margin-bottom: calc(var(--spacing) * 4);
+ }
+ .mb-6 {
+ margin-bottom: calc(var(--spacing) * 6);
+ }
+ .mb-8 {
+ margin-bottom: calc(var(--spacing) * 8);
+ }
+ .ml-2 {
+ margin-left: calc(var(--spacing) * 2);
+ }
+ .ml-4 {
+ margin-left: calc(var(--spacing) * 4);
+ }
+ .block {
+ display: block;
+ }
+ .contents {
+ display: contents;
+ }
+ .flex {
+ display: flex;
+ }
+ .flow-root {
+ display: flow-root;
+ }
+ .grid {
+ display: grid;
+ }
+ .hidden {
+ display: none;
+ }
+ .inline {
+ display: inline;
+ }
+ .inline-block {
+ display: inline-block;
+ }
+ .inline-flex {
+ display: inline-flex;
+ }
+ .inline-grid {
+ display: inline-grid;
+ }
+ .inline-table {
+ display: inline-table;
+ }
+ .list-item {
+ display: list-item;
+ }
+ .table {
+ display: table;
+ }
+ .table-caption {
+ display: table-caption;
+ }
+ .table-cell {
+ display: table-cell;
+ }
+ .table-column {
+ display: table-column;
+ }
+ .table-column-group {
+ display: table-column-group;
+ }
+ .table-footer-group {
+ display: table-footer-group;
+ }
+ .table-header-group {
+ display: table-header-group;
+ }
+ .table-row {
+ display: table-row;
+ }
+ .table-row-group {
+ display: table-row-group;
+ }
+ .h-2 {
+ height: calc(var(--spacing) * 2);
+ }
+ .min-h-full {
+ min-height: 100%;
+ }
+ .min-h-screen {
+ min-height: 100vh;
+ }
+ .w-2 {
+ width: calc(var(--spacing) * 2);
+ }
+ .w-full {
+ width: 100%;
+ }
+ .max-w-6xl {
+ max-width: var(--container-6xl);
+ }
+ .max-w-none {
+ max-width: none;
+ }
+ .min-w-0 {
+ min-width: calc(var(--spacing) * 0);
+ }
+ .min-w-full {
+ min-width: 100%;
+ }
+ .flex-1 {
+ flex: 1;
+ }
+ .shrink {
+ flex-shrink: 1;
+ }
+ .grow {
+ flex-grow: 1;
+ }
+ .border-collapse {
+ border-collapse: collapse;
+ }
+ .translate-none {
+ translate: none;
+ }
+ .scale-3d {
+ scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z);
+ }
+ .transform {
+ transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
+ }
+ .cursor-pointer {
+ cursor: pointer;
+ }
+ .touch-pinch-zoom {
+ --tw-pinch-zoom: pinch-zoom;
+ touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,);
+ }
+ .resize {
+ resize: both;
+ }
+ .list-inside {
+ list-style-position: inside;
+ }
+ .list-decimal {
+ list-style-type: decimal;
+ }
+ .list-disc {
+ list-style-type: disc;
+ }
+ .flex-wrap {
+ flex-wrap: wrap;
+ }
+ .items-center {
+ align-items: center;
+ }
+ .items-start {
+ align-items: flex-start;
+ }
+ .justify-between {
+ justify-content: space-between;
+ }
+ .gap-2 {
+ gap: calc(var(--spacing) * 2);
+ }
+ .gap-3 {
+ gap: calc(var(--spacing) * 3);
+ }
+ .gap-4 {
+ gap: calc(var(--spacing) * 4);
+ }
+ .space-y-1 {
+ :where(& > :not(:last-child)) {
+ --tw-space-y-reverse: 0;
+ margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));
+ margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));
+ }
+ }
+ .space-y-2 {
+ :where(& > :not(:last-child)) {
+ --tw-space-y-reverse: 0;
+ margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));
+ margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
+ }
+ }
+ .space-y-4 {
+ :where(& > :not(:last-child)) {
+ --tw-space-y-reverse: 0;
+ margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));
+ margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)));
+ }
+ }
+ .space-y-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-space-y-reverse: 1;
+ }
+ }
+ .space-x-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-space-x-reverse: 1;
+ }
+ }
+ .divide-x {
+ :where(& > :not(:last-child)) {
+ --tw-divide-x-reverse: 0;
+ border-inline-style: var(--tw-border-style);
+ border-inline-start-width: calc(1px * var(--tw-divide-x-reverse));
+ border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse)));
+ }
+ }
+ .divide-y {
+ :where(& > :not(:last-child)) {
+ --tw-divide-y-reverse: 0;
+ border-bottom-style: var(--tw-border-style);
+ border-top-style: var(--tw-border-style);
+ border-top-width: calc(1px * var(--tw-divide-y-reverse));
+ border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
+ }
+ }
+ .divide-y-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-divide-y-reverse: 1;
+ }
+ }
+ .truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .overflow-auto {
+ overflow: auto;
+ }
+ .overflow-x-auto {
+ overflow-x: auto;
+ }
+ .rounded {
+ border-radius: var(--radius);
+ }
+ .rounded-full {
+ border-radius: calc(infinity * 1px);
+ }
+ .rounded-lg {
+ border-radius: var(--radius-lg);
+ }
+ .rounded-md {
+ border-radius: var(--radius-md);
+ }
+ .rounded-s {
+ border-start-start-radius: var(--radius);
+ border-end-start-radius: var(--radius);
+ }
+ .rounded-ss {
+ border-start-start-radius: var(--radius);
+ }
+ .rounded-e {
+ border-start-end-radius: var(--radius);
+ border-end-end-radius: var(--radius);
+ }
+ .rounded-se {
+ border-start-end-radius: var(--radius);
+ }
+ .rounded-ee {
+ border-end-end-radius: var(--radius);
+ }
+ .rounded-es {
+ border-end-start-radius: var(--radius);
+ }
+ .rounded-t {
+ border-top-left-radius: var(--radius);
+ border-top-right-radius: var(--radius);
+ }
+ .rounded-l {
+ border-top-left-radius: var(--radius);
+ border-bottom-left-radius: var(--radius);
+ }
+ .rounded-tl {
+ border-top-left-radius: var(--radius);
+ }
+ .rounded-r {
+ border-top-right-radius: var(--radius);
+ border-bottom-right-radius: var(--radius);
+ }
+ .rounded-tr {
+ border-top-right-radius: var(--radius);
+ }
+ .rounded-b {
+ border-bottom-right-radius: var(--radius);
+ border-bottom-left-radius: var(--radius);
+ }
+ .rounded-br {
+ border-bottom-right-radius: var(--radius);
+ }
+ .rounded-bl {
+ border-bottom-left-radius: var(--radius);
+ }
+ .border {
+ border-style: var(--tw-border-style);
+ border-width: 1px;
+ }
+ .border-x {
+ border-inline-style: var(--tw-border-style);
+ border-inline-width: 1px;
+ }
+ .border-y {
+ border-block-style: var(--tw-border-style);
+ border-block-width: 1px;
+ }
+ .border-s {
+ border-inline-start-style: var(--tw-border-style);
+ border-inline-start-width: 1px;
+ }
+ .border-e {
+ border-inline-end-style: var(--tw-border-style);
+ border-inline-end-width: 1px;
+ }
+ .border-t {
+ border-top-style: var(--tw-border-style);
+ border-top-width: 1px;
+ }
+ .border-r {
+ border-right-style: var(--tw-border-style);
+ border-right-width: 1px;
+ }
+ .border-b {
+ border-bottom-style: var(--tw-border-style);
+ border-bottom-width: 1px;
+ }
+ .border-l {
+ border-left-style: var(--tw-border-style);
+ border-left-width: 1px;
+ }
+ .border-l-4 {
+ border-left-style: var(--tw-border-style);
+ border-left-width: 4px;
+ }
+ .border-blue-700 {
+ border-color: var(--color-blue-700);
+ }
+ .border-border {
+ border-color: var(--color-border);
+ }
+ .border-gray-700 {
+ border-color: var(--color-gray-700);
+ }
+ .border-red-500 {
+ border-color: var(--color-red-500);
+ }
+ .border-red-700 {
+ border-color: var(--color-red-700);
+ }
+ .bg-background {
+ background-color: var(--color-background);
+ }
+ .bg-blue-600 {
+ background-color: var(--color-blue-600);
+ }
+ .bg-blue-900 {
+ background-color: var(--color-blue-900);
+ }
+ .bg-gray-600 {
+ background-color: var(--color-gray-600);
+ }
+ .bg-gray-800 {
+ background-color: var(--color-gray-800);
+ }
+ .bg-gray-900 {
+ background-color: var(--color-gray-900);
+ }
+ .bg-green-500 {
+ background-color: var(--color-green-500);
+ }
+ .bg-panel {
+ background-color: var(--color-panel);
+ }
+ .bg-red-100 {
+ background-color: var(--color-red-100);
+ }
+ .bg-red-500 {
+ background-color: var(--color-red-500);
+ }
+ .bg-red-900 {
+ background-color: var(--color-red-900);
+ }
+ .bg-repeat {
+ background-repeat: repeat;
+ }
+ .mask-no-clip {
+ mask-clip: no-clip;
+ }
+ .mask-repeat {
+ mask-repeat: repeat;
+ }
+ .p-4 {
+ padding: calc(var(--spacing) * 4);
+ }
+ .p-6 {
+ padding: calc(var(--spacing) * 6);
+ }
+ .px-1 {
+ padding-inline: calc(var(--spacing) * 1);
+ }
+ .px-4 {
+ padding-inline: calc(var(--spacing) * 4);
+ }
+ .py-0\.5 {
+ padding-block: calc(var(--spacing) * 0.5);
+ }
+ .py-2 {
+ padding-block: calc(var(--spacing) * 2);
+ }
+ .py-8 {
+ padding-block: calc(var(--spacing) * 8);
+ }
+ .pl-4 {
+ padding-left: calc(var(--spacing) * 4);
+ }
+ .text-center {
+ text-align: center;
+ }
+ .text-left {
+ text-align: left;
+ }
+ .font-mono {
+ font-family: var(--font-mono);
+ }
+ .text-2xl {
+ font-size: var(--text-2xl);
+ line-height: var(--tw-leading, var(--text-2xl--line-height));
+ }
+ .text-3xl {
+ font-size: var(--text-3xl);
+ line-height: var(--tw-leading, var(--text-3xl--line-height));
+ }
+ .text-base {
+ font-size: var(--text-base);
+ line-height: var(--tw-leading, var(--text-base--line-height));
+ }
+ .text-lg {
+ font-size: var(--text-lg);
+ line-height: var(--tw-leading, var(--text-lg--line-height));
+ }
+ .text-sm {
+ font-size: var(--text-sm);
+ line-height: var(--tw-leading, var(--text-sm--line-height));
+ }
+ .text-xl {
+ font-size: var(--text-xl);
+ line-height: var(--tw-leading, var(--text-xl--line-height));
+ }
+ .leading-relaxed {
+ --tw-leading: var(--leading-relaxed);
+ line-height: var(--leading-relaxed);
+ }
+ .font-bold {
+ --tw-font-weight: var(--font-weight-bold);
+ font-weight: var(--font-weight-bold);
+ }
+ .font-medium {
+ --tw-font-weight: var(--font-weight-medium);
+ font-weight: var(--font-weight-medium);
+ }
+ .font-semibold {
+ --tw-font-weight: var(--font-weight-semibold);
+ font-weight: var(--font-weight-semibold);
+ }
+ .text-wrap {
+ text-wrap: wrap;
+ }
+ .text-clip {
+ text-overflow: clip;
+ }
+ .text-ellipsis {
+ text-overflow: ellipsis;
+ }
+ .text-accent {
+ color: var(--color-accent);
+ }
+ .text-blue-100 {
+ color: var(--color-blue-100);
+ }
+ .text-blue-200 {
+ color: var(--color-blue-200);
+ }
+ .text-blue-400 {
+ color: var(--color-blue-400);
+ }
+ .text-gray-300 {
+ color: var(--color-gray-300);
+ }
+ .text-gray-400 {
+ color: var(--color-gray-400);
+ }
+ .text-green-400 {
+ color: var(--color-green-400);
+ }
+ .text-muted {
+ color: var(--color-muted);
+ }
+ .text-primary {
+ color: var(--color-primary);
+ }
+ .text-red-100 {
+ color: var(--color-red-100);
+ }
+ .text-red-200 {
+ color: var(--color-red-200);
+ }
+ .text-red-400 {
+ color: var(--color-red-400);
+ }
+ .text-red-800 {
+ color: var(--color-red-800);
+ }
+ .text-secondary {
+ color: var(--color-secondary);
+ }
+ .text-white {
+ color: var(--color-white);
+ }
+ .text-yellow-400 {
+ color: var(--color-yellow-400);
+ }
+ .capitalize {
+ text-transform: capitalize;
+ }
+ .lowercase {
+ text-transform: lowercase;
+ }
+ .normal-case {
+ text-transform: none;
+ }
+ .uppercase {
+ text-transform: uppercase;
+ }
+ .italic {
+ font-style: italic;
+ }
+ .not-italic {
+ font-style: normal;
+ }
+ .diagonal-fractions {
+ --tw-numeric-fraction: diagonal-fractions;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .lining-nums {
+ --tw-numeric-figure: lining-nums;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .oldstyle-nums {
+ --tw-numeric-figure: oldstyle-nums;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .ordinal {
+ --tw-ordinal: ordinal;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .proportional-nums {
+ --tw-numeric-spacing: proportional-nums;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .slashed-zero {
+ --tw-slashed-zero: slashed-zero;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .stacked-fractions {
+ --tw-numeric-fraction: stacked-fractions;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .tabular-nums {
+ --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,);
+ }
+ .normal-nums {
+ font-variant-numeric: normal;
+ }
+ .line-through {
+ text-decoration-line: line-through;
+ }
+ .no-underline {
+ text-decoration-line: none;
+ }
+ .overline {
+ text-decoration-line: overline;
+ }
+ .underline {
+ text-decoration-line: underline;
+ }
+ .antialiased {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+ .subpixel-antialiased {
+ -webkit-font-smoothing: auto;
+ -moz-osx-font-smoothing: auto;
+ }
+ .shadow {
+ --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
+ }
+ .inset-ring {
+ --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-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);
+ }
+ .blur {
+ --tw-blur: blur(8px);
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .drop-shadow {
+ --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06)));
+ --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06));
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .invert {
+ --tw-invert: invert(100%);
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .filter {
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .backdrop-blur {
+ --tw-backdrop-blur: blur(8px);
+ -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,);
+ }
+ .backdrop-grayscale {
+ --tw-backdrop-grayscale: grayscale(100%);
+ -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,);
+ }
+ .backdrop-invert {
+ --tw-backdrop-invert: invert(100%);
+ -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,);
+ }
+ .backdrop-sepia {
+ --tw-backdrop-sepia: sepia(100%);
+ -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,);
+ }
+ .backdrop-filter {
+ -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,);
+ }
+ .transition-colors {
+ 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));
+ }
+ .ease-in {
+ --tw-ease: var(--ease-in);
+ transition-timing-function: var(--ease-in);
+ }
+ .ease-in-out {
+ --tw-ease: var(--ease-in-out);
+ transition-timing-function: var(--ease-in-out);
+ }
+ .ease-out {
+ --tw-ease: var(--ease-out);
+ transition-timing-function: var(--ease-out);
+ }
+ .divide-x-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-divide-x-reverse: 1;
+ }
+ }
+ .ring-inset {
+ --tw-ring-inset: inset;
+ }
+ .hover\:border-gray-600 {
+ &:hover {
+ @media (hover: hover) {
+ border-color: var(--color-gray-600);
+ }
+ }
+ }
+ .hover\:bg-blue-700 {
+ &:hover {
+ @media (hover: hover) {
+ background-color: var(--color-blue-700);
+ }
+ }
+ }
+ .hover\:text-accent-300 {
+ &:hover {
+ @media (hover: hover) {
+ color: var(--color-accent-300);
+ }
+ }
+ }
+ .hover\:text-blue-300 {
+ &:hover {
+ @media (hover: hover) {
+ color: var(--color-blue-300);
+ }
+ }
+ }
+}
+@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-rotate-x {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-rotate-y {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-rotate-z {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-skew-x {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-skew-y {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-pan-x {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-pan-y {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-pinch-zoom {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-space-y-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-space-x-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-divide-x-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-border-style {
+ syntax: "*";
+ inherits: false;
+ initial-value: solid;
+}
+@property --tw-divide-y-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-leading {
+ 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-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: 0px;
+}
+@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-blur {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-brightness {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-contrast {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-grayscale {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-hue-rotate {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-invert {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-opacity {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-saturate {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-sepia {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow-color {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow-alpha {
+ syntax: "";
+ inherits: false;
+ initial-value: 100%;
+}
+@property --tw-drop-shadow-size {
+ syntax: "*";
+ inherits: false;
+}
+@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;
+}
+@property --tw-ease {
+ syntax: "*";
+ inherits: false;
+}
+@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-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-scale-z: 1;
+ --tw-rotate-x: initial;
+ --tw-rotate-y: initial;
+ --tw-rotate-z: initial;
+ --tw-skew-x: initial;
+ --tw-skew-y: initial;
+ --tw-pan-x: initial;
+ --tw-pan-y: initial;
+ --tw-pinch-zoom: initial;
+ --tw-space-y-reverse: 0;
+ --tw-space-x-reverse: 0;
+ --tw-divide-x-reverse: 0;
+ --tw-border-style: solid;
+ --tw-divide-y-reverse: 0;
+ --tw-leading: 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-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-blur: initial;
+ --tw-brightness: initial;
+ --tw-contrast: initial;
+ --tw-grayscale: initial;
+ --tw-hue-rotate: initial;
+ --tw-invert: initial;
+ --tw-opacity: initial;
+ --tw-saturate: initial;
+ --tw-sepia: initial;
+ --tw-drop-shadow: initial;
+ --tw-drop-shadow-color: initial;
+ --tw-drop-shadow-alpha: 100%;
+ --tw-drop-shadow-size: initial;
+ --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;
+ --tw-ease: initial;
+ }
+ }
+}
diff --git a/tsunami/demo/pomodoro/app.go b/tsunami/demo/pomodoro/app.go
new file mode 100644
index 0000000000..eda19c0835
--- /dev/null
+++ b/tsunami/demo/pomodoro/app.go
@@ -0,0 +1,204 @@
+package main
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/wavetermdev/waveterm/tsunami/app"
+ "github.com/wavetermdev/waveterm/tsunami/vdom"
+)
+
+type Mode struct {
+ Name string `json:"name"`
+ Duration int `json:"duration"` // in minutes
+}
+
+var (
+ WorkMode = Mode{Name: "Work", Duration: 25}
+ BreakMode = Mode{Name: "Break", Duration: 5}
+
+ // Data atom to expose remaining seconds to external systems
+ remainingSecondsAtom = app.DataAtom("remainingSeconds", WorkMode.Duration*60)
+)
+
+type TimerDisplayProps struct {
+ RemainingSeconds int `json:"remainingSeconds"`
+ Mode string `json:"mode"`
+}
+
+type ControlButtonsProps struct {
+ IsRunning bool `json:"isRunning"`
+ OnStart func() `json:"onStart"`
+ OnPause func() `json:"onPause"`
+ OnReset func() `json:"onReset"`
+ OnMode func(int) `json:"onMode"`
+}
+
+
+var TimerDisplay = app.DefineComponent("TimerDisplay",
+ func(props TimerDisplayProps) any {
+ minutes := props.RemainingSeconds / 60
+ seconds := props.RemainingSeconds % 60
+ return vdom.H("div",
+ map[string]any{"className": "bg-slate-700 p-8 rounded-lg mb-8 text-center"},
+ vdom.H("div",
+ map[string]any{"className": "text-xl text-blue-400 mb-2"},
+ props.Mode,
+ ),
+ vdom.H("div",
+ map[string]any{"className": "text-6xl font-bold font-mono text-slate-100"},
+ fmt.Sprintf("%02d:%02d", minutes, seconds),
+ ),
+ )
+ },
+)
+
+var ControlButtons = app.DefineComponent("ControlButtons",
+ func(props ControlButtonsProps) any {
+ return vdom.H("div",
+ map[string]any{"className": "flex flex-col gap-4"},
+ vdom.IfElse(props.IsRunning,
+ vdom.H("button",
+ map[string]any{
+ "className": "px-6 py-3 text-lg border-none rounded bg-blue-500 text-white cursor-pointer hover:bg-blue-600 transition-colors duration-200",
+ "onClick": props.OnPause,
+ },
+ "Pause",
+ ),
+ vdom.H("button",
+ map[string]any{
+ "className": "px-6 py-3 text-lg border-none rounded bg-blue-500 text-white cursor-pointer hover:bg-blue-600 transition-colors duration-200",
+ "onClick": props.OnStart,
+ },
+ "Start",
+ ),
+ ),
+ vdom.H("button",
+ map[string]any{
+ "className": "px-6 py-3 text-lg border-none rounded bg-blue-500 text-white cursor-pointer hover:bg-blue-600 transition-colors duration-200",
+ "onClick": props.OnReset,
+ },
+ "Reset",
+ ),
+ vdom.H("div",
+ map[string]any{"className": "flex gap-4 mt-4"},
+ vdom.H("button",
+ map[string]any{
+ "className": "flex-1 px-3 py-3 text-base border-none rounded bg-green-500 text-white cursor-pointer hover:bg-green-600 transition-colors duration-200",
+ "onClick": func() { props.OnMode(WorkMode.Duration) },
+ },
+ "Work Mode",
+ ),
+ vdom.H("button",
+ map[string]any{
+ "className": "flex-1 px-3 py-3 text-base border-none rounded bg-green-500 text-white cursor-pointer hover:bg-green-600 transition-colors duration-200",
+ "onClick": func() { props.OnMode(BreakMode.Duration) },
+ },
+ "Break Mode",
+ ),
+ ),
+ )
+ },
+)
+
+var App = app.DefineComponent("App",
+ func(_ struct{}) any {
+ app.UseSetAppTitle("Pomodoro Timer (Tsunami Demo)")
+
+ isRunning := app.UseLocal(false)
+ mode := app.UseLocal(WorkMode.Name)
+ isComplete := app.UseLocal(false)
+ startTime := app.UseRef(time.Time{})
+ totalDuration := app.UseRef(time.Duration(0))
+
+ // Timer that updates every second using the new pattern
+ app.UseTicker(time.Second, func() {
+ if !isRunning.Get() {
+ return
+ }
+
+ elapsed := time.Since(startTime.Current)
+ remaining := totalDuration.Current - elapsed
+
+ if remaining <= 0 {
+ // Timer completed
+ isRunning.Set(false)
+ remainingSecondsAtom.Set(0)
+ isComplete.Set(true)
+ return
+ }
+
+ newSeconds := int(remaining.Seconds())
+
+ // Only send update if value actually changed
+ if newSeconds != remainingSecondsAtom.Get() {
+ remainingSecondsAtom.Set(newSeconds)
+ }
+ }, []any{isRunning.Get()})
+
+ startTimer := func() {
+ if isRunning.Get() {
+ return // Timer already running
+ }
+
+ isComplete.Set(false)
+ startTime.Current = time.Now()
+ totalDuration.Current = time.Duration(remainingSecondsAtom.Get()) * time.Second
+ isRunning.Set(true)
+ }
+
+ pauseTimer := func() {
+ if !isRunning.Get() {
+ return
+ }
+
+ // Calculate remaining time and update remainingSeconds
+ elapsed := time.Since(startTime.Current)
+ remaining := totalDuration.Current - elapsed
+ if remaining > 0 {
+ remainingSecondsAtom.Set(int(remaining.Seconds()))
+ }
+ isRunning.Set(false)
+ }
+
+ resetTimer := func() {
+ isRunning.Set(false)
+ isComplete.Set(false)
+ if mode.Get() == WorkMode.Name {
+ remainingSecondsAtom.Set(WorkMode.Duration * 60)
+ } else {
+ remainingSecondsAtom.Set(BreakMode.Duration * 60)
+ }
+ }
+
+ changeMode := func(duration int) {
+ isRunning.Set(false)
+ isComplete.Set(false)
+ remainingSecondsAtom.Set(duration * 60)
+ if duration == WorkMode.Duration {
+ mode.Set(WorkMode.Name)
+ } else {
+ mode.Set(BreakMode.Name)
+ }
+ }
+
+ return vdom.H("div",
+ map[string]any{"className": "max-w-sm mx-auto my-8 p-8 bg-slate-800 rounded-xl text-slate-100 font-sans"},
+ vdom.H("h1",
+ map[string]any{"className": "text-center text-slate-100 mb-8 text-3xl"},
+ "Pomodoro Timer",
+ ),
+ TimerDisplay(TimerDisplayProps{
+ RemainingSeconds: remainingSecondsAtom.Get(),
+ Mode: mode.Get(),
+ }),
+ ControlButtons(ControlButtonsProps{
+ IsRunning: isRunning.Get(),
+ OnStart: startTimer,
+ OnPause: pauseTimer,
+ OnReset: resetTimer,
+ OnMode: changeMode,
+ }),
+ )
+ },
+)
diff --git a/tsunami/demo/pomodoro/go.mod b/tsunami/demo/pomodoro/go.mod
new file mode 100644
index 0000000000..9e5e8362d4
--- /dev/null
+++ b/tsunami/demo/pomodoro/go.mod
@@ -0,0 +1,12 @@
+module tsunami/app/pomodoro
+
+go 1.24.6
+
+require github.com/wavetermdev/waveterm/tsunami v0.0.0
+
+require (
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/outrigdev/goid v0.3.0 // indirect
+)
+
+replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami
diff --git a/tsunami/demo/pomodoro/go.sum b/tsunami/demo/pomodoro/go.sum
new file mode 100644
index 0000000000..4c44991dfc
--- /dev/null
+++ b/tsunami/demo/pomodoro/go.sum
@@ -0,0 +1,4 @@
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=
+github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=
diff --git a/tsunami/demo/pomodoro/static/tw.css b/tsunami/demo/pomodoro/static/tw.css
new file mode 100644
index 0000000000..9ed99608a3
--- /dev/null
+++ b/tsunami/demo/pomodoro/static/tw.css
@@ -0,0 +1,1240 @@
+/*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */
+@layer properties;
+@layer theme, base, components, utilities;
+@layer theme {
+ :root, :host {
+ --font-sans: "Inter", sans-serif;
+ --font-mono: "Hack", monospace;
+ --color-red-100: oklch(93.6% 0.032 17.717);
+ --color-red-500: oklch(63.7% 0.237 25.331);
+ --color-red-800: oklch(44.4% 0.177 26.899);
+ --color-green-500: oklch(72.3% 0.219 149.579);
+ --color-green-600: oklch(62.7% 0.194 149.214);
+ --color-blue-400: oklch(70.7% 0.165 254.624);
+ --color-blue-500: oklch(62.3% 0.214 259.815);
+ --color-blue-600: oklch(54.6% 0.245 262.881);
+ --color-slate-100: oklch(96.8% 0.007 247.896);
+ --color-slate-700: oklch(37.2% 0.044 257.287);
+ --color-slate-800: oklch(27.9% 0.041 260.031);
+ --color-white: #fff;
+ --spacing: 0.25rem;
+ --container-sm: 24rem;
+ --text-sm: 0.875rem;
+ --text-sm--line-height: calc(1.25 / 0.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);
+ --text-xl: 1.25rem;
+ --text-xl--line-height: calc(1.75 / 1.25);
+ --text-2xl: 1.5rem;
+ --text-2xl--line-height: calc(2 / 1.5);
+ --text-3xl: 1.875rem;
+ --text-3xl--line-height: calc(2.25 / 1.875);
+ --text-6xl: 3.75rem;
+ --text-6xl--line-height: 1;
+ --font-weight-bold: 700;
+ --leading-relaxed: 1.625;
+ --radius-lg: 0.5rem;
+ --radius-xl: 0.75rem;
+ --ease-in: cubic-bezier(0.4, 0, 1, 1);
+ --ease-out: cubic-bezier(0, 0, 0.2, 1);
+ --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
+ --default-transition-duration: 150ms;
+ --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ --default-font-family: var(--font-sans);
+ --default-mono-font-family: var(--font-mono);
+ --radius: 8px;
+ --color-background: rgb(34, 34, 34);
+ --color-primary: rgb(247, 247, 247);
+ --color-secondary: rgba(215, 218, 224, 0.7);
+ --color-muted: rgba(215, 218, 224, 0.5);
+ --color-accent-300: rgb(110, 231, 133);
+ --color-panel: rgba(255, 255, 255, 0.12);
+ --color-border: rgba(255, 255, 255, 0.16);
+ --color-accent: rgb(88, 193, 66);
+ }
+}
+@layer base {
+ *, ::after, ::before, ::backdrop, ::file-selector-button {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ border: 0 solid;
+ }
+ html, :host {
+ line-height: 1.5;
+ -webkit-text-size-adjust: 100%;
+ tab-size: 4;
+ 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;
+ 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 {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+ }
+ sub {
+ bottom: -0.25em;
+ }
+ sup {
+ top: -0.5em;
+ }
+ table {
+ text-indent: 0;
+ border-color: inherit;
+ border-collapse: collapse;
+ }
+ :-moz-focusring {
+ outline: auto;
+ }
+ progress {
+ vertical-align: baseline;
+ }
+ summary {
+ display: list-item;
+ }
+ ol, ul, menu {
+ list-style: none;
+ }
+ img, svg, video, canvas, audio, iframe, embed, object {
+ display: block;
+ vertical-align: middle;
+ }
+ img, video {
+ max-width: 100%;
+ height: auto;
+ }
+ button, input, select, optgroup, textarea, ::file-selector-button {
+ font: inherit;
+ font-feature-settings: inherit;
+ font-variation-settings: inherit;
+ letter-spacing: inherit;
+ color: inherit;
+ border-radius: 0;
+ background-color: transparent;
+ opacity: 1;
+ }
+ :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)) {
+ 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, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-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"]), ::file-selector-button {
+ appearance: button;
+ }
+ ::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
+ height: auto;
+ }
+ [hidden]:where(:not([hidden="until-found"])) {
+ display: none !important;
+ }
+}
+@layer utilities {
+ .collapse {
+ visibility: collapse;
+ }
+ .invisible {
+ visibility: hidden;
+ }
+ .visible {
+ visibility: visible;
+ }
+ .sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border-width: 0;
+ }
+ .not-sr-only {
+ position: static;
+ width: auto;
+ height: auto;
+ padding: 0;
+ margin: 0;
+ overflow: visible;
+ clip-path: none;
+ white-space: normal;
+ }
+ .absolute {
+ position: absolute;
+ }
+ .fixed {
+ position: fixed;
+ }
+ .relative {
+ position: relative;
+ }
+ .static {
+ position: static;
+ }
+ .sticky {
+ position: sticky;
+ }
+ .isolate {
+ isolation: isolate;
+ }
+ .isolation-auto {
+ isolation: auto;
+ }
+ .container {
+ width: 100%;
+ @media (width >= 40rem) {
+ max-width: 40rem;
+ }
+ @media (width >= 48rem) {
+ max-width: 48rem;
+ }
+ @media (width >= 64rem) {
+ max-width: 64rem;
+ }
+ @media (width >= 80rem) {
+ max-width: 80rem;
+ }
+ @media (width >= 96rem) {
+ max-width: 96rem;
+ }
+ }
+ .mx-auto {
+ margin-inline: auto;
+ }
+ .my-6 {
+ margin-block: calc(var(--spacing) * 6);
+ }
+ .my-8 {
+ margin-block: calc(var(--spacing) * 8);
+ }
+ .mt-3 {
+ margin-top: calc(var(--spacing) * 3);
+ }
+ .mt-4 {
+ margin-top: calc(var(--spacing) * 4);
+ }
+ .mt-5 {
+ margin-top: calc(var(--spacing) * 5);
+ }
+ .mt-6 {
+ margin-top: calc(var(--spacing) * 6);
+ }
+ .mb-2 {
+ margin-bottom: calc(var(--spacing) * 2);
+ }
+ .mb-3 {
+ margin-bottom: calc(var(--spacing) * 3);
+ }
+ .mb-4 {
+ margin-bottom: calc(var(--spacing) * 4);
+ }
+ .mb-8 {
+ margin-bottom: calc(var(--spacing) * 8);
+ }
+ .ml-4 {
+ margin-left: calc(var(--spacing) * 4);
+ }
+ .block {
+ display: block;
+ }
+ .contents {
+ display: contents;
+ }
+ .flex {
+ display: flex;
+ }
+ .flow-root {
+ display: flow-root;
+ }
+ .grid {
+ display: grid;
+ }
+ .hidden {
+ display: none;
+ }
+ .inline {
+ display: inline;
+ }
+ .inline-block {
+ display: inline-block;
+ }
+ .inline-flex {
+ display: inline-flex;
+ }
+ .inline-grid {
+ display: inline-grid;
+ }
+ .inline-table {
+ display: inline-table;
+ }
+ .list-item {
+ display: list-item;
+ }
+ .table {
+ display: table;
+ }
+ .table-caption {
+ display: table-caption;
+ }
+ .table-cell {
+ display: table-cell;
+ }
+ .table-column {
+ display: table-column;
+ }
+ .table-column-group {
+ display: table-column-group;
+ }
+ .table-footer-group {
+ display: table-footer-group;
+ }
+ .table-header-group {
+ display: table-header-group;
+ }
+ .table-row {
+ display: table-row;
+ }
+ .table-row-group {
+ display: table-row-group;
+ }
+ .min-h-full {
+ min-height: 100%;
+ }
+ .min-h-screen {
+ min-height: 100vh;
+ }
+ .w-full {
+ width: 100%;
+ }
+ .max-w-none {
+ max-width: none;
+ }
+ .max-w-sm {
+ max-width: var(--container-sm);
+ }
+ .min-w-full {
+ min-width: 100%;
+ }
+ .flex-1 {
+ flex: 1;
+ }
+ .shrink {
+ flex-shrink: 1;
+ }
+ .grow {
+ flex-grow: 1;
+ }
+ .border-collapse {
+ border-collapse: collapse;
+ }
+ .translate-none {
+ translate: none;
+ }
+ .scale-3d {
+ scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z);
+ }
+ .transform {
+ transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
+ }
+ .cursor-pointer {
+ cursor: pointer;
+ }
+ .touch-pinch-zoom {
+ --tw-pinch-zoom: pinch-zoom;
+ touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,);
+ }
+ .resize {
+ resize: both;
+ }
+ .list-inside {
+ list-style-position: inside;
+ }
+ .list-decimal {
+ list-style-type: decimal;
+ }
+ .list-disc {
+ list-style-type: disc;
+ }
+ .flex-col {
+ flex-direction: column;
+ }
+ .flex-wrap {
+ flex-wrap: wrap;
+ }
+ .gap-4 {
+ gap: calc(var(--spacing) * 4);
+ }
+ .space-y-1 {
+ :where(& > :not(:last-child)) {
+ --tw-space-y-reverse: 0;
+ margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));
+ margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));
+ }
+ }
+ .space-y-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-space-y-reverse: 1;
+ }
+ }
+ .space-x-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-space-x-reverse: 1;
+ }
+ }
+ .divide-x {
+ :where(& > :not(:last-child)) {
+ --tw-divide-x-reverse: 0;
+ border-inline-style: var(--tw-border-style);
+ border-inline-start-width: calc(1px * var(--tw-divide-x-reverse));
+ border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse)));
+ }
+ }
+ .divide-y {
+ :where(& > :not(:last-child)) {
+ --tw-divide-y-reverse: 0;
+ border-bottom-style: var(--tw-border-style);
+ border-top-style: var(--tw-border-style);
+ border-top-width: calc(1px * var(--tw-divide-y-reverse));
+ border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
+ }
+ }
+ .divide-y-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-divide-y-reverse: 1;
+ }
+ }
+ .truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .overflow-auto {
+ overflow: auto;
+ }
+ .overflow-x-auto {
+ overflow-x: auto;
+ }
+ .rounded {
+ border-radius: var(--radius);
+ }
+ .rounded-lg {
+ border-radius: var(--radius-lg);
+ }
+ .rounded-xl {
+ border-radius: var(--radius-xl);
+ }
+ .rounded-s {
+ border-start-start-radius: var(--radius);
+ border-end-start-radius: var(--radius);
+ }
+ .rounded-ss {
+ border-start-start-radius: var(--radius);
+ }
+ .rounded-e {
+ border-start-end-radius: var(--radius);
+ border-end-end-radius: var(--radius);
+ }
+ .rounded-se {
+ border-start-end-radius: var(--radius);
+ }
+ .rounded-ee {
+ border-end-end-radius: var(--radius);
+ }
+ .rounded-es {
+ border-end-start-radius: var(--radius);
+ }
+ .rounded-t {
+ border-top-left-radius: var(--radius);
+ border-top-right-radius: var(--radius);
+ }
+ .rounded-l {
+ border-top-left-radius: var(--radius);
+ border-bottom-left-radius: var(--radius);
+ }
+ .rounded-tl {
+ border-top-left-radius: var(--radius);
+ }
+ .rounded-r {
+ border-top-right-radius: var(--radius);
+ border-bottom-right-radius: var(--radius);
+ }
+ .rounded-tr {
+ border-top-right-radius: var(--radius);
+ }
+ .rounded-b {
+ border-bottom-right-radius: var(--radius);
+ border-bottom-left-radius: var(--radius);
+ }
+ .rounded-br {
+ border-bottom-right-radius: var(--radius);
+ }
+ .rounded-bl {
+ border-bottom-left-radius: var(--radius);
+ }
+ .border {
+ border-style: var(--tw-border-style);
+ border-width: 1px;
+ }
+ .border-x {
+ border-inline-style: var(--tw-border-style);
+ border-inline-width: 1px;
+ }
+ .border-y {
+ border-block-style: var(--tw-border-style);
+ border-block-width: 1px;
+ }
+ .border-s {
+ border-inline-start-style: var(--tw-border-style);
+ border-inline-start-width: 1px;
+ }
+ .border-e {
+ border-inline-end-style: var(--tw-border-style);
+ border-inline-end-width: 1px;
+ }
+ .border-t {
+ border-top-style: var(--tw-border-style);
+ border-top-width: 1px;
+ }
+ .border-r {
+ border-right-style: var(--tw-border-style);
+ border-right-width: 1px;
+ }
+ .border-b {
+ border-bottom-style: var(--tw-border-style);
+ border-bottom-width: 1px;
+ }
+ .border-l {
+ border-left-style: var(--tw-border-style);
+ border-left-width: 1px;
+ }
+ .border-l-4 {
+ border-left-style: var(--tw-border-style);
+ border-left-width: 4px;
+ }
+ .border-none {
+ --tw-border-style: none;
+ border-style: none;
+ }
+ .border-border {
+ border-color: var(--color-border);
+ }
+ .border-red-500 {
+ border-color: var(--color-red-500);
+ }
+ .bg-background {
+ background-color: var(--color-background);
+ }
+ .bg-blue-500 {
+ background-color: var(--color-blue-500);
+ }
+ .bg-green-500 {
+ background-color: var(--color-green-500);
+ }
+ .bg-panel {
+ background-color: var(--color-panel);
+ }
+ .bg-red-100 {
+ background-color: var(--color-red-100);
+ }
+ .bg-slate-700 {
+ background-color: var(--color-slate-700);
+ }
+ .bg-slate-800 {
+ background-color: var(--color-slate-800);
+ }
+ .bg-repeat {
+ background-repeat: repeat;
+ }
+ .mask-no-clip {
+ mask-clip: no-clip;
+ }
+ .mask-repeat {
+ mask-repeat: repeat;
+ }
+ .p-4 {
+ padding: calc(var(--spacing) * 4);
+ }
+ .p-8 {
+ padding: calc(var(--spacing) * 8);
+ }
+ .px-1 {
+ padding-inline: calc(var(--spacing) * 1);
+ }
+ .px-3 {
+ padding-inline: calc(var(--spacing) * 3);
+ }
+ .px-4 {
+ padding-inline: calc(var(--spacing) * 4);
+ }
+ .px-6 {
+ padding-inline: calc(var(--spacing) * 6);
+ }
+ .py-0\.5 {
+ padding-block: calc(var(--spacing) * 0.5);
+ }
+ .py-2 {
+ padding-block: calc(var(--spacing) * 2);
+ }
+ .py-3 {
+ padding-block: calc(var(--spacing) * 3);
+ }
+ .pl-4 {
+ padding-left: calc(var(--spacing) * 4);
+ }
+ .text-center {
+ text-align: center;
+ }
+ .text-left {
+ text-align: left;
+ }
+ .font-mono {
+ font-family: var(--font-mono);
+ }
+ .font-sans {
+ font-family: var(--font-sans);
+ }
+ .text-2xl {
+ font-size: var(--text-2xl);
+ line-height: var(--tw-leading, var(--text-2xl--line-height));
+ }
+ .text-3xl {
+ font-size: var(--text-3xl);
+ line-height: var(--tw-leading, var(--text-3xl--line-height));
+ }
+ .text-6xl {
+ font-size: var(--text-6xl);
+ line-height: var(--tw-leading, var(--text-6xl--line-height));
+ }
+ .text-base {
+ font-size: var(--text-base);
+ line-height: var(--tw-leading, var(--text-base--line-height));
+ }
+ .text-lg {
+ font-size: var(--text-lg);
+ line-height: var(--tw-leading, var(--text-lg--line-height));
+ }
+ .text-sm {
+ font-size: var(--text-sm);
+ line-height: var(--tw-leading, var(--text-sm--line-height));
+ }
+ .text-xl {
+ font-size: var(--text-xl);
+ line-height: var(--tw-leading, var(--text-xl--line-height));
+ }
+ .leading-relaxed {
+ --tw-leading: var(--leading-relaxed);
+ line-height: var(--leading-relaxed);
+ }
+ .font-bold {
+ --tw-font-weight: var(--font-weight-bold);
+ font-weight: var(--font-weight-bold);
+ }
+ .text-wrap {
+ text-wrap: wrap;
+ }
+ .text-clip {
+ text-overflow: clip;
+ }
+ .text-ellipsis {
+ text-overflow: ellipsis;
+ }
+ .text-accent {
+ color: var(--color-accent);
+ }
+ .text-blue-400 {
+ color: var(--color-blue-400);
+ }
+ .text-muted {
+ color: var(--color-muted);
+ }
+ .text-primary {
+ color: var(--color-primary);
+ }
+ .text-red-800 {
+ color: var(--color-red-800);
+ }
+ .text-secondary {
+ color: var(--color-secondary);
+ }
+ .text-slate-100 {
+ color: var(--color-slate-100);
+ }
+ .text-white {
+ color: var(--color-white);
+ }
+ .capitalize {
+ text-transform: capitalize;
+ }
+ .lowercase {
+ text-transform: lowercase;
+ }
+ .normal-case {
+ text-transform: none;
+ }
+ .uppercase {
+ text-transform: uppercase;
+ }
+ .italic {
+ font-style: italic;
+ }
+ .not-italic {
+ font-style: normal;
+ }
+ .diagonal-fractions {
+ --tw-numeric-fraction: diagonal-fractions;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .lining-nums {
+ --tw-numeric-figure: lining-nums;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .oldstyle-nums {
+ --tw-numeric-figure: oldstyle-nums;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .ordinal {
+ --tw-ordinal: ordinal;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .proportional-nums {
+ --tw-numeric-spacing: proportional-nums;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .slashed-zero {
+ --tw-slashed-zero: slashed-zero;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .stacked-fractions {
+ --tw-numeric-fraction: stacked-fractions;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .tabular-nums {
+ --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,);
+ }
+ .normal-nums {
+ font-variant-numeric: normal;
+ }
+ .line-through {
+ text-decoration-line: line-through;
+ }
+ .no-underline {
+ text-decoration-line: none;
+ }
+ .overline {
+ text-decoration-line: overline;
+ }
+ .underline {
+ text-decoration-line: underline;
+ }
+ .antialiased {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+ .subpixel-antialiased {
+ -webkit-font-smoothing: auto;
+ -moz-osx-font-smoothing: auto;
+ }
+ .shadow {
+ --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
+ }
+ .inset-ring {
+ --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-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);
+ }
+ .blur {
+ --tw-blur: blur(8px);
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .drop-shadow {
+ --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06)));
+ --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06));
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .invert {
+ --tw-invert: invert(100%);
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .filter {
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .backdrop-blur {
+ --tw-backdrop-blur: blur(8px);
+ -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,);
+ }
+ .backdrop-grayscale {
+ --tw-backdrop-grayscale: grayscale(100%);
+ -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,);
+ }
+ .backdrop-invert {
+ --tw-backdrop-invert: invert(100%);
+ -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,);
+ }
+ .backdrop-sepia {
+ --tw-backdrop-sepia: sepia(100%);
+ -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,);
+ }
+ .backdrop-filter {
+ -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,);
+ }
+ .transition-colors {
+ 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));
+ }
+ .duration-200 {
+ --tw-duration: 200ms;
+ transition-duration: 200ms;
+ }
+ .ease-in {
+ --tw-ease: var(--ease-in);
+ transition-timing-function: var(--ease-in);
+ }
+ .ease-in-out {
+ --tw-ease: var(--ease-in-out);
+ transition-timing-function: var(--ease-in-out);
+ }
+ .ease-out {
+ --tw-ease: var(--ease-out);
+ transition-timing-function: var(--ease-out);
+ }
+ .divide-x-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-divide-x-reverse: 1;
+ }
+ }
+ .ring-inset {
+ --tw-ring-inset: inset;
+ }
+ .hover\:bg-blue-600 {
+ &:hover {
+ @media (hover: hover) {
+ background-color: var(--color-blue-600);
+ }
+ }
+ }
+ .hover\:bg-green-600 {
+ &:hover {
+ @media (hover: hover) {
+ background-color: var(--color-green-600);
+ }
+ }
+ }
+ .hover\:text-accent-300 {
+ &:hover {
+ @media (hover: hover) {
+ color: var(--color-accent-300);
+ }
+ }
+ }
+}
+@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-rotate-x {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-rotate-y {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-rotate-z {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-skew-x {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-skew-y {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-pan-x {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-pan-y {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-pinch-zoom {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-space-y-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-space-x-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-divide-x-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-border-style {
+ syntax: "*";
+ inherits: false;
+ initial-value: solid;
+}
+@property --tw-divide-y-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-leading {
+ 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-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: 0px;
+}
+@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-blur {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-brightness {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-contrast {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-grayscale {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-hue-rotate {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-invert {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-opacity {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-saturate {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-sepia {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow-color {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow-alpha {
+ syntax: "";
+ inherits: false;
+ initial-value: 100%;
+}
+@property --tw-drop-shadow-size {
+ syntax: "*";
+ inherits: false;
+}
+@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;
+}
+@property --tw-duration {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-ease {
+ syntax: "*";
+ inherits: false;
+}
+@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-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-scale-z: 1;
+ --tw-rotate-x: initial;
+ --tw-rotate-y: initial;
+ --tw-rotate-z: initial;
+ --tw-skew-x: initial;
+ --tw-skew-y: initial;
+ --tw-pan-x: initial;
+ --tw-pan-y: initial;
+ --tw-pinch-zoom: initial;
+ --tw-space-y-reverse: 0;
+ --tw-space-x-reverse: 0;
+ --tw-divide-x-reverse: 0;
+ --tw-border-style: solid;
+ --tw-divide-y-reverse: 0;
+ --tw-leading: 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-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-blur: initial;
+ --tw-brightness: initial;
+ --tw-contrast: initial;
+ --tw-grayscale: initial;
+ --tw-hue-rotate: initial;
+ --tw-invert: initial;
+ --tw-opacity: initial;
+ --tw-saturate: initial;
+ --tw-sepia: initial;
+ --tw-drop-shadow: initial;
+ --tw-drop-shadow-color: initial;
+ --tw-drop-shadow-alpha: 100%;
+ --tw-drop-shadow-size: initial;
+ --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;
+ --tw-duration: initial;
+ --tw-ease: initial;
+ }
+ }
+}
diff --git a/tsunami/demo/recharts/app.go b/tsunami/demo/recharts/app.go
new file mode 100644
index 0000000000..b558292d1a
--- /dev/null
+++ b/tsunami/demo/recharts/app.go
@@ -0,0 +1,459 @@
+package main
+
+import (
+ "math"
+ "time"
+
+ "github.com/wavetermdev/waveterm/tsunami/app"
+ "github.com/wavetermdev/waveterm/tsunami/vdom"
+)
+
+// Global atoms for config and data
+var (
+ chartDataAtom = app.DataAtom("chartData", generateInitialData())
+ chartTypeAtom = app.ConfigAtom("chartType", "line")
+ isAnimatingAtom = app.SharedAtom("isAnimating", false)
+)
+
+type DataPoint struct {
+ Time int `json:"time"`
+ CPU float64 `json:"cpu"`
+ Mem float64 `json:"mem"`
+ Disk float64 `json:"disk"`
+}
+
+func generateInitialData() []DataPoint {
+ data := make([]DataPoint, 20)
+ for i := 0; i < 20; i++ {
+ data[i] = DataPoint{
+ Time: i,
+ CPU: 50 + 30*math.Sin(float64(i)*0.3) + 10*math.Sin(float64(i)*0.7),
+ Mem: 40 + 25*math.Cos(float64(i)*0.4) + 15*math.Sin(float64(i)*0.9),
+ Disk: 30 + 20*math.Sin(float64(i)*0.2) + 10*math.Cos(float64(i)*1.1),
+ }
+ }
+ return data
+}
+
+func generateNewDataPoint(currentData []DataPoint) DataPoint {
+ lastTime := 0
+ if len(currentData) > 0 {
+ lastTime = currentData[len(currentData)-1].Time
+ }
+ newTime := lastTime + 1
+
+ return DataPoint{
+ Time: newTime,
+ CPU: 50 + 30*math.Sin(float64(newTime)*0.3) + 10*math.Sin(float64(newTime)*0.7),
+ Mem: 40 + 25*math.Cos(float64(newTime)*0.4) + 15*math.Sin(float64(newTime)*0.9),
+ Disk: 30 + 20*math.Sin(float64(newTime)*0.2) + 10*math.Cos(float64(newTime)*1.1),
+ }
+}
+
+var InfoSection = app.DefineComponent("InfoSection", func(_ struct{}) any {
+ return vdom.H("div", map[string]any{
+ "className": "bg-blue-50 border border-blue-200 rounded-lg p-4",
+ },
+ vdom.H("h3", map[string]any{
+ "className": "text-lg font-semibold text-blue-900 mb-2",
+ }, "Recharts Integration Features"),
+ vdom.H("ul", map[string]any{
+ "className": "space-y-2 text-blue-800",
+ },
+ vdom.H("li", map[string]any{
+ "className": "flex items-start gap-2",
+ },
+ vdom.H("span", map[string]any{
+ "className": "text-blue-500 mt-1",
+ }, "•"),
+ "Support for all major Recharts components (LineChart, AreaChart, BarChart, etc.)",
+ ),
+ vdom.H("li", map[string]any{
+ "className": "flex items-start gap-2",
+ },
+ vdom.H("span", map[string]any{
+ "className": "text-blue-500 mt-1",
+ }, "•"),
+ "Live data updates with animation support",
+ ),
+ vdom.H("li", map[string]any{
+ "className": "flex items-start gap-2",
+ },
+ vdom.H("span", map[string]any{
+ "className": "text-blue-500 mt-1",
+ }, "•"),
+ "Responsive containers that resize with the window",
+ ),
+ vdom.H("li", map[string]any{
+ "className": "flex items-start gap-2",
+ },
+ vdom.H("span", map[string]any{
+ "className": "text-blue-500 mt-1",
+ }, "•"),
+ "Full prop support for customization and styling",
+ ),
+ vdom.H("li", map[string]any{
+ "className": "flex items-start gap-2",
+ },
+ vdom.H("span", map[string]any{
+ "className": "text-blue-500 mt-1",
+ }, "•"),
+ "Uses recharts: namespace to dispatch to the recharts handler",
+ ),
+ ),
+ )
+},
+)
+
+type MiniChartsProps struct {
+ ChartData []DataPoint `json:"chartData"`
+}
+
+var MiniCharts = app.DefineComponent("MiniCharts",
+ func(props MiniChartsProps) any {
+ return vdom.H("div", map[string]any{
+ "className": "grid grid-cols-1 md:grid-cols-3 gap-6 mb-6",
+ },
+ // CPU Mini Chart
+ vdom.H("div", map[string]any{
+ "className": "bg-white rounded-lg shadow-sm border p-4",
+ },
+ vdom.H("h3", map[string]any{
+ "className": "text-lg font-medium text-gray-900 mb-3",
+ }, "CPU Usage"),
+ vdom.H("div", map[string]any{
+ "className": "h-32",
+ },
+ vdom.H("recharts:ResponsiveContainer", map[string]any{
+ "width": "100%",
+ "height": "100%",
+ },
+ vdom.H("recharts:LineChart", map[string]any{
+ "data": props.ChartData,
+ },
+ vdom.H("recharts:Line", map[string]any{
+ "type": "monotone",
+ "dataKey": "cpu",
+ "stroke": "#8884d8",
+ "strokeWidth": 2,
+ "dot": false,
+ }),
+ ),
+ ),
+ ),
+ ),
+
+ // Memory Mini Chart
+ vdom.H("div", map[string]any{
+ "className": "bg-white rounded-lg shadow-sm border p-4",
+ },
+ vdom.H("h3", map[string]any{
+ "className": "text-lg font-medium text-gray-900 mb-3",
+ }, "Memory Usage"),
+ vdom.H("div", map[string]any{
+ "className": "h-32",
+ },
+ vdom.H("recharts:ResponsiveContainer", map[string]any{
+ "width": "100%",
+ "height": "100%",
+ },
+ vdom.H("recharts:AreaChart", map[string]any{
+ "data": props.ChartData,
+ },
+ vdom.H("recharts:Area", map[string]any{
+ "type": "monotone",
+ "dataKey": "mem",
+ "stroke": "#82ca9d",
+ "fill": "#82ca9d",
+ }),
+ ),
+ ),
+ ),
+ ),
+
+ // Disk Mini Chart
+ vdom.H("div", map[string]any{
+ "className": "bg-white rounded-lg shadow-sm border p-4",
+ },
+ vdom.H("h3", map[string]any{
+ "className": "text-lg font-medium text-gray-900 mb-3",
+ }, "Disk Usage"),
+ vdom.H("div", map[string]any{
+ "className": "h-32",
+ },
+ vdom.H("recharts:ResponsiveContainer", map[string]any{
+ "width": "100%",
+ "height": "100%",
+ },
+ vdom.H("recharts:BarChart", map[string]any{
+ "data": props.ChartData,
+ },
+ vdom.H("recharts:Bar", map[string]any{
+ "dataKey": "disk",
+ "fill": "#ffc658",
+ }),
+ ),
+ ),
+ ),
+ ),
+ )
+ },
+)
+
+var App = app.DefineComponent("App",
+ func(_ struct{}) any {
+ app.UseSetAppTitle("Recharts Demo")
+
+ tickerFn := func() {
+ if !isAnimatingAtom.Get() {
+ return
+ }
+ chartDataAtom.SetFn(func(currentData []DataPoint) []DataPoint {
+ newData := append(currentData, generateNewDataPoint(currentData))
+ if len(newData) > 20 {
+ newData = newData[1:]
+ }
+ return newData
+ })
+ }
+ app.UseTicker(time.Second, tickerFn, []any{})
+
+ handleStartStop := func() {
+ isAnimatingAtom.Set(!isAnimatingAtom.Get())
+ }
+
+ handleReset := func() {
+ chartDataAtom.Set(generateInitialData())
+ isAnimatingAtom.Set(false)
+ }
+
+ handleChartTypeChange := func(newType string) {
+ chartTypeAtom.Set(newType)
+ }
+
+ chartData := chartDataAtom.Get()
+ chartType := chartTypeAtom.Get()
+ isAnimating := isAnimatingAtom.Get()
+
+ return vdom.H("div", map[string]any{
+ "className": "min-h-screen bg-gray-50 p-6",
+ },
+ vdom.H("div", map[string]any{
+ "className": "max-w-6xl mx-auto",
+ },
+ // Header
+ vdom.H("div", map[string]any{
+ "className": "mb-8",
+ },
+ vdom.H("h1", map[string]any{
+ "className": "text-3xl font-bold text-gray-900 mb-2",
+ }, "Recharts Integration Demo"),
+ vdom.H("p", map[string]any{
+ "className": "text-gray-600",
+ }, "Demonstrating recharts components in Tsunami VDOM system"),
+ ),
+
+ // Controls
+ vdom.H("div", map[string]any{
+ "className": "bg-white rounded-lg shadow-sm border p-4 mb-6",
+ },
+ vdom.H("div", map[string]any{
+ "className": "flex items-center gap-4 flex-wrap",
+ },
+ // Chart type selector
+ vdom.H("div", map[string]any{
+ "className": "flex items-center gap-2",
+ },
+ vdom.H("label", map[string]any{
+ "className": "text-sm font-medium text-gray-700",
+ }, "Chart Type:"),
+ vdom.H("select", map[string]any{
+ "className": "px-3 py-1 border border-gray-300 rounded-md text-sm bg-white text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
+ "value": chartType,
+ "onChange": func(e vdom.VDomEvent) {
+ handleChartTypeChange(e.TargetValue)
+ },
+ },
+ vdom.H("option", map[string]any{"value": "line"}, "Line Chart"),
+ vdom.H("option", map[string]any{"value": "area"}, "Area Chart"),
+ vdom.H("option", map[string]any{"value": "bar"}, "Bar Chart"),
+ ),
+ ),
+
+ // Animation controls
+ vdom.H("button", map[string]any{
+ "className": vdom.Classes(
+ "px-4 py-2 rounded-md text-sm font-medium transition-colors",
+ vdom.IfElse(isAnimating,
+ "bg-red-500 hover:bg-red-600 text-white",
+ "bg-green-500 hover:bg-green-600 text-white",
+ ),
+ ),
+ "onClick": handleStartStop,
+ }, vdom.IfElse(isAnimating, "Stop Animation", "Start Animation")),
+
+ vdom.H("button", map[string]any{
+ "className": "px-4 py-2 bg-gray-500 hover:bg-gray-600 text-white rounded-md text-sm font-medium transition-colors",
+ "onClick": handleReset,
+ }, "Reset Data"),
+
+ // Status indicator
+ vdom.H("div", map[string]any{
+ "className": "flex items-center gap-2",
+ },
+ vdom.H("div", map[string]any{
+ "className": vdom.Classes(
+ "w-2 h-2 rounded-full",
+ vdom.IfElse(isAnimating, "bg-green-500", "bg-gray-400"),
+ ),
+ }),
+ vdom.H("span", map[string]any{
+ "className": "text-sm text-gray-600",
+ }, vdom.IfElse(isAnimating, "Live Updates", "Static")),
+ ),
+ ),
+ ),
+
+ // Main chart
+ vdom.H("div", map[string]any{
+ "className": "bg-white rounded-lg shadow-sm border p-6 mb-6",
+ },
+ vdom.H("h2", map[string]any{
+ "className": "text-xl font-semibold text-gray-900 mb-4",
+ }, "System Metrics Over Time"),
+ vdom.H("div", map[string]any{
+ "className": "w-full h-96",
+ },
+ // Main chart - switches based on chartType
+ vdom.IfElse(chartType == "line",
+ // Line Chart
+ vdom.H("recharts:ResponsiveContainer", map[string]any{
+ "width": "100%",
+ "height": "100%",
+ },
+ vdom.H("recharts:LineChart", map[string]any{
+ "data": chartData,
+ },
+ vdom.H("recharts:CartesianGrid", map[string]any{
+ "strokeDasharray": "3 3",
+ }),
+ vdom.H("recharts:XAxis", map[string]any{
+ "dataKey": "time",
+ }),
+ vdom.H("recharts:YAxis", nil),
+ vdom.H("recharts:Tooltip", nil),
+ vdom.H("recharts:Legend", nil),
+ vdom.H("recharts:Line", map[string]any{
+ "type": "monotone",
+ "dataKey": "cpu",
+ "stroke": "#8884d8",
+ "name": "CPU %",
+ }),
+ vdom.H("recharts:Line", map[string]any{
+ "type": "monotone",
+ "dataKey": "mem",
+ "stroke": "#82ca9d",
+ "name": "Memory %",
+ }),
+ vdom.H("recharts:Line", map[string]any{
+ "type": "monotone",
+ "dataKey": "disk",
+ "stroke": "#ffc658",
+ "name": "Disk %",
+ }),
+ ),
+ ),
+ vdom.IfElse(chartType == "area",
+ // Area Chart
+ vdom.H("recharts:ResponsiveContainer", map[string]any{
+ "width": "100%",
+ "height": "100%",
+ },
+ vdom.H("recharts:AreaChart", map[string]any{
+ "data": chartData,
+ },
+ vdom.H("recharts:CartesianGrid", map[string]any{
+ "strokeDasharray": "3 3",
+ }),
+ vdom.H("recharts:XAxis", map[string]any{
+ "dataKey": "time",
+ }),
+ vdom.H("recharts:YAxis", nil),
+ vdom.H("recharts:Tooltip", nil),
+ vdom.H("recharts:Legend", nil),
+ vdom.H("recharts:Area", map[string]any{
+ "type": "monotone",
+ "dataKey": "cpu",
+ "stackId": "1",
+ "stroke": "#8884d8",
+ "fill": "#8884d8",
+ "name": "CPU %",
+ }),
+ vdom.H("recharts:Area", map[string]any{
+ "type": "monotone",
+ "dataKey": "mem",
+ "stackId": "1",
+ "stroke": "#82ca9d",
+ "fill": "#82ca9d",
+ "name": "Memory %",
+ }),
+ vdom.H("recharts:Area", map[string]any{
+ "type": "monotone",
+ "dataKey": "disk",
+ "stackId": "1",
+ "stroke": "#ffc658",
+ "fill": "#ffc658",
+ "name": "Disk %",
+ }),
+ ),
+ ),
+ // Bar Chart
+ vdom.H("recharts:ResponsiveContainer", map[string]any{
+ "width": "100%",
+ "height": "100%",
+ },
+ vdom.H("recharts:BarChart", map[string]any{
+ "data": chartData,
+ },
+ vdom.H("recharts:CartesianGrid", map[string]any{
+ "strokeDasharray": "3 3",
+ }),
+ vdom.H("recharts:XAxis", map[string]any{
+ "dataKey": "time",
+ }),
+ vdom.H("recharts:YAxis", nil),
+ vdom.H("recharts:Tooltip", nil),
+ vdom.H("recharts:Legend", nil),
+ vdom.H("recharts:Bar", map[string]any{
+ "dataKey": "cpu",
+ "fill": "#8884d8",
+ "name": "CPU %",
+ }),
+ vdom.H("recharts:Bar", map[string]any{
+ "dataKey": "mem",
+ "fill": "#82ca9d",
+ "name": "Memory %",
+ }),
+ vdom.H("recharts:Bar", map[string]any{
+ "dataKey": "disk",
+ "fill": "#ffc658",
+ "name": "Disk %",
+ }),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+
+ // Mini charts row
+ MiniCharts(MiniChartsProps{
+ ChartData: chartData,
+ }),
+
+ // Info section
+ InfoSection(struct{}{}),
+ ),
+ )
+ },
+)
diff --git a/tsunami/demo/recharts/go.mod b/tsunami/demo/recharts/go.mod
new file mode 100644
index 0000000000..dfe7fef813
--- /dev/null
+++ b/tsunami/demo/recharts/go.mod
@@ -0,0 +1,12 @@
+module tsunami/app/recharts
+
+go 1.24.6
+
+require github.com/wavetermdev/waveterm/tsunami v0.0.0
+
+require (
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/outrigdev/goid v0.3.0 // indirect
+)
+
+replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami
diff --git a/tsunami/demo/recharts/go.sum b/tsunami/demo/recharts/go.sum
new file mode 100644
index 0000000000..4c44991dfc
--- /dev/null
+++ b/tsunami/demo/recharts/go.sum
@@ -0,0 +1,4 @@
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=
+github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=
diff --git a/tsunami/demo/recharts/static/tw.css b/tsunami/demo/recharts/static/tw.css
new file mode 100644
index 0000000000..cc96f5a20c
--- /dev/null
+++ b/tsunami/demo/recharts/static/tw.css
@@ -0,0 +1,1308 @@
+/*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */
+@layer properties;
+@layer theme, base, components, utilities;
+@layer theme {
+ :root, :host {
+ --font-sans: "Inter", sans-serif;
+ --font-mono: "Hack", monospace;
+ --color-red-500: oklch(63.7% 0.237 25.331);
+ --color-red-600: oklch(57.7% 0.245 27.325);
+ --color-green-500: oklch(72.3% 0.219 149.579);
+ --color-green-600: oklch(62.7% 0.194 149.214);
+ --color-blue-50: oklch(97% 0.014 254.604);
+ --color-blue-200: oklch(88.2% 0.059 254.128);
+ --color-blue-500: oklch(62.3% 0.214 259.815);
+ --color-blue-800: oklch(42.4% 0.199 265.638);
+ --color-blue-900: oklch(37.9% 0.146 265.522);
+ --color-gray-50: oklch(98.5% 0.002 247.839);
+ --color-gray-300: oklch(87.2% 0.01 258.338);
+ --color-gray-400: oklch(70.7% 0.022 261.325);
+ --color-gray-500: oklch(55.1% 0.027 264.364);
+ --color-gray-600: oklch(44.6% 0.03 256.802);
+ --color-gray-700: oklch(37.3% 0.034 259.733);
+ --color-gray-900: oklch(21% 0.034 264.665);
+ --color-white: #fff;
+ --spacing: 0.25rem;
+ --container-6xl: 72rem;
+ --text-sm: 0.875rem;
+ --text-sm--line-height: calc(1.25 / 0.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);
+ --text-xl: 1.25rem;
+ --text-xl--line-height: calc(1.75 / 1.25);
+ --text-2xl: 1.5rem;
+ --text-2xl--line-height: calc(2 / 1.5);
+ --text-3xl: 1.875rem;
+ --text-3xl--line-height: calc(2.25 / 1.875);
+ --font-weight-medium: 500;
+ --font-weight-semibold: 600;
+ --font-weight-bold: 700;
+ --leading-relaxed: 1.625;
+ --radius-md: 0.375rem;
+ --radius-lg: 0.5rem;
+ --ease-in: cubic-bezier(0.4, 0, 1, 1);
+ --ease-out: cubic-bezier(0, 0, 0.2, 1);
+ --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
+ --default-transition-duration: 150ms;
+ --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ --default-font-family: var(--font-sans);
+ --default-mono-font-family: var(--font-mono);
+ --radius: 8px;
+ --color-background: rgb(34, 34, 34);
+ --color-primary: rgb(247, 247, 247);
+ --color-secondary: rgba(215, 218, 224, 0.7);
+ --color-muted: rgba(215, 218, 224, 0.5);
+ --color-accent-300: rgb(110, 231, 133);
+ --color-panel: rgba(255, 255, 255, 0.12);
+ --color-border: rgba(255, 255, 255, 0.16);
+ --color-accent: rgb(88, 193, 66);
+ }
+}
+@layer base {
+ *, ::after, ::before, ::backdrop, ::file-selector-button {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ border: 0 solid;
+ }
+ html, :host {
+ line-height: 1.5;
+ -webkit-text-size-adjust: 100%;
+ tab-size: 4;
+ 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;
+ 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 {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+ }
+ sub {
+ bottom: -0.25em;
+ }
+ sup {
+ top: -0.5em;
+ }
+ table {
+ text-indent: 0;
+ border-color: inherit;
+ border-collapse: collapse;
+ }
+ :-moz-focusring {
+ outline: auto;
+ }
+ progress {
+ vertical-align: baseline;
+ }
+ summary {
+ display: list-item;
+ }
+ ol, ul, menu {
+ list-style: none;
+ }
+ img, svg, video, canvas, audio, iframe, embed, object {
+ display: block;
+ vertical-align: middle;
+ }
+ img, video {
+ max-width: 100%;
+ height: auto;
+ }
+ button, input, select, optgroup, textarea, ::file-selector-button {
+ font: inherit;
+ font-feature-settings: inherit;
+ font-variation-settings: inherit;
+ letter-spacing: inherit;
+ color: inherit;
+ border-radius: 0;
+ background-color: transparent;
+ opacity: 1;
+ }
+ :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)) {
+ 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, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-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"]), ::file-selector-button {
+ appearance: button;
+ }
+ ::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
+ height: auto;
+ }
+ [hidden]:where(:not([hidden="until-found"])) {
+ display: none !important;
+ }
+}
+@layer utilities {
+ .collapse {
+ visibility: collapse;
+ }
+ .invisible {
+ visibility: hidden;
+ }
+ .visible {
+ visibility: visible;
+ }
+ .sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border-width: 0;
+ }
+ .not-sr-only {
+ position: static;
+ width: auto;
+ height: auto;
+ padding: 0;
+ margin: 0;
+ overflow: visible;
+ clip-path: none;
+ white-space: normal;
+ }
+ .absolute {
+ position: absolute;
+ }
+ .fixed {
+ position: fixed;
+ }
+ .relative {
+ position: relative;
+ }
+ .static {
+ position: static;
+ }
+ .sticky {
+ position: sticky;
+ }
+ .isolate {
+ isolation: isolate;
+ }
+ .isolation-auto {
+ isolation: auto;
+ }
+ .container {
+ width: 100%;
+ @media (width >= 40rem) {
+ max-width: 40rem;
+ }
+ @media (width >= 48rem) {
+ max-width: 48rem;
+ }
+ @media (width >= 64rem) {
+ max-width: 64rem;
+ }
+ @media (width >= 80rem) {
+ max-width: 80rem;
+ }
+ @media (width >= 96rem) {
+ max-width: 96rem;
+ }
+ }
+ .mx-auto {
+ margin-inline: auto;
+ }
+ .my-6 {
+ margin-block: calc(var(--spacing) * 6);
+ }
+ .mt-1 {
+ margin-top: calc(var(--spacing) * 1);
+ }
+ .mt-3 {
+ margin-top: calc(var(--spacing) * 3);
+ }
+ .mt-4 {
+ margin-top: calc(var(--spacing) * 4);
+ }
+ .mt-5 {
+ margin-top: calc(var(--spacing) * 5);
+ }
+ .mt-6 {
+ margin-top: calc(var(--spacing) * 6);
+ }
+ .mb-2 {
+ margin-bottom: calc(var(--spacing) * 2);
+ }
+ .mb-3 {
+ margin-bottom: calc(var(--spacing) * 3);
+ }
+ .mb-4 {
+ margin-bottom: calc(var(--spacing) * 4);
+ }
+ .mb-6 {
+ margin-bottom: calc(var(--spacing) * 6);
+ }
+ .mb-8 {
+ margin-bottom: calc(var(--spacing) * 8);
+ }
+ .ml-4 {
+ margin-left: calc(var(--spacing) * 4);
+ }
+ .block {
+ display: block;
+ }
+ .contents {
+ display: contents;
+ }
+ .flex {
+ display: flex;
+ }
+ .flow-root {
+ display: flow-root;
+ }
+ .grid {
+ display: grid;
+ }
+ .hidden {
+ display: none;
+ }
+ .inline {
+ display: inline;
+ }
+ .inline-block {
+ display: inline-block;
+ }
+ .inline-flex {
+ display: inline-flex;
+ }
+ .inline-grid {
+ display: inline-grid;
+ }
+ .inline-table {
+ display: inline-table;
+ }
+ .list-item {
+ display: list-item;
+ }
+ .table {
+ display: table;
+ }
+ .table-caption {
+ display: table-caption;
+ }
+ .table-cell {
+ display: table-cell;
+ }
+ .table-column {
+ display: table-column;
+ }
+ .table-column-group {
+ display: table-column-group;
+ }
+ .table-footer-group {
+ display: table-footer-group;
+ }
+ .table-header-group {
+ display: table-header-group;
+ }
+ .table-row {
+ display: table-row;
+ }
+ .table-row-group {
+ display: table-row-group;
+ }
+ .h-2 {
+ height: calc(var(--spacing) * 2);
+ }
+ .h-32 {
+ height: calc(var(--spacing) * 32);
+ }
+ .h-96 {
+ height: calc(var(--spacing) * 96);
+ }
+ .min-h-full {
+ min-height: 100%;
+ }
+ .min-h-screen {
+ min-height: 100vh;
+ }
+ .w-2 {
+ width: calc(var(--spacing) * 2);
+ }
+ .w-full {
+ width: 100%;
+ }
+ .max-w-6xl {
+ max-width: var(--container-6xl);
+ }
+ .max-w-none {
+ max-width: none;
+ }
+ .min-w-full {
+ min-width: 100%;
+ }
+ .shrink {
+ flex-shrink: 1;
+ }
+ .grow {
+ flex-grow: 1;
+ }
+ .border-collapse {
+ border-collapse: collapse;
+ }
+ .translate-none {
+ translate: none;
+ }
+ .scale-3d {
+ scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z);
+ }
+ .transform {
+ transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
+ }
+ .touch-pinch-zoom {
+ --tw-pinch-zoom: pinch-zoom;
+ touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,);
+ }
+ .resize {
+ resize: both;
+ }
+ .list-inside {
+ list-style-position: inside;
+ }
+ .list-decimal {
+ list-style-type: decimal;
+ }
+ .list-disc {
+ list-style-type: disc;
+ }
+ .grid-cols-1 {
+ grid-template-columns: repeat(1, minmax(0, 1fr));
+ }
+ .flex-wrap {
+ flex-wrap: wrap;
+ }
+ .items-center {
+ align-items: center;
+ }
+ .items-start {
+ align-items: flex-start;
+ }
+ .gap-2 {
+ gap: calc(var(--spacing) * 2);
+ }
+ .gap-4 {
+ gap: calc(var(--spacing) * 4);
+ }
+ .gap-6 {
+ gap: calc(var(--spacing) * 6);
+ }
+ .space-y-1 {
+ :where(& > :not(:last-child)) {
+ --tw-space-y-reverse: 0;
+ margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));
+ margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));
+ }
+ }
+ .space-y-2 {
+ :where(& > :not(:last-child)) {
+ --tw-space-y-reverse: 0;
+ margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));
+ margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
+ }
+ }
+ .space-y-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-space-y-reverse: 1;
+ }
+ }
+ .space-x-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-space-x-reverse: 1;
+ }
+ }
+ .divide-x {
+ :where(& > :not(:last-child)) {
+ --tw-divide-x-reverse: 0;
+ border-inline-style: var(--tw-border-style);
+ border-inline-start-width: calc(1px * var(--tw-divide-x-reverse));
+ border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse)));
+ }
+ }
+ .divide-y {
+ :where(& > :not(:last-child)) {
+ --tw-divide-y-reverse: 0;
+ border-bottom-style: var(--tw-border-style);
+ border-top-style: var(--tw-border-style);
+ border-top-width: calc(1px * var(--tw-divide-y-reverse));
+ border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
+ }
+ }
+ .divide-y-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-divide-y-reverse: 1;
+ }
+ }
+ .truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .overflow-auto {
+ overflow: auto;
+ }
+ .overflow-x-auto {
+ overflow-x: auto;
+ }
+ .rounded {
+ border-radius: var(--radius);
+ }
+ .rounded-full {
+ border-radius: calc(infinity * 1px);
+ }
+ .rounded-lg {
+ border-radius: var(--radius-lg);
+ }
+ .rounded-md {
+ border-radius: var(--radius-md);
+ }
+ .rounded-s {
+ border-start-start-radius: var(--radius);
+ border-end-start-radius: var(--radius);
+ }
+ .rounded-ss {
+ border-start-start-radius: var(--radius);
+ }
+ .rounded-e {
+ border-start-end-radius: var(--radius);
+ border-end-end-radius: var(--radius);
+ }
+ .rounded-se {
+ border-start-end-radius: var(--radius);
+ }
+ .rounded-ee {
+ border-end-end-radius: var(--radius);
+ }
+ .rounded-es {
+ border-end-start-radius: var(--radius);
+ }
+ .rounded-t {
+ border-top-left-radius: var(--radius);
+ border-top-right-radius: var(--radius);
+ }
+ .rounded-l {
+ border-top-left-radius: var(--radius);
+ border-bottom-left-radius: var(--radius);
+ }
+ .rounded-tl {
+ border-top-left-radius: var(--radius);
+ }
+ .rounded-r {
+ border-top-right-radius: var(--radius);
+ border-bottom-right-radius: var(--radius);
+ }
+ .rounded-tr {
+ border-top-right-radius: var(--radius);
+ }
+ .rounded-b {
+ border-bottom-right-radius: var(--radius);
+ border-bottom-left-radius: var(--radius);
+ }
+ .rounded-br {
+ border-bottom-right-radius: var(--radius);
+ }
+ .rounded-bl {
+ border-bottom-left-radius: var(--radius);
+ }
+ .border {
+ border-style: var(--tw-border-style);
+ border-width: 1px;
+ }
+ .border-x {
+ border-inline-style: var(--tw-border-style);
+ border-inline-width: 1px;
+ }
+ .border-y {
+ border-block-style: var(--tw-border-style);
+ border-block-width: 1px;
+ }
+ .border-s {
+ border-inline-start-style: var(--tw-border-style);
+ border-inline-start-width: 1px;
+ }
+ .border-e {
+ border-inline-end-style: var(--tw-border-style);
+ border-inline-end-width: 1px;
+ }
+ .border-t {
+ border-top-style: var(--tw-border-style);
+ border-top-width: 1px;
+ }
+ .border-r {
+ border-right-style: var(--tw-border-style);
+ border-right-width: 1px;
+ }
+ .border-b {
+ border-bottom-style: var(--tw-border-style);
+ border-bottom-width: 1px;
+ }
+ .border-l {
+ border-left-style: var(--tw-border-style);
+ border-left-width: 1px;
+ }
+ .border-l-4 {
+ border-left-style: var(--tw-border-style);
+ border-left-width: 4px;
+ }
+ .border-blue-200 {
+ border-color: var(--color-blue-200);
+ }
+ .border-border {
+ border-color: var(--color-border);
+ }
+ .border-gray-300 {
+ border-color: var(--color-gray-300);
+ }
+ .bg-background {
+ background-color: var(--color-background);
+ }
+ .bg-blue-50 {
+ background-color: var(--color-blue-50);
+ }
+ .bg-gray-50 {
+ background-color: var(--color-gray-50);
+ }
+ .bg-gray-400 {
+ background-color: var(--color-gray-400);
+ }
+ .bg-gray-500 {
+ background-color: var(--color-gray-500);
+ }
+ .bg-green-500 {
+ background-color: var(--color-green-500);
+ }
+ .bg-panel {
+ background-color: var(--color-panel);
+ }
+ .bg-red-500 {
+ background-color: var(--color-red-500);
+ }
+ .bg-white {
+ background-color: var(--color-white);
+ }
+ .bg-repeat {
+ background-repeat: repeat;
+ }
+ .mask-no-clip {
+ mask-clip: no-clip;
+ }
+ .mask-repeat {
+ mask-repeat: repeat;
+ }
+ .p-4 {
+ padding: calc(var(--spacing) * 4);
+ }
+ .p-6 {
+ padding: calc(var(--spacing) * 6);
+ }
+ .px-1 {
+ padding-inline: calc(var(--spacing) * 1);
+ }
+ .px-3 {
+ padding-inline: calc(var(--spacing) * 3);
+ }
+ .px-4 {
+ padding-inline: calc(var(--spacing) * 4);
+ }
+ .py-0\.5 {
+ padding-block: calc(var(--spacing) * 0.5);
+ }
+ .py-1 {
+ padding-block: calc(var(--spacing) * 1);
+ }
+ .py-2 {
+ padding-block: calc(var(--spacing) * 2);
+ }
+ .pl-4 {
+ padding-left: calc(var(--spacing) * 4);
+ }
+ .text-left {
+ text-align: left;
+ }
+ .font-mono {
+ font-family: var(--font-mono);
+ }
+ .text-2xl {
+ font-size: var(--text-2xl);
+ line-height: var(--tw-leading, var(--text-2xl--line-height));
+ }
+ .text-3xl {
+ font-size: var(--text-3xl);
+ line-height: var(--tw-leading, var(--text-3xl--line-height));
+ }
+ .text-base {
+ font-size: var(--text-base);
+ line-height: var(--tw-leading, var(--text-base--line-height));
+ }
+ .text-lg {
+ font-size: var(--text-lg);
+ line-height: var(--tw-leading, var(--text-lg--line-height));
+ }
+ .text-sm {
+ font-size: var(--text-sm);
+ line-height: var(--tw-leading, var(--text-sm--line-height));
+ }
+ .text-xl {
+ font-size: var(--text-xl);
+ line-height: var(--tw-leading, var(--text-xl--line-height));
+ }
+ .leading-relaxed {
+ --tw-leading: var(--leading-relaxed);
+ line-height: var(--leading-relaxed);
+ }
+ .font-bold {
+ --tw-font-weight: var(--font-weight-bold);
+ font-weight: var(--font-weight-bold);
+ }
+ .font-medium {
+ --tw-font-weight: var(--font-weight-medium);
+ font-weight: var(--font-weight-medium);
+ }
+ .font-semibold {
+ --tw-font-weight: var(--font-weight-semibold);
+ font-weight: var(--font-weight-semibold);
+ }
+ .text-wrap {
+ text-wrap: wrap;
+ }
+ .text-clip {
+ text-overflow: clip;
+ }
+ .text-ellipsis {
+ text-overflow: ellipsis;
+ }
+ .text-accent {
+ color: var(--color-accent);
+ }
+ .text-blue-500 {
+ color: var(--color-blue-500);
+ }
+ .text-blue-800 {
+ color: var(--color-blue-800);
+ }
+ .text-blue-900 {
+ color: var(--color-blue-900);
+ }
+ .text-gray-600 {
+ color: var(--color-gray-600);
+ }
+ .text-gray-700 {
+ color: var(--color-gray-700);
+ }
+ .text-gray-900 {
+ color: var(--color-gray-900);
+ }
+ .text-muted {
+ color: var(--color-muted);
+ }
+ .text-primary {
+ color: var(--color-primary);
+ }
+ .text-secondary {
+ color: var(--color-secondary);
+ }
+ .text-white {
+ color: var(--color-white);
+ }
+ .capitalize {
+ text-transform: capitalize;
+ }
+ .lowercase {
+ text-transform: lowercase;
+ }
+ .normal-case {
+ text-transform: none;
+ }
+ .uppercase {
+ text-transform: uppercase;
+ }
+ .italic {
+ font-style: italic;
+ }
+ .not-italic {
+ font-style: normal;
+ }
+ .diagonal-fractions {
+ --tw-numeric-fraction: diagonal-fractions;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .lining-nums {
+ --tw-numeric-figure: lining-nums;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .oldstyle-nums {
+ --tw-numeric-figure: oldstyle-nums;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .ordinal {
+ --tw-ordinal: ordinal;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .proportional-nums {
+ --tw-numeric-spacing: proportional-nums;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .slashed-zero {
+ --tw-slashed-zero: slashed-zero;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .stacked-fractions {
+ --tw-numeric-fraction: stacked-fractions;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .tabular-nums {
+ --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,);
+ }
+ .normal-nums {
+ font-variant-numeric: normal;
+ }
+ .line-through {
+ text-decoration-line: line-through;
+ }
+ .no-underline {
+ text-decoration-line: none;
+ }
+ .overline {
+ text-decoration-line: overline;
+ }
+ .underline {
+ text-decoration-line: underline;
+ }
+ .antialiased {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+ .subpixel-antialiased {
+ -webkit-font-smoothing: auto;
+ -moz-osx-font-smoothing: auto;
+ }
+ .shadow {
+ --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
+ }
+ .shadow-sm {
+ --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
+ }
+ .inset-ring {
+ --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-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);
+ }
+ .blur {
+ --tw-blur: blur(8px);
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .drop-shadow {
+ --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06)));
+ --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06));
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .invert {
+ --tw-invert: invert(100%);
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .filter {
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .backdrop-blur {
+ --tw-backdrop-blur: blur(8px);
+ -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,);
+ }
+ .backdrop-grayscale {
+ --tw-backdrop-grayscale: grayscale(100%);
+ -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,);
+ }
+ .backdrop-invert {
+ --tw-backdrop-invert: invert(100%);
+ -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,);
+ }
+ .backdrop-sepia {
+ --tw-backdrop-sepia: sepia(100%);
+ -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,);
+ }
+ .backdrop-filter {
+ -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,);
+ }
+ .transition-colors {
+ 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));
+ }
+ .ease-in {
+ --tw-ease: var(--ease-in);
+ transition-timing-function: var(--ease-in);
+ }
+ .ease-in-out {
+ --tw-ease: var(--ease-in-out);
+ transition-timing-function: var(--ease-in-out);
+ }
+ .ease-out {
+ --tw-ease: var(--ease-out);
+ transition-timing-function: var(--ease-out);
+ }
+ .divide-x-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-divide-x-reverse: 1;
+ }
+ }
+ .ring-inset {
+ --tw-ring-inset: inset;
+ }
+ .hover\:bg-gray-600 {
+ &:hover {
+ @media (hover: hover) {
+ background-color: var(--color-gray-600);
+ }
+ }
+ }
+ .hover\:bg-green-600 {
+ &:hover {
+ @media (hover: hover) {
+ background-color: var(--color-green-600);
+ }
+ }
+ }
+ .hover\:bg-red-600 {
+ &:hover {
+ @media (hover: hover) {
+ background-color: var(--color-red-600);
+ }
+ }
+ }
+ .hover\:text-accent-300 {
+ &:hover {
+ @media (hover: hover) {
+ color: var(--color-accent-300);
+ }
+ }
+ }
+ .focus\:border-blue-500 {
+ &:focus {
+ border-color: var(--color-blue-500);
+ }
+ }
+ .focus\:ring-2 {
+ &:focus {
+ --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);
+ }
+ }
+ .focus\:ring-blue-500 {
+ &:focus {
+ --tw-ring-color: var(--color-blue-500);
+ }
+ }
+ .md\:grid-cols-3 {
+ @media (width >= 48rem) {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+ }
+}
+@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-rotate-x {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-rotate-y {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-rotate-z {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-skew-x {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-skew-y {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-pan-x {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-pan-y {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-pinch-zoom {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-space-y-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-space-x-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-divide-x-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-border-style {
+ syntax: "*";
+ inherits: false;
+ initial-value: solid;
+}
+@property --tw-divide-y-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-leading {
+ 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-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: 0px;
+}
+@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-blur {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-brightness {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-contrast {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-grayscale {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-hue-rotate {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-invert {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-opacity {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-saturate {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-sepia {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow-color {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow-alpha {
+ syntax: "";
+ inherits: false;
+ initial-value: 100%;
+}
+@property --tw-drop-shadow-size {
+ syntax: "*";
+ inherits: false;
+}
+@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;
+}
+@property --tw-ease {
+ syntax: "*";
+ inherits: false;
+}
+@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-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-scale-z: 1;
+ --tw-rotate-x: initial;
+ --tw-rotate-y: initial;
+ --tw-rotate-z: initial;
+ --tw-skew-x: initial;
+ --tw-skew-y: initial;
+ --tw-pan-x: initial;
+ --tw-pan-y: initial;
+ --tw-pinch-zoom: initial;
+ --tw-space-y-reverse: 0;
+ --tw-space-x-reverse: 0;
+ --tw-divide-x-reverse: 0;
+ --tw-border-style: solid;
+ --tw-divide-y-reverse: 0;
+ --tw-leading: 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-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-blur: initial;
+ --tw-brightness: initial;
+ --tw-contrast: initial;
+ --tw-grayscale: initial;
+ --tw-hue-rotate: initial;
+ --tw-invert: initial;
+ --tw-opacity: initial;
+ --tw-saturate: initial;
+ --tw-sepia: initial;
+ --tw-drop-shadow: initial;
+ --tw-drop-shadow-color: initial;
+ --tw-drop-shadow-alpha: 100%;
+ --tw-drop-shadow-size: initial;
+ --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;
+ --tw-ease: initial;
+ }
+ }
+}
diff --git a/tsunami/demo/tabletest/app.go b/tsunami/demo/tabletest/app.go
new file mode 100644
index 0000000000..cb086f5709
--- /dev/null
+++ b/tsunami/demo/tabletest/app.go
@@ -0,0 +1,114 @@
+package main
+
+import (
+ "fmt"
+
+ "github.com/wavetermdev/waveterm/tsunami/app"
+ "github.com/wavetermdev/waveterm/tsunami/ui"
+ "github.com/wavetermdev/waveterm/tsunami/vdom"
+)
+
+// Sample data structure for the table
+type Person struct {
+ Name string `json:"name"`
+ Age int `json:"age"`
+ Email string `json:"email"`
+ City string `json:"city"`
+}
+
+// Create the table component for Person data
+var PersonTable = ui.MakeTableComponent[Person]("PersonTable")
+
+// Sample data exposed as DataAtom for external system access
+var sampleData = app.DataAtom("sampleData", []Person{
+ {Name: "Alice Johnson", Age: 28, Email: "alice@example.com", City: "New York"},
+ {Name: "Bob Smith", Age: 34, Email: "bob@example.com", City: "Los Angeles"},
+ {Name: "Carol Davis", Age: 22, Email: "carol@example.com", City: "Chicago"},
+ {Name: "David Wilson", Age: 41, Email: "david@example.com", City: "Houston"},
+ {Name: "Eve Brown", Age: 29, Email: "eve@example.com", City: "Phoenix"},
+ {Name: "Frank Miller", Age: 37, Email: "frank@example.com", City: "Philadelphia"},
+ {Name: "Grace Lee", Age: 25, Email: "grace@example.com", City: "San Antonio"},
+ {Name: "Henry Taylor", Age: 33, Email: "henry@example.com", City: "San Diego"},
+ {Name: "Ivy Chen", Age: 26, Email: "ivy@example.com", City: "Dallas"},
+ {Name: "Jack Anderson", Age: 31, Email: "jack@example.com", City: "San Jose"},
+})
+
+// The App component is the required entry point for every Tsunami application
+var App = app.DefineComponent("App", func(_ struct{}) any {
+ app.UseSetAppTitle("Table Test Demo")
+
+ // Define table columns
+ columns := []ui.TableColumn[Person]{
+ {
+ AccessorKey: "Name",
+ Header: "Full Name",
+ Sortable: true,
+ Width: "200px",
+ },
+ {
+ AccessorKey: "Age",
+ Header: "Age",
+ Sortable: true,
+ Width: "80px",
+ },
+ {
+ AccessorKey: "Email",
+ Header: "Email Address",
+ Sortable: true,
+ Width: "250px",
+ },
+ {
+ AccessorKey: "City",
+ Header: "City",
+ Sortable: true,
+ Width: "150px",
+ },
+ }
+
+ // Handle row clicks
+ handleRowClick := func(person Person, idx int) {
+ fmt.Printf("Clicked on row %d: %s from %s\n", idx, person.Name, person.City)
+ }
+
+ // Handle sorting
+ handleSort := func(column string, direction string) {
+ fmt.Printf("Sorting by %s in %s order\n", column, direction)
+ }
+
+ return vdom.H("div", map[string]any{
+ "className": "max-w-6xl mx-auto p-6 space-y-6",
+ },
+ vdom.H("div", map[string]any{
+ "className": "text-center",
+ },
+ vdom.H("h1", map[string]any{
+ "className": "text-3xl font-bold text-white mb-2",
+ }, "Table Component Demo"),
+ vdom.H("p", map[string]any{
+ "className": "text-gray-300",
+ }, "Testing the Tsunami table component with sample data"),
+ ),
+
+ vdom.H("div", map[string]any{
+ "className": "bg-gray-800 p-4 rounded-lg",
+ },
+ PersonTable(ui.TableProps[Person]{
+ Data: sampleData.Get(),
+ Columns: columns,
+ OnRowClick: handleRowClick,
+ OnSort: handleSort,
+ DefaultSort: "Name",
+ Selectable: true,
+ Pagination: &ui.PaginationConfig{
+ PageSize: 5,
+ CurrentPage: 0,
+ ShowSizes: []int{5, 10, 25},
+ },
+ }),
+ ),
+
+ vdom.H("div", map[string]any{
+ "className": "text-center text-gray-400 text-sm",
+ }, "Click on rows to see interactions. Try sorting by clicking column headers."),
+ )
+})
\ No newline at end of file
diff --git a/tsunami/demo/tabletest/go.mod b/tsunami/demo/tabletest/go.mod
new file mode 100644
index 0000000000..86b7209a5a
--- /dev/null
+++ b/tsunami/demo/tabletest/go.mod
@@ -0,0 +1,12 @@
+module tsunami/app/tabletest
+
+go 1.24.6
+
+require github.com/wavetermdev/waveterm/tsunami v0.0.0
+
+require (
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/outrigdev/goid v0.3.0 // indirect
+)
+
+replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami
diff --git a/tsunami/demo/tabletest/go.sum b/tsunami/demo/tabletest/go.sum
new file mode 100644
index 0000000000..4c44991dfc
--- /dev/null
+++ b/tsunami/demo/tabletest/go.sum
@@ -0,0 +1,4 @@
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=
+github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=
diff --git a/tsunami/demo/tabletest/static/tw.css b/tsunami/demo/tabletest/static/tw.css
new file mode 100644
index 0000000000..96e58c618c
--- /dev/null
+++ b/tsunami/demo/tabletest/static/tw.css
@@ -0,0 +1,1292 @@
+/*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */
+@layer properties;
+@layer theme, base, components, utilities;
+@layer theme {
+ :root, :host {
+ --font-sans: "Inter", sans-serif;
+ --font-mono: "Hack", monospace;
+ --color-red-100: oklch(93.6% 0.032 17.717);
+ --color-red-500: oklch(63.7% 0.237 25.331);
+ --color-red-800: oklch(44.4% 0.177 26.899);
+ --color-blue-400: oklch(70.7% 0.165 254.624);
+ --color-blue-500: oklch(62.3% 0.214 259.815);
+ --color-blue-600: oklch(54.6% 0.245 262.881);
+ --color-blue-700: oklch(48.8% 0.243 264.376);
+ --color-blue-900: oklch(37.9% 0.146 265.522);
+ --color-gray-200: oklch(92.8% 0.006 264.531);
+ --color-gray-300: oklch(87.2% 0.01 258.338);
+ --color-gray-400: oklch(70.7% 0.022 261.325);
+ --color-gray-500: oklch(55.1% 0.027 264.364);
+ --color-gray-600: oklch(44.6% 0.03 256.802);
+ --color-gray-700: oklch(37.3% 0.034 259.733);
+ --color-gray-800: oklch(27.8% 0.033 256.848);
+ --color-gray-900: oklch(21% 0.034 264.665);
+ --color-white: #fff;
+ --spacing: 0.25rem;
+ --container-6xl: 72rem;
+ --text-sm: 0.875rem;
+ --text-sm--line-height: calc(1.25 / 0.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);
+ --text-xl: 1.25rem;
+ --text-xl--line-height: calc(1.75 / 1.25);
+ --text-2xl: 1.5rem;
+ --text-2xl--line-height: calc(2 / 1.5);
+ --text-3xl: 1.875rem;
+ --text-3xl--line-height: calc(2.25 / 1.875);
+ --font-weight-semibold: 600;
+ --font-weight-bold: 700;
+ --leading-relaxed: 1.625;
+ --radius-lg: 0.5rem;
+ --ease-in: cubic-bezier(0.4, 0, 1, 1);
+ --ease-out: cubic-bezier(0, 0, 0.2, 1);
+ --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
+ --default-transition-duration: 150ms;
+ --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ --default-font-family: var(--font-sans);
+ --default-mono-font-family: var(--font-mono);
+ --radius: 8px;
+ --color-background: rgb(34, 34, 34);
+ --color-primary: rgb(247, 247, 247);
+ --color-secondary: rgba(215, 218, 224, 0.7);
+ --color-muted: rgba(215, 218, 224, 0.5);
+ --color-accent-300: rgb(110, 231, 133);
+ --color-panel: rgba(255, 255, 255, 0.12);
+ --color-border: rgba(255, 255, 255, 0.16);
+ --color-accent: rgb(88, 193, 66);
+ }
+}
+@layer base {
+ *, ::after, ::before, ::backdrop, ::file-selector-button {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ border: 0 solid;
+ }
+ html, :host {
+ line-height: 1.5;
+ -webkit-text-size-adjust: 100%;
+ tab-size: 4;
+ 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;
+ 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 {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+ }
+ sub {
+ bottom: -0.25em;
+ }
+ sup {
+ top: -0.5em;
+ }
+ table {
+ text-indent: 0;
+ border-color: inherit;
+ border-collapse: collapse;
+ }
+ :-moz-focusring {
+ outline: auto;
+ }
+ progress {
+ vertical-align: baseline;
+ }
+ summary {
+ display: list-item;
+ }
+ ol, ul, menu {
+ list-style: none;
+ }
+ img, svg, video, canvas, audio, iframe, embed, object {
+ display: block;
+ vertical-align: middle;
+ }
+ img, video {
+ max-width: 100%;
+ height: auto;
+ }
+ button, input, select, optgroup, textarea, ::file-selector-button {
+ font: inherit;
+ font-feature-settings: inherit;
+ font-variation-settings: inherit;
+ letter-spacing: inherit;
+ color: inherit;
+ border-radius: 0;
+ background-color: transparent;
+ opacity: 1;
+ }
+ :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)) {
+ 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, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-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"]), ::file-selector-button {
+ appearance: button;
+ }
+ ::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
+ height: auto;
+ }
+ [hidden]:where(:not([hidden="until-found"])) {
+ display: none !important;
+ }
+}
+@layer utilities {
+ .collapse {
+ visibility: collapse;
+ }
+ .invisible {
+ visibility: hidden;
+ }
+ .visible {
+ visibility: visible;
+ }
+ .sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border-width: 0;
+ }
+ .not-sr-only {
+ position: static;
+ width: auto;
+ height: auto;
+ padding: 0;
+ margin: 0;
+ overflow: visible;
+ clip-path: none;
+ white-space: normal;
+ }
+ .absolute {
+ position: absolute;
+ }
+ .fixed {
+ position: fixed;
+ }
+ .relative {
+ position: relative;
+ }
+ .static {
+ position: static;
+ }
+ .sticky {
+ position: sticky;
+ }
+ .isolate {
+ isolation: isolate;
+ }
+ .isolation-auto {
+ isolation: auto;
+ }
+ .container {
+ width: 100%;
+ @media (width >= 40rem) {
+ max-width: 40rem;
+ }
+ @media (width >= 48rem) {
+ max-width: 48rem;
+ }
+ @media (width >= 64rem) {
+ max-width: 64rem;
+ }
+ @media (width >= 80rem) {
+ max-width: 80rem;
+ }
+ @media (width >= 96rem) {
+ max-width: 96rem;
+ }
+ }
+ .mx-1 {
+ margin-inline: calc(var(--spacing) * 1);
+ }
+ .mx-auto {
+ margin-inline: auto;
+ }
+ .my-6 {
+ margin-block: calc(var(--spacing) * 6);
+ }
+ .mt-3 {
+ margin-top: calc(var(--spacing) * 3);
+ }
+ .mt-4 {
+ margin-top: calc(var(--spacing) * 4);
+ }
+ .mt-5 {
+ margin-top: calc(var(--spacing) * 5);
+ }
+ .mt-6 {
+ margin-top: calc(var(--spacing) * 6);
+ }
+ .mb-2 {
+ margin-bottom: calc(var(--spacing) * 2);
+ }
+ .mb-3 {
+ margin-bottom: calc(var(--spacing) * 3);
+ }
+ .mb-4 {
+ margin-bottom: calc(var(--spacing) * 4);
+ }
+ .ml-4 {
+ margin-left: calc(var(--spacing) * 4);
+ }
+ .block {
+ display: block;
+ }
+ .contents {
+ display: contents;
+ }
+ .flex {
+ display: flex;
+ }
+ .flow-root {
+ display: flow-root;
+ }
+ .grid {
+ display: grid;
+ }
+ .hidden {
+ display: none;
+ }
+ .inline {
+ display: inline;
+ }
+ .inline-block {
+ display: inline-block;
+ }
+ .inline-flex {
+ display: inline-flex;
+ }
+ .inline-grid {
+ display: inline-grid;
+ }
+ .inline-table {
+ display: inline-table;
+ }
+ .list-item {
+ display: list-item;
+ }
+ .table {
+ display: table;
+ }
+ .table-caption {
+ display: table-caption;
+ }
+ .table-cell {
+ display: table-cell;
+ }
+ .table-column {
+ display: table-column;
+ }
+ .table-column-group {
+ display: table-column-group;
+ }
+ .table-footer-group {
+ display: table-footer-group;
+ }
+ .table-header-group {
+ display: table-header-group;
+ }
+ .table-row {
+ display: table-row;
+ }
+ .table-row-group {
+ display: table-row-group;
+ }
+ .min-h-full {
+ min-height: 100%;
+ }
+ .min-h-screen {
+ min-height: 100vh;
+ }
+ .w-full {
+ width: 100%;
+ }
+ .max-w-6xl {
+ max-width: var(--container-6xl);
+ }
+ .max-w-none {
+ max-width: none;
+ }
+ .min-w-full {
+ min-width: 100%;
+ }
+ .shrink {
+ flex-shrink: 1;
+ }
+ .grow {
+ flex-grow: 1;
+ }
+ .border-collapse {
+ border-collapse: collapse;
+ }
+ .translate-none {
+ translate: none;
+ }
+ .scale-3d {
+ scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z);
+ }
+ .transform {
+ transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
+ }
+ .cursor-pointer {
+ cursor: pointer;
+ }
+ .touch-pinch-zoom {
+ --tw-pinch-zoom: pinch-zoom;
+ touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,);
+ }
+ .resize {
+ resize: both;
+ }
+ .list-inside {
+ list-style-position: inside;
+ }
+ .list-decimal {
+ list-style-type: decimal;
+ }
+ .list-disc {
+ list-style-type: disc;
+ }
+ .flex-wrap {
+ flex-wrap: wrap;
+ }
+ .items-center {
+ align-items: center;
+ }
+ .justify-between {
+ justify-content: space-between;
+ }
+ .gap-2 {
+ gap: calc(var(--spacing) * 2);
+ }
+ .gap-3 {
+ gap: calc(var(--spacing) * 3);
+ }
+ .space-y-1 {
+ :where(& > :not(:last-child)) {
+ --tw-space-y-reverse: 0;
+ margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));
+ margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));
+ }
+ }
+ .space-y-6 {
+ :where(& > :not(:last-child)) {
+ --tw-space-y-reverse: 0;
+ margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));
+ margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)));
+ }
+ }
+ .space-y-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-space-y-reverse: 1;
+ }
+ }
+ .space-x-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-space-x-reverse: 1;
+ }
+ }
+ .divide-x {
+ :where(& > :not(:last-child)) {
+ --tw-divide-x-reverse: 0;
+ border-inline-style: var(--tw-border-style);
+ border-inline-start-width: calc(1px * var(--tw-divide-x-reverse));
+ border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse)));
+ }
+ }
+ .divide-y {
+ :where(& > :not(:last-child)) {
+ --tw-divide-y-reverse: 0;
+ border-bottom-style: var(--tw-border-style);
+ border-top-style: var(--tw-border-style);
+ border-top-width: calc(1px * var(--tw-divide-y-reverse));
+ border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
+ }
+ }
+ .divide-y-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-divide-y-reverse: 1;
+ }
+ }
+ .divide-gray-700 {
+ :where(& > :not(:last-child)) {
+ border-color: var(--color-gray-700);
+ }
+ }
+ .truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .overflow-auto {
+ overflow: auto;
+ }
+ .overflow-x-auto {
+ overflow-x: auto;
+ }
+ .rounded {
+ border-radius: var(--radius);
+ }
+ .rounded-lg {
+ border-radius: var(--radius-lg);
+ }
+ .rounded-s {
+ border-start-start-radius: var(--radius);
+ border-end-start-radius: var(--radius);
+ }
+ .rounded-ss {
+ border-start-start-radius: var(--radius);
+ }
+ .rounded-e {
+ border-start-end-radius: var(--radius);
+ border-end-end-radius: var(--radius);
+ }
+ .rounded-se {
+ border-start-end-radius: var(--radius);
+ }
+ .rounded-ee {
+ border-end-end-radius: var(--radius);
+ }
+ .rounded-es {
+ border-end-start-radius: var(--radius);
+ }
+ .rounded-t {
+ border-top-left-radius: var(--radius);
+ border-top-right-radius: var(--radius);
+ }
+ .rounded-l {
+ border-top-left-radius: var(--radius);
+ border-bottom-left-radius: var(--radius);
+ }
+ .rounded-tl {
+ border-top-left-radius: var(--radius);
+ }
+ .rounded-r {
+ border-top-right-radius: var(--radius);
+ border-bottom-right-radius: var(--radius);
+ }
+ .rounded-tr {
+ border-top-right-radius: var(--radius);
+ }
+ .rounded-b {
+ border-bottom-right-radius: var(--radius);
+ border-bottom-left-radius: var(--radius);
+ }
+ .rounded-br {
+ border-bottom-right-radius: var(--radius);
+ }
+ .rounded-bl {
+ border-bottom-left-radius: var(--radius);
+ }
+ .border {
+ border-style: var(--tw-border-style);
+ border-width: 1px;
+ }
+ .border-x {
+ border-inline-style: var(--tw-border-style);
+ border-inline-width: 1px;
+ }
+ .border-y {
+ border-block-style: var(--tw-border-style);
+ border-block-width: 1px;
+ }
+ .border-s {
+ border-inline-start-style: var(--tw-border-style);
+ border-inline-start-width: 1px;
+ }
+ .border-e {
+ border-inline-end-style: var(--tw-border-style);
+ border-inline-end-width: 1px;
+ }
+ .border-t {
+ border-top-style: var(--tw-border-style);
+ border-top-width: 1px;
+ }
+ .border-r {
+ border-right-style: var(--tw-border-style);
+ border-right-width: 1px;
+ }
+ .border-b {
+ border-bottom-style: var(--tw-border-style);
+ border-bottom-width: 1px;
+ }
+ .border-l {
+ border-left-style: var(--tw-border-style);
+ border-left-width: 1px;
+ }
+ .border-l-4 {
+ border-left-style: var(--tw-border-style);
+ border-left-width: 4px;
+ }
+ .border-border {
+ border-color: var(--color-border);
+ }
+ .border-gray-600 {
+ border-color: var(--color-gray-600);
+ }
+ .border-red-500 {
+ border-color: var(--color-red-500);
+ }
+ .bg-background {
+ background-color: var(--color-background);
+ }
+ .bg-blue-600 {
+ background-color: var(--color-blue-600);
+ }
+ .bg-blue-900 {
+ background-color: var(--color-blue-900);
+ }
+ .bg-gray-600 {
+ background-color: var(--color-gray-600);
+ }
+ .bg-gray-700 {
+ background-color: var(--color-gray-700);
+ }
+ .bg-gray-800 {
+ background-color: var(--color-gray-800);
+ }
+ .bg-gray-900 {
+ background-color: var(--color-gray-900);
+ }
+ .bg-panel {
+ background-color: var(--color-panel);
+ }
+ .bg-red-100 {
+ background-color: var(--color-red-100);
+ }
+ .bg-repeat {
+ background-repeat: repeat;
+ }
+ .mask-no-clip {
+ mask-clip: no-clip;
+ }
+ .mask-repeat {
+ mask-repeat: repeat;
+ }
+ .p-3 {
+ padding: calc(var(--spacing) * 3);
+ }
+ .p-4 {
+ padding: calc(var(--spacing) * 4);
+ }
+ .p-6 {
+ padding: calc(var(--spacing) * 6);
+ }
+ .px-1 {
+ padding-inline: calc(var(--spacing) * 1);
+ }
+ .px-2 {
+ padding-inline: calc(var(--spacing) * 2);
+ }
+ .px-3 {
+ padding-inline: calc(var(--spacing) * 3);
+ }
+ .px-4 {
+ padding-inline: calc(var(--spacing) * 4);
+ }
+ .py-0\.5 {
+ padding-block: calc(var(--spacing) * 0.5);
+ }
+ .py-1 {
+ padding-block: calc(var(--spacing) * 1);
+ }
+ .py-1\.5 {
+ padding-block: calc(var(--spacing) * 1.5);
+ }
+ .py-2 {
+ padding-block: calc(var(--spacing) * 2);
+ }
+ .py-3 {
+ padding-block: calc(var(--spacing) * 3);
+ }
+ .pl-4 {
+ padding-left: calc(var(--spacing) * 4);
+ }
+ .text-center {
+ text-align: center;
+ }
+ .text-left {
+ text-align: left;
+ }
+ .font-mono {
+ font-family: var(--font-mono);
+ }
+ .text-2xl {
+ font-size: var(--text-2xl);
+ line-height: var(--tw-leading, var(--text-2xl--line-height));
+ }
+ .text-3xl {
+ font-size: var(--text-3xl);
+ line-height: var(--tw-leading, var(--text-3xl--line-height));
+ }
+ .text-base {
+ font-size: var(--text-base);
+ line-height: var(--tw-leading, var(--text-base--line-height));
+ }
+ .text-lg {
+ font-size: var(--text-lg);
+ line-height: var(--tw-leading, var(--text-lg--line-height));
+ }
+ .text-sm {
+ font-size: var(--text-sm);
+ line-height: var(--tw-leading, var(--text-sm--line-height));
+ }
+ .text-xl {
+ font-size: var(--text-xl);
+ line-height: var(--tw-leading, var(--text-xl--line-height));
+ }
+ .leading-relaxed {
+ --tw-leading: var(--leading-relaxed);
+ line-height: var(--leading-relaxed);
+ }
+ .font-bold {
+ --tw-font-weight: var(--font-weight-bold);
+ font-weight: var(--font-weight-bold);
+ }
+ .font-semibold {
+ --tw-font-weight: var(--font-weight-semibold);
+ font-weight: var(--font-weight-semibold);
+ }
+ .text-wrap {
+ text-wrap: wrap;
+ }
+ .text-clip {
+ text-overflow: clip;
+ }
+ .text-ellipsis {
+ text-overflow: ellipsis;
+ }
+ .text-accent {
+ color: var(--color-accent);
+ }
+ .text-blue-400 {
+ color: var(--color-blue-400);
+ }
+ .text-gray-200 {
+ color: var(--color-gray-200);
+ }
+ .text-gray-300 {
+ color: var(--color-gray-300);
+ }
+ .text-gray-400 {
+ color: var(--color-gray-400);
+ }
+ .text-gray-500 {
+ color: var(--color-gray-500);
+ }
+ .text-muted {
+ color: var(--color-muted);
+ }
+ .text-primary {
+ color: var(--color-primary);
+ }
+ .text-red-800 {
+ color: var(--color-red-800);
+ }
+ .text-secondary {
+ color: var(--color-secondary);
+ }
+ .text-white {
+ color: var(--color-white);
+ }
+ .capitalize {
+ text-transform: capitalize;
+ }
+ .lowercase {
+ text-transform: lowercase;
+ }
+ .normal-case {
+ text-transform: none;
+ }
+ .uppercase {
+ text-transform: uppercase;
+ }
+ .italic {
+ font-style: italic;
+ }
+ .not-italic {
+ font-style: normal;
+ }
+ .diagonal-fractions {
+ --tw-numeric-fraction: diagonal-fractions;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .lining-nums {
+ --tw-numeric-figure: lining-nums;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .oldstyle-nums {
+ --tw-numeric-figure: oldstyle-nums;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .ordinal {
+ --tw-ordinal: ordinal;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .proportional-nums {
+ --tw-numeric-spacing: proportional-nums;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .slashed-zero {
+ --tw-slashed-zero: slashed-zero;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .stacked-fractions {
+ --tw-numeric-fraction: stacked-fractions;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .tabular-nums {
+ --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,);
+ }
+ .normal-nums {
+ font-variant-numeric: normal;
+ }
+ .line-through {
+ text-decoration-line: line-through;
+ }
+ .no-underline {
+ text-decoration-line: none;
+ }
+ .overline {
+ text-decoration-line: overline;
+ }
+ .underline {
+ text-decoration-line: underline;
+ }
+ .antialiased {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+ .subpixel-antialiased {
+ -webkit-font-smoothing: auto;
+ -moz-osx-font-smoothing: auto;
+ }
+ .shadow {
+ --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
+ }
+ .inset-ring {
+ --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-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);
+ }
+ .blur {
+ --tw-blur: blur(8px);
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .drop-shadow {
+ --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06)));
+ --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06));
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .invert {
+ --tw-invert: invert(100%);
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .filter {
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .backdrop-blur {
+ --tw-backdrop-blur: blur(8px);
+ -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,);
+ }
+ .backdrop-grayscale {
+ --tw-backdrop-grayscale: grayscale(100%);
+ -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,);
+ }
+ .backdrop-invert {
+ --tw-backdrop-invert: invert(100%);
+ -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,);
+ }
+ .backdrop-sepia {
+ --tw-backdrop-sepia: sepia(100%);
+ -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,);
+ }
+ .backdrop-filter {
+ -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,);
+ }
+ .transition-colors {
+ 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));
+ }
+ .ease-in {
+ --tw-ease: var(--ease-in);
+ transition-timing-function: var(--ease-in);
+ }
+ .ease-in-out {
+ --tw-ease: var(--ease-in-out);
+ transition-timing-function: var(--ease-in-out);
+ }
+ .ease-out {
+ --tw-ease: var(--ease-out);
+ transition-timing-function: var(--ease-out);
+ }
+ .divide-x-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-divide-x-reverse: 1;
+ }
+ }
+ .ring-inset {
+ --tw-ring-inset: inset;
+ }
+ .hover\:bg-blue-700 {
+ &:hover {
+ @media (hover: hover) {
+ background-color: var(--color-blue-700);
+ }
+ }
+ }
+ .hover\:bg-gray-700 {
+ &:hover {
+ @media (hover: hover) {
+ background-color: var(--color-gray-700);
+ }
+ }
+ }
+ .hover\:bg-gray-800 {
+ &:hover {
+ @media (hover: hover) {
+ background-color: var(--color-gray-800);
+ }
+ }
+ }
+ .hover\:text-accent-300 {
+ &:hover {
+ @media (hover: hover) {
+ color: var(--color-accent-300);
+ }
+ }
+ }
+ .focus\:bg-gray-600 {
+ &:focus {
+ background-color: var(--color-gray-600);
+ }
+ }
+ .focus\:ring-1 {
+ &:focus {
+ --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);
+ }
+ }
+ .focus\:ring-blue-500 {
+ &:focus {
+ --tw-ring-color: var(--color-blue-500);
+ }
+ }
+ .focus\:outline-none {
+ &:focus {
+ --tw-outline-style: none;
+ outline-style: none;
+ }
+ }
+}
+@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-rotate-x {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-rotate-y {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-rotate-z {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-skew-x {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-skew-y {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-pan-x {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-pan-y {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-pinch-zoom {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-space-y-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-space-x-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-divide-x-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-border-style {
+ syntax: "*";
+ inherits: false;
+ initial-value: solid;
+}
+@property --tw-divide-y-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-leading {
+ 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-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: 0px;
+}
+@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-blur {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-brightness {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-contrast {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-grayscale {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-hue-rotate {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-invert {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-opacity {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-saturate {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-sepia {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow-color {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow-alpha {
+ syntax: "";
+ inherits: false;
+ initial-value: 100%;
+}
+@property --tw-drop-shadow-size {
+ syntax: "*";
+ inherits: false;
+}
+@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;
+}
+@property --tw-ease {
+ syntax: "*";
+ inherits: false;
+}
+@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-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-scale-z: 1;
+ --tw-rotate-x: initial;
+ --tw-rotate-y: initial;
+ --tw-rotate-z: initial;
+ --tw-skew-x: initial;
+ --tw-skew-y: initial;
+ --tw-pan-x: initial;
+ --tw-pan-y: initial;
+ --tw-pinch-zoom: initial;
+ --tw-space-y-reverse: 0;
+ --tw-space-x-reverse: 0;
+ --tw-divide-x-reverse: 0;
+ --tw-border-style: solid;
+ --tw-divide-y-reverse: 0;
+ --tw-leading: 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-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-blur: initial;
+ --tw-brightness: initial;
+ --tw-contrast: initial;
+ --tw-grayscale: initial;
+ --tw-hue-rotate: initial;
+ --tw-invert: initial;
+ --tw-opacity: initial;
+ --tw-saturate: initial;
+ --tw-sepia: initial;
+ --tw-drop-shadow: initial;
+ --tw-drop-shadow-color: initial;
+ --tw-drop-shadow-alpha: 100%;
+ --tw-drop-shadow-size: initial;
+ --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;
+ --tw-ease: initial;
+ }
+ }
+}
diff --git a/tsunami/demo/todo/app.go b/tsunami/demo/todo/app.go
new file mode 100644
index 0000000000..82c5ac6dc8
--- /dev/null
+++ b/tsunami/demo/todo/app.go
@@ -0,0 +1,180 @@
+package main
+
+import (
+ _ "embed"
+ "strconv"
+
+ "github.com/wavetermdev/waveterm/tsunami/app"
+ "github.com/wavetermdev/waveterm/tsunami/vdom"
+)
+
+// Basic domain types with json tags for props
+type Todo struct {
+ Id int `json:"id"`
+ Text string `json:"text"`
+ Completed bool `json:"completed"`
+}
+
+// Prop types demonstrate parent->child data flow
+type TodoListProps struct {
+ Todos []Todo `json:"todos"`
+ OnToggle func(int) `json:"onToggle"`
+ OnDelete func(int) `json:"onDelete"`
+}
+
+type TodoItemProps struct {
+ Todo Todo `json:"todo"`
+ OnToggle func() `json:"onToggle"`
+ OnDelete func() `json:"onDelete"`
+}
+
+type InputFieldProps struct {
+ Value string `json:"value"`
+ OnChange func(string) `json:"onChange"`
+ OnEnter func() `json:"onEnter"`
+}
+
+// Reusable input component showing keyboard event handling
+var InputField = app.DefineComponent("InputField", func(props InputFieldProps) any {
+ // Example of special key handling with VDomFunc
+ keyDown := &vdom.VDomFunc{
+ Type: vdom.ObjectType_Func,
+ Fn: func(event vdom.VDomEvent) { props.OnEnter() },
+ StopPropagation: true,
+ PreventDefault: true,
+ Keys: []string{"Enter", "Cmd:Enter"},
+ }
+
+ return vdom.H("input", map[string]any{
+ "className": "flex-1 p-2 border border-border rounded",
+ "type": "text",
+ "placeholder": "What needs to be done?",
+ "value": props.Value,
+ "onChange": func(e vdom.VDomEvent) {
+ props.OnChange(e.TargetValue)
+ },
+ "onKeyDown": keyDown,
+ })
+},
+)
+
+// Item component showing conditional classes and event handling
+var TodoItem = app.DefineComponent("TodoItem", func(props TodoItemProps) any {
+ return vdom.H("div", map[string]any{
+ "className": vdom.Classes("flex items-center gap-2.5 p-2 border border-border rounded", vdom.If(props.Todo.Completed, "opacity-70")),
+ },
+ vdom.H("input", map[string]any{
+ "className": "w-4 h-4",
+ "type": "checkbox",
+ "checked": props.Todo.Completed,
+ "onChange": props.OnToggle,
+ }),
+ vdom.H("span", map[string]any{
+ "className": vdom.Classes("flex-1", vdom.If(props.Todo.Completed, "line-through")),
+ }, props.Todo.Text),
+ vdom.H("button", map[string]any{
+ "className": "text-red-500 cursor-pointer px-2 py-1 rounded",
+ "onClick": props.OnDelete,
+ }, "×"),
+ )
+},
+)
+
+// List component demonstrating mapping over data, using WithKey to set key on a component
+var TodoList = app.DefineComponent("TodoList", func(props TodoListProps) any {
+ return vdom.H("div", map[string]any{
+ "className": "flex flex-col gap-2",
+ }, vdom.ForEach(props.Todos, func(todo Todo, _ int) any {
+ return TodoItem(TodoItemProps{
+ Todo: todo,
+ OnToggle: func() { props.OnToggle(todo.Id) },
+ OnDelete: func() { props.OnDelete(todo.Id) },
+ }).WithKey(strconv.Itoa(todo.Id))
+ }))
+},
+)
+
+// Root component showing state management and composition
+var App = app.DefineComponent("App", func(_ any) any {
+ app.UseSetAppTitle("Todo App (Tsunami Demo)")
+
+ // Multiple local atoms example
+ todosAtom := app.UseLocal([]Todo{
+ {Id: 1, Text: "Learn VDOM", Completed: false},
+ {Id: 2, Text: "Build a todo app", Completed: false},
+ })
+ nextIdAtom := app.UseLocal(3)
+ inputTextAtom := app.UseLocal("")
+
+ // Event handlers modifying multiple pieces of state
+ addTodo := func() {
+ if inputTextAtom.Get() == "" {
+ return
+ }
+ todosAtom.SetFn(func(todos []Todo) []Todo {
+ return append(todos, Todo{
+ Id: nextIdAtom.Get(),
+ Text: inputTextAtom.Get(),
+ Completed: false,
+ })
+ })
+ nextIdAtom.Set(nextIdAtom.Get() + 1)
+ inputTextAtom.Set("")
+ }
+
+ // Immutable state update pattern
+ toggleTodo := func(id int) {
+ todosAtom.SetFn(func(todos []Todo) []Todo {
+ for i := range todos {
+ if todos[i].Id == id {
+ todos[i].Completed = !todos[i].Completed
+ break
+ }
+ }
+ return todos
+ })
+ }
+
+ deleteTodo := func(id int) {
+ todosAtom.SetFn(func(todos []Todo) []Todo {
+ newTodos := make([]Todo, 0, len(todos)-1)
+ for _, todo := range todos {
+ if todo.Id != id {
+ newTodos = append(newTodos, todo)
+ }
+ }
+ return newTodos
+ })
+ }
+
+ return vdom.H("div", map[string]any{
+ "className": "max-w-[500px] m-5 font-sans",
+ },
+ vdom.H("div", map[string]any{
+ "className": "mb-5",
+ }, vdom.H("h1", map[string]any{
+ "className": "text-2xl font-bold",
+ }, "Todo List")),
+
+ vdom.H("div", map[string]any{
+ "className": "flex gap-2.5 mb-5",
+ },
+ InputField(InputFieldProps{
+ Value: inputTextAtom.Get(),
+ OnChange: inputTextAtom.Set,
+ OnEnter: addTodo,
+ }),
+ vdom.H("button", map[string]any{
+ "className": "px-4 py-2 border border-border rounded cursor-pointer",
+ "onClick": addTodo,
+ }, "Add Todo"),
+ ),
+
+ TodoList(TodoListProps{
+ Todos: todosAtom.Get(),
+ OnToggle: toggleTodo,
+ OnDelete: deleteTodo,
+ }),
+ )
+},
+)
diff --git a/tsunami/demo/todo/go.mod b/tsunami/demo/todo/go.mod
new file mode 100644
index 0000000000..f82b52b1cc
--- /dev/null
+++ b/tsunami/demo/todo/go.mod
@@ -0,0 +1,12 @@
+module tsunami/app/todo
+
+go 1.24.6
+
+require github.com/wavetermdev/waveterm/tsunami v0.0.0
+
+require (
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/outrigdev/goid v0.3.0 // indirect
+)
+
+replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami
diff --git a/tsunami/demo/todo/go.sum b/tsunami/demo/todo/go.sum
new file mode 100644
index 0000000000..4c44991dfc
--- /dev/null
+++ b/tsunami/demo/todo/go.sum
@@ -0,0 +1,4 @@
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=
+github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=
diff --git a/tsunami/demo/todo/static/tw.css b/tsunami/demo/todo/static/tw.css
new file mode 100644
index 0000000000..58f65736ad
--- /dev/null
+++ b/tsunami/demo/todo/static/tw.css
@@ -0,0 +1,1165 @@
+/*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */
+@layer properties;
+@layer theme, base, components, utilities;
+@layer theme {
+ :root, :host {
+ --font-sans: "Inter", sans-serif;
+ --font-mono: "Hack", monospace;
+ --color-red-500: oklch(63.7% 0.237 25.331);
+ --spacing: 0.25rem;
+ --text-sm: 0.875rem;
+ --text-sm--line-height: calc(1.25 / 0.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);
+ --text-xl: 1.25rem;
+ --text-xl--line-height: calc(1.75 / 1.25);
+ --text-2xl: 1.5rem;
+ --text-2xl--line-height: calc(2 / 1.5);
+ --text-3xl: 1.875rem;
+ --text-3xl--line-height: calc(2.25 / 1.875);
+ --font-weight-bold: 700;
+ --leading-relaxed: 1.625;
+ --radius-lg: 0.5rem;
+ --ease-in: cubic-bezier(0.4, 0, 1, 1);
+ --ease-out: cubic-bezier(0, 0, 0.2, 1);
+ --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
+ --default-transition-duration: 150ms;
+ --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ --default-font-family: var(--font-sans);
+ --default-mono-font-family: var(--font-mono);
+ --radius: 8px;
+ --color-background: rgb(34, 34, 34);
+ --color-primary: rgb(247, 247, 247);
+ --color-secondary: rgba(215, 218, 224, 0.7);
+ --color-muted: rgba(215, 218, 224, 0.5);
+ --color-accent-300: rgb(110, 231, 133);
+ --color-panel: rgba(255, 255, 255, 0.12);
+ --color-border: rgba(255, 255, 255, 0.16);
+ --color-accent: rgb(88, 193, 66);
+ }
+}
+@layer base {
+ *, ::after, ::before, ::backdrop, ::file-selector-button {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ border: 0 solid;
+ }
+ html, :host {
+ line-height: 1.5;
+ -webkit-text-size-adjust: 100%;
+ tab-size: 4;
+ 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;
+ 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 {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+ }
+ sub {
+ bottom: -0.25em;
+ }
+ sup {
+ top: -0.5em;
+ }
+ table {
+ text-indent: 0;
+ border-color: inherit;
+ border-collapse: collapse;
+ }
+ :-moz-focusring {
+ outline: auto;
+ }
+ progress {
+ vertical-align: baseline;
+ }
+ summary {
+ display: list-item;
+ }
+ ol, ul, menu {
+ list-style: none;
+ }
+ img, svg, video, canvas, audio, iframe, embed, object {
+ display: block;
+ vertical-align: middle;
+ }
+ img, video {
+ max-width: 100%;
+ height: auto;
+ }
+ button, input, select, optgroup, textarea, ::file-selector-button {
+ font: inherit;
+ font-feature-settings: inherit;
+ font-variation-settings: inherit;
+ letter-spacing: inherit;
+ color: inherit;
+ border-radius: 0;
+ background-color: transparent;
+ opacity: 1;
+ }
+ :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)) {
+ 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, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-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"]), ::file-selector-button {
+ appearance: button;
+ }
+ ::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
+ height: auto;
+ }
+ [hidden]:where(:not([hidden="until-found"])) {
+ display: none !important;
+ }
+}
+@layer utilities {
+ .collapse {
+ visibility: collapse;
+ }
+ .invisible {
+ visibility: hidden;
+ }
+ .visible {
+ visibility: visible;
+ }
+ .sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border-width: 0;
+ }
+ .not-sr-only {
+ position: static;
+ width: auto;
+ height: auto;
+ padding: 0;
+ margin: 0;
+ overflow: visible;
+ clip-path: none;
+ white-space: normal;
+ }
+ .absolute {
+ position: absolute;
+ }
+ .fixed {
+ position: fixed;
+ }
+ .relative {
+ position: relative;
+ }
+ .static {
+ position: static;
+ }
+ .sticky {
+ position: sticky;
+ }
+ .isolate {
+ isolation: isolate;
+ }
+ .isolation-auto {
+ isolation: auto;
+ }
+ .container {
+ width: 100%;
+ @media (width >= 40rem) {
+ max-width: 40rem;
+ }
+ @media (width >= 48rem) {
+ max-width: 48rem;
+ }
+ @media (width >= 64rem) {
+ max-width: 64rem;
+ }
+ @media (width >= 80rem) {
+ max-width: 80rem;
+ }
+ @media (width >= 96rem) {
+ max-width: 96rem;
+ }
+ }
+ .m-5 {
+ margin: calc(var(--spacing) * 5);
+ }
+ .my-6 {
+ margin-block: calc(var(--spacing) * 6);
+ }
+ .mt-3 {
+ margin-top: calc(var(--spacing) * 3);
+ }
+ .mt-4 {
+ margin-top: calc(var(--spacing) * 4);
+ }
+ .mt-5 {
+ margin-top: calc(var(--spacing) * 5);
+ }
+ .mt-6 {
+ margin-top: calc(var(--spacing) * 6);
+ }
+ .mb-2 {
+ margin-bottom: calc(var(--spacing) * 2);
+ }
+ .mb-3 {
+ margin-bottom: calc(var(--spacing) * 3);
+ }
+ .mb-4 {
+ margin-bottom: calc(var(--spacing) * 4);
+ }
+ .mb-5 {
+ margin-bottom: calc(var(--spacing) * 5);
+ }
+ .ml-4 {
+ margin-left: calc(var(--spacing) * 4);
+ }
+ .block {
+ display: block;
+ }
+ .contents {
+ display: contents;
+ }
+ .flex {
+ display: flex;
+ }
+ .flow-root {
+ display: flow-root;
+ }
+ .grid {
+ display: grid;
+ }
+ .hidden {
+ display: none;
+ }
+ .inline {
+ display: inline;
+ }
+ .inline-block {
+ display: inline-block;
+ }
+ .inline-flex {
+ display: inline-flex;
+ }
+ .inline-grid {
+ display: inline-grid;
+ }
+ .inline-table {
+ display: inline-table;
+ }
+ .list-item {
+ display: list-item;
+ }
+ .table {
+ display: table;
+ }
+ .table-caption {
+ display: table-caption;
+ }
+ .table-cell {
+ display: table-cell;
+ }
+ .table-column {
+ display: table-column;
+ }
+ .table-column-group {
+ display: table-column-group;
+ }
+ .table-footer-group {
+ display: table-footer-group;
+ }
+ .table-header-group {
+ display: table-header-group;
+ }
+ .table-row {
+ display: table-row;
+ }
+ .table-row-group {
+ display: table-row-group;
+ }
+ .h-4 {
+ height: calc(var(--spacing) * 4);
+ }
+ .min-h-full {
+ min-height: 100%;
+ }
+ .min-h-screen {
+ min-height: 100vh;
+ }
+ .w-4 {
+ width: calc(var(--spacing) * 4);
+ }
+ .w-full {
+ width: 100%;
+ }
+ .max-w-\[500px\] {
+ max-width: 500px;
+ }
+ .max-w-none {
+ max-width: none;
+ }
+ .min-w-full {
+ min-width: 100%;
+ }
+ .flex-1 {
+ flex: 1;
+ }
+ .shrink {
+ flex-shrink: 1;
+ }
+ .grow {
+ flex-grow: 1;
+ }
+ .border-collapse {
+ border-collapse: collapse;
+ }
+ .translate-none {
+ translate: none;
+ }
+ .scale-3d {
+ scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z);
+ }
+ .transform {
+ transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
+ }
+ .cursor-pointer {
+ cursor: pointer;
+ }
+ .touch-pinch-zoom {
+ --tw-pinch-zoom: pinch-zoom;
+ touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,);
+ }
+ .resize {
+ resize: both;
+ }
+ .list-inside {
+ list-style-position: inside;
+ }
+ .list-decimal {
+ list-style-type: decimal;
+ }
+ .list-disc {
+ list-style-type: disc;
+ }
+ .flex-col {
+ flex-direction: column;
+ }
+ .flex-wrap {
+ flex-wrap: wrap;
+ }
+ .items-center {
+ align-items: center;
+ }
+ .gap-2 {
+ gap: calc(var(--spacing) * 2);
+ }
+ .gap-2\.5 {
+ gap: calc(var(--spacing) * 2.5);
+ }
+ .space-y-1 {
+ :where(& > :not(:last-child)) {
+ --tw-space-y-reverse: 0;
+ margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));
+ margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));
+ }
+ }
+ .space-y-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-space-y-reverse: 1;
+ }
+ }
+ .space-x-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-space-x-reverse: 1;
+ }
+ }
+ .divide-x {
+ :where(& > :not(:last-child)) {
+ --tw-divide-x-reverse: 0;
+ border-inline-style: var(--tw-border-style);
+ border-inline-start-width: calc(1px * var(--tw-divide-x-reverse));
+ border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse)));
+ }
+ }
+ .divide-y {
+ :where(& > :not(:last-child)) {
+ --tw-divide-y-reverse: 0;
+ border-bottom-style: var(--tw-border-style);
+ border-top-style: var(--tw-border-style);
+ border-top-width: calc(1px * var(--tw-divide-y-reverse));
+ border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
+ }
+ }
+ .divide-y-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-divide-y-reverse: 1;
+ }
+ }
+ .truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .overflow-auto {
+ overflow: auto;
+ }
+ .overflow-x-auto {
+ overflow-x: auto;
+ }
+ .rounded {
+ border-radius: var(--radius);
+ }
+ .rounded-lg {
+ border-radius: var(--radius-lg);
+ }
+ .rounded-s {
+ border-start-start-radius: var(--radius);
+ border-end-start-radius: var(--radius);
+ }
+ .rounded-ss {
+ border-start-start-radius: var(--radius);
+ }
+ .rounded-e {
+ border-start-end-radius: var(--radius);
+ border-end-end-radius: var(--radius);
+ }
+ .rounded-se {
+ border-start-end-radius: var(--radius);
+ }
+ .rounded-ee {
+ border-end-end-radius: var(--radius);
+ }
+ .rounded-es {
+ border-end-start-radius: var(--radius);
+ }
+ .rounded-t {
+ border-top-left-radius: var(--radius);
+ border-top-right-radius: var(--radius);
+ }
+ .rounded-l {
+ border-top-left-radius: var(--radius);
+ border-bottom-left-radius: var(--radius);
+ }
+ .rounded-tl {
+ border-top-left-radius: var(--radius);
+ }
+ .rounded-r {
+ border-top-right-radius: var(--radius);
+ border-bottom-right-radius: var(--radius);
+ }
+ .rounded-tr {
+ border-top-right-radius: var(--radius);
+ }
+ .rounded-b {
+ border-bottom-right-radius: var(--radius);
+ border-bottom-left-radius: var(--radius);
+ }
+ .rounded-br {
+ border-bottom-right-radius: var(--radius);
+ }
+ .rounded-bl {
+ border-bottom-left-radius: var(--radius);
+ }
+ .border {
+ border-style: var(--tw-border-style);
+ border-width: 1px;
+ }
+ .border-x {
+ border-inline-style: var(--tw-border-style);
+ border-inline-width: 1px;
+ }
+ .border-y {
+ border-block-style: var(--tw-border-style);
+ border-block-width: 1px;
+ }
+ .border-s {
+ border-inline-start-style: var(--tw-border-style);
+ border-inline-start-width: 1px;
+ }
+ .border-e {
+ border-inline-end-style: var(--tw-border-style);
+ border-inline-end-width: 1px;
+ }
+ .border-t {
+ border-top-style: var(--tw-border-style);
+ border-top-width: 1px;
+ }
+ .border-r {
+ border-right-style: var(--tw-border-style);
+ border-right-width: 1px;
+ }
+ .border-b {
+ border-bottom-style: var(--tw-border-style);
+ border-bottom-width: 1px;
+ }
+ .border-l {
+ border-left-style: var(--tw-border-style);
+ border-left-width: 1px;
+ }
+ .border-l-4 {
+ border-left-style: var(--tw-border-style);
+ border-left-width: 4px;
+ }
+ .border-border {
+ border-color: var(--color-border);
+ }
+ .bg-background {
+ background-color: var(--color-background);
+ }
+ .bg-panel {
+ background-color: var(--color-panel);
+ }
+ .bg-repeat {
+ background-repeat: repeat;
+ }
+ .mask-no-clip {
+ mask-clip: no-clip;
+ }
+ .mask-repeat {
+ mask-repeat: repeat;
+ }
+ .p-2 {
+ padding: calc(var(--spacing) * 2);
+ }
+ .p-4 {
+ padding: calc(var(--spacing) * 4);
+ }
+ .px-1 {
+ padding-inline: calc(var(--spacing) * 1);
+ }
+ .px-2 {
+ padding-inline: calc(var(--spacing) * 2);
+ }
+ .px-4 {
+ padding-inline: calc(var(--spacing) * 4);
+ }
+ .py-0\.5 {
+ padding-block: calc(var(--spacing) * 0.5);
+ }
+ .py-1 {
+ padding-block: calc(var(--spacing) * 1);
+ }
+ .py-2 {
+ padding-block: calc(var(--spacing) * 2);
+ }
+ .pl-4 {
+ padding-left: calc(var(--spacing) * 4);
+ }
+ .text-left {
+ text-align: left;
+ }
+ .font-mono {
+ font-family: var(--font-mono);
+ }
+ .font-sans {
+ font-family: var(--font-sans);
+ }
+ .text-2xl {
+ font-size: var(--text-2xl);
+ line-height: var(--tw-leading, var(--text-2xl--line-height));
+ }
+ .text-3xl {
+ font-size: var(--text-3xl);
+ line-height: var(--tw-leading, var(--text-3xl--line-height));
+ }
+ .text-base {
+ font-size: var(--text-base);
+ line-height: var(--tw-leading, var(--text-base--line-height));
+ }
+ .text-lg {
+ font-size: var(--text-lg);
+ line-height: var(--tw-leading, var(--text-lg--line-height));
+ }
+ .text-sm {
+ font-size: var(--text-sm);
+ line-height: var(--tw-leading, var(--text-sm--line-height));
+ }
+ .text-xl {
+ font-size: var(--text-xl);
+ line-height: var(--tw-leading, var(--text-xl--line-height));
+ }
+ .leading-relaxed {
+ --tw-leading: var(--leading-relaxed);
+ line-height: var(--leading-relaxed);
+ }
+ .font-bold {
+ --tw-font-weight: var(--font-weight-bold);
+ font-weight: var(--font-weight-bold);
+ }
+ .text-wrap {
+ text-wrap: wrap;
+ }
+ .text-clip {
+ text-overflow: clip;
+ }
+ .text-ellipsis {
+ text-overflow: ellipsis;
+ }
+ .text-accent {
+ color: var(--color-accent);
+ }
+ .text-muted {
+ color: var(--color-muted);
+ }
+ .text-primary {
+ color: var(--color-primary);
+ }
+ .text-red-500 {
+ color: var(--color-red-500);
+ }
+ .text-secondary {
+ color: var(--color-secondary);
+ }
+ .capitalize {
+ text-transform: capitalize;
+ }
+ .lowercase {
+ text-transform: lowercase;
+ }
+ .normal-case {
+ text-transform: none;
+ }
+ .uppercase {
+ text-transform: uppercase;
+ }
+ .italic {
+ font-style: italic;
+ }
+ .not-italic {
+ font-style: normal;
+ }
+ .diagonal-fractions {
+ --tw-numeric-fraction: diagonal-fractions;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .lining-nums {
+ --tw-numeric-figure: lining-nums;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .oldstyle-nums {
+ --tw-numeric-figure: oldstyle-nums;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .ordinal {
+ --tw-ordinal: ordinal;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .proportional-nums {
+ --tw-numeric-spacing: proportional-nums;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .slashed-zero {
+ --tw-slashed-zero: slashed-zero;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .stacked-fractions {
+ --tw-numeric-fraction: stacked-fractions;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .tabular-nums {
+ --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,);
+ }
+ .normal-nums {
+ font-variant-numeric: normal;
+ }
+ .line-through {
+ text-decoration-line: line-through;
+ }
+ .no-underline {
+ text-decoration-line: none;
+ }
+ .overline {
+ text-decoration-line: overline;
+ }
+ .underline {
+ text-decoration-line: underline;
+ }
+ .antialiased {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+ .subpixel-antialiased {
+ -webkit-font-smoothing: auto;
+ -moz-osx-font-smoothing: auto;
+ }
+ .opacity-70 {
+ opacity: 70%;
+ }
+ .shadow {
+ --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
+ }
+ .inset-ring {
+ --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-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);
+ }
+ .blur {
+ --tw-blur: blur(8px);
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .drop-shadow {
+ --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06)));
+ --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06));
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .invert {
+ --tw-invert: invert(100%);
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .filter {
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .backdrop-blur {
+ --tw-backdrop-blur: blur(8px);
+ -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,);
+ }
+ .backdrop-grayscale {
+ --tw-backdrop-grayscale: grayscale(100%);
+ -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,);
+ }
+ .backdrop-invert {
+ --tw-backdrop-invert: invert(100%);
+ -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,);
+ }
+ .backdrop-sepia {
+ --tw-backdrop-sepia: sepia(100%);
+ -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,);
+ }
+ .backdrop-filter {
+ -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,);
+ }
+ .ease-in {
+ --tw-ease: var(--ease-in);
+ transition-timing-function: var(--ease-in);
+ }
+ .ease-in-out {
+ --tw-ease: var(--ease-in-out);
+ transition-timing-function: var(--ease-in-out);
+ }
+ .ease-out {
+ --tw-ease: var(--ease-out);
+ transition-timing-function: var(--ease-out);
+ }
+ .divide-x-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-divide-x-reverse: 1;
+ }
+ }
+ .ring-inset {
+ --tw-ring-inset: inset;
+ }
+ .hover\:text-accent-300 {
+ &:hover {
+ @media (hover: hover) {
+ color: var(--color-accent-300);
+ }
+ }
+ }
+}
+@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-rotate-x {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-rotate-y {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-rotate-z {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-skew-x {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-skew-y {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-pan-x {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-pan-y {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-pinch-zoom {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-space-y-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-space-x-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-divide-x-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-border-style {
+ syntax: "*";
+ inherits: false;
+ initial-value: solid;
+}
+@property --tw-divide-y-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-leading {
+ 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-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: 0px;
+}
+@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-blur {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-brightness {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-contrast {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-grayscale {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-hue-rotate {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-invert {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-opacity {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-saturate {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-sepia {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow-color {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow-alpha {
+ syntax: "";
+ inherits: false;
+ initial-value: 100%;
+}
+@property --tw-drop-shadow-size {
+ syntax: "*";
+ inherits: false;
+}
+@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;
+}
+@property --tw-ease {
+ syntax: "*";
+ inherits: false;
+}
+@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-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-scale-z: 1;
+ --tw-rotate-x: initial;
+ --tw-rotate-y: initial;
+ --tw-rotate-z: initial;
+ --tw-skew-x: initial;
+ --tw-skew-y: initial;
+ --tw-pan-x: initial;
+ --tw-pan-y: initial;
+ --tw-pinch-zoom: initial;
+ --tw-space-y-reverse: 0;
+ --tw-space-x-reverse: 0;
+ --tw-divide-x-reverse: 0;
+ --tw-border-style: solid;
+ --tw-divide-y-reverse: 0;
+ --tw-leading: 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-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-blur: initial;
+ --tw-brightness: initial;
+ --tw-contrast: initial;
+ --tw-grayscale: initial;
+ --tw-hue-rotate: initial;
+ --tw-invert: initial;
+ --tw-opacity: initial;
+ --tw-saturate: initial;
+ --tw-sepia: initial;
+ --tw-drop-shadow: initial;
+ --tw-drop-shadow-color: initial;
+ --tw-drop-shadow-alpha: 100%;
+ --tw-drop-shadow-size: initial;
+ --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;
+ --tw-ease: initial;
+ }
+ }
+}
diff --git a/tsunami/demo/todo/style.css b/tsunami/demo/todo/style.css
new file mode 100644
index 0000000000..89d4ce82c6
--- /dev/null
+++ b/tsunami/demo/todo/style.css
@@ -0,0 +1,68 @@
+.todo-app {
+ max-width: 500px;
+ margin: 20px;
+ font-family: sans-serif;
+}
+.todo-header {
+ margin-bottom: 20px;
+}
+.todo-form {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 20px;
+}
+.todo-input {
+ flex: 1;
+ padding: 8px;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ background: var(--input-bg);
+ color: var(--text-color);
+}
+.todo-button {
+ padding: 8px 16px;
+ background: var(--button-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ color: var(--text-color);
+ cursor: pointer;
+}
+.todo-button:hover {
+ background: var(--button-hover-bg);
+}
+.todo-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.todo-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ background: var(--block-bg);
+}
+.todo-item.completed {
+ opacity: 0.7;
+}
+.todo-item.completed .todo-text {
+ text-decoration: line-through;
+}
+.todo-text {
+ flex: 1;
+}
+.todo-checkbox {
+ width: 16px;
+ height: 16px;
+}
+.todo-delete {
+ color: var(--error-color);
+ cursor: pointer;
+ padding: 4px 8px;
+ border-radius: 4px;
+}
+.todo-delete:hover {
+ background: var(--error-bg);
+}
diff --git a/tsunami/demo/tsunamiconfig/app.go b/tsunami/demo/tsunamiconfig/app.go
new file mode 100644
index 0000000000..77730763ac
--- /dev/null
+++ b/tsunami/demo/tsunamiconfig/app.go
@@ -0,0 +1,370 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/wavetermdev/waveterm/tsunami/app"
+ "github.com/wavetermdev/waveterm/tsunami/vdom"
+)
+
+// Global atoms for config
+var (
+ serverURLAtom = app.ConfigAtom("serverURL", "")
+)
+
+type URLInputProps struct {
+ Value string `json:"value"`
+ OnChange func(string) `json:"onChange"`
+ OnSubmit func() `json:"onSubmit"`
+ IsLoading bool `json:"isLoading"`
+}
+
+type JSONEditorProps struct {
+ Value string `json:"value"`
+ OnChange func(string) `json:"onChange"`
+ OnSubmit func() `json:"onSubmit"`
+ IsLoading bool `json:"isLoading"`
+ Placeholder string `json:"placeholder"`
+}
+
+type ErrorDisplayProps struct {
+ Message string `json:"message"`
+}
+
+type SuccessDisplayProps struct {
+ Message string `json:"message"`
+}
+
+// parseURL takes flexible URL input and returns a normalized base URL
+func parseURL(input string) (string, error) {
+ if input == "" {
+ return "", fmt.Errorf("URL cannot be empty")
+ }
+
+ input = strings.TrimSpace(input)
+
+ // Handle just port number (e.g., "52848")
+ if portRegex := regexp.MustCompile(`^\d+$`); portRegex.MatchString(input) {
+ return fmt.Sprintf("http://localhost:%s", input), nil
+ }
+
+ // Add http:// if no protocol specified
+ if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") {
+ input = "http://" + input
+ }
+
+ // Parse the URL to validate and extract components
+ parsedURL, err := url.Parse(input)
+ if err != nil {
+ return "", fmt.Errorf("invalid URL format: %v", err)
+ }
+
+ if parsedURL.Host == "" {
+ return "", fmt.Errorf("no host specified in URL")
+ }
+
+ // Return base URL (scheme + host)
+ baseURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host)
+ return baseURL, nil
+}
+
+// fetchConfig fetches JSON from the /api/config endpoint
+func fetchConfig(baseURL string) (string, error) {
+ configURL := baseURL + "/api/config"
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Get(configURL)
+ if err != nil {
+ return "", fmt.Errorf("failed to connect to %s: %v", configURL, err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("server returned status %d from %s", resp.StatusCode, configURL)
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("failed to read response: %v", err)
+ }
+
+ // Validate that it's valid JSON
+ var jsonObj interface{}
+ if err := json.Unmarshal(body, &jsonObj); err != nil {
+ return "", fmt.Errorf("response is not valid JSON: %v", err)
+ }
+
+ // Pretty print the JSON
+ prettyJSON, err := json.MarshalIndent(jsonObj, "", " ")
+ if err != nil {
+ return "", fmt.Errorf("failed to format JSON: %v", err)
+ }
+
+ return string(prettyJSON), nil
+}
+
+// postConfig sends JSON to the /api/config endpoint
+func postConfig(baseURL, jsonContent string) error {
+ configURL := baseURL + "/api/config"
+
+ // Validate JSON before sending
+ var jsonObj interface{}
+ if err := json.Unmarshal([]byte(jsonContent), &jsonObj); err != nil {
+ return fmt.Errorf("invalid JSON: %v", err)
+ }
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Post(configURL, "application/json", strings.NewReader(jsonContent))
+ if err != nil {
+ return fmt.Errorf("failed to send request to %s: %v", configURL, err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body))
+ }
+
+ return nil
+}
+
+var URLInput = app.DefineComponent("URLInput",
+ func(props URLInputProps) any {
+ keyHandler := &vdom.VDomFunc{
+ Type: "func",
+ Fn: func(event vdom.VDomEvent) {
+ if !props.IsLoading {
+ props.OnSubmit()
+ }
+ },
+ Keys: []string{"Enter"},
+ PreventDefault: true,
+ }
+
+ return vdom.H("div", map[string]any{
+ "className": "flex gap-2 mb-4",
+ },
+ vdom.H("input", map[string]any{
+ "className": "flex-1 px-3 py-2 bg-slate-700 border border-slate-600 rounded text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500",
+ "type": "text",
+ "placeholder": "Enter URL (e.g., localhost:52848, http://localhost:52848/api/config, or just 52848)",
+ "value": props.Value,
+ "disabled": props.IsLoading,
+ "onChange": func(e vdom.VDomEvent) {
+ props.OnChange(e.TargetValue)
+ },
+ "onKeyDown": keyHandler,
+ }),
+ vdom.H("button", map[string]any{
+ "className": vdom.Classes(
+ "px-4 py-2 rounded font-medium cursor-pointer transition-colors",
+ vdom.IfElse(props.IsLoading,
+ "bg-slate-600 text-slate-400 cursor-not-allowed",
+ "bg-blue-600 text-white hover:bg-blue-700",
+ ),
+ ),
+ "onClick": vdom.If(!props.IsLoading, props.OnSubmit),
+ "disabled": props.IsLoading,
+ }, vdom.IfElse(props.IsLoading, "Loading...", "Fetch")),
+ )
+ },
+)
+
+var JSONEditor = app.DefineComponent("JSONEditor",
+ func(props JSONEditorProps) any {
+ if props.Value == "" && props.Placeholder == "" {
+ return vdom.H("div", map[string]any{
+ "className": "text-slate-400 text-center py-8",
+ }, "Enter a URL above and click Fetch to load configuration")
+ }
+
+ return vdom.H("div", map[string]any{
+ "className": "flex flex-col",
+ },
+ vdom.H("textarea", map[string]any{
+ "className": "w-full h-96 px-3 py-2 bg-slate-700 border border-slate-600 rounded text-slate-100 font-mono text-sm resize-y focus:outline-none focus:ring-2 focus:ring-blue-500",
+ "value": props.Value,
+ "placeholder": props.Placeholder,
+ "disabled": props.IsLoading,
+ "onChange": func(e vdom.VDomEvent) {
+ props.OnChange(e.TargetValue)
+ },
+ }),
+ vdom.If(props.Value != "",
+ vdom.H("button", map[string]any{
+ "className": vdom.Classes(
+ "mt-2 w-full py-2 rounded font-medium cursor-pointer transition-colors",
+ vdom.IfElse(props.IsLoading,
+ "bg-slate-600 text-slate-400 cursor-not-allowed",
+ "bg-green-600 text-white hover:bg-green-700",
+ ),
+ ),
+ "onClick": vdom.If(!props.IsLoading, props.OnSubmit),
+ "disabled": props.IsLoading,
+ }, vdom.IfElse(props.IsLoading, "Submitting...", "Submit Changes")),
+ ),
+ )
+ },
+)
+
+var ErrorDisplay = app.DefineComponent("ErrorDisplay",
+ func(props ErrorDisplayProps) any {
+ if props.Message == "" {
+ return nil
+ }
+
+ return vdom.H("div", map[string]any{
+ "className": "bg-red-900 border border-red-700 text-red-100 px-4 py-3 rounded mb-4",
+ },
+ vdom.H("div", map[string]any{
+ "className": "font-medium",
+ }, "Error"),
+ vdom.H("div", map[string]any{
+ "className": "text-sm mt-1",
+ }, props.Message),
+ )
+ },
+)
+
+var SuccessDisplay = app.DefineComponent("SuccessDisplay",
+ func(props SuccessDisplayProps) any {
+ if props.Message == "" {
+ return nil
+ }
+
+ return vdom.H("div", map[string]any{
+ "className": "bg-green-900 border border-green-700 text-green-100 px-4 py-3 rounded mb-4",
+ },
+ vdom.H("div", map[string]any{
+ "className": "font-medium",
+ }, "Success"),
+ vdom.H("div", map[string]any{
+ "className": "text-sm mt-1",
+ }, props.Message),
+ )
+ },
+)
+
+var App = app.DefineComponent("App",
+ func(_ struct{}) any {
+ app.UseSetAppTitle("Tsunami Config Manager")
+
+ // Get atom value once at the top
+ urlInput := serverURLAtom.Get()
+ jsonContent := app.UseLocal("")
+ errorMessage := app.UseLocal("")
+ successMessage := app.UseLocal("")
+ isLoading := app.UseLocal(false)
+ lastFetch := app.UseLocal("")
+ currentBaseURL := app.UseLocal("")
+
+ clearMessages := func() {
+ errorMessage.Set("")
+ successMessage.Set("")
+ }
+
+ fetchConfigData := func() {
+ clearMessages()
+
+ baseURL, err := parseURL(serverURLAtom.Get())
+ if err != nil {
+ errorMessage.Set(err.Error())
+ return
+ }
+
+ isLoading.Set(true)
+ currentBaseURL.Set(baseURL)
+
+ go func() {
+ defer func() {
+ isLoading.Set(false)
+ }()
+
+ content, err := fetchConfig(baseURL)
+ if err != nil {
+ errorMessage.Set(err.Error())
+ return
+ }
+
+ jsonContent.Set(content)
+ lastFetch.Set(time.Now().Format("2006-01-02 15:04:05"))
+ successMessage.Set(fmt.Sprintf("Successfully fetched config from %s", baseURL))
+ }()
+ }
+
+ submitConfigData := func() {
+ if currentBaseURL.Get() == "" {
+ errorMessage.Set("No base URL available. Please fetch config first.")
+ return
+ }
+
+ clearMessages()
+ isLoading.Set(true)
+
+ go func() {
+ defer func() {
+ isLoading.Set(false)
+ }()
+
+ err := postConfig(currentBaseURL.Get(), jsonContent.Get())
+ if err != nil {
+ errorMessage.Set(fmt.Sprintf("Failed to submit config: %v", err))
+ return
+ }
+
+ successMessage.Set(fmt.Sprintf("Successfully submitted config to %s", currentBaseURL.Get()))
+ }()
+ }
+
+ return vdom.H("div", map[string]any{
+ "className": "max-w-4xl mx-auto p-6 bg-slate-800 text-slate-100 min-h-screen",
+ },
+ vdom.H("div", map[string]any{
+ "className": "mb-6",
+ },
+ vdom.H("h1", map[string]any{
+ "className": "text-3xl font-bold mb-2",
+ }, "Tsunami Config Manager"),
+ vdom.H("p", map[string]any{
+ "className": "text-slate-400",
+ }, "Fetch and edit configuration from remote servers"),
+ ),
+
+ URLInput(URLInputProps{
+ Value: urlInput,
+ OnChange: serverURLAtom.Set,
+ OnSubmit: fetchConfigData,
+ IsLoading: isLoading.Get(),
+ }),
+
+ ErrorDisplay(ErrorDisplayProps{
+ Message: errorMessage.Get(),
+ }),
+
+ SuccessDisplay(SuccessDisplayProps{
+ Message: successMessage.Get(),
+ }),
+
+ vdom.If(lastFetch.Get() != "",
+ vdom.H("div", map[string]any{
+ "className": "text-sm text-slate-400 mb-4",
+ }, fmt.Sprintf("Last fetched: %s from %s", lastFetch.Get(), currentBaseURL.Get())),
+ ),
+
+ JSONEditor(JSONEditorProps{
+ Value: jsonContent.Get(),
+ OnChange: jsonContent.Set,
+ OnSubmit: submitConfigData,
+ IsLoading: isLoading.Get(),
+ Placeholder: "JSON configuration will appear here after fetching...",
+ }),
+ )
+ },
+)
diff --git a/tsunami/demo/tsunamiconfig/go.mod b/tsunami/demo/tsunamiconfig/go.mod
new file mode 100644
index 0000000000..165622f27a
--- /dev/null
+++ b/tsunami/demo/tsunamiconfig/go.mod
@@ -0,0 +1,12 @@
+module tsunami/app/tsunamiconfig
+
+go 1.24.6
+
+require github.com/wavetermdev/waveterm/tsunami v0.0.0
+
+require (
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/outrigdev/goid v0.3.0 // indirect
+)
+
+replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami
diff --git a/tsunami/demo/tsunamiconfig/go.sum b/tsunami/demo/tsunamiconfig/go.sum
new file mode 100644
index 0000000000..4c44991dfc
--- /dev/null
+++ b/tsunami/demo/tsunamiconfig/go.sum
@@ -0,0 +1,4 @@
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=
+github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=
diff --git a/tsunami/demo/tsunamiconfig/static/tw.css b/tsunami/demo/tsunamiconfig/static/tw.css
new file mode 100644
index 0000000000..e06a738e3a
--- /dev/null
+++ b/tsunami/demo/tsunamiconfig/static/tw.css
@@ -0,0 +1,1283 @@
+/*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */
+@layer properties;
+@layer theme, base, components, utilities;
+@layer theme {
+ :root, :host {
+ --font-sans: "Inter", sans-serif;
+ --font-mono: "Hack", monospace;
+ --color-red-100: oklch(93.6% 0.032 17.717);
+ --color-red-500: oklch(63.7% 0.237 25.331);
+ --color-red-700: oklch(50.5% 0.213 27.518);
+ --color-red-800: oklch(44.4% 0.177 26.899);
+ --color-red-900: oklch(39.6% 0.141 25.723);
+ --color-green-100: oklch(96.2% 0.044 156.743);
+ --color-green-600: oklch(62.7% 0.194 149.214);
+ --color-green-700: oklch(52.7% 0.154 150.069);
+ --color-green-900: oklch(39.3% 0.095 152.535);
+ --color-blue-500: oklch(62.3% 0.214 259.815);
+ --color-blue-600: oklch(54.6% 0.245 262.881);
+ --color-blue-700: oklch(48.8% 0.243 264.376);
+ --color-slate-100: oklch(96.8% 0.007 247.896);
+ --color-slate-400: oklch(70.4% 0.04 256.788);
+ --color-slate-600: oklch(44.6% 0.043 257.281);
+ --color-slate-700: oklch(37.2% 0.044 257.287);
+ --color-slate-800: oklch(27.9% 0.041 260.031);
+ --color-white: #fff;
+ --spacing: 0.25rem;
+ --container-4xl: 56rem;
+ --text-sm: 0.875rem;
+ --text-sm--line-height: calc(1.25 / 0.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);
+ --text-xl: 1.25rem;
+ --text-xl--line-height: calc(1.75 / 1.25);
+ --text-2xl: 1.5rem;
+ --text-2xl--line-height: calc(2 / 1.5);
+ --text-3xl: 1.875rem;
+ --text-3xl--line-height: calc(2.25 / 1.875);
+ --font-weight-medium: 500;
+ --font-weight-bold: 700;
+ --leading-relaxed: 1.625;
+ --radius-lg: 0.5rem;
+ --ease-in: cubic-bezier(0.4, 0, 1, 1);
+ --ease-out: cubic-bezier(0, 0, 0.2, 1);
+ --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
+ --default-transition-duration: 150ms;
+ --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ --default-font-family: var(--font-sans);
+ --default-mono-font-family: var(--font-mono);
+ --radius: 8px;
+ --color-background: rgb(34, 34, 34);
+ --color-primary: rgb(247, 247, 247);
+ --color-secondary: rgba(215, 218, 224, 0.7);
+ --color-muted: rgba(215, 218, 224, 0.5);
+ --color-accent-300: rgb(110, 231, 133);
+ --color-panel: rgba(255, 255, 255, 0.12);
+ --color-border: rgba(255, 255, 255, 0.16);
+ --color-accent: rgb(88, 193, 66);
+ }
+}
+@layer base {
+ *, ::after, ::before, ::backdrop, ::file-selector-button {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ border: 0 solid;
+ }
+ html, :host {
+ line-height: 1.5;
+ -webkit-text-size-adjust: 100%;
+ tab-size: 4;
+ 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;
+ 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 {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+ }
+ sub {
+ bottom: -0.25em;
+ }
+ sup {
+ top: -0.5em;
+ }
+ table {
+ text-indent: 0;
+ border-color: inherit;
+ border-collapse: collapse;
+ }
+ :-moz-focusring {
+ outline: auto;
+ }
+ progress {
+ vertical-align: baseline;
+ }
+ summary {
+ display: list-item;
+ }
+ ol, ul, menu {
+ list-style: none;
+ }
+ img, svg, video, canvas, audio, iframe, embed, object {
+ display: block;
+ vertical-align: middle;
+ }
+ img, video {
+ max-width: 100%;
+ height: auto;
+ }
+ button, input, select, optgroup, textarea, ::file-selector-button {
+ font: inherit;
+ font-feature-settings: inherit;
+ font-variation-settings: inherit;
+ letter-spacing: inherit;
+ color: inherit;
+ border-radius: 0;
+ background-color: transparent;
+ opacity: 1;
+ }
+ :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)) {
+ 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, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-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"]), ::file-selector-button {
+ appearance: button;
+ }
+ ::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
+ height: auto;
+ }
+ [hidden]:where(:not([hidden="until-found"])) {
+ display: none !important;
+ }
+}
+@layer utilities {
+ .collapse {
+ visibility: collapse;
+ }
+ .invisible {
+ visibility: hidden;
+ }
+ .visible {
+ visibility: visible;
+ }
+ .sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border-width: 0;
+ }
+ .not-sr-only {
+ position: static;
+ width: auto;
+ height: auto;
+ padding: 0;
+ margin: 0;
+ overflow: visible;
+ clip-path: none;
+ white-space: normal;
+ }
+ .absolute {
+ position: absolute;
+ }
+ .fixed {
+ position: fixed;
+ }
+ .relative {
+ position: relative;
+ }
+ .static {
+ position: static;
+ }
+ .sticky {
+ position: sticky;
+ }
+ .isolate {
+ isolation: isolate;
+ }
+ .isolation-auto {
+ isolation: auto;
+ }
+ .container {
+ width: 100%;
+ @media (width >= 40rem) {
+ max-width: 40rem;
+ }
+ @media (width >= 48rem) {
+ max-width: 48rem;
+ }
+ @media (width >= 64rem) {
+ max-width: 64rem;
+ }
+ @media (width >= 80rem) {
+ max-width: 80rem;
+ }
+ @media (width >= 96rem) {
+ max-width: 96rem;
+ }
+ }
+ .mx-auto {
+ margin-inline: auto;
+ }
+ .my-6 {
+ margin-block: calc(var(--spacing) * 6);
+ }
+ .mt-1 {
+ margin-top: calc(var(--spacing) * 1);
+ }
+ .mt-2 {
+ margin-top: calc(var(--spacing) * 2);
+ }
+ .mt-3 {
+ margin-top: calc(var(--spacing) * 3);
+ }
+ .mt-4 {
+ margin-top: calc(var(--spacing) * 4);
+ }
+ .mt-5 {
+ margin-top: calc(var(--spacing) * 5);
+ }
+ .mt-6 {
+ margin-top: calc(var(--spacing) * 6);
+ }
+ .mb-2 {
+ margin-bottom: calc(var(--spacing) * 2);
+ }
+ .mb-3 {
+ margin-bottom: calc(var(--spacing) * 3);
+ }
+ .mb-4 {
+ margin-bottom: calc(var(--spacing) * 4);
+ }
+ .mb-6 {
+ margin-bottom: calc(var(--spacing) * 6);
+ }
+ .ml-4 {
+ margin-left: calc(var(--spacing) * 4);
+ }
+ .block {
+ display: block;
+ }
+ .contents {
+ display: contents;
+ }
+ .flex {
+ display: flex;
+ }
+ .flow-root {
+ display: flow-root;
+ }
+ .grid {
+ display: grid;
+ }
+ .hidden {
+ display: none;
+ }
+ .inline {
+ display: inline;
+ }
+ .inline-block {
+ display: inline-block;
+ }
+ .inline-flex {
+ display: inline-flex;
+ }
+ .inline-grid {
+ display: inline-grid;
+ }
+ .inline-table {
+ display: inline-table;
+ }
+ .list-item {
+ display: list-item;
+ }
+ .table {
+ display: table;
+ }
+ .table-caption {
+ display: table-caption;
+ }
+ .table-cell {
+ display: table-cell;
+ }
+ .table-column {
+ display: table-column;
+ }
+ .table-column-group {
+ display: table-column-group;
+ }
+ .table-footer-group {
+ display: table-footer-group;
+ }
+ .table-header-group {
+ display: table-header-group;
+ }
+ .table-row {
+ display: table-row;
+ }
+ .table-row-group {
+ display: table-row-group;
+ }
+ .h-96 {
+ height: calc(var(--spacing) * 96);
+ }
+ .min-h-full {
+ min-height: 100%;
+ }
+ .min-h-screen {
+ min-height: 100vh;
+ }
+ .w-full {
+ width: 100%;
+ }
+ .max-w-4xl {
+ max-width: var(--container-4xl);
+ }
+ .max-w-none {
+ max-width: none;
+ }
+ .min-w-full {
+ min-width: 100%;
+ }
+ .flex-1 {
+ flex: 1;
+ }
+ .shrink {
+ flex-shrink: 1;
+ }
+ .grow {
+ flex-grow: 1;
+ }
+ .border-collapse {
+ border-collapse: collapse;
+ }
+ .translate-none {
+ translate: none;
+ }
+ .scale-3d {
+ scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z);
+ }
+ .transform {
+ transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
+ }
+ .cursor-not-allowed {
+ cursor: not-allowed;
+ }
+ .cursor-pointer {
+ cursor: pointer;
+ }
+ .touch-pinch-zoom {
+ --tw-pinch-zoom: pinch-zoom;
+ touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,);
+ }
+ .resize {
+ resize: both;
+ }
+ .resize-y {
+ resize: vertical;
+ }
+ .list-inside {
+ list-style-position: inside;
+ }
+ .list-decimal {
+ list-style-type: decimal;
+ }
+ .list-disc {
+ list-style-type: disc;
+ }
+ .flex-col {
+ flex-direction: column;
+ }
+ .flex-wrap {
+ flex-wrap: wrap;
+ }
+ .gap-2 {
+ gap: calc(var(--spacing) * 2);
+ }
+ .space-y-1 {
+ :where(& > :not(:last-child)) {
+ --tw-space-y-reverse: 0;
+ margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));
+ margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));
+ }
+ }
+ .space-y-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-space-y-reverse: 1;
+ }
+ }
+ .space-x-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-space-x-reverse: 1;
+ }
+ }
+ .divide-x {
+ :where(& > :not(:last-child)) {
+ --tw-divide-x-reverse: 0;
+ border-inline-style: var(--tw-border-style);
+ border-inline-start-width: calc(1px * var(--tw-divide-x-reverse));
+ border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse)));
+ }
+ }
+ .divide-y {
+ :where(& > :not(:last-child)) {
+ --tw-divide-y-reverse: 0;
+ border-bottom-style: var(--tw-border-style);
+ border-top-style: var(--tw-border-style);
+ border-top-width: calc(1px * var(--tw-divide-y-reverse));
+ border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
+ }
+ }
+ .divide-y-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-divide-y-reverse: 1;
+ }
+ }
+ .truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .overflow-auto {
+ overflow: auto;
+ }
+ .overflow-x-auto {
+ overflow-x: auto;
+ }
+ .rounded {
+ border-radius: var(--radius);
+ }
+ .rounded-lg {
+ border-radius: var(--radius-lg);
+ }
+ .rounded-s {
+ border-start-start-radius: var(--radius);
+ border-end-start-radius: var(--radius);
+ }
+ .rounded-ss {
+ border-start-start-radius: var(--radius);
+ }
+ .rounded-e {
+ border-start-end-radius: var(--radius);
+ border-end-end-radius: var(--radius);
+ }
+ .rounded-se {
+ border-start-end-radius: var(--radius);
+ }
+ .rounded-ee {
+ border-end-end-radius: var(--radius);
+ }
+ .rounded-es {
+ border-end-start-radius: var(--radius);
+ }
+ .rounded-t {
+ border-top-left-radius: var(--radius);
+ border-top-right-radius: var(--radius);
+ }
+ .rounded-l {
+ border-top-left-radius: var(--radius);
+ border-bottom-left-radius: var(--radius);
+ }
+ .rounded-tl {
+ border-top-left-radius: var(--radius);
+ }
+ .rounded-r {
+ border-top-right-radius: var(--radius);
+ border-bottom-right-radius: var(--radius);
+ }
+ .rounded-tr {
+ border-top-right-radius: var(--radius);
+ }
+ .rounded-b {
+ border-bottom-right-radius: var(--radius);
+ border-bottom-left-radius: var(--radius);
+ }
+ .rounded-br {
+ border-bottom-right-radius: var(--radius);
+ }
+ .rounded-bl {
+ border-bottom-left-radius: var(--radius);
+ }
+ .border {
+ border-style: var(--tw-border-style);
+ border-width: 1px;
+ }
+ .border-x {
+ border-inline-style: var(--tw-border-style);
+ border-inline-width: 1px;
+ }
+ .border-y {
+ border-block-style: var(--tw-border-style);
+ border-block-width: 1px;
+ }
+ .border-s {
+ border-inline-start-style: var(--tw-border-style);
+ border-inline-start-width: 1px;
+ }
+ .border-e {
+ border-inline-end-style: var(--tw-border-style);
+ border-inline-end-width: 1px;
+ }
+ .border-t {
+ border-top-style: var(--tw-border-style);
+ border-top-width: 1px;
+ }
+ .border-r {
+ border-right-style: var(--tw-border-style);
+ border-right-width: 1px;
+ }
+ .border-b {
+ border-bottom-style: var(--tw-border-style);
+ border-bottom-width: 1px;
+ }
+ .border-l {
+ border-left-style: var(--tw-border-style);
+ border-left-width: 1px;
+ }
+ .border-l-4 {
+ border-left-style: var(--tw-border-style);
+ border-left-width: 4px;
+ }
+ .border-border {
+ border-color: var(--color-border);
+ }
+ .border-green-700 {
+ border-color: var(--color-green-700);
+ }
+ .border-red-500 {
+ border-color: var(--color-red-500);
+ }
+ .border-red-700 {
+ border-color: var(--color-red-700);
+ }
+ .border-slate-600 {
+ border-color: var(--color-slate-600);
+ }
+ .bg-background {
+ background-color: var(--color-background);
+ }
+ .bg-blue-600 {
+ background-color: var(--color-blue-600);
+ }
+ .bg-green-600 {
+ background-color: var(--color-green-600);
+ }
+ .bg-green-900 {
+ background-color: var(--color-green-900);
+ }
+ .bg-panel {
+ background-color: var(--color-panel);
+ }
+ .bg-red-100 {
+ background-color: var(--color-red-100);
+ }
+ .bg-red-900 {
+ background-color: var(--color-red-900);
+ }
+ .bg-slate-600 {
+ background-color: var(--color-slate-600);
+ }
+ .bg-slate-700 {
+ background-color: var(--color-slate-700);
+ }
+ .bg-slate-800 {
+ background-color: var(--color-slate-800);
+ }
+ .bg-repeat {
+ background-repeat: repeat;
+ }
+ .mask-no-clip {
+ mask-clip: no-clip;
+ }
+ .mask-repeat {
+ mask-repeat: repeat;
+ }
+ .p-4 {
+ padding: calc(var(--spacing) * 4);
+ }
+ .p-6 {
+ padding: calc(var(--spacing) * 6);
+ }
+ .px-1 {
+ padding-inline: calc(var(--spacing) * 1);
+ }
+ .px-3 {
+ padding-inline: calc(var(--spacing) * 3);
+ }
+ .px-4 {
+ padding-inline: calc(var(--spacing) * 4);
+ }
+ .py-0\.5 {
+ padding-block: calc(var(--spacing) * 0.5);
+ }
+ .py-2 {
+ padding-block: calc(var(--spacing) * 2);
+ }
+ .py-3 {
+ padding-block: calc(var(--spacing) * 3);
+ }
+ .py-8 {
+ padding-block: calc(var(--spacing) * 8);
+ }
+ .pl-4 {
+ padding-left: calc(var(--spacing) * 4);
+ }
+ .text-center {
+ text-align: center;
+ }
+ .text-left {
+ text-align: left;
+ }
+ .font-mono {
+ font-family: var(--font-mono);
+ }
+ .text-2xl {
+ font-size: var(--text-2xl);
+ line-height: var(--tw-leading, var(--text-2xl--line-height));
+ }
+ .text-3xl {
+ font-size: var(--text-3xl);
+ line-height: var(--tw-leading, var(--text-3xl--line-height));
+ }
+ .text-base {
+ font-size: var(--text-base);
+ line-height: var(--tw-leading, var(--text-base--line-height));
+ }
+ .text-lg {
+ font-size: var(--text-lg);
+ line-height: var(--tw-leading, var(--text-lg--line-height));
+ }
+ .text-sm {
+ font-size: var(--text-sm);
+ line-height: var(--tw-leading, var(--text-sm--line-height));
+ }
+ .text-xl {
+ font-size: var(--text-xl);
+ line-height: var(--tw-leading, var(--text-xl--line-height));
+ }
+ .leading-relaxed {
+ --tw-leading: var(--leading-relaxed);
+ line-height: var(--leading-relaxed);
+ }
+ .font-bold {
+ --tw-font-weight: var(--font-weight-bold);
+ font-weight: var(--font-weight-bold);
+ }
+ .font-medium {
+ --tw-font-weight: var(--font-weight-medium);
+ font-weight: var(--font-weight-medium);
+ }
+ .text-wrap {
+ text-wrap: wrap;
+ }
+ .text-clip {
+ text-overflow: clip;
+ }
+ .text-ellipsis {
+ text-overflow: ellipsis;
+ }
+ .text-accent {
+ color: var(--color-accent);
+ }
+ .text-green-100 {
+ color: var(--color-green-100);
+ }
+ .text-muted {
+ color: var(--color-muted);
+ }
+ .text-primary {
+ color: var(--color-primary);
+ }
+ .text-red-100 {
+ color: var(--color-red-100);
+ }
+ .text-red-800 {
+ color: var(--color-red-800);
+ }
+ .text-secondary {
+ color: var(--color-secondary);
+ }
+ .text-slate-100 {
+ color: var(--color-slate-100);
+ }
+ .text-slate-400 {
+ color: var(--color-slate-400);
+ }
+ .text-white {
+ color: var(--color-white);
+ }
+ .capitalize {
+ text-transform: capitalize;
+ }
+ .lowercase {
+ text-transform: lowercase;
+ }
+ .normal-case {
+ text-transform: none;
+ }
+ .uppercase {
+ text-transform: uppercase;
+ }
+ .italic {
+ font-style: italic;
+ }
+ .not-italic {
+ font-style: normal;
+ }
+ .diagonal-fractions {
+ --tw-numeric-fraction: diagonal-fractions;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .lining-nums {
+ --tw-numeric-figure: lining-nums;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .oldstyle-nums {
+ --tw-numeric-figure: oldstyle-nums;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .ordinal {
+ --tw-ordinal: ordinal;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .proportional-nums {
+ --tw-numeric-spacing: proportional-nums;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .slashed-zero {
+ --tw-slashed-zero: slashed-zero;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .stacked-fractions {
+ --tw-numeric-fraction: stacked-fractions;
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
+ }
+ .tabular-nums {
+ --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,);
+ }
+ .normal-nums {
+ font-variant-numeric: normal;
+ }
+ .line-through {
+ text-decoration-line: line-through;
+ }
+ .no-underline {
+ text-decoration-line: none;
+ }
+ .overline {
+ text-decoration-line: overline;
+ }
+ .underline {
+ text-decoration-line: underline;
+ }
+ .antialiased {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+ .subpixel-antialiased {
+ -webkit-font-smoothing: auto;
+ -moz-osx-font-smoothing: auto;
+ }
+ .placeholder-slate-400 {
+ &::placeholder {
+ color: var(--color-slate-400);
+ }
+ }
+ .shadow {
+ --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
+ }
+ .inset-ring {
+ --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-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);
+ }
+ .blur {
+ --tw-blur: blur(8px);
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .drop-shadow {
+ --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06)));
+ --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06));
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .invert {
+ --tw-invert: invert(100%);
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .filter {
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .backdrop-blur {
+ --tw-backdrop-blur: blur(8px);
+ -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,);
+ }
+ .backdrop-grayscale {
+ --tw-backdrop-grayscale: grayscale(100%);
+ -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,);
+ }
+ .backdrop-invert {
+ --tw-backdrop-invert: invert(100%);
+ -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,);
+ }
+ .backdrop-sepia {
+ --tw-backdrop-sepia: sepia(100%);
+ -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,);
+ }
+ .backdrop-filter {
+ -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,);
+ }
+ .transition-colors {
+ 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));
+ }
+ .ease-in {
+ --tw-ease: var(--ease-in);
+ transition-timing-function: var(--ease-in);
+ }
+ .ease-in-out {
+ --tw-ease: var(--ease-in-out);
+ transition-timing-function: var(--ease-in-out);
+ }
+ .ease-out {
+ --tw-ease: var(--ease-out);
+ transition-timing-function: var(--ease-out);
+ }
+ .divide-x-reverse {
+ :where(& > :not(:last-child)) {
+ --tw-divide-x-reverse: 1;
+ }
+ }
+ .ring-inset {
+ --tw-ring-inset: inset;
+ }
+ .hover\:bg-blue-700 {
+ &:hover {
+ @media (hover: hover) {
+ background-color: var(--color-blue-700);
+ }
+ }
+ }
+ .hover\:bg-green-700 {
+ &:hover {
+ @media (hover: hover) {
+ background-color: var(--color-green-700);
+ }
+ }
+ }
+ .hover\:text-accent-300 {
+ &:hover {
+ @media (hover: hover) {
+ color: var(--color-accent-300);
+ }
+ }
+ }
+ .focus\:ring-2 {
+ &:focus {
+ --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);
+ }
+ }
+ .focus\:ring-blue-500 {
+ &:focus {
+ --tw-ring-color: var(--color-blue-500);
+ }
+ }
+ .focus\:outline-none {
+ &:focus {
+ --tw-outline-style: none;
+ outline-style: none;
+ }
+ }
+}
+@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-rotate-x {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-rotate-y {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-rotate-z {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-skew-x {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-skew-y {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-pan-x {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-pan-y {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-pinch-zoom {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-space-y-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-space-x-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-divide-x-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-border-style {
+ syntax: "*";
+ inherits: false;
+ initial-value: solid;
+}
+@property --tw-divide-y-reverse {
+ syntax: "*";
+ inherits: false;
+ initial-value: 0;
+}
+@property --tw-leading {
+ 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-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: 0px;
+}
+@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-blur {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-brightness {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-contrast {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-grayscale {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-hue-rotate {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-invert {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-opacity {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-saturate {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-sepia {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow-color {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow-alpha {
+ syntax: "";
+ inherits: false;
+ initial-value: 100%;
+}
+@property --tw-drop-shadow-size {
+ syntax: "*";
+ inherits: false;
+}
+@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;
+}
+@property --tw-ease {
+ syntax: "*";
+ inherits: false;
+}
+@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-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-scale-z: 1;
+ --tw-rotate-x: initial;
+ --tw-rotate-y: initial;
+ --tw-rotate-z: initial;
+ --tw-skew-x: initial;
+ --tw-skew-y: initial;
+ --tw-pan-x: initial;
+ --tw-pan-y: initial;
+ --tw-pinch-zoom: initial;
+ --tw-space-y-reverse: 0;
+ --tw-space-x-reverse: 0;
+ --tw-divide-x-reverse: 0;
+ --tw-border-style: solid;
+ --tw-divide-y-reverse: 0;
+ --tw-leading: 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-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-blur: initial;
+ --tw-brightness: initial;
+ --tw-contrast: initial;
+ --tw-grayscale: initial;
+ --tw-hue-rotate: initial;
+ --tw-invert: initial;
+ --tw-opacity: initial;
+ --tw-saturate: initial;
+ --tw-sepia: initial;
+ --tw-drop-shadow: initial;
+ --tw-drop-shadow-color: initial;
+ --tw-drop-shadow-alpha: 100%;
+ --tw-drop-shadow-size: initial;
+ --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;
+ --tw-ease: initial;
+ }
+ }
+}
diff --git a/tsunami/engine/asyncnotify.go b/tsunami/engine/asyncnotify.go
new file mode 100644
index 0000000000..c9ebbb2b18
--- /dev/null
+++ b/tsunami/engine/asyncnotify.go
@@ -0,0 +1,162 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package engine
+
+import (
+ "time"
+)
+
+const NotifyMaxCadence = 10 * time.Millisecond
+const NotifyDebounceTime = 500 * time.Microsecond
+const NotifyMaxDebounceTime = 2 * time.Millisecond
+
+func (c *ClientImpl) notifyAsyncRenderWork() {
+ c.notifyOnce.Do(func() {
+ c.notifyWakeCh = make(chan struct{}, 1)
+ go c.asyncInitiationLoop()
+ })
+
+ nowNs := time.Now().UnixNano()
+ c.notifyLastEventNs.Store(nowNs)
+ // Establish batch start if there's no active batch.
+ if c.notifyBatchStartNs.Load() == 0 {
+ c.notifyBatchStartNs.CompareAndSwap(0, nowNs)
+ }
+ // Coalesced wake-up.
+ select {
+ case c.notifyWakeCh <- struct{}{}:
+ default:
+ }
+}
+
+func (c *ClientImpl) asyncInitiationLoop() {
+ var (
+ lastSent time.Time
+ timer *time.Timer
+ timerC <-chan time.Time
+ )
+
+ schedule := func() {
+ firstNs := c.notifyBatchStartNs.Load()
+ if firstNs == 0 {
+ // No pending batch; stop timer if running.
+ if timer != nil {
+ if !timer.Stop() {
+ select {
+ case <-timer.C:
+ default:
+ }
+ }
+ }
+ timerC = nil
+ return
+ }
+ lastNs := c.notifyLastEventNs.Load()
+
+ first := time.Unix(0, firstNs)
+ last := time.Unix(0, lastNs)
+ cadenceReady := lastSent.Add(NotifyMaxCadence)
+
+ // Reset the 2ms "max debounce" window at the cadence boundary:
+ // deadline = max(first, cadenceReady) + 2ms
+ anchor := first
+ if cadenceReady.After(anchor) {
+ anchor = cadenceReady
+ }
+ deadline := anchor.Add(NotifyMaxDebounceTime)
+
+ // candidate = min(last+500us, deadline)
+ candidate := last.Add(NotifyDebounceTime)
+ if deadline.Before(candidate) {
+ candidate = deadline
+ }
+
+ // final target = max(cadenceReady, candidate)
+ target := candidate
+ if cadenceReady.After(target) {
+ target = cadenceReady
+ }
+
+ d := time.Until(target)
+ if d < 0 {
+ d = 0
+ }
+ if timer == nil {
+ timer = time.NewTimer(d)
+ } else {
+ if !timer.Stop() {
+ select {
+ case <-timer.C:
+ default:
+ }
+ }
+ timer.Reset(d)
+ }
+ timerC = timer.C
+ }
+
+ for {
+ select {
+ case <-c.notifyWakeCh:
+ schedule()
+
+ case <-timerC:
+ now := time.Now()
+
+ // Recompute right before sending; if a late event arrived,
+ // push the fire time out to respect the debounce.
+ firstNs := c.notifyBatchStartNs.Load()
+ if firstNs == 0 {
+ // Nothing to do.
+ continue
+ }
+ lastNs := c.notifyLastEventNs.Load()
+
+ first := time.Unix(0, firstNs)
+ last := time.Unix(0, lastNs)
+ cadenceReady := lastSent.Add(NotifyMaxCadence)
+
+ anchor := first
+ if cadenceReady.After(anchor) {
+ anchor = cadenceReady
+ }
+ deadline := anchor.Add(NotifyMaxDebounceTime)
+
+ candidate := last.Add(NotifyDebounceTime)
+ if deadline.Before(candidate) {
+ candidate = deadline
+ }
+ target := candidate
+ if cadenceReady.After(target) {
+ target = cadenceReady
+ }
+
+ // If we're early (because a new event just came in), reschedule.
+ if now.Before(target) {
+ d := time.Until(target)
+ if d < 0 {
+ d = 0
+ }
+ if !timer.Stop() {
+ select {
+ case <-timer.C:
+ default:
+ }
+ }
+ timer.Reset(d)
+ continue
+ }
+
+ // Fire.
+ _ = c.SendAsyncInitiation()
+ lastSent = now
+
+ // Close current batch; a concurrent notify will CAS a new start.
+ c.notifyBatchStartNs.Store(0)
+
+ // If anything is already pending, this will arm the next timer.
+ schedule()
+ }
+ }
+}
diff --git a/tsunami/engine/atomimpl.go b/tsunami/engine/atomimpl.go
new file mode 100644
index 0000000000..3d9c5a4896
--- /dev/null
+++ b/tsunami/engine/atomimpl.go
@@ -0,0 +1,86 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package engine
+
+import (
+ "encoding/json"
+ "fmt"
+ "sync"
+)
+
+type AtomImpl[T any] struct {
+ lock *sync.Mutex
+ val T
+ usedBy map[string]bool // component waveid -> true
+}
+
+func MakeAtomImpl[T any](initialVal T) *AtomImpl[T] {
+ return &AtomImpl[T]{
+ lock: &sync.Mutex{},
+ val: initialVal,
+ usedBy: make(map[string]bool),
+ }
+}
+
+func (a *AtomImpl[T]) GetVal() any {
+ a.lock.Lock()
+ defer a.lock.Unlock()
+ return a.val
+}
+
+func (a *AtomImpl[T]) setVal_nolock(val any) error {
+ if val == nil {
+ var zero T
+ a.val = zero
+ return nil
+ }
+
+ // Try direct assignment if it's already type T
+ if typed, ok := val.(T); ok {
+ a.val = typed
+ return nil
+ }
+
+ // Try JSON marshaling/unmarshaling
+ jsonBytes, err := json.Marshal(val)
+ if err != nil {
+ var result T
+ return fmt.Errorf("failed to adapt type from %T => %T, input type failed to marshal: %w", val, result, err)
+ }
+
+ var result T
+ if err := json.Unmarshal(jsonBytes, &result); err != nil {
+ return fmt.Errorf("failed to adapt type from %T => %T: %w", val, result, err)
+ }
+
+ a.val = result
+ return nil
+}
+
+func (a *AtomImpl[T]) SetVal(val any) error {
+ a.lock.Lock()
+ defer a.lock.Unlock()
+ return a.setVal_nolock(val)
+}
+
+func (a *AtomImpl[T]) SetUsedBy(waveId string, used bool) {
+ a.lock.Lock()
+ defer a.lock.Unlock()
+ if used {
+ a.usedBy[waveId] = true
+ } else {
+ delete(a.usedBy, waveId)
+ }
+}
+
+func (a *AtomImpl[T]) GetUsedBy() []string {
+ a.lock.Lock()
+ defer a.lock.Unlock()
+
+ keys := make([]string, 0, len(a.usedBy))
+ for compId := range a.usedBy {
+ keys = append(keys, compId)
+ }
+ return keys
+}
diff --git a/tsunami/engine/clientimpl.go b/tsunami/engine/clientimpl.go
new file mode 100644
index 0000000000..73697c1754
--- /dev/null
+++ b/tsunami/engine/clientimpl.go
@@ -0,0 +1,318 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package engine
+
+import (
+ "context"
+ "fmt"
+ "io/fs"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+ "unicode"
+
+ "github.com/google/uuid"
+ "github.com/wavetermdev/waveterm/tsunami/rpctypes"
+ "github.com/wavetermdev/waveterm/tsunami/util"
+ "github.com/wavetermdev/waveterm/tsunami/vdom"
+)
+
+const TsunamiListenAddrEnvVar = "TSUNAMI_LISTENADDR"
+const DefaultListenAddr = "localhost:0"
+const DefaultComponentName = "App"
+
+type ssEvent struct {
+ Event string
+ Data []byte
+}
+
+var defaultClient = makeClient()
+
+type ClientImpl struct {
+ Lock *sync.Mutex
+ Root *RootElem
+ RootElem *vdom.VDomElem
+ CurrentClientId string
+ ServerId string
+ IsDone bool
+ DoneReason string
+ DoneCh chan struct{}
+ SSEventCh chan ssEvent
+ GlobalEventHandler func(event vdom.VDomEvent)
+ UrlHandlerMux *http.ServeMux
+ SetupFn func()
+ AssetsFS fs.FS
+ StaticFS fs.FS
+ ManifestFileBytes []byte
+
+ // for notification
+ // Atomics so we never drop "last event" timing info even if wakeCh is full.
+ // 0 means "no pending batch".
+ notifyOnce sync.Once
+ notifyWakeCh chan struct{}
+ notifyBatchStartNs atomic.Int64 // ns of first event in current batch
+ notifyLastEventNs atomic.Int64 // ns of most recent event
+}
+
+func makeClient() *ClientImpl {
+ client := &ClientImpl{
+ Lock: &sync.Mutex{},
+ DoneCh: make(chan struct{}),
+ SSEventCh: make(chan ssEvent, 100),
+ UrlHandlerMux: http.NewServeMux(),
+ ServerId: uuid.New().String(),
+ RootElem: vdom.H(DefaultComponentName, nil),
+ }
+ client.Root = MakeRoot(client)
+ return client
+}
+
+func GetDefaultClient() *ClientImpl {
+ return defaultClient
+}
+
+func (c *ClientImpl) GetIsDone() bool {
+ c.Lock.Lock()
+ defer c.Lock.Unlock()
+ return c.IsDone
+}
+
+func (c *ClientImpl) checkClientId(clientId string) error {
+ if clientId == "" {
+ return fmt.Errorf("client id cannot be empty")
+ }
+ c.Lock.Lock()
+ defer c.Lock.Unlock()
+ if c.CurrentClientId == "" || c.CurrentClientId == clientId {
+ c.CurrentClientId = clientId
+ return nil
+ }
+ return fmt.Errorf("client id mismatch: expected %s, got %s", c.CurrentClientId, clientId)
+}
+
+func (c *ClientImpl) clientTakeover(clientId string) {
+ c.Lock.Lock()
+ defer c.Lock.Unlock()
+ c.CurrentClientId = clientId
+}
+
+func (c *ClientImpl) doShutdown(reason string) {
+ c.Lock.Lock()
+ defer c.Lock.Unlock()
+ if c.IsDone {
+ return
+ }
+ c.DoneReason = reason
+ c.IsDone = true
+ close(c.DoneCh)
+}
+
+func (c *ClientImpl) SetGlobalEventHandler(handler func(event vdom.VDomEvent)) {
+ c.GlobalEventHandler = handler
+}
+
+func (c *ClientImpl) getFaviconPath() string {
+ if c.StaticFS != nil {
+ faviconNames := []string{"favicon.ico", "favicon.png", "favicon.svg", "favicon.gif", "favicon.jpg"}
+ for _, name := range faviconNames {
+ if _, err := c.StaticFS.Open(name); err == nil {
+ return "/static/" + name
+ }
+ }
+ }
+ return "/wave-logo-256.png"
+}
+
+func (c *ClientImpl) makeBackendOpts() *rpctypes.VDomBackendOpts {
+ return &rpctypes.VDomBackendOpts{
+ Title: c.Root.AppTitle,
+ GlobalKeyboardEvents: c.GlobalEventHandler != nil,
+ FaviconPath: c.getFaviconPath(),
+ }
+}
+
+func (c *ClientImpl) runMainE() error {
+ if c.SetupFn != nil {
+ c.SetupFn()
+ }
+ err := c.listenAndServe(context.Background())
+ if err != nil {
+ return err
+ }
+ <-c.DoneCh
+ return nil
+}
+
+func (c *ClientImpl) RegisterSetupFn(fn func()) {
+ c.SetupFn = fn
+}
+
+func (c *ClientImpl) RunMain() {
+ err := c.runMainE()
+ if err != nil {
+ fmt.Println(err)
+ os.Exit(1)
+ }
+}
+
+func (c *ClientImpl) listenAndServe(ctx context.Context) error {
+ // Create HTTP handlers
+ handlers := newHTTPHandlers(c)
+
+ // Create a new ServeMux and register handlers
+ mux := http.NewServeMux()
+ handlers.registerHandlers(mux, handlerOpts{
+ AssetsFS: c.AssetsFS,
+ StaticFS: c.StaticFS,
+ ManifestFile: c.ManifestFileBytes,
+ })
+
+ // Determine listen address from environment variable or use default
+ listenAddr := os.Getenv(TsunamiListenAddrEnvVar)
+ if listenAddr == "" {
+ listenAddr = DefaultListenAddr
+ }
+
+ // Create server and listen on specified address
+ server := &http.Server{
+ Addr: listenAddr,
+ Handler: mux,
+ }
+
+ // Start listening
+ listener, err := net.Listen("tcp", listenAddr)
+ if err != nil {
+ return fmt.Errorf("failed to listen: %v", err)
+ }
+
+ // Log the address we're listening on
+ port := listener.Addr().(*net.TCPAddr).Port
+ log.Printf("[tsunami] listening at http://localhost:%d", port)
+
+ // Serve in a goroutine so we don't block
+ go func() {
+ if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
+ log.Printf("HTTP server error: %v", err)
+ }
+ }()
+
+ // Wait for context cancellation and shutdown server gracefully
+ go func() {
+ <-ctx.Done()
+ log.Printf("Context canceled, shutting down server...")
+ if err := server.Shutdown(context.Background()); err != nil {
+ log.Printf("Server shutdown error: %v", err)
+ }
+ }()
+
+ return nil
+}
+
+func (c *ClientImpl) SendAsyncInitiation() error {
+ log.Printf("send async initiation\n")
+ if c.GetIsDone() {
+ return fmt.Errorf("client is done")
+ }
+
+ select {
+ case c.SSEventCh <- ssEvent{Event: "asyncinitiation", Data: nil}:
+ return nil
+ default:
+ return fmt.Errorf("SSEvent channel is full")
+ }
+}
+
+func makeNullRendered() *rpctypes.RenderedElem {
+ return &rpctypes.RenderedElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag}
+}
+
+func structToProps(props any) map[string]any {
+ m, err := util.StructToMap(props)
+ if err != nil {
+ return nil
+ }
+ return m
+}
+
+func DefineComponentEx[P any](client *ClientImpl, name string, renderFn func(props P) any) vdom.Component[P] {
+ if name == "" {
+ panic("Component name cannot be empty")
+ }
+ if !unicode.IsUpper(rune(name[0])) {
+ panic("Component name must start with an uppercase letter")
+ }
+ err := client.registerComponent(name, renderFn)
+ if err != nil {
+ panic(err)
+ }
+ return func(props P) *vdom.VDomElem {
+ return vdom.H(name, structToProps(props))
+ }
+}
+
+func (c *ClientImpl) registerComponent(name string, cfunc any) error {
+ return c.Root.RegisterComponent(name, cfunc)
+}
+
+func (c *ClientImpl) fullRender() (*rpctypes.VDomBackendUpdate, error) {
+ opts := &RenderOpts{Resync: true}
+ c.Root.RunWork(opts)
+ c.Root.Render(c.RootElem, opts)
+ renderedVDom := c.Root.MakeRendered()
+ if renderedVDom == nil {
+ renderedVDom = makeNullRendered()
+ }
+ return &rpctypes.VDomBackendUpdate{
+ Type: "backendupdate",
+ Ts: time.Now().UnixMilli(),
+ ServerId: c.ServerId,
+ HasWork: len(c.Root.EffectWorkQueue) > 0,
+ FullUpdate: true,
+ Opts: c.makeBackendOpts(),
+ RenderUpdates: []rpctypes.VDomRenderUpdate{
+ {UpdateType: "root", VDom: renderedVDom},
+ },
+ RefOperations: c.Root.GetRefOperations(),
+ }, nil
+}
+
+func (c *ClientImpl) incrementalRender() (*rpctypes.VDomBackendUpdate, error) {
+ opts := &RenderOpts{Resync: false}
+ c.Root.RunWork(opts)
+ renderedVDom := c.Root.MakeRendered()
+ if renderedVDom == nil {
+ renderedVDom = makeNullRendered()
+ }
+ return &rpctypes.VDomBackendUpdate{
+ Type: "backendupdate",
+ Ts: time.Now().UnixMilli(),
+ ServerId: c.ServerId,
+ HasWork: len(c.Root.EffectWorkQueue) > 0,
+ FullUpdate: false,
+ Opts: c.makeBackendOpts(),
+ RenderUpdates: []rpctypes.VDomRenderUpdate{
+ {UpdateType: "root", VDom: renderedVDom},
+ },
+ RefOperations: c.Root.GetRefOperations(),
+ }, nil
+}
+
+func (c *ClientImpl) HandleDynFunc(pattern string, fn func(http.ResponseWriter, *http.Request)) {
+ if !strings.HasPrefix(pattern, "/dyn/") {
+ log.Printf("invalid dyn pattern: %s (must start with /dyn/)", pattern)
+ return
+ }
+ c.UrlHandlerMux.HandleFunc(pattern, fn)
+}
+
+func (c *ClientImpl) RunEvents(events []vdom.VDomEvent) {
+ for _, event := range events {
+ c.Root.Event(event, c.GlobalEventHandler)
+ }
+}
diff --git a/tsunami/engine/comp.go b/tsunami/engine/comp.go
new file mode 100644
index 0000000000..2bbea1a74a
--- /dev/null
+++ b/tsunami/engine/comp.go
@@ -0,0 +1,52 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package engine
+
+import "github.com/wavetermdev/waveterm/tsunami/vdom"
+
+// so components either render to another component (or fragment)
+// or to a base element (text or vdom). base elements can then render children
+
+type ChildKey struct {
+ Tag string
+ Idx int
+ Key string
+}
+
+// ComponentImpl represents a node in the persistent shadow component tree.
+// This is Tsunami's equivalent to React's Fiber nodes - it maintains component
+// identity, state, and lifecycle across renders while the VDomElem input/output
+// structures are ephemeral.
+type ComponentImpl struct {
+ WaveId string // Unique identifier for this component instance
+ Tag string // Component type (HTML tag, custom component name, "#text", etc.)
+ Key string // User-provided key for reconciliation (like React keys)
+ ContainingComp string // Which vdom component's render function created this ComponentImpl
+ Elem *vdom.VDomElem // Reference to the current input VDomElem being rendered
+ Mounted bool // Whether this component is currently mounted
+
+ // Hooks system (React-like)
+ Hooks []*Hook // Array of hooks (state, effects, etc.) attached to this component
+
+ // Atom dependency tracking
+ UsedAtoms map[string]bool // atomName -> true, tracks which atoms this component uses
+
+ // Component content - exactly ONE of these patterns is used:
+
+ // Pattern 1: Text nodes
+ Text string // For "#text" components - stores the actual text content
+
+ // Pattern 2: Base/DOM elements with children
+ Children []*ComponentImpl // For HTML tags, fragments - array of child components
+
+ // Pattern 3: Custom components that render to other components
+ RenderedComp *ComponentImpl // For custom components - points to what this component rendered to
+}
+
+func (c *ComponentImpl) compMatch(tag string, key string) bool {
+ if c == nil {
+ return false
+ }
+ return c.Tag == tag && c.Key == key
+}
diff --git a/tsunami/engine/globalctx.go b/tsunami/engine/globalctx.go
new file mode 100644
index 0000000000..0d03a93710
--- /dev/null
+++ b/tsunami/engine/globalctx.go
@@ -0,0 +1,159 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package engine
+
+import (
+ "sync"
+
+ "github.com/outrigdev/goid"
+ "github.com/wavetermdev/waveterm/tsunami/vdom"
+)
+
+const (
+ GlobalContextType_async = "async"
+ GlobalContextType_render = "render"
+ GlobalContextType_effect = "effect"
+ GlobalContextType_event = "event"
+)
+
+// is set ONLY when we're in the render function of a component
+// used for hooks, and automatic dependency tracking
+var globalRenderContext *RenderContextImpl
+var globalRenderGoId uint64
+
+var globalEventContext *EventContextImpl
+var globalEventGoId uint64
+
+var globalEffectContext *EffectContextImpl
+var globalEffectGoId uint64
+
+var globalCtxMutex sync.Mutex
+
+type EventContextImpl struct {
+ Event vdom.VDomEvent
+ Root *RootElem
+}
+
+type EffectContextImpl struct {
+ WorkElem EffectWorkElem
+ WorkType string // "run" or "unmount"
+ Root *RootElem
+}
+
+func setGlobalRenderContext(vc *RenderContextImpl) {
+ globalCtxMutex.Lock()
+ defer globalCtxMutex.Unlock()
+ globalRenderContext = vc
+ globalRenderGoId = goid.Get()
+}
+
+func clearGlobalRenderContext() {
+ globalCtxMutex.Lock()
+ defer globalCtxMutex.Unlock()
+ globalRenderContext = nil
+ globalRenderGoId = 0
+}
+
+func withGlobalRenderCtx[T any](vc *RenderContextImpl, fn func() T) T {
+ setGlobalRenderContext(vc)
+ defer clearGlobalRenderContext()
+ return fn()
+}
+
+func GetGlobalRenderContext() *RenderContextImpl {
+ globalCtxMutex.Lock()
+ defer globalCtxMutex.Unlock()
+ gid := goid.Get()
+ if gid != globalRenderGoId {
+ return nil
+ }
+ return globalRenderContext
+}
+
+func setGlobalEventContext(ec *EventContextImpl) {
+ globalCtxMutex.Lock()
+ defer globalCtxMutex.Unlock()
+ globalEventContext = ec
+ globalEventGoId = goid.Get()
+}
+
+func clearGlobalEventContext() {
+ globalCtxMutex.Lock()
+ defer globalCtxMutex.Unlock()
+ globalEventContext = nil
+ globalEventGoId = 0
+}
+
+func withGlobalEventCtx[T any](ec *EventContextImpl, fn func() T) T {
+ setGlobalEventContext(ec)
+ defer clearGlobalEventContext()
+ return fn()
+}
+
+func GetGlobalEventContext() *EventContextImpl {
+ globalCtxMutex.Lock()
+ defer globalCtxMutex.Unlock()
+ gid := goid.Get()
+ if gid != globalEventGoId {
+ return nil
+ }
+ return globalEventContext
+}
+
+func setGlobalEffectContext(ec *EffectContextImpl) {
+ globalCtxMutex.Lock()
+ defer globalCtxMutex.Unlock()
+ globalEffectContext = ec
+ globalEffectGoId = goid.Get()
+}
+
+func clearGlobalEffectContext() {
+ globalCtxMutex.Lock()
+ defer globalCtxMutex.Unlock()
+ globalEffectContext = nil
+ globalEffectGoId = 0
+}
+
+func withGlobalEffectCtx[T any](ec *EffectContextImpl, fn func() T) T {
+ setGlobalEffectContext(ec)
+ defer clearGlobalEffectContext()
+ return fn()
+}
+
+func GetGlobalEffectContext() *EffectContextImpl {
+ globalCtxMutex.Lock()
+ defer globalCtxMutex.Unlock()
+ gid := goid.Get()
+ if gid != globalEffectGoId {
+ return nil
+ }
+ return globalEffectContext
+}
+
+// inContextType returns the current global context type.
+// Returns one of:
+// - GlobalContextType_render: when in a component render function
+// - GlobalContextType_event: when in an event handler
+// - GlobalContextType_effect: when in an effect function
+// - GlobalContextType_async: when not in any specific context (default/async)
+func inContextType() string {
+ globalCtxMutex.Lock()
+ defer globalCtxMutex.Unlock()
+
+ gid := goid.Get()
+
+ if globalRenderContext != nil && gid == globalRenderGoId {
+ return GlobalContextType_render
+ }
+
+ if globalEventContext != nil && gid == globalEventGoId {
+ return GlobalContextType_event
+ }
+
+ if globalEffectContext != nil && gid == globalEffectGoId {
+ return GlobalContextType_effect
+ }
+
+ return GlobalContextType_async
+}
diff --git a/tsunami/engine/hooks.go b/tsunami/engine/hooks.go
new file mode 100644
index 0000000000..d4db65d8cc
--- /dev/null
+++ b/tsunami/engine/hooks.go
@@ -0,0 +1,167 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package engine
+
+import (
+ "log"
+ "strconv"
+
+ "github.com/wavetermdev/waveterm/tsunami/vdom"
+)
+
+// generic hook structure
+type Hook struct {
+ Init bool // is initialized
+ Idx int // index in the hook array
+ Fn func() func() // for useEffect
+ UnmountFn func() // for useEffect
+ Val any // for useState, useMemo, useRef
+ Deps []any
+}
+
+type RenderContextImpl struct {
+ Root *RootElem
+ Comp *ComponentImpl
+ HookIdx int
+ RenderOpts *RenderOpts
+ UsedAtoms map[string]bool // Track atoms used during this render
+}
+
+func makeContextVal(root *RootElem, comp *ComponentImpl, opts *RenderOpts) *RenderContextImpl {
+ return &RenderContextImpl{
+ Root: root,
+ Comp: comp,
+ HookIdx: 0,
+ RenderOpts: opts,
+ UsedAtoms: make(map[string]bool),
+ }
+}
+
+func (vc *RenderContextImpl) GetCompWaveId() string {
+ if vc.Comp == nil {
+ return ""
+ }
+ return vc.Comp.WaveId
+}
+
+func (vc *RenderContextImpl) getOrderedHook() *Hook {
+ if vc.Comp == nil {
+ panic("tsunami hooks must be called within a component (vc.Comp is nil)")
+ }
+ for len(vc.Comp.Hooks) <= vc.HookIdx {
+ vc.Comp.Hooks = append(vc.Comp.Hooks, &Hook{Idx: len(vc.Comp.Hooks)})
+ }
+ hookVal := vc.Comp.Hooks[vc.HookIdx]
+ vc.HookIdx++
+ return hookVal
+}
+
+func (vc *RenderContextImpl) getCompName() string {
+ if vc.Comp == nil || vc.Comp.Elem == nil {
+ return ""
+ }
+ return vc.Comp.Elem.Tag
+}
+
+func UseRenderTs(vc *RenderContextImpl) int64 {
+ return vc.Root.RenderTs
+}
+
+func UseId(vc *RenderContextImpl) string {
+ return vc.GetCompWaveId()
+}
+
+func UseLocal(vc *RenderContextImpl, initialVal any) string {
+ hookVal := vc.getOrderedHook()
+ atomName := "$local." + vc.GetCompWaveId() + "#" + strconv.Itoa(hookVal.Idx)
+ if !hookVal.Init {
+ hookVal.Init = true
+ atom := MakeAtomImpl(initialVal)
+ vc.Root.RegisterAtom(atomName, atom)
+ closedAtomName := atomName
+ hookVal.UnmountFn = func() {
+ vc.Root.RemoveAtom(closedAtomName)
+ }
+ }
+ return atomName
+}
+
+func UseVDomRef(vc *RenderContextImpl) any {
+ hookVal := vc.getOrderedHook()
+ if !hookVal.Init {
+ hookVal.Init = true
+ refId := vc.GetCompWaveId() + ":" + strconv.Itoa(hookVal.Idx)
+ hookVal.Val = &vdom.VDomRef{Type: vdom.ObjectType_Ref, RefId: refId}
+ }
+ refVal, ok := hookVal.Val.(*vdom.VDomRef)
+ if !ok {
+ panic("UseVDomRef hook value is not a ref (possible out of order or conditional hooks)")
+ }
+ return refVal
+}
+
+func UseRef(vc *RenderContextImpl, hookInitialVal any) any {
+ hookVal := vc.getOrderedHook()
+ if !hookVal.Init {
+ hookVal.Init = true
+ hookVal.Val = hookInitialVal
+ }
+ return hookVal.Val
+}
+
+func depsEqual(deps1 []any, deps2 []any) bool {
+ if len(deps1) != len(deps2) {
+ return false
+ }
+ for i := range deps1 {
+ if deps1[i] != deps2[i] {
+ return false
+ }
+ }
+ return true
+}
+
+func UseEffect(vc *RenderContextImpl, fn func() func(), deps []any) {
+ hookVal := vc.getOrderedHook()
+ compTag := ""
+ if vc.Comp != nil {
+ compTag = vc.Comp.Tag
+ }
+ if !hookVal.Init {
+ hookVal.Init = true
+ hookVal.Fn = fn
+ hookVal.Deps = deps
+ vc.Root.addEffectWork(vc.GetCompWaveId(), hookVal.Idx, compTag)
+ return
+ }
+ // If deps is nil, always run (like React with no dependency array)
+ if deps == nil {
+ hookVal.Fn = fn
+ hookVal.Deps = deps
+ vc.Root.addEffectWork(vc.GetCompWaveId(), hookVal.Idx, compTag)
+ return
+ }
+
+ if depsEqual(hookVal.Deps, deps) {
+ return
+ }
+ hookVal.Fn = fn
+ hookVal.Deps = deps
+ vc.Root.addEffectWork(vc.GetCompWaveId(), hookVal.Idx, compTag)
+}
+
+func UseResync(vc *RenderContextImpl) bool {
+ if vc.RenderOpts == nil {
+ return false
+ }
+ return vc.RenderOpts.Resync
+}
+
+func UseSetAppTitle(vc *RenderContextImpl, title string) {
+ if vc.getCompName() != "App" {
+ log.Printf("UseSetAppTitle can only be called from the App component")
+ return
+ }
+ vc.Root.AppTitle = title
+}
diff --git a/tsunami/engine/render.go b/tsunami/engine/render.go
new file mode 100644
index 0000000000..5da8dc6262
--- /dev/null
+++ b/tsunami/engine/render.go
@@ -0,0 +1,319 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package engine
+
+import (
+ "fmt"
+ "reflect"
+ "unicode"
+
+ "github.com/google/uuid"
+ "github.com/wavetermdev/waveterm/tsunami/rpctypes"
+ "github.com/wavetermdev/waveterm/tsunami/util"
+ "github.com/wavetermdev/waveterm/tsunami/vdom"
+)
+
+// see render.md for a complete guide to how tsunami rendering, lifecycle, and reconciliation works
+
+type RenderOpts struct {
+ Resync bool
+}
+
+func (r *RootElem) Render(elem *vdom.VDomElem, opts *RenderOpts) {
+ r.render(elem, &r.Root, "root", opts)
+}
+
+func getElemKey(elem *vdom.VDomElem) string {
+ if elem == nil {
+ return ""
+ }
+ keyVal, ok := elem.Props[vdom.KeyPropKey]
+ if !ok {
+ return ""
+ }
+ return fmt.Sprint(keyVal)
+}
+
+func (r *RootElem) render(elem *vdom.VDomElem, comp **ComponentImpl, containingComp string, opts *RenderOpts) {
+ if elem == nil || elem.Tag == "" {
+ r.unmount(comp)
+ return
+ }
+ elemKey := getElemKey(elem)
+ if *comp == nil || !(*comp).compMatch(elem.Tag, elemKey) {
+ r.unmount(comp)
+ r.createComp(elem.Tag, elemKey, containingComp, comp)
+ }
+ (*comp).Elem = elem
+ if elem.Tag == vdom.TextTag {
+ // Pattern 1: Text Nodes
+ r.renderText(elem.Text, comp)
+ return
+ }
+ if isBaseTag(elem.Tag) {
+ // Pattern 2: Base elements
+ r.renderSimple(elem, comp, containingComp, opts)
+ return
+ }
+ cfunc := r.CFuncs[elem.Tag]
+ if cfunc == nil {
+ text := fmt.Sprintf("<%s>", elem.Tag)
+ r.renderText(text, comp)
+ return
+ }
+ // Pattern 3: components
+ r.renderComponent(cfunc, elem, comp, opts)
+}
+
+// Pattern 1
+func (r *RootElem) renderText(text string, comp **ComponentImpl) {
+ // No need to clear Children/Comp - text components cannot have them
+ if (*comp).Text != text {
+ (*comp).Text = text
+ }
+}
+
+// Pattern 2
+func (r *RootElem) renderSimple(elem *vdom.VDomElem, comp **ComponentImpl, containingComp string, opts *RenderOpts) {
+ if (*comp).RenderedComp != nil {
+ // Clear Comp since base elements don't use it
+ r.unmount(&(*comp).RenderedComp)
+ }
+ (*comp).Children = r.renderChildren(elem.Children, (*comp).Children, containingComp, opts)
+}
+
+// Pattern 3
+func (r *RootElem) renderComponent(cfunc any, elem *vdom.VDomElem, comp **ComponentImpl, opts *RenderOpts) {
+ if (*comp).Children != nil {
+ // Clear Children since custom components don't use them
+ for _, child := range (*comp).Children {
+ r.unmount(&child)
+ }
+ (*comp).Children = nil
+ }
+ props := make(map[string]any)
+ for k, v := range elem.Props {
+ props[k] = v
+ }
+ props[ChildrenPropKey] = elem.Children
+ vc := makeContextVal(r, *comp, opts)
+ rtnElemArr := withGlobalRenderCtx(vc, func() []vdom.VDomElem {
+ renderedElem := callCFuncWithErrorGuard(cfunc, props, elem.Tag)
+ return vdom.ToElems(renderedElem)
+ })
+
+ // Process atom usage after render
+ r.updateComponentAtomUsage(*comp, vc.UsedAtoms)
+
+ var rtnElem *vdom.VDomElem
+ if len(rtnElemArr) == 0 {
+ rtnElem = nil
+ } else if len(rtnElemArr) == 1 {
+ rtnElem = &rtnElemArr[0]
+ } else {
+ rtnElem = &vdom.VDomElem{Tag: vdom.FragmentTag, Children: rtnElemArr}
+ }
+ r.render(rtnElem, &(*comp).RenderedComp, elem.Tag, opts)
+}
+
+func (r *RootElem) unmount(comp **ComponentImpl) {
+ if *comp == nil {
+ return
+ }
+ waveId := (*comp).WaveId
+ for _, hook := range (*comp).Hooks {
+ if hook.UnmountFn != nil {
+ hook.UnmountFn()
+ }
+ }
+ if (*comp).RenderedComp != nil {
+ r.unmount(&(*comp).RenderedComp)
+ }
+ if (*comp).Children != nil {
+ for _, child := range (*comp).Children {
+ r.unmount(&child)
+ }
+ }
+ delete(r.CompMap, waveId)
+ r.cleanupUsedByForUnmount(*comp)
+ *comp = nil
+}
+
+func (r *RootElem) createComp(tag string, key string, containingComp string, comp **ComponentImpl) {
+ *comp = &ComponentImpl{WaveId: uuid.New().String(), Tag: tag, Key: key, ContainingComp: containingComp}
+ r.CompMap[(*comp).WaveId] = *comp
+}
+
+// handles reconcilation
+// maps children via key or index (exclusively)
+func (r *RootElem) renderChildren(elems []vdom.VDomElem, curChildren []*ComponentImpl, containingComp string, opts *RenderOpts) []*ComponentImpl {
+ newChildren := make([]*ComponentImpl, len(elems))
+ curCM := make(map[ChildKey]*ComponentImpl)
+ usedMap := make(map[*ComponentImpl]bool)
+ for idx, child := range curChildren {
+ if child.Key != "" {
+ curCM[ChildKey{Tag: child.Tag, Idx: 0, Key: child.Key}] = child
+ } else {
+ curCM[ChildKey{Tag: child.Tag, Idx: idx, Key: ""}] = child
+ }
+ }
+ for idx, elem := range elems {
+ elemKey := getElemKey(&elem)
+ var curChild *ComponentImpl
+ if elemKey != "" {
+ curChild = curCM[ChildKey{Tag: elem.Tag, Idx: 0, Key: elemKey}]
+ } else {
+ curChild = curCM[ChildKey{Tag: elem.Tag, Idx: idx, Key: ""}]
+ }
+ usedMap[curChild] = true
+ newChildren[idx] = curChild
+ r.render(&elem, &newChildren[idx], containingComp, opts)
+ }
+ for _, child := range curChildren {
+ if !usedMap[child] {
+ r.unmount(&child)
+ }
+ }
+ return newChildren
+}
+
+// creates an error component for display when a component panics
+func renderErrorComponent(componentName string, errorMsg string) any {
+ return vdom.H("div", map[string]any{
+ "className": "p-4 border border-red-500 bg-red-100 text-red-800 rounded font-mono",
+ },
+ vdom.H("div", map[string]any{
+ "className": "font-bold mb-2",
+ }, fmt.Sprintf("Component Error: %s", componentName)),
+ vdom.H("div", nil, errorMsg),
+ )
+}
+
+// safely calls the component function with panic recovery
+func callCFuncWithErrorGuard(cfunc any, props map[string]any, componentName string) (result any) {
+ defer func() {
+ if panicErr := util.PanicHandler(fmt.Sprintf("render component '%s'", componentName), recover()); panicErr != nil {
+ result = renderErrorComponent(componentName, panicErr.Error())
+ }
+ }()
+
+ result = callCFunc(cfunc, props)
+ return result
+}
+
+// uses reflection to call the component function
+func callCFunc(cfunc any, props map[string]any) any {
+ rval := reflect.ValueOf(cfunc)
+ rtype := rval.Type()
+
+ if rtype.NumIn() != 1 {
+ fmt.Printf("component function must have exactly 1 parameter, got %d\n", rtype.NumIn())
+ return nil
+ }
+
+ argType := rtype.In(0)
+
+ var arg1Val reflect.Value
+ if argType.Kind() == reflect.Interface && argType.NumMethod() == 0 {
+ arg1Val = reflect.New(argType)
+ } else {
+ arg1Val = reflect.New(argType)
+ if argType.Kind() == reflect.Map {
+ arg1Val.Elem().Set(reflect.ValueOf(props))
+ } else {
+ err := util.MapToStruct(props, arg1Val.Interface())
+ if err != nil {
+ fmt.Printf("error converting props: %v\n", err)
+ }
+ }
+ }
+ rtnVal := rval.Call([]reflect.Value{arg1Val.Elem()})
+ if len(rtnVal) == 0 {
+ return nil
+ }
+ return rtnVal[0].Interface()
+}
+
+func convertPropsToVDom(props map[string]any) map[string]any {
+ if len(props) == 0 {
+ return nil
+ }
+ vdomProps := make(map[string]any)
+ for k, v := range props {
+ if v == nil {
+ continue
+ }
+ if vdomFunc, ok := v.(vdom.VDomFunc); ok {
+ // ensure Type is set on all VDomFuncs
+ vdomFunc.Type = vdom.ObjectType_Func
+ vdomProps[k] = vdomFunc
+ continue
+ }
+ if vdomRef, ok := v.(vdom.VDomRef); ok {
+ // ensure Type is set on all VDomRefs
+ vdomRef.Type = vdom.ObjectType_Ref
+ vdomProps[k] = vdomRef
+ continue
+ }
+ val := reflect.ValueOf(v)
+ if val.Kind() == reflect.Func {
+ // convert go functions passed to event handlers to VDomFuncs
+ vdomProps[k] = vdom.VDomFunc{Type: vdom.ObjectType_Func}
+ continue
+ }
+ vdomProps[k] = v
+ }
+ return vdomProps
+}
+
+func (r *RootElem) MakeRendered() *rpctypes.RenderedElem {
+ if r.Root == nil {
+ return nil
+ }
+ return r.convertCompToRendered(r.Root)
+}
+
+func (r *RootElem) convertCompToRendered(c *ComponentImpl) *rpctypes.RenderedElem {
+ if c == nil {
+ return nil
+ }
+ if c.RenderedComp != nil {
+ return r.convertCompToRendered(c.RenderedComp)
+ }
+ if len(c.Children) == 0 && r.CFuncs[c.Tag] != nil {
+ return nil
+ }
+ return r.convertBaseToRendered(c)
+}
+
+func (r *RootElem) convertBaseToRendered(c *ComponentImpl) *rpctypes.RenderedElem {
+ elem := &rpctypes.RenderedElem{WaveId: c.WaveId, Tag: c.Tag}
+ if c.Elem != nil {
+ elem.Props = convertPropsToVDom(c.Elem.Props)
+ }
+ for _, child := range c.Children {
+ childElem := r.convertCompToRendered(child)
+ if childElem != nil {
+ elem.Children = append(elem.Children, *childElem)
+ }
+ }
+ if c.Tag == vdom.TextTag {
+ elem.Text = c.Text
+ }
+ return elem
+}
+
+func isBaseTag(tag string) bool {
+ if tag == "" {
+ return false
+ }
+ if tag == vdom.TextTag || tag == vdom.WaveTextTag || tag == vdom.WaveNullTag || tag == vdom.FragmentTag {
+ return true
+ }
+ if tag[0] == '#' {
+ return true
+ }
+ firstChar := rune(tag[0])
+ return unicode.IsLower(firstChar)
+}
diff --git a/tsunami/engine/render.md b/tsunami/engine/render.md
new file mode 100644
index 0000000000..b3fc0b64af
--- /dev/null
+++ b/tsunami/engine/render.md
@@ -0,0 +1,262 @@
+# Tsunami Rendering Engine
+
+The Tsunami rendering engine implements a React-like component system with virtual DOM reconciliation. It maintains a persistent shadow component tree that efficiently updates in response to new VDom input, similar to React's Fiber architecture.
+
+## Core Architecture
+
+### Two-Phase VDom System
+
+Tsunami uses separate types for different phases of the rendering pipeline:
+
+- **VDomElem**: Input format used by developers (JSX-like elements created with `vdom.H()`)
+- **ComponentImpl**: Internal shadow tree that maintains component identity and state across renders
+- **RenderedElem**: Output format sent to the frontend with populated WaveIds
+
+This separation mirrors React's approach where JSX elements, Fiber nodes, and DOM operations use different data structures optimized for their specific purposes.
+
+### ComponentImpl: The Shadow Tree
+
+The `ComponentImpl` structure is Tsunami's equivalent to React's Fiber nodes. It maintains a persistent tree that survives between renders, preserving component identity, state, and lifecycle information.
+
+Each ComponentImpl contains:
+
+- **Identity fields**: WaveId (unique identifier), Tag (component type), Key (for reconciliation)
+- **State management**: Hooks array for React-like state and effects
+- **Content organization**: Exactly one of three mutually exclusive patterns
+
+## Three Component Patterns
+
+The engine organizes components into three distinct patterns, each using different fields in ComponentImpl:
+
+### Pattern 1: Text Components
+
+```go
+Text string // Text content (Pattern 1: text nodes only)
+Children = nil // Not used
+RenderedComp = nil // Not used
+```
+
+Used for `#text` components that render string content directly. These are the leaf nodes of the component tree.
+
+**Example**: `vdom.H("#text", nil, "Hello World")` creates a ComponentImpl with `Text = "Hello World"`
+
+### Pattern 2: Base/DOM Elements
+
+```go
+Text = "" // Not used
+Children []*ComponentImpl // Child components (Pattern 2: containers only)
+RenderedComp = nil // Not used
+```
+
+Used for HTML elements, fragments, and Wave-specific elements that act as containers. These components render multiple children but don't transform into other component types.
+
+**Example**: `vdom.H("div", nil, child1, child2)` creates a ComponentImpl with `Children = [child1Comp, child2Comp]`
+
+**Base elements include**:
+
+- HTML tags with lowercase first letter (`"div"`, `"span"`, `"button"`)
+- Hash-prefixed special elements (`"#fragment"`, `"#text"`)
+- Wave-specific elements (`"wave:text"`, `"wave:null"`)
+
+### Pattern 3: Custom Components
+
+```go
+Text = "" // Not used
+Children = nil // Not used
+RenderedComp *ComponentImpl // Rendered output (Pattern 3: custom components only)
+```
+
+Used for user-defined components that transform into other components through their render functions. These create component chains where custom components render to base elements.
+
+**Example**: A `TodoItem` component renders to a `div`, creating the chain:
+
+```
+TodoItem ComponentImpl (Pattern 3)
+└── RenderedComp → div ComponentImpl (Pattern 2)
+ └── Children → [text, button, etc.]
+```
+
+## Rendering Flow
+
+### 1. Reconciliation and Pattern Routing
+
+The main `render()` function performs React-like reconciliation:
+
+1. **Null handling**: `elem == nil` unmounts the component
+2. **Component matching**: Existing components are reused if tag and key match
+3. **Pattern routing**: Elements are routed to the appropriate pattern based on tag type
+
+```go
+if elem.Tag == vdom.TextTag {
+ // Pattern 1: Text Nodes
+ r.renderText(elem.Text, comp)
+} else if isBaseTag(elem.Tag) {
+ // Pattern 2: Base elements
+ r.renderSimple(elem, comp, opts)
+} else {
+ // Pattern 3: Custom components
+ r.renderComponent(cfunc, elem, comp, opts)
+}
+```
+
+### 2. Pattern-Specific Rendering
+
+Each pattern has its own rendering function that manages field usage:
+
+**renderText()**: Simply stores text content, no cleanup needed since text components can't have other patterns.
+
+**renderSimple()**: Clears any existing `RenderedComp` (Pattern 3) and renders children into the `Children` field (Pattern 2).
+
+**renderComponent()**: Clears any existing `Children` (Pattern 2), calls the component function, and renders the result into `RenderedComp` (Pattern 3).
+
+### 3. Component Function Execution
+
+Custom components are Go functions called via reflection:
+
+1. **Props conversion**: The VDomElem props map is converted to the expected Go struct type
+2. **Function execution**: The component function is called with context and typed props
+3. **Result processing**: Returned elements are converted to VDomElem arrays
+4. **Fragment wrapping**: Multiple returned elements are automatically wrapped in fragments
+
+```go
+// Single element: renders directly to RenderedComp
+// Multiple elements: wrapped in fragment, then rendered to RenderedComp
+if len(rtnElemArr) == 1 {
+ rtnElem = &rtnElemArr[0]
+} else {
+ rtnElem = &vdom.VDomElem{Tag: vdom.FragmentTag, Children: rtnElemArr}
+}
+```
+
+## Key-Based Reconciliation
+
+The children reconciliation system implements React's key-matching logic:
+
+### ChildKey Structure
+
+```go
+type ChildKey struct {
+ Tag string // Component type must match
+ Idx int // Position index for non-keyed elements
+ Key string // Explicit key for keyed elements
+}
+```
+
+### Matching Rules
+
+1. **Keyed elements**: Match by tag + key, position ignored
+
+ - `` only matches `
`
+ - Position changes don't break identity
+
+2. **Non-keyed elements**: Match by tag + position
+
+ - `
` at position 0 only matches `
` at position 0
+ - Moving elements breaks identity and causes remount
+
+3. **Key transitions**: Keyed and non-keyed elements never match
+ - `
` → `
` causes remount
+ - Adding/removing keys breaks component identity
+
+### Reconciliation Algorithm
+
+```go
+// Build map of existing children by ChildKey
+for idx, child := range curChildren {
+ if child.Key != "" {
+ curCM[ChildKey{Tag: child.Tag, Idx: 0, Key: child.Key}] = child
+ } else {
+ curCM[ChildKey{Tag: child.Tag, Idx: idx, Key: ""}] = child
+ }
+}
+
+// Match new elements against existing map
+for idx, elem := range elems {
+ elemKey := getElemKey(&elem)
+ if elemKey != "" {
+ curChild = curCM[ChildKey{Tag: elem.Tag, Idx: 0, Key: elemKey}]
+ } else {
+ curChild = curCM[ChildKey{Tag: elem.Tag, Idx: idx, Key: ""}]
+ }
+ // Reuse existing component or create new one
+}
+```
+
+## Component Lifecycle
+
+### Mounting
+
+New components are created with:
+
+- Unique WaveId for tracking
+- Tag and Key for reconciliation
+- Registration in global ComponentMap
+- Empty pattern fields (populated during rendering)
+
+### Unmounting
+
+The unmounting process ensures complete cleanup:
+
+1. **Hook cleanup**: All hook `UnmountFn` callbacks are executed
+2. **Pattern-specific cleanup**:
+ - Pattern 3: Recursively unmount `RenderedComp`
+ - Pattern 2: Recursively unmount all `Children`
+ - Pattern 1: No child cleanup needed
+3. **Global cleanup**: Remove from ComponentMap and dependency tracking
+
+This prevents memory leaks and ensures proper lifecycle management.
+
+### Component vs Rendered Content Lifecycle
+
+A key distinction in Tsunami (matching React) is that component mounting/unmounting is separate from what they render:
+
+- **Component returns `nil`**: Component stays mounted (keeps state/hooks), but `RenderedComp` becomes `nil`
+- **Component returns content again**: Component reuses existing identity, new content gets mounted
+
+This preserves component state across rendering/not-rendering cycles.
+
+## Output Generation
+
+The shadow tree gets converted to frontend-ready format through `MakeRendered()`:
+
+1. **Component chain following**: For Pattern 3 components, follow `RenderedComp` until reaching a base element
+2. **Base element conversion**: Convert Pattern 1/2 components to RenderedElem with WaveIds
+3. **Null component filtering**: Components with `RenderedComp == nil` don't appear in output
+
+Only base elements (Pattern 1/2) appear in the final output - custom components (Pattern 3) are invisible, having transformed into base elements.
+
+## React Similarities and Differences
+
+### Similarities
+
+- **Reconciliation**: Same key-based matching and component reuse logic
+- **Hooks**: Same lifecycle patterns with cleanup functions
+- **Component identity**: Persistent component instances across renders
+- **Null rendering**: Components can render nothing while staying mounted
+
+### Key Differences
+
+- **Server-side**: Runs entirely in Go backend, sends VDom to frontend
+- **Component chaining**: Pattern 3 allows direct component-to-component rendering via `RenderedComp`
+- **Explicit patterns**: Three mutually exclusive patterns vs React's more flexible structure
+- **Type separation**: Clear separation between input VDom, shadow tree, and output types
+
+### Performance Optimizations
+
+The three-pattern system provides significant optimizations:
+
+- **Base element efficiency**: HTML elements use `Children` directly without intermediate transformation nodes
+- **Component chain efficiency**: Custom components chain via `RenderedComp` without wrapper overhead
+- **Memory efficiency**: Each pattern only allocates fields it actually uses
+
+This avoids React's issue where every element creates wrapper nodes, leading to shorter traversal paths and fewer allocations.
+
+## Pattern Transition Rules
+
+Components never transition between patterns - they maintain their pattern for their entire lifecycle:
+
+- **Tag determines pattern**: `#text` → Pattern 1, base tags → Pattern 2, custom tags → Pattern 3
+- **Tag changes cause remount**: Different tag = different component = complete unmount/remount
+- **Pattern fields are exclusive**: Only one pattern's fields are populated per component
+
+This ensures clean memory management and predictable behavior - no cross-pattern cleanup is needed within individual render functions.
diff --git a/tsunami/engine/rootelem.go b/tsunami/engine/rootelem.go
new file mode 100644
index 0000000000..15ed18442f
--- /dev/null
+++ b/tsunami/engine/rootelem.go
@@ -0,0 +1,453 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package engine
+
+import (
+ "fmt"
+ "log"
+ "reflect"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/wavetermdev/waveterm/tsunami/rpctypes"
+ "github.com/wavetermdev/waveterm/tsunami/util"
+ "github.com/wavetermdev/waveterm/tsunami/vdom"
+)
+
+const ChildrenPropKey = "children"
+
+type EffectWorkElem struct {
+ WaveId string
+ EffectIndex int
+ CompTag string
+}
+
+type genAtom interface {
+ GetVal() any
+ SetVal(any) error
+ SetUsedBy(string, bool)
+ GetUsedBy() []string
+}
+
+type RootElem struct {
+ Root *ComponentImpl
+ RenderTs int64
+ AppTitle string
+ CFuncs map[string]any // component name => render function
+ CompMap map[string]*ComponentImpl // component waveid -> component
+ EffectWorkQueue []*EffectWorkElem
+ needsRenderMap map[string]bool // key: waveid
+ needsRenderLock sync.Mutex
+ Atoms map[string]genAtom // key: atomName
+ atomLock sync.Mutex
+ RefOperations []vdom.VDomRefOperation
+ Client *ClientImpl
+}
+
+func (r *RootElem) addRenderWork(id string) {
+ defer func() {
+ if inContextType() == GlobalContextType_async {
+ r.Client.notifyAsyncRenderWork()
+ }
+ }()
+
+ r.needsRenderLock.Lock()
+ defer r.needsRenderLock.Unlock()
+
+ if r.needsRenderMap == nil {
+ r.needsRenderMap = make(map[string]bool)
+ }
+ r.needsRenderMap[id] = true
+}
+
+func (r *RootElem) getAndClearRenderWork() []string {
+ r.needsRenderLock.Lock()
+ defer r.needsRenderLock.Unlock()
+
+ if len(r.needsRenderMap) == 0 {
+ return nil
+ }
+
+ ids := make([]string, 0, len(r.needsRenderMap))
+ for id := range r.needsRenderMap {
+ ids = append(ids, id)
+ }
+ r.needsRenderMap = nil
+ return ids
+}
+
+func (r *RootElem) addEffectWork(id string, effectIndex int, compTag string) {
+ r.EffectWorkQueue = append(r.EffectWorkQueue, &EffectWorkElem{WaveId: id, EffectIndex: effectIndex, CompTag: compTag})
+}
+
+func (r *RootElem) GetDataMap() map[string]any {
+ r.atomLock.Lock()
+ defer r.atomLock.Unlock()
+
+ result := make(map[string]any)
+ for atomName, atom := range r.Atoms {
+ if strings.HasPrefix(atomName, "$data.") {
+ strippedName := strings.TrimPrefix(atomName, "$data.")
+ result[strippedName] = atom.GetVal()
+ }
+ }
+ return result
+}
+
+func (r *RootElem) GetConfigMap() map[string]any {
+ r.atomLock.Lock()
+ defer r.atomLock.Unlock()
+
+ result := make(map[string]any)
+ for atomName, atom := range r.Atoms {
+ if strings.HasPrefix(atomName, "$config.") {
+ strippedName := strings.TrimPrefix(atomName, "$config.")
+ result[strippedName] = atom.GetVal()
+ }
+ }
+ return result
+}
+
+func MakeRoot(client *ClientImpl) *RootElem {
+ return &RootElem{
+ Root: nil,
+ CFuncs: make(map[string]any),
+ CompMap: make(map[string]*ComponentImpl),
+ Atoms: make(map[string]genAtom),
+ Client: client,
+ }
+}
+
+func (r *RootElem) RegisterAtom(name string, atom genAtom) {
+ r.atomLock.Lock()
+ defer r.atomLock.Unlock()
+
+ if _, ok := r.Atoms[name]; ok {
+ panic(fmt.Sprintf("atom %s already exists", name))
+ }
+ r.Atoms[name] = atom
+}
+
+// cleanupUsedByForUnmount uses the reverse mapping for efficient cleanup
+func (r *RootElem) cleanupUsedByForUnmount(comp *ComponentImpl) {
+ r.atomLock.Lock()
+ defer r.atomLock.Unlock()
+
+ // Use reverse mapping for efficient cleanup
+ for atomName := range comp.UsedAtoms {
+ if atom, ok := r.Atoms[atomName]; ok {
+ atom.SetUsedBy(comp.WaveId, false)
+ }
+ }
+
+ // Clear the component's atom tracking
+ comp.UsedAtoms = nil
+}
+
+func (r *RootElem) updateComponentAtomUsage(comp *ComponentImpl, newUsedAtoms map[string]bool) {
+ r.atomLock.Lock()
+ defer r.atomLock.Unlock()
+
+ oldUsedAtoms := comp.UsedAtoms
+
+ // Remove component from atoms it no longer uses
+ for atomName := range oldUsedAtoms {
+ if !newUsedAtoms[atomName] {
+ if atom, ok := r.Atoms[atomName]; ok {
+ atom.SetUsedBy(comp.WaveId, false)
+ }
+ }
+ }
+
+ // Add component to atoms it now uses
+ for atomName := range newUsedAtoms {
+ if !oldUsedAtoms[atomName] {
+ if atom, ok := r.Atoms[atomName]; ok {
+ atom.SetUsedBy(comp.WaveId, true)
+ }
+ }
+ }
+
+ // Update component's atom usage map
+ if len(newUsedAtoms) == 0 {
+ comp.UsedAtoms = nil
+ } else {
+ comp.UsedAtoms = make(map[string]bool)
+ for atomName := range newUsedAtoms {
+ comp.UsedAtoms[atomName] = true
+ }
+ }
+}
+
+func (r *RootElem) AtomAddRenderWork(atomName string) {
+ r.atomLock.Lock()
+ defer r.atomLock.Unlock()
+
+ atom, ok := r.Atoms[atomName]
+ if !ok {
+ return
+ }
+ usedBy := atom.GetUsedBy()
+ if len(usedBy) == 0 {
+ return
+ }
+ for _, compId := range usedBy {
+ r.addRenderWork(compId)
+ }
+}
+
+func (r *RootElem) GetAtomVal(name string) any {
+ r.atomLock.Lock()
+ defer r.atomLock.Unlock()
+
+ atom, ok := r.Atoms[name]
+ if !ok {
+ return nil
+ }
+ return atom.GetVal()
+}
+
+func (r *RootElem) SetAtomVal(name string, val any) error {
+ r.atomLock.Lock()
+ defer r.atomLock.Unlock()
+
+ atom, ok := r.Atoms[name]
+ if !ok {
+ return fmt.Errorf("atom %q not found", name)
+ }
+ return atom.SetVal(val)
+}
+
+func (r *RootElem) RemoveAtom(name string) {
+ r.atomLock.Lock()
+ defer r.atomLock.Unlock()
+
+ delete(r.Atoms, name)
+}
+
+func validateCFunc(cfunc any) error {
+ if cfunc == nil {
+ return fmt.Errorf("Component function cannot b nil")
+ }
+ rval := reflect.ValueOf(cfunc)
+ if rval.Kind() != reflect.Func {
+ return fmt.Errorf("Component function must be a function")
+ }
+ rtype := rval.Type()
+ if rtype.NumIn() != 1 {
+ return fmt.Errorf("Component function must take exactly 1 argument")
+ }
+ if rtype.NumOut() != 1 {
+ return fmt.Errorf("Component function must return exactly 1 value")
+ }
+ // first argument can be a map[string]any, or a struct, or ptr to struct (we'll reflect the value into it)
+ arg1Type := rtype.In(0)
+ if arg1Type.Kind() == reflect.Ptr {
+ arg1Type = arg1Type.Elem()
+ }
+ if arg1Type.Kind() == reflect.Map {
+ if arg1Type.Key().Kind() != reflect.String ||
+ !(arg1Type.Elem().Kind() == reflect.Interface && arg1Type.Elem().NumMethod() == 0) {
+ return fmt.Errorf("Map argument must be map[string]any")
+ }
+ } else if arg1Type.Kind() != reflect.Struct &&
+ !(arg1Type.Kind() == reflect.Interface && arg1Type.NumMethod() == 0) {
+ return fmt.Errorf("Component function argument must be map[string]any, struct, or any")
+ }
+ return nil
+}
+
+func (r *RootElem) RegisterComponent(name string, cfunc any) error {
+ if err := validateCFunc(cfunc); err != nil {
+ return err
+ }
+ r.CFuncs[name] = cfunc
+ return nil
+}
+
+func callVDomFn(fnVal any, data vdom.VDomEvent) {
+ if fnVal == nil {
+ return
+ }
+ fn := fnVal
+ if vdf, ok := fnVal.(*vdom.VDomFunc); ok {
+ fn = vdf.Fn
+ }
+ if fn == nil {
+ return
+ }
+ rval := reflect.ValueOf(fn)
+ if rval.Kind() != reflect.Func {
+ return
+ }
+ rtype := rval.Type()
+ if rtype.NumIn() == 0 {
+ rval.Call(nil)
+ return
+ }
+ if rtype.NumIn() == 1 {
+ rval.Call([]reflect.Value{reflect.ValueOf(data)})
+ return
+ }
+}
+
+func (r *RootElem) Event(event vdom.VDomEvent, globalEventHandler func(vdom.VDomEvent)) {
+ defer func() {
+ if event.GlobalEventType != "" {
+ util.PanicHandler(fmt.Sprintf("Global event handler - event:%s", event.GlobalEventType), recover())
+ } else {
+ comp := r.CompMap[event.WaveId]
+ tag := ""
+ if comp != nil && comp.Elem != nil {
+ tag = comp.Elem.Tag
+ }
+ compName := ""
+ if comp != nil {
+ compName = comp.ContainingComp
+ }
+ util.PanicHandler(fmt.Sprintf("Event handler - comp: %s, tag: %s, prop: %s", compName, tag, event.EventType), recover())
+ }
+ }()
+
+ eventCtx := &EventContextImpl{Event: event, Root: r}
+ withGlobalEventCtx(eventCtx, func() any {
+ if event.GlobalEventType != "" {
+ if globalEventHandler == nil {
+ log.Printf("global event %s but no handler", event.GlobalEventType)
+ return nil
+ }
+ globalEventHandler(event)
+ return nil
+ }
+
+ comp := r.CompMap[event.WaveId]
+ if comp == nil || comp.Elem == nil {
+ return nil
+ }
+
+ fnVal := comp.Elem.Props[event.EventType]
+ callVDomFn(fnVal, event)
+ return nil
+ })
+}
+
+func (r *RootElem) runEffectUnmount(work *EffectWorkElem, hook *Hook) {
+ defer func() {
+ comp := r.CompMap[work.WaveId]
+ compName := ""
+ if comp != nil {
+ compName = comp.ContainingComp
+ }
+ util.PanicHandler(fmt.Sprintf("UseEffect unmount - comp: %s", compName), recover())
+ }()
+ if hook.UnmountFn == nil {
+ return
+ }
+ effectCtx := &EffectContextImpl{
+ WorkElem: *work,
+ WorkType: "unmount",
+ Root: r,
+ }
+ withGlobalEffectCtx(effectCtx, func() any {
+ hook.UnmountFn()
+ return nil
+ })
+}
+
+func (r *RootElem) runEffect(work *EffectWorkElem, hook *Hook) {
+ defer func() {
+ comp := r.CompMap[work.WaveId]
+ compName := ""
+ if comp != nil {
+ compName = comp.ContainingComp
+ }
+ util.PanicHandler(fmt.Sprintf("UseEffect run - comp: %s", compName), recover())
+ }()
+ if hook.Fn == nil {
+ return
+ }
+ effectCtx := &EffectContextImpl{
+ WorkElem: *work,
+ WorkType: "run",
+ Root: r,
+ }
+ unmountFn := withGlobalEffectCtx(effectCtx, func() func() {
+ return hook.Fn()
+ })
+ hook.UnmountFn = unmountFn
+}
+
+// this will be called by the frontend to say the DOM has been mounted
+// it will eventually send any updated "refs" to the backend as well
+func (r *RootElem) RunWork(opts *RenderOpts) {
+ workQueue := r.EffectWorkQueue
+ r.EffectWorkQueue = nil
+ // first, run effect cleanups
+ for _, work := range workQueue {
+ comp := r.CompMap[work.WaveId]
+ if comp == nil {
+ continue
+ }
+ hook := comp.Hooks[work.EffectIndex]
+ r.runEffectUnmount(work, hook)
+ }
+ // now run, new effects
+ for _, work := range workQueue {
+ comp := r.CompMap[work.WaveId]
+ if comp == nil {
+ continue
+ }
+ hook := comp.Hooks[work.EffectIndex]
+ r.runEffect(work, hook)
+ }
+ // now check if we need a render
+ renderIds := r.getAndClearRenderWork()
+ if len(renderIds) > 0 {
+ r.render(r.Root.Elem, &r.Root, "root", opts)
+ }
+}
+
+func (r *RootElem) UpdateRef(updateRef rpctypes.VDomRefUpdate) {
+ refId := updateRef.RefId
+ split := strings.SplitN(refId, ":", 2)
+ if len(split) != 2 {
+ log.Printf("invalid ref id: %s\n", refId)
+ return
+ }
+ waveId := split[0]
+ hookIdx, err := strconv.Atoi(split[1])
+ if err != nil {
+ log.Printf("invalid ref id (bad hook idx): %s\n", refId)
+ return
+ }
+ comp := r.CompMap[waveId]
+ if comp == nil {
+ return
+ }
+ if hookIdx < 0 || hookIdx >= len(comp.Hooks) {
+ return
+ }
+ hook := comp.Hooks[hookIdx]
+ if hook == nil {
+ return
+ }
+ ref, ok := hook.Val.(*vdom.VDomRef)
+ if !ok {
+ return
+ }
+ ref.HasCurrent = updateRef.HasCurrent
+ ref.Position = updateRef.Position
+ r.addRenderWork(waveId)
+}
+
+func (r *RootElem) QueueRefOp(op vdom.VDomRefOperation) {
+ r.RefOperations = append(r.RefOperations, op)
+}
+
+func (r *RootElem) GetRefOperations() []vdom.VDomRefOperation {
+ ops := r.RefOperations
+ r.RefOperations = nil
+ return ops
+}
diff --git a/tsunami/engine/serverhandlers.go b/tsunami/engine/serverhandlers.go
new file mode 100644
index 0000000000..05b8a38364
--- /dev/null
+++ b/tsunami/engine/serverhandlers.go
@@ -0,0 +1,472 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package engine
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "io/fs"
+ "log"
+ "mime"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/wavetermdev/waveterm/tsunami/rpctypes"
+ "github.com/wavetermdev/waveterm/tsunami/util"
+)
+
+const SSEKeepAliveDuration = 5 * time.Second
+
+func init() {
+ // Add explicit mapping for .json files
+ mime.AddExtensionType(".json", "application/json")
+}
+
+type handlerOpts struct {
+ AssetsFS fs.FS
+ StaticFS fs.FS
+ ManifestFile []byte
+}
+
+type httpHandlers struct {
+ Client *ClientImpl
+ renderLock sync.Mutex
+}
+
+func newHTTPHandlers(client *ClientImpl) *httpHandlers {
+ return &httpHandlers{
+ Client: client,
+ }
+}
+
+func (h *httpHandlers) registerHandlers(mux *http.ServeMux, opts handlerOpts) {
+ mux.HandleFunc("/api/render", h.handleRender)
+ mux.HandleFunc("/api/updates", h.handleSSE)
+ mux.HandleFunc("/api/data", h.handleData)
+ mux.HandleFunc("/api/config", h.handleConfig)
+ mux.HandleFunc("/api/manifest", h.handleManifest(opts.ManifestFile))
+ mux.HandleFunc("/dyn/", h.handleDynContent)
+
+ // Add handler for static files at /static/ path
+ if opts.StaticFS != nil {
+ mux.HandleFunc("/static/", h.handleStaticPathFiles(opts.StaticFS))
+ }
+
+ // Add fallback handler for embedded static files in production mode
+ if opts.AssetsFS != nil {
+ mux.HandleFunc("/", h.handleStaticFiles(opts.AssetsFS))
+ }
+}
+
+func (h *httpHandlers) handleRender(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ panicErr := util.PanicHandler("handleRender", recover())
+ if panicErr != nil {
+ http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
+ }
+ }()
+
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ var feUpdate rpctypes.VDomFrontendUpdate
+ if err := json.Unmarshal(body, &feUpdate); err != nil {
+ http.Error(w, fmt.Sprintf("failed to parse JSON: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ if feUpdate.ForceTakeover {
+ h.Client.clientTakeover(feUpdate.ClientId)
+ }
+
+ if err := h.Client.checkClientId(feUpdate.ClientId); err != nil {
+ http.Error(w, fmt.Sprintf("client id error: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ startTime := time.Now()
+ update, err := h.processFrontendUpdate(&feUpdate)
+ duration := time.Since(startTime)
+
+ if err != nil {
+ http.Error(w, fmt.Sprintf("render error: %v", err), http.StatusInternalServerError)
+ return
+ }
+ if update == nil {
+ w.WriteHeader(http.StatusOK)
+ log.Printf("render %4s %4dms %4dk %s", "none", duration.Milliseconds(), 0, feUpdate.Reason)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+
+ // Encode to bytes first to calculate size
+ responseBytes, err := json.Marshal(update)
+ if err != nil {
+ log.Printf("failed to encode response: %v", err)
+ http.Error(w, "failed to encode response", http.StatusInternalServerError)
+ return
+ }
+
+ updateSizeKB := len(responseBytes) / 1024
+ renderType := "inc"
+ if update.FullUpdate {
+ renderType = "full"
+ }
+ log.Printf("render %4s %4dms %4dk %s", renderType, duration.Milliseconds(), updateSizeKB, feUpdate.Reason)
+
+ if _, err := w.Write(responseBytes); err != nil {
+ log.Printf("failed to write response: %v", err)
+ }
+}
+
+func (h *httpHandlers) processFrontendUpdate(feUpdate *rpctypes.VDomFrontendUpdate) (*rpctypes.VDomBackendUpdate, error) {
+ h.renderLock.Lock()
+ defer h.renderLock.Unlock()
+
+ if feUpdate.Dispose {
+ log.Printf("got dispose from frontend\n")
+ h.Client.doShutdown("got dispose from frontend")
+ return nil, nil
+ }
+
+ if h.Client.GetIsDone() {
+ return nil, nil
+ }
+
+ h.Client.Root.RenderTs = feUpdate.Ts
+
+ // run events
+ h.Client.RunEvents(feUpdate.Events)
+ // update refs
+ for _, ref := range feUpdate.RefUpdates {
+ h.Client.Root.UpdateRef(ref)
+ }
+
+ var update *rpctypes.VDomBackendUpdate
+ var renderErr error
+
+ if feUpdate.Resync || true {
+ update, renderErr = h.Client.fullRender()
+ } else {
+ update, renderErr = h.Client.incrementalRender()
+ }
+
+ if renderErr != nil {
+ return nil, renderErr
+ }
+
+ update.CreateTransferElems()
+ return update, nil
+}
+
+func (h *httpHandlers) handleData(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ panicErr := util.PanicHandler("handleData", recover())
+ if panicErr != nil {
+ http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
+ }
+ }()
+
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ result := h.Client.Root.GetDataMap()
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(result); err != nil {
+ log.Printf("failed to encode data response: %v", err)
+ http.Error(w, "failed to encode response", http.StatusInternalServerError)
+ }
+}
+
+func (h *httpHandlers) handleConfig(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ panicErr := util.PanicHandler("handleConfig", recover())
+ if panicErr != nil {
+ http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
+ }
+ }()
+
+ switch r.Method {
+ case http.MethodGet:
+ h.handleConfigGet(w, r)
+ case http.MethodPost:
+ h.handleConfigPost(w, r)
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (h *httpHandlers) handleConfigGet(w http.ResponseWriter, _ *http.Request) {
+ result := h.Client.Root.GetConfigMap()
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(result); err != nil {
+ log.Printf("failed to encode config response: %v", err)
+ http.Error(w, "failed to encode response", http.StatusInternalServerError)
+ }
+}
+
+func (h *httpHandlers) handleConfigPost(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ var configData map[string]any
+ if err := json.Unmarshal(body, &configData); err != nil {
+ http.Error(w, fmt.Sprintf("failed to parse JSON: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ var failedKeys []string
+ for key, value := range configData {
+ atomName := "$config." + key
+ if err := h.Client.Root.SetAtomVal(atomName, value); err != nil {
+ failedKeys = append(failedKeys, key)
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+
+ var response map[string]any
+ if len(failedKeys) > 0 {
+ response = map[string]any{
+ "error": fmt.Sprintf("Failed to update keys: %s", strings.Join(failedKeys, ", ")),
+ }
+ } else {
+ response = map[string]any{
+ "success": true,
+ }
+ }
+
+ w.WriteHeader(http.StatusOK)
+
+ json.NewEncoder(w).Encode(response)
+}
+
+func (h *httpHandlers) handleDynContent(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ panicErr := util.PanicHandler("handleDynContent", recover())
+ if panicErr != nil {
+ http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
+ }
+ }()
+
+ // Strip /assets prefix and update the request URL
+ r.URL.Path = strings.TrimPrefix(r.URL.Path, "/dyn")
+ if r.URL.Path == "" {
+ r.URL.Path = "/"
+ }
+
+ h.Client.UrlHandlerMux.ServeHTTP(w, r)
+}
+
+func (h *httpHandlers) handleSSE(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ panicErr := util.PanicHandler("handleSSE", recover())
+ if panicErr != nil {
+ http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
+ }
+ }()
+
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ clientId := r.URL.Query().Get("clientId")
+ if err := h.Client.checkClientId(clientId); err != nil {
+ http.Error(w, fmt.Sprintf("client id error: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ // Set SSE headers
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache, no-transform")
+ w.Header().Set("Connection", "keep-alive")
+ w.Header().Set("X-Accel-Buffering", "no")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("X-Accel-Buffering", "no") // nginx hint
+
+ // Use ResponseController for better flushing control
+ rc := http.NewResponseController(w)
+ if err := rc.Flush(); err != nil {
+ http.Error(w, "streaming not supported", http.StatusInternalServerError)
+ return
+ }
+
+ // Create a ticker for keepalive packets
+ keepaliveTicker := time.NewTicker(SSEKeepAliveDuration)
+ defer keepaliveTicker.Stop()
+
+ for {
+ select {
+ case <-r.Context().Done():
+ return
+ case <-keepaliveTicker.C:
+ // Send keepalive comment
+ fmt.Fprintf(w, ": keepalive\n\n")
+ rc.Flush()
+ case event := <-h.Client.SSEventCh:
+ if event.Event == "" {
+ break
+ }
+ fmt.Fprintf(w, "event: %s\n", event.Event)
+ fmt.Fprintf(w, "data: %s\n", string(event.Data))
+ fmt.Fprintf(w, "\n")
+ rc.Flush()
+ }
+ }
+}
+
+// serveFileDirectly serves a file directly from an embed.FS to avoid redirect loops
+// when serving directory paths that end with "/"
+func serveFileDirectly(w http.ResponseWriter, r *http.Request, embeddedFS fs.FS, requestPath, fileName string) bool {
+ if !strings.HasSuffix(requestPath, "/") {
+ return false
+ }
+
+ // Try to serve the specified file from that directory
+ var filePath string
+ if requestPath == "/" {
+ filePath = fileName
+ } else {
+ filePath = strings.TrimPrefix(requestPath, "/") + fileName
+ }
+
+ file, err := embeddedFS.Open(filePath)
+ if err != nil {
+ return false
+ }
+ defer file.Close()
+
+ // Get file info for modification time
+ fileInfo, err := file.Stat()
+ if err != nil {
+ return false
+ }
+
+ // Serve the file directly with proper mod time
+ http.ServeContent(w, r, fileName, fileInfo.ModTime(), file.(io.ReadSeeker))
+ return true
+}
+
+func (h *httpHandlers) handleStaticFiles(embeddedFS fs.FS) http.HandlerFunc {
+ fileServer := http.FileServer(http.FS(embeddedFS))
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ panicErr := util.PanicHandler("handleStaticFiles", recover())
+ if panicErr != nil {
+ http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
+ }
+ }()
+
+ // Skip if this is an API, files, or static request (already handled by other handlers)
+ if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/files/") || strings.HasPrefix(r.URL.Path, "/static/") {
+ http.NotFound(w, r)
+ return
+ }
+
+ // Handle any path ending with "/" to avoid redirect loops
+ if serveFileDirectly(w, r, embeddedFS, r.URL.Path, "index.html") {
+ return
+ }
+
+ // For other files, check if they exist before serving
+ filePath := strings.TrimPrefix(r.URL.Path, "/")
+ _, err := embeddedFS.Open(filePath)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ // Serve the file using the file server
+ fileServer.ServeHTTP(w, r)
+ }
+}
+
+func (h *httpHandlers) handleManifest(manifestFileBytes []byte) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ panicErr := util.PanicHandler("handleManifest", recover())
+ if panicErr != nil {
+ http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
+ }
+ }()
+
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ if manifestFileBytes == nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(manifestFileBytes)
+ }
+}
+
+func (h *httpHandlers) handleStaticPathFiles(staticFS fs.FS) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ panicErr := util.PanicHandler("handleStaticPathFiles", recover())
+ if panicErr != nil {
+ http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
+ }
+ }()
+
+ // Strip /static/ prefix from the path
+ filePath := strings.TrimPrefix(r.URL.Path, "/static/")
+ if filePath == "" {
+ // Handle requests to "/static/" directly
+ if serveFileDirectly(w, r, staticFS, "/", "index.html") {
+ return
+ }
+ http.NotFound(w, r)
+ return
+ }
+
+ // Handle directory paths ending with "/" to avoid redirect loops
+ strippedPath := "/" + filePath
+ if serveFileDirectly(w, r, staticFS, strippedPath, "index.html") {
+ return
+ }
+
+ // Check if file exists in staticFS
+ _, err := staticFS.Open(filePath)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ // Create a file server and serve the file
+ fileServer := http.FileServer(http.FS(staticFS))
+
+ // Temporarily modify the URL path for the file server
+ originalPath := r.URL.Path
+ r.URL.Path = "/" + filePath
+ fileServer.ServeHTTP(w, r)
+ r.URL.Path = originalPath
+ }
+}
diff --git a/tsunami/frontend/.gitignore b/tsunami/frontend/.gitignore
new file mode 100644
index 0000000000..f1f6cef32c
--- /dev/null
+++ b/tsunami/frontend/.gitignore
@@ -0,0 +1 @@
+scaffold/
\ No newline at end of file
diff --git a/tsunami/frontend/index.html b/tsunami/frontend/index.html
new file mode 100644
index 0000000000..99ed1d7f3d
--- /dev/null
+++ b/tsunami/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
Tsunami App
+
+
+
+
+
+
+
diff --git a/tsunami/frontend/package.json b/tsunami/frontend/package.json
new file mode 100644
index 0000000000..8f392d58bf
--- /dev/null
+++ b/tsunami/frontend/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "tsunami-frontend",
+ "author": {
+ "name": "Command Line Inc",
+ "email": "info@commandline.dev"
+ },
+ "description": "Tsunami Frontend - React application",
+ "license": "Apache-2.0",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "build:dev": "NODE_ENV=development vite build --mode development",
+ "preview": "vite preview",
+ "type-check": "tsc --noEmit"
+ },
+ "dependencies": {
+ "clsx": "^2.1.1",
+ "debug": "^4.4.1",
+ "jotai": "^2.13.1",
+ "react": "^19.1.1",
+ "react-dom": "^19.1.1",
+ "react-markdown": "^10.1.0",
+ "recharts": "^3.1.2",
+ "tailwind-merge": "^3.3.1"
+ },
+ "devDependencies": {
+ "@tailwindcss/cli": "^4.1.12",
+ "@tailwindcss/vite": "^4.0.17",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "@vitejs/plugin-react-swc": "^4.0.1",
+ "tailwindcss": "^4.1.12",
+ "typescript": "^5.9.2",
+ "vite": "^6.0.0"
+ }
+}
diff --git a/tsunami/frontend/public/fonts/hack-bold.woff2 b/tsunami/frontend/public/fonts/hack-bold.woff2
new file mode 100644
index 0000000000..1155477e96
Binary files /dev/null and b/tsunami/frontend/public/fonts/hack-bold.woff2 differ
diff --git a/tsunami/frontend/public/fonts/hack-regular.woff2 b/tsunami/frontend/public/fonts/hack-regular.woff2
new file mode 100644
index 0000000000..524465cf51
Binary files /dev/null and b/tsunami/frontend/public/fonts/hack-regular.woff2 differ
diff --git a/tsunami/frontend/public/fonts/inter-variable.woff2 b/tsunami/frontend/public/fonts/inter-variable.woff2
new file mode 100644
index 0000000000..22a12b04e1
Binary files /dev/null and b/tsunami/frontend/public/fonts/inter-variable.woff2 differ
diff --git a/tsunami/frontend/public/wave-logo-256.png b/tsunami/frontend/public/wave-logo-256.png
new file mode 100644
index 0000000000..d360280f1d
Binary files /dev/null and b/tsunami/frontend/public/wave-logo-256.png differ
diff --git a/tsunami/frontend/src/app.tsx b/tsunami/frontend/src/app.tsx
new file mode 100644
index 0000000000..38c94e0a51
--- /dev/null
+++ b/tsunami/frontend/src/app.tsx
@@ -0,0 +1,18 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { TsunamiModel } from "@/model/tsunami-model";
+import { VDomView } from "./vdom";
+
+// Global model instance
+const globalModel = new TsunamiModel();
+
+function App() {
+ return (
+
+
+
+ );
+}
+
+export default App;
diff --git a/tsunami/frontend/src/element/markdown.tsx b/tsunami/frontend/src/element/markdown.tsx
new file mode 100644
index 0000000000..632964f86f
--- /dev/null
+++ b/tsunami/frontend/src/element/markdown.tsx
@@ -0,0 +1,87 @@
+import React from 'react';
+import ReactMarkdown, { Components } from 'react-markdown';
+import { twMerge } from 'tailwind-merge';
+
+interface MarkdownProps {
+ text?: string;
+ style?: React.CSSProperties;
+ className?: string;
+ scrollable?: boolean;
+}
+
+const markdownComponents: Partial
= {
+ h1: ({ children }) => {children}
,
+ h2: ({ children }) => {children}
,
+ h3: ({ children }) => {children}
,
+ h4: ({ children }) => {children}
,
+ h5: ({ children }) => {children}
,
+ h6: ({ children }) => {children}
,
+ p: ({ children }) => {children}
,
+ a: ({ href, children }) => (
+
+ {children}
+
+ ),
+ ul: ({ children }) => ,
+ ol: ({ children }) => {children}
,
+ li: ({ children }) => {children},
+ code: ({ className, children }) => {
+ const isInline = !className;
+ if (isInline) {
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+
+ {children}
+
+ );
+ },
+ pre: ({ children }) => (
+
+ {children}
+
+ ),
+ blockquote: ({ children }) => (
+
+ {children}
+
+ ),
+ hr: () =>
,
+ table: ({ children }) => (
+
+ ),
+ th: ({ children }) => (
+
+ {children}
+ |
+ ),
+ td: ({ children }) => (
+
+ {children}
+ |
+ ),
+};
+
+export function Markdown({ text, style, className, scrollable = true }: MarkdownProps) {
+ const scrollClasses = scrollable ? "overflow-auto" : "";
+ const baseClasses = "prose prose-sm max-w-none";
+
+ return (
+
+
+ {text || ''}
+
+
+ );
+}
\ No newline at end of file
diff --git a/tsunami/frontend/src/input.tsx b/tsunami/frontend/src/input.tsx
new file mode 100644
index 0000000000..08fbbeae6d
--- /dev/null
+++ b/tsunami/frontend/src/input.tsx
@@ -0,0 +1,107 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import * as React from "react";
+
+type Props = {
+ value?: string;
+ onChange?: (e: React.ChangeEvent) => void;
+ onInput?: (e: React.FormEvent) => void;
+ ttlMs?: number; // default 100
+ ref?: React.Ref;
+ _tagName: "input" | "textarea";
+} & Omit & React.TextareaHTMLAttributes, "value" | "onChange" | "onInput">;
+
+/**
+ * OptimisticInput - A React input component that provides optimistic UI updates for Tsunami's framework.
+ *
+ * Problem: In Tsunami's reactive framework, every onChange event is sent to the server, which can cause
+ * the cursor to jump or typing to feel laggy as the server responds with updates.
+ *
+ * Solution: This component applies updates optimistically by maintaining a "shadow" value that shows
+ * immediately in the UI while waiting for server acknowledgment. If the server responds with the same
+ * value within the TTL period (default 100ms), the optimistic update is confirmed. If the server
+ * doesn't respond or responds with a different value, the input reverts to the server value.
+ *
+ * Key behaviors:
+ * - For controlled inputs (value provided): Uses optimistic updates with shadow state
+ * - For uncontrolled inputs (value undefined): Behaves like a normal React input
+ * - Skips optimistic logic when disabled or readonly
+ * - Handles IME composition properly to avoid interfering with multi-byte character input
+ * - Supports both onChange and onInput event handlers
+ * - Preserves cursor position through React's natural behavior (no manual cursor management)
+ *
+ * Example usage:
+ * ```tsx
+ * sendToServer(e.target.value)}
+ * ttlMs={200}
+ * />
+ * ```
+ */
+function OptimisticInput({ value, onChange, onInput, ttlMs = 100, ref: forwardedRef, _tagName, ...rest }: Props) {
+ const [shadow, setShadow] = React.useState(null);
+ const timer = React.useRef(undefined);
+
+ const startTTL = React.useCallback(() => {
+ if (timer.current) clearTimeout(timer.current);
+ timer.current = window.setTimeout(() => {
+ // no ack within TTL → revert to server
+ setShadow(null);
+ // caret will follow serverValue; optionally restore selRef here if you track a server caret
+ }, ttlMs);
+ }, [ttlMs]);
+
+ const handleChange = (e: React.ChangeEvent) => {
+ // Skip validation during IME composition
+ // (works in modern browsers/React via nativeEvent)
+ // @ts-expect-error React typing doesn't surface this directly
+ if (e.nativeEvent?.isComposing) return;
+
+ // If uncontrolled (value is undefined), skip optimistic logic
+ if (value === undefined) {
+ onChange?.(e);
+ onInput?.(e);
+ return;
+ }
+
+ // Skip optimistic logic if readonly or disabled
+ if (rest.disabled || rest.readOnly) {
+ onChange?.(e);
+ onInput?.(e);
+ return;
+ }
+
+ const v = e.currentTarget.value;
+ setShadow(v); // optimistic echo
+ startTTL(); // wait for ack
+ onChange?.(e);
+ onInput?.(e);
+ };
+
+ // Ack: backend caught up → drop shadow (and stop the TTL)
+ React.useLayoutEffect(() => {
+ if (shadow !== null && shadow === value) {
+ setShadow(null);
+ if (timer.current) clearTimeout(timer.current);
+ }
+ }, [value, shadow]);
+
+ React.useEffect(
+ () => () => {
+ if (timer.current) clearTimeout(timer.current);
+ },
+ []
+ );
+
+ const realValue = value === undefined ? undefined : (shadow ?? value ?? "");
+
+ if (_tagName === "textarea") {
+ return