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 }) =>
    {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 }) => ( +
    + + {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