diff --git a/.github/ISSUE_TEMPLATE/BugReport.yml b/.github/ISSUE_TEMPLATE/BugReport.yml
new file mode 100644
index 0000000..372d818
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/BugReport.yml
@@ -0,0 +1,63 @@
+---
+name: Bug
+description: File a bug/issue
+labels: [bug, triage]
+assignees:
+ - michenriksen
+body:
+ - type: checkboxes
+ attributes:
+ label: Is there an existing issue for this?
+ description: Please search to see if an issue already exists for the bug you encountered.
+ options:
+ - label: I have searched the existing issues
+ required: true
+ - type: textarea
+ attributes:
+ label: Current Behavior
+ description: A concise description of what you're experiencing.
+ validations:
+ required: false
+ - type: textarea
+ attributes:
+ label: Expected Behavior
+ description: A concise description of what you expected to happen.
+ validations:
+ required: false
+ - type: textarea
+ attributes:
+ label: Steps To Reproduce
+ description: Steps to reproduce the behavior.
+ placeholder: |
+ 1. In this environment...
+ 2. With this config...
+ 3. Run '...'
+ 4. See error...
+ validations:
+ required: false
+ - type: textarea
+ attributes:
+ label: Environment
+ description: |
+ examples:
+ - **OS**: macOS 13.3.1
+ - **Architecture**: arm
+ - **Gokiburi**: v0.1.x
+ - **Browser**: Google Chrome 113.0.xxxx.xx
+ value: |
+ - OS:
+ - Architecture:
+ - Gokiburi:
+ - Browser:
+ render: markdown
+ validations:
+ required: false
+ - type: textarea
+ attributes:
+ label: Anything else?
+ description: |
+ Links? References? Anything that will give more context about the issue you are encountering!
+
+ Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..a8ac5aa
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,12 @@
+---
+blank_issues_enabled: false
+contact_links:
+ - name: 🛟 Help & Support
+ url: https://github.com/michenriksen/gokiburi/discussions/categories/q-a
+ about: Please ask and answer questions here.
+ - name: 💡 Feature Requests & Ideas
+ url: https://github.com/michenriksen/gokiburi/discussions/categories/ideas
+ about: Please add feature requests and ideas here.
+ - name: 🛡️ Vulnerability Disclosure
+ url: https://michenriksen.com/
+ about: Please e-mail me if you think you have found a vulnerability or other sensitive issue.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..48be7d5
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,38 @@
+---
+version: 2
+updates:
+ - package-ecosystem: "gomod"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ assignees:
+ - michenriksen
+ labels:
+ - deps
+ - gomod
+ - automerge
+ commit-message:
+ prefix: "chore(deps)"
+ - package-ecosystem: "npm"
+ directory: "/web/app"
+ schedule:
+ interval: "weekly"
+ assignees:
+ - michenriksen
+ labels:
+ - deps
+ - npm
+ - automerge
+ commit-message:
+ prefix: "chore(deps)"
+ - package-ecosystem: GitHub-actions
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ assignees:
+ - michenriksen
+ labels:
+ - deps
+ - ghactions
+ commit-message:
+ prefix: "chore(ci)"
diff --git a/.github/images/gokiburi_dark.png b/.github/images/gokiburi_dark.png
new file mode 100644
index 0000000..59c13b3
Binary files /dev/null and b/.github/images/gokiburi_dark.png differ
diff --git a/.github/images/gokiburi_light.png b/.github/images/gokiburi_light.png
new file mode 100644
index 0000000..6b23123
Binary files /dev/null and b/.github/images/gokiburi_light.png differ
diff --git a/.github/images/social_preview.png b/.github/images/social_preview.png
new file mode 100644
index 0000000..5f6c82f
Binary files /dev/null and b/.github/images/social_preview.png differ
diff --git a/.github/images/web_ui_coverage.png b/.github/images/web_ui_coverage.png
new file mode 100644
index 0000000..fd26099
Binary files /dev/null and b/.github/images/web_ui_coverage.png differ
diff --git a/.github/images/web_ui_overview.png b/.github/images/web_ui_overview.png
new file mode 100644
index 0000000..232aefb
Binary files /dev/null and b/.github/images/web_ui_overview.png differ
diff --git a/.github/images/web_ui_settings.png b/.github/images/web_ui_settings.png
new file mode 100644
index 0000000..6d21a34
Binary files /dev/null and b/.github/images/web_ui_settings.png differ
diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml
new file mode 100644
index 0000000..ae3e78c
--- /dev/null
+++ b/.github/workflows/audit.yml
@@ -0,0 +1,148 @@
+---
+name: Security Audit
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ schedule:
+ - cron: "10 13 * * 1"
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ run:
+ runs-on: ubuntu-latest
+ outputs:
+ go_changes: ${{ steps.filter.outputs.go_changes }}
+ fe_changes: ${{ steps.filter.outputs.fe_changes }}
+ gh_actions_changes: ${{ steps.filter.outputs.gh_actions_changes }}
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Check modified files
+ uses: dorny/paths-filter@v2
+ id: filter
+ with:
+ filters: |
+ go_changes:
+ - '**.go'
+ - 'go.mod'
+ fe_changes:
+ - 'web/app/**.ts'
+ - 'web/app/**.js'
+ - 'web/app/**.cjs'
+ - 'web/app/**.json'
+ - 'web/app/**.svelte'
+ - 'web/app/package.json'
+ gha_changes:
+ - '.github/**.yml'
+
+ - name: Install Python
+ if: >-
+ ${{ github.actor != 'dependabot[bot]' &&
+ !contains('["push", "pull_request"]', github.event_name) ||
+ needs.check_changes.outputs.go_changes == 'true' ||
+ needs.check_changes.outputs.fe_changes == 'true' ||
+ needs.check_changes.outputs.gha_changes == 'true' }}
+ uses: actions/setup-python@v4
+ with:
+ python-version: "3.10"
+
+ - name: Install Semgrep
+ if: >-
+ ${{ github.actor != 'dependabot[bot]' &&
+ !contains('["push", "pull_request"]', github.event_name) ||
+ needs.check_changes.outputs.go_changes == 'true' ||
+ needs.check_changes.outputs.fe_changes == 'true' ||
+ needs.check_changes.outputs.gha_changes == 'true' }}
+ run: |
+ python -m pip install --upgrade pip
+ pip install semgrep
+
+ - name: Install Go
+ if: >-
+ ${{ github.actor != 'dependabot[bot]' &&
+ !contains('["push", "pull_request"]', github.event_name) ||
+ needs.check_changes.outputs.go_changes == 'true' }}
+ uses: actions/setup-go@v4
+ with:
+ go-version: "stable"
+
+ - uses: actions/cache@v3
+ if: >-
+ ${{ github.actor != 'dependabot[bot]' &&
+ !contains('["push", "pull_request"]', github.event_name) ||
+ needs.check_changes.outputs.go_changes == 'true' }}
+ with:
+ path: |
+ ~/.cache/go-build
+ ~/go/pkg/mod
+ key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
+ restore-keys: |
+ ${{ runner.os }}-go-
+
+ - name: Install govulncheck
+ if: >-
+ ${{ github.actor != 'dependabot[bot]' &&
+ !contains('["push", "pull_request"]', github.event_name) ||
+ needs.check_changes.outputs.go_changes == 'true' }}
+ run: go install golang.org/x/vuln/cmd/govulncheck@latest
+
+ - name: Create Dummy Frontend Build
+ if: >-
+ ${{ github.actor != 'dependabot[bot]' &&
+ !contains('["push", "pull_request"]', github.event_name) ||
+ needs.check_changes.outputs.go_changes == 'true' }}
+ run: mkdir web/app/build && touch web/app/build/dummy.txt
+
+ - name: Install Go Dependencies
+ if: >-
+ ${{ github.actor != 'dependabot[bot]' &&
+ !contains('["push", "pull_request"]', github.event_name) ||
+ needs.check_changes.outputs.go_changes == 'true' }}
+ run: go mod tidy
+
+ - name: Audit Go Code
+ if: >-
+ ${{ github.actor != 'dependabot[bot]' &&
+ !contains('["push", "pull_request"]', github.event_name) ||
+ needs.check_changes.outputs.go_changes == 'true' }}
+ run: make audit
+
+ - name: Install Node
+ if: >-
+ ${{ github.actor != 'dependabot[bot]' &&
+ !contains('["push", "pull_request"]', github.event_name) ||
+ needs.check_changes.outputs.fe_changes == 'true' }}
+ uses: actions/setup-node@v3
+ with:
+ node-version: "latest"
+ cache: "npm"
+ cache-dependency-path: "web/app/package-lock.json"
+
+ - name: Install NPM dependencies
+ if: >-
+ ${{ github.actor != 'dependabot[bot]' &&
+ !contains('["push", "pull_request"]', github.event_name) ||
+ needs.check_changes.outputs.fe_changes == 'true' }}
+ run: npm ci && git diff --exit-code
+ working-directory: web/app
+
+ - name: Audit Frontend Code
+ if: >-
+ ${{ github.actor != 'dependabot[bot]' &&
+ !contains('["push", "pull_request"]', github.event_name) ||
+ needs.check_changes.outputs.fe_changes == 'true' }}
+ run: make audit-fe
+
+ - name: Audit GitHub Actions
+ if: >-
+ ${{ github.actor != 'dependabot[bot]' &&
+ !contains('["push", "pull_request"]', github.event_name) ||
+ needs.check_changes.outputs.gha_changes == 'true' }}
+ run: make audit-gha
diff --git a/.github/workflows/build-fe.yml b/.github/workflows/build-fe.yml
new file mode 100644
index 0000000..a96d3dc
--- /dev/null
+++ b/.github/workflows/build-fe.yml
@@ -0,0 +1,62 @@
+---
+name: Build Frontend
+
+permissions:
+ contents: read
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - "web/app/**.json"
+ - "web/app/**.cjs"
+ - "web/app/**.ts"
+ - "web/app/**.js"
+ - "web/app/**.svelte"
+ pull_request:
+ branches: [main]
+ paths:
+ - "web/app/**.json"
+ - "web/app/**.cjs"
+ - "web/app/**.ts"
+ - "web/app/**.js"
+ - "web/app/**.svelte"
+ schedule:
+ - cron: "0 10 * * 1"
+ workflow_call:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ run:
+ name: Build Frontend
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ strategy:
+ fail-fast: true
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ - name: Install Node
+ uses: actions/setup-node@v3
+ with:
+ node-version: "latest"
+ cache: "npm"
+ cache-dependency-path: "web/app/package-lock.json"
+
+ - name: Install NPM dependencies
+ run: npm ci && git diff --exit-code
+ working-directory: web/app
+
+ - name: make lint-fe
+ run: make lint-fe
+
+ - name: make build-fe
+ run: make build-fe
+
+ - name: make test-fe
+ run: make test-fe
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..a636285
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,90 @@
+---
+name: Build
+
+permissions:
+ contents: read
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - "go.mod"
+ - "**.go"
+ pull_request:
+ branches: [main]
+ paths:
+ - "go.mod"
+ - "**.go"
+ schedule:
+ - cron: "0 10 * * 1"
+ workflow_call:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ strategy:
+ fail-fast: true
+ matrix:
+ go: ["stable", "oldstable"]
+
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v3
+
+ - name: Install Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: ${{ matrix.go }}
+ check-latest: true
+
+ - uses: actions/cache@v3
+ with:
+ path: |
+ ~/.cache/go-build
+ ~/go/pkg/mod
+ key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
+ restore-keys: |
+ ${{ runner.os }}-go-
+
+ - name: Go Tidy
+ run: go mod tidy && git diff --exit-code
+
+ - name: Create Dummy Frontend Build
+ run: mkdir web/app/build && touch web/app/build/dummy.txt
+
+ - name: Go Vet
+ run: go vet ./...
+
+ - name: Go Mod
+ run: go mod download
+
+ - name: Go Mod Verify
+ run: go mod verify
+
+ - name: Go Build
+ run: go build -o /dev/null ./...
+
+ - name: make test
+ run: make test
+
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/setup-go@v4
+ with:
+ go-version: "stable"
+ - uses: actions/checkout@v3
+
+ - name: Create Dummy Frontend Build
+ run: mkdir web/app/build && touch web/app/build/dummy.txt
+
+ - name: golangci-lint
+ uses: golangci/golangci-lint-action@v3
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..07edf73
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,66 @@
+---
+name: Release
+
+permissions:
+ contents: write
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "Release version (e.g., v1.0.0)"
+ required: true
+
+jobs:
+ run:
+ name: Release
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+ with:
+ fetch-depth: 0
+ - run: git fetch --force --tags
+
+ - name: Create release
+ run: |
+ git config --global user.email "mchnrksn@gmail.com"
+ git config --global user.name "Michael Henriksen"
+ git tag -a "$VERSION" -m "$VERSION"
+ env:
+ VERSION: ${{ github.event.inputs.version }}
+
+ - uses: actions/setup-go@v4
+ with:
+ go-version: stable
+
+ - name: Install Node
+ uses: actions/setup-node@v3
+ with:
+ node-version: "latest"
+ cache: "npm"
+ cache-dependency-path: "web/app/package-lock.json"
+
+ - name: Install NPM dependencies
+ run: npm ci && git diff --exit-code
+ working-directory: web/app
+
+ - name: Install Syft
+ run: curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
+
+ - name: Run GoReleaser
+ uses: goreleaser/goreleaser-action@v4
+ with:
+ distribution: goreleaser
+ version: latest
+ args: release --clean
+ env:
+ VERSION: ${{ github.event.inputs.version }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GOPATH: ${{ github.workspace }}/go
+
+ - name: Notify Go proxy about new release
+ run: go list -m "github.com/michenriksen/gokiburi@$VERSION" || true
+ env:
+ GOPROXY: proxy.golang.org
+ VERSION: ${{ github.event.inputs.version }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8812756
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,125 @@
+# Created by https://www.toptal.com/developers/gitignore/api/go,vim,visualstudiocode,macos,linux
+# Edit at https://www.toptal.com/developers/gitignore?templates=go,vim,visualstudiocode,macos,linux
+
+### Go ###
+# If you prefer the allow list template instead of the deny list, see community template:
+# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
+#
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, built with `go test -c`
+*.test
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+!internal/pkg/coverparser/testdata/numbers/coverprofile.out
+!internal/pkg/runner/testdata/coverprofile.out
+
+# Dependency directories (remove the comment below to include it)
+vendor/
+
+# Go workspace file
+go.work
+
+### Linux ###
+*~
+
+# temporary files which can be created if a process still has a handle open of a deleted file
+.fuse_hidden*
+
+# KDE directory preferences
+.directory
+
+# Linux trash folder which might appear on any partition or disk
+.Trash-*
+
+# .nfs files are created when an open file is removed but is still being accessed
+.nfs*
+
+### macOS ###
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+### macOS Patch ###
+# iCloud generated files
+*.icloud
+
+### Vim ###
+# Swap
+[._]*.s[a-v][a-z]
+!*.svg # comment out if you don't need vector files
+[._]*.sw[a-p]
+[._]s[a-rt-v][a-z]
+[._]ss[a-gi-z]
+[._]sw[a-p]
+
+# Session
+Session.vim
+Sessionx.vim
+
+# Temporary
+.netrwhist
+# Auto-generated tag files
+tags
+# Persistent undo
+[._]*.un~
+
+### VisualStudioCode ###
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+!.vscode/*.code-snippets
+
+# Local History for Visual Studio Code
+.history/
+
+# Built Visual Studio Code Extensions
+*.vsix
+
+### VisualStudioCode Patch ###
+# Ignore all local history of files
+.history
+.ionide
+
+# End of https://www.toptal.com/developers/gitignore/api/go,vim,visualstudiocode,macos,linux
+
+/bin
+/.go
+/.licenses*
+/coverage/
+/dist
+/dependencies.csv
+*.spdx.sbom
+
diff --git a/.golangci.yaml b/.golangci.yaml
new file mode 100644
index 0000000..2352d88
--- /dev/null
+++ b/.golangci.yaml
@@ -0,0 +1,217 @@
+---
+run:
+ go: "1.20"
+
+issues:
+ exclude-rules:
+ - path: _test\.go
+ linters:
+ - errcheck
+ - forcetypeassert
+ - gosec
+ - revive
+ - wsl
+
+linters-settings:
+ depguard:
+ list-type: denylist
+ packages-with-error-message:
+ - github.com/stretchr/testify/assert: "use github.com/stretchr/testify/require for faster tests"
+ - github.com/davecgh/go-spew: "spew is only for temporary debugging"
+ additional-guards:
+ - list-type: denylist
+ packages-with-error-message:
+ - github.com/stretchr/testify: "testify must only be used in test files"
+ ignore-file-rules:
+ - "**/*_test.go"
+ - list-type: denylist
+ packages-with-error-message:
+ - github.com/brianvoe/gofakeit: "gofakeit must only be used in test files"
+ ignore-file-rules:
+ - "**/*_test.go"
+
+ forbidigo:
+ forbid:
+ - 'fmt\.Print.*'
+ - 'log\.Print.*'
+
+ gofumpt:
+ extra-rules: true
+
+ goimports:
+ local-prefixes: github.com/michenriksen/gokiburi
+
+ nolintlint:
+ require-explanation: true
+ require-specific: true
+
+ wrapcheck:
+ ignorePackageGlobs:
+ - github.com/labstack/echo/*
+ - github.com/invopop/validation
+ - encoding/json
+
+ revive:
+ rules:
+ - name: argument-limit
+ severity: warning
+ disabled: false
+ arguments: [4]
+ - name: atomic
+ severity: warning
+ disabled: false
+ - name: bool-literal-in-expr
+ severity: warning
+ disabled: false
+ - name: cognitive-complexity
+ severity: warning
+ disabled: false
+ arguments: [20]
+ - name: comment-spacings
+ severity: warning
+ disabled: false
+ arguments:
+ - nolint
+ - "#nosec"
+ - name: constant-logical-expr
+ severity: warning
+ disabled: false
+ - name: context-as-argument
+ severity: warning
+ disabled: false
+ - name: cyclomatic
+ severity: warning
+ disabled: false
+ arguments: [20]
+ - name: context-keys-type
+ severity: warning
+ disabled: false
+ - name: datarace
+ severity: warning
+ disabled: false
+ - name: deep-exit
+ severity: warning
+ disabled: false
+ - name: defer
+ severity: warning
+ disabled: false
+ - name: duplicated-imports
+ severity: warning
+ disabled: false
+ - name: early-return
+ severity: warning
+ disabled: false
+ - name: empty-block
+ severity: warning
+ disabled: false
+ - name: error-return
+ severity: warning
+ disabled: false
+ - name: error-strings
+ severity: warning
+ disabled: false
+ - name: errorf
+ severity: warning
+ disabled: false
+ - name: function-result-limit
+ severity: warning
+ disabled: false
+ arguments: [3]
+ - name: identical-branches
+ severity: warning
+ disabled: false
+ - name: if-return
+ severity: warning
+ disabled: false
+ - name: increment-decrement
+ severity: warning
+ disabled: false
+ - name: import-shadowing
+ severity: warning
+ disabled: false
+ - name: line-length-limit
+ severity: warning
+ disabled: false
+ arguments: [120]
+ - name: modifies-parameter
+ severity: warning
+ disabled: false
+ - name: modifies-value-receiver
+ severity: warning
+ disabled: false
+ - name: optimize-operands-order
+ severity: warning
+ disabled: false
+ - name: range
+ severity: warning
+ disabled: false
+ - name: range-val-in-closure
+ severity: warning
+ disabled: false
+ - name: range-val-address
+ severity: warning
+ disabled: false
+ - name: redefines-builtin-id
+ severity: warning
+ disabled: false
+ - name: string-of-int
+ severity: warning
+ disabled: false
+ - name: struct-tag
+ severity: warning
+ disabled: false
+ - name: var-naming
+ severity: warning
+ disabled: false
+ - name: var-declaration
+ severity: warning
+ disabled: false
+ - name: unconditional-recursion
+ severity: warning
+ disabled: false
+ - name: unnecessary-stmt
+ severity: warning
+ disabled: false
+ - name: unreachable-code
+ severity: warning
+ disabled: false
+ - name: unused-parameter
+ severity: warning
+ disabled: false
+ - name: unused-receiver
+ severity: warning
+ disabled: false
+ - name: use-any
+ severity: warning
+ disabled: false
+
+linters:
+ disable-all: true
+ enable:
+ - bidichk
+ - depguard
+ - errcheck
+ - errname
+ - forcetypeassert
+ - gocheckcompilerdirectives
+ - gocritic
+ - godot
+ - gofumpt
+ - goimports
+ - gosec
+ - gosimple
+ - ineffassign
+ - makezero
+ - misspell
+ - nolintlint
+ - prealloc
+ - revive
+ - staticcheck
+ - tenv
+ - testpackage
+ - thelper
+ - typecheck
+ - unused
+ - usestdlibvars
+ - wrapcheck
+ - wsl
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
new file mode 100644
index 0000000..77e1531
--- /dev/null
+++ b/.goreleaser.yaml
@@ -0,0 +1,90 @@
+# This is an example .goreleaser.yml file with some sensible defaults.
+# Make sure to check the documentation at https://goreleaser.com
+---
+project_name: gokiburi
+report_sizes: true
+env:
+ - BUILD_COMMIT={{ .FullCommit }}
+ - BUILD_TIME={{ .Date }}
+before:
+ hooks:
+ - make clean
+ - go mod tidy
+ - make dep-csv
+ - make sbom
+ - make build-fe
+builds:
+ - env:
+ - CGO_ENABLED=0
+ goos:
+ - linux
+ - darwin
+ - windows
+ goarch:
+ - amd64
+ - arm64
+ goarm:
+ - 6
+ - 7
+ ignore:
+ - goos: darwin
+ goarch: arm64
+ goarm: 6
+ - goos: darwin
+ goarch: arm64
+ goarm: 7
+ mod_timestamp: "{{ .CommitTimestamp }}"
+ flags:
+ - "-trimpath"
+ asmflags:
+ - "all=-trimpath={{ .Env.GOPATH }}"
+ gcflags:
+ - "all=-trimpath={{ .Env.GOPATH }}"
+ ldflags:
+ - "-s -w"
+ - "-X github.com/michenriksen/gokiburi/internal/gokiburi.buildVersion={{ .Version }}"
+ - "-X github.com/michenriksen/gokiburi/internal/gokiburi.buildCommit={{ .FullCommit }}"
+ - "-X github.com/michenriksen/gokiburi/internal/gokiburi.buildTime={{ .Date }}"
+
+archives:
+ - format: tar.gz
+ name_template: >-
+ {{ .ProjectName }}_
+ {{- title .Os }}_
+ {{- if eq .Arch "amd64" }}x86_64
+ {{- else if eq .Arch "386" }}i386
+ {{- else }}{{ .Arch }}{{ end }}
+ {{- if .Arm }}v{{ .Arm }}{{ end }}
+ # use zip for windows archives
+ format_overrides:
+ - goos: windows
+ format: zip
+ files:
+ - README.md
+ - LICENSE.md
+ - dependencies.csv
+ - "{{ .ProjectName }}.spdx.sbom"
+
+checksum:
+ name_template: "checksums.txt"
+snapshot:
+ name_template: "{{ incpatch .Version }}-next"
+changelog:
+ sort: asc
+ filters:
+ exclude:
+ - "^chore"
+ - "^docs"
+ - "^test"
+ - "merge conflict"
+ - "Merge pull request"
+ - "Merge remote-tracking branch"
+ - "Merge branch"
+
+release:
+ draft: true
+ replace_existing_draft: true
+# The lines beneath this are called `modelines`. See `:help modeline`
+# Feel free to remove those if you don't want/use them.
+# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
+# vim: set ts=2 sw=2 tw=0 fo=cnqoj
diff --git a/.syft.yaml b/.syft.yaml
new file mode 100644
index 0000000..66221b5
--- /dev/null
+++ b/.syft.yaml
@@ -0,0 +1,4 @@
+---
+golang:
+ search-local-mod-cache-licenses: true
+ search-remote-licenses: true
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..286eb3e
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Michael Henriksen
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..515b44b
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,134 @@
+DBG_MAKEFILE ?=
+ifeq ($(DBG_MAKEFILE),1)
+ $(warning ***** starting Makefile for goal(s) "$(MAKECMDGOALS)")
+ $(warning ***** $(shell date))
+else
+ MAKEFLAGS += -s
+endif
+
+BIN ?= gokiburi
+
+APP_NAME ?= $(BIN)
+
+ifeq ($(VERSION),)
+ VERSION := $(shell git log -1 --format="%ad-%h" --date=format-local:"%Y%m%d%H%M%S" --abbrev=12)
+endif
+
+DBG ?=
+
+MAKEFLAGS += --no-builtin-rules
+MAKEFLAGS += --warn-undefined-variables
+.SUFFIXES:
+
+OS := $(if $(GOOS),$(GOOS),$(shell go env GOOS))
+ARCH := $(if $(GOARCH),$(GOARCH),$(shell go env GOARCH))
+
+TAG := $(VERSION)__$(OS)_$(ARCH)
+
+BIN_EXTENSION :=
+ifeq ($(OS), windows)
+ BIN_EXTENSION := .exe
+endif
+
+SHELL := /usr/bin/env bash -o errexit -o pipefail -o nounset
+
+GOFLAGS ?=
+HTTP_PROXY ?=
+HTTPS_PROXY ?=
+
+export BIN
+export BIN_EXTENSION
+export APP_NAME
+export VERSION
+export DBG
+export OS
+export ARCH
+export GOFLAGS
+export HTTP_PROXY
+export HTTPS_PROXY
+
+build: # @HELP builds binary to ./build directory for one platform ($OS/$ARCH)
+build: build-fe
+ scripts/build.sh
+
+build-fe: # @HELP builds frontend, as defined in ./scripts/build-fe.sh
+build-fe:
+ scripts/build-fe.sh
+
+version: # @HELP outputs the version string
+version:
+ echo $(VERSION)
+
+version-next: # @HELP outputs the next version string based on commits.
+version-next:
+ svu next
+
+test-all: # @HELP runs tests for Go and frontend
+test-all: test test-fe
+
+test: # @HELP runs tests, as defined in ./scripts/test.sh
+test:
+ scripts/test.sh ./...
+
+test-fe: # @HELP runs frontend tests, as defined in ./scripts/test-fe.sh
+test-fe:
+ scripts/test-fe.sh
+
+lint-all: # @HELP runs linting for Go and frontend
+lint-all: lint lint-fe
+
+lint: # @HELP runs linting, as defined in ./scripts/lint.sh
+lint:
+ scripts/lint.sh ./...
+
+audit-all: # @HELP runs Go, frontend, and GitHub Actions security audits
+audit-all:
+ scripts/audit.sh
+
+audit: # @HELP runs Go security audits, as defined in ./scripts/audit.sh
+audit:
+ scripts/audit.sh go
+
+audit-fe: # @HELP runs frontend security audits, as defined in ./scripts/audit.sh
+audit-fe:
+ scripts/audit.sh fe
+
+audit-gha: # @HELP runs GitHub Actions security audits, as defined in ./scripts/audit.sh
+audit-gha:
+ scripts/audit.sh gha
+
+lint-fe: # @HELP runs frontend linting, as defined in ./scripts/lint-fe.sh
+lint-fe:
+ scripts/lint-fe.sh
+
+clean: # @HELP removes build artifacts
+clean:
+ rm -rf ./bin
+ rm -rf ./dist
+ rm -rf ./web/app/build
+ rm -f ./dependencies.csv
+ rm -f ./gokiburi.spdx.sbom
+
+dep-csv: # @HELP generates CVS of dependencies to ./dependencies.csv
+dep-csv:
+ scripts/dep-csv.sh
+
+sbom: # @HELP generates SBOM file to ./gokiburi.spdx.sbom
+sbom:
+ scripts/sbom.sh
+
+help: # @HELP prints this message
+help:
+ echo "VARIABLES:"
+ echo " BIN = $(BIN)"
+ echo " OS = $(OS)"
+ echo " ARCH = $(ARCH)"
+ echo " DBG = $(DBG)"
+ echo " GOFLAGS = $(GOFLAGS)"
+ echo
+ echo "TARGETS:"
+ grep -E '^.*: *# *@HELP' $(MAKEFILE_LIST) \
+ | awk ' \
+ BEGIN {FS = ": *# *@HELP"}; \
+ { printf " %-30s %s\n", $$1, $$2 }; \
+ '
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e9b3b03
--- /dev/null
+++ b/README.md
@@ -0,0 +1,65 @@
+# Gokiburi: Automatic Test Runs for Go Projects
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Gokiburi is a powerful and user-friendly testing tool designed to enhance the developer experience in Go projects. It automatically runs tests in real-time by monitoring file changes, ensuring that your code remains robust and reliable throughout the development process. With Gokiburi, you can focus on writing code while it takes care of running tests and keeping you informed about your project's health.
+
+## Highlights
+
+- **Real-time monitoring**: Gokiburi keeps an eye on your Go project files and automatically triggers tests for the package where the file belongs as soon as a change is detected.
+- **Sleek web UI**: Easily monitor and sift through test results using Gokiburi's intuitive web interface. Gain insights into your project's code coverage and quickly identify areas that need improvement.
+- **Configurable notifications**: Stay informed about your project's health with customizable browser and sound notifications. Gokiburi will promptly notify you if something isn’t right, allowing you to fix the issue faster and more efficiently.
+
+## Usage
+
+> **Note**
+> Gokiburi is currently a **work in progress** and should be regarded as beta software. Although I've been using it personally without any major problems, it may still contain bugs and quirks. If you encounter any issues or odd behavior, please don't hesitate to [create a new issue](https://github.com/michenriksen/gokiburi/issues/new).
+
+Begin using Gokiburi with these steps:
+
+1. Head to your Go project's root directory in a terminal.
+2. Launch Gokiburi by entering the `gokiburi` command:
+
+```shell
+~/src/github.com/example/project $ gokiburi
+```
+
+Gokiburi will keep an eye on directories, monitoring for any changes in `.go` source files. When a modification or new file is detected, Gokiburi automatically runs tests for the package specified in the affected file.
+
+You can, of course, use Gokiburi as a basic test runner and monitor the results on your terminal. However, its true potential shines when utilizing the web UI, which is accessible by default at [http://localhost:9393/].
+
+The web UI offers a comprehensive view of all test results, complete with search and filter capabilities. This allows you to focus on the specific results you're interested in:
+
+![Gokiburi web UI](.github/images/web_ui_overview.png)
+
+By clicking the gear icon button, you can access Gokiburi's web UI settings. Here, among other options, you can activate sound and browser notifications. This way, you'll be instantly alerted when something goes wrong, even when you're busy in your editor:
+
+![Gokiburi web UI settings](.github/images/web_ui_settings.png)
+
+Gokiburi runs tests with code coverage by default. When you click the coverage button for a package, it opens the coverage report view. This helps you effortlessly pinpoint parts of your code with robust coverage, as well as areas that could use improvement:
+
+![Gokiburi coverage report](.github/images/web_ui_coverage.png)
+
+## Installation
+
+It’s recommended to install the most recent pre-compiled binary release for your operating system and architecture from the [releases page](https://github.com/michenriksen/gokiburi/releases).
+
+If you have Go installed, it’s also possible to install the latest development version with the command:
+
+```shell
+$ go install github.com/michenriksen/gokiburi@latest
+```
+
+The `@latest` tag can also be replaced with a specific release tag if you would rather not install the latest code.
diff --git a/cmd/root/command.go b/cmd/root/command.go
new file mode 100644
index 0000000..6f92e53
--- /dev/null
+++ b/cmd/root/command.go
@@ -0,0 +1,76 @@
+package root
+
+import (
+ "os"
+ "os/signal"
+ "path/filepath"
+ "syscall"
+ "time"
+
+ "github.com/michenriksen/gokiburi/internal/gokiburi"
+ "github.com/michenriksen/gokiburi/internal/pkg/config"
+
+ "github.com/spf13/cobra"
+)
+
+// Run root command.
+func Run() {
+ cobra.CheckErr(Build().Execute())
+}
+
+// Build root command.
+//
+// Registers usage information and command flags.
+func Build() *cobra.Command {
+ app := gokiburi.New()
+
+ cmd := &cobra.Command{
+ Use: "gokiburi [flags] [dir]",
+ Version: gokiburi.Version(),
+ Args: cobra.RangeArgs(0, 1),
+ PreRun: func(cmd *cobra.Command, args []string) {
+ cfg, err := config.Load(cmd)
+ cobra.CheckErr(err)
+
+ dir, err := os.Getwd()
+ cobra.CheckErr(err)
+
+ if len(args) == 1 {
+ dir, err = filepath.Abs(args[0])
+ cobra.CheckErr(err)
+ }
+
+ cobra.CheckErr(app.Init(cfg, dir, gokiburi.InitLogger))
+
+ c := make(chan os.Signal, 1)
+ signal.Notify(c, os.Interrupt, syscall.SIGTERM)
+
+ go func() {
+ <-c
+ app.Cancel()
+ os.Exit(1) //nolint:revive // call to `os.Exit` is intended here.
+ }()
+ },
+ Run: func(cmd *cobra.Command, args []string) {
+ app.Run()
+ },
+ }
+
+ cmd.PersistentFlags().Bool("viper", true, "use Viper for configuration")
+ cmd.PersistentFlags().Bool("debug", false, "log debugging information")
+ cmd.PersistentFlags().Bool("json", false, "log in JSON format")
+ cmd.PersistentFlags().Bool("quiet", false, "log only warnings and errors")
+
+ cmd.Flags().String("shuffle", "off", "randomize the execution order of tests (off,on,N)")
+ cmd.Flags().String("covermode", "count", "mode for coverage analysis (set,count,atomic)")
+ cmd.Flags().Bool("race", false, "enable data race detector")
+ cmd.Flags().Bool("short", false, "tell long-running tests to shorten their runtime")
+ cmd.Flags().Duration("timeout", 10*time.Minute, "timeout for test runs")
+ cmd.Flags().StringSlice("skip-paths", nil, "additional paths for watcher to skip")
+ cmd.Flags().String("listen-address", "127.0.0.1", "address to run web server on")
+ cmd.Flags().Int("listen-port", 9393, "port to run web server on")
+
+ cmd.SetVersionTemplate(gokiburi.VersionTemplate())
+
+ return cmd
+}
diff --git a/dependencies.csv.tmpl b/dependencies.csv.tmpl
new file mode 100644
index 0000000..581f258
--- /dev/null
+++ b/dependencies.csv.tmpl
@@ -0,0 +1,5 @@
+"Package","Version","Type","Licences"
+{{- range .Artifacts}}
+"{{.Name}}","{{.Version}}","{{.Type}}","{{.Licenses}}"
+{{- end}}
+
diff --git a/docs/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..b306d6a
--- /dev/null
+++ b/docs/CODE_OF_CONDUCT.md
@@ -0,0 +1,131 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, caste, color, religion, or sexual
+identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+- Demonstrating empathy and kindness toward other people
+- Being respectful of differing opinions, viewpoints, and experiences
+- Giving and gracefully accepting constructive feedback
+- Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+- Focusing on what is best not just for us as individuals, but for the overall
+ community
+
+Examples of unacceptable behavior include:
+
+- The use of sexualized language or imagery, and sexual attention or advances of
+ any kind
+- Trolling, insulting or derogatory comments, and personal or political attacks
+- Public or private harassment
+- Publishing others' private information, such as a physical or email address,
+ without their explicit permission
+- Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official e-mail address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at `mchnrksn+coc[at]gmail[dot]com`.
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series of
+actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or permanent
+ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within the
+community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.1, available at
+[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
+
+Community Impact Guidelines were inspired by
+[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
+
+For answers to common questions about this code of conduct, see the FAQ at
+[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
+[https://www.contributor-covenant.org/translations][translations].
+
+[homepage]: https://www.contributor-covenant.org
+[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
+[Mozilla CoC]: https://github.com/mozilla/diversity
+[FAQ]: https://www.contributor-covenant.org/faq
+[translations]: https://www.contributor-covenant.org/translations
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
new file mode 100644
index 0000000..cc52453
--- /dev/null
+++ b/docs/RELEASING.md
@@ -0,0 +1,48 @@
+# Release Runbook
+
+This document describes the steps needed to release a new version of Gokiburi:
+
+## 1. Preparations
+
+1. [ ] Ensure latest code is fetched: `git pull --rebase origin main`
+2. [ ] Tidy go dependencies: `go mod tidy`
+3. [ ] Tidy frontend dependencies in `web/app`: `npm install; npm prune`
+4. [ ] Check for vulnerable code and dependencies: `make audit`
+5. [ ] Check code is correctly formatted: `make lint-all`
+6. [ ] Check all tests pass: `make test-all`
+7. [ ] Build new snapshot binary: `goreleaser build --snapshot --single-target`
+8. [ ] Run binary: `./dist/gokiburi_*/gokiburi`
+9. [ ] Confirm web UI loads on `http://localhost:9393/`
+
+## 2. Manual Acceptance Test
+
+1. [ ] Confirm no errors in developer console
+2. [ ] Toggle on sound notifications in app bar
+3. [ ] Open settings and confirm sound notifications are active for all events
+4. [ ] Press **Run All Tests** app bar button
+5. [ ] Confirm sound notification is played
+6. [ ] Click on test and confirm output and start time are shown
+7. [ ] Click on coverage button and confirm code coverage is rendered
+8. [ ] Perform search and confirm tests are filtered
+9. [ ] Modify other filter options and confirm tests are filtered
+10. [ ] Open settings and disable sound notifications for passing tests
+11. [ ] Modify source file and confirm package tests are run
+12. [ ] Confirm sound notification is NOT played
+13. [ ] Press **Pause Automatic Test Runs** app bar button
+14. [ ] Modify source file and confirm package tests are NOT run
+15. [ ] Press **Resume Automatic Test Runs** app bar button
+16. [ ] Confirm state goes back to **Live**
+17. [ ] Press **Clear All Results** and confirm test results are cleared
+18. [ ] Confirm no new errors in developer console
+19. [ ] Reload UI and confirm test results are cleared
+20. [ ] Press `Ctrl+C` in terminal and confirm application shuts down with no errors
+
+## 3. Release
+
+1. [ ] Get next semantic release version: `make version-next`
+2. [ ] Run [Release workflow] with next version
+3. [ ] Go to [Releases page] and verify new draft release
+4. [ ] Edit release and check **Create a discussion for this release** and press the **Publish release** button
+
+[Release workflow]: https://github.com/michenriksen/gokiburi/actions/workflows/release.yml
+[Releases page]: https://github.com/michenriksen/gokiburi/releases
diff --git a/docs/THANKS.md b/docs/THANKS.md
new file mode 100644
index 0000000..0f6d197
--- /dev/null
+++ b/docs/THANKS.md
@@ -0,0 +1,17 @@
+# Thanks :bow:
+
+Here is a list of individuals and projects, in no specific order, that I'd like to express my gratitude to for making the development of Gokiburi a smooth process:
+
+- https://github.com/fsnotify/fsnotify file system monitoring
+- https://github.com/spf13/cobra CLI framework
+- https://github.com/invopop/validation data validation
+- https://github.com/charmbracelet/log CLI logging
+- https://github.com/stretchr/testify unit testing toolkit
+- https://github.com/labstack/echo web server framework
+- https://kit.svelte.dev/ frontend framework
+- https://www.skeleton.dev/ UI toolkit
+- https://tailwindcss.com/ CSS framework
+- https://github.com/Templarian/MaterialDesign-JS UI icons
+- https://howlerjs.com/ web audio library
+- https://freesound.org/people/Eponn/packs/35313/ notification sounds
+- https://github.com/smartystreets/goconvey/ inspiration for Gokiburi
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..8452606
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,52 @@
+module github.com/michenriksen/gokiburi
+
+go 1.19
+
+require (
+ github.com/aidarkhanov/nanoid/v2 v2.0.5
+ github.com/charmbracelet/lipgloss v0.7.1
+ github.com/charmbracelet/log v0.2.1
+ github.com/dustin/go-humanize v1.0.1
+ github.com/fsnotify/fsnotify v1.6.0
+ github.com/invopop/validation v0.3.0
+ github.com/labstack/echo/v4 v4.10.2
+ github.com/spf13/cobra v1.7.0
+ github.com/spf13/viper v1.15.0
+ github.com/stretchr/testify v1.8.2
+ golang.org/x/net v0.9.0
+ golang.org/x/tools v0.8.0
+)
+
+require (
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/go-logfmt/logfmt v0.6.0 // indirect
+ github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
+ github.com/hashicorp/hcl v1.0.0 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/labstack/gommon v0.4.0 // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/magiconair/properties v1.8.7 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.18 // indirect
+ github.com/mattn/go-runewidth v0.0.14 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // indirect
+ github.com/muesli/reflow v0.3.0 // indirect
+ github.com/muesli/termenv v0.15.1 // indirect
+ github.com/pelletier/go-toml/v2 v2.0.6 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/rivo/uniseg v0.2.0 // indirect
+ github.com/spf13/afero v1.9.3 // indirect
+ github.com/spf13/cast v1.5.0 // indirect
+ github.com/spf13/jwalterweatherman v1.1.0 // indirect
+ github.com/spf13/pflag v1.0.5 // indirect
+ github.com/subosito/gotenv v1.4.2 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
+ golang.org/x/crypto v0.6.0 // indirect
+ golang.org/x/sys v0.7.0 // indirect
+ golang.org/x/text v0.9.0 // indirect
+ golang.org/x/time v0.3.0 // indirect
+ gopkg.in/ini.v1 v1.67.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..cc3a857
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,544 @@
+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
+cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
+cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
+cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
+cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
+cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
+cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
+cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
+cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
+cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
+cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
+cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
+cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
+cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
+cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
+cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
+cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
+cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
+cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
+cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
+cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
+cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
+cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
+cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
+cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
+cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
+cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
+cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
+cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
+cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
+cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
+cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
+cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
+cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
+cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
+dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/aidarkhanov/nanoid/v2 v2.0.5 h1:HLx5RyDuvOZ6YxlhYTxSU8Il+q7xVKmXM62MfSxziN0=
+github.com/aidarkhanov/nanoid/v2 v2.0.5/go.mod h1:YF/U48D1yA3AoGGUdRrCV95J/KJBShvR9TyLqQwdtlI=
+github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ=
+github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E=
+github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
+github.com/charmbracelet/log v0.2.1 h1:1z7jpkk4yKyjwlmKmKMM5qnEDSpV32E7XtWhuv0mTZE=
+github.com/charmbracelet/log v0.2.1/go.mod h1:GwFfjewhcVDWLrpAbY5A0Hin9YOlEn40eWT4PNaxFT4=
+github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
+github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
+github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
+github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
+github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
+github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
+github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
+github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
+github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
+github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
+github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
+github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
+github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
+github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
+github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
+github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
+github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
+github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
+github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
+github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
+github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
+github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
+github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
+github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
+github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/invopop/validation v0.3.0 h1:o260kbjXzoBO/ypXDSSrCLL7SxEFUXBsX09YTE9AxZw=
+github.com/invopop/validation v0.3.0/go.mod h1:qIBG6APYLp2Wu3/96p3idYjP8ffTKVmQBfKiZbw0Hts=
+github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
+github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN2M=
+github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k=
+github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
+github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
+github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
+github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
+github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
+github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
+github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs=
+github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ=
+github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU=
+github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
+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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/afero v1.9.3 h1:41FoI0fD7OR7mGcKE/aOiLkGreyf8ifIOQmJANWogMk=
+github.com/spf13/afero v1.9.3/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
+github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
+github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
+github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
+github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
+github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
+github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.15.0 h1:js3yy885G8xwJa6iOISGFwd+qlUo5AvyXb7CiihdtiU=
+github.com/spf13/viper v1.15.0/go.mod h1:fFcTBJxvhhzSJiZy8n+PeW6t8l+KeT/uTARa0jHOQLA=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
+github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8=
+github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
+github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
+github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
+github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
+go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
+go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
+golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
+golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
+golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
+golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
+golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
+golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
+golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
+golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
+golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
+golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
+golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
+golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
+golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
+golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
+golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
+golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
+golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
+golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
+golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
+golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
+golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
+golang.org/x/tools v0.8.0 h1:vSDcovVPld282ceKgDimkRSC8kpaH1dgyc9UMzlt84Y=
+golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
+google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
+google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
+google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
+google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
+google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
+google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
+google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
+google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
+google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
+google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
+google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
+google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
+google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
+google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
+google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
+google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
+google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
+google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
+google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
+google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
+google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
+google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
+google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
+google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
+google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
+google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
+google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
+google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
+google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
+honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
+honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
+rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
+rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
+rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
diff --git a/internal/gokiburi/app.go b/internal/gokiburi/app.go
new file mode 100644
index 0000000..850f28c
--- /dev/null
+++ b/internal/gokiburi/app.go
@@ -0,0 +1,293 @@
+package gokiburi
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/charmbracelet/log"
+
+ "github.com/michenriksen/gokiburi/internal/pkg/config"
+ "github.com/michenriksen/gokiburi/internal/pkg/coverparser"
+ "github.com/michenriksen/gokiburi/internal/pkg/runner"
+ "github.com/michenriksen/gokiburi/internal/pkg/server"
+ "github.com/michenriksen/gokiburi/internal/pkg/state"
+ "github.com/michenriksen/gokiburi/internal/pkg/watcher"
+)
+
+const (
+ logKeyPath = "path"
+ logKeyOp = "op"
+ logKeyErr = "error"
+)
+
+// Option configures an [App].
+type Option func(*App)
+
+// App for gokiburi.
+//
+// Acts as the central point of the application.
+type App struct {
+ ctx context.Context
+ ctxCancel context.CancelFunc
+ state state.State
+ dir string
+ config *config.Config
+ stdout io.Writer
+ stderr io.Writer
+ stdin io.Reader
+ logger *log.Logger
+ watcher *watcher.Watcher
+ runner *runner.Runner
+ server *server.Server
+ parser *coverparser.Parser
+}
+
+// New App returns a new app.
+//
+// Call [App.Init] to further initialize the application.
+func New(opts ...Option) *App {
+ ctx, cancel := context.WithCancel(context.Background())
+
+ app := &App{
+ ctx: ctx,
+ ctxCancel: cancel,
+ state: state.Init,
+ stdout: os.Stdout,
+ stderr: os.Stderr,
+ stdin: os.Stdin,
+ }
+
+ for _, opt := range opts {
+ opt(app)
+ }
+
+ return app
+}
+
+// Init application with configuration, root directory, and initializer functions.
+func (a *App) Init(cfg *config.Config, dir string, inits ...Initializer) error {
+ a.config = cfg
+ a.dir = dir
+
+ for _, initFunc := range inits {
+ if err := initFunc(a); err != nil {
+ return fmt.Errorf("initializing app: %w", err)
+ }
+ }
+
+ return nil
+}
+
+func (a *App) Run() {
+ a.logger.Info("starting gokiburi...", "version", Version())
+ a.logger.Debug("debugging is enabled")
+
+ a.watcher = watcher.New(a.ctx, a.dir,
+ watcher.WithLogger(a.logger.WithPrefix("watcher")),
+ watcher.WithSkipPaths(a.config.SkipPaths...),
+ )
+ a.runner = runner.New(a.ctx, a.dir,
+ runner.WithLogger(a.logger.WithPrefix("runner")),
+ runner.WithCovermode(a.config.Covermode),
+ runner.WithShuffle(a.config.Shuffle),
+ runner.WithRaceDetection(a.config.RaceDetection),
+ runner.WithShort(a.config.Short),
+ runner.WithTimeout(a.config.Timeout),
+ )
+ a.server = server.New(a.ctx, a.dir,
+ server.WithLogger(a.logger.WithPrefix("server")),
+ )
+ a.parser = coverparser.New(a.ctx, a.dir,
+ coverparser.WithLogger(a.logger.WithPrefix("coverparser")),
+ )
+
+ a.startServer()
+ a.startWatcher()
+ a.setState(state.Ready)
+
+ for {
+ select {
+ case cmd, ok := <-a.server.Commands:
+ if !ok {
+ continue
+ }
+
+ a.handleCommand(cmd)
+ case event, ok := <-a.watcher.Events:
+ if !ok {
+ continue
+ }
+
+ a.handleEvent(event)
+ }
+ }
+}
+
+func (a *App) Cancel() {
+ a.stdout.Write([]byte("\r")) //nolint:errcheck // we don't care if this write fails.
+ a.logger.Warn("caught interrupt; shutting down...")
+ a.setState(state.Closing)
+
+ a.ctxCancel()
+ a.server.Close()
+
+ time.Sleep(2 * time.Second)
+}
+
+func (a *App) handleCommand(cmd *server.Command) {
+ switch cmd.Instruction {
+ case server.Pause:
+ a.logger.Info("pausing automatic test runs")
+ a.setState(state.Paused)
+ case server.Resume:
+ a.logger.Info("resuming automatic test runs")
+ a.setState(state.Ready)
+ case server.RunTests:
+ a.runTests(cmd.Data)
+ }
+}
+
+func (a *App) handleEvent(event *watcher.EventBatch) {
+ for path, op := range event.Events {
+ a.logger.Debug("watcher event", logKeyPath, path, logKeyOp, op)
+ }
+
+ pkgMap := make(map[string]struct{})
+
+ for _, path := range event.Paths() {
+ if _, ok := pkgMap[path]; ok {
+ continue
+ }
+
+ pkg, err := a.runner.PackageForFile(path)
+ if err != nil {
+ a.logger.Error("error determining package for file", logKeyErr, err)
+ continue
+ }
+
+ pkgMap[pkg] = struct{}{}
+ }
+
+ pkgs := make([]string, 0, len(pkgMap))
+
+ for pkg := range pkgMap {
+ pkgs = append(pkgs, pkg)
+ }
+
+ a.runTests(pkgs...)
+}
+
+func (a *App) setState(s state.State) {
+ a.state = s
+ a.server.SetState(s)
+}
+
+func (a *App) startServer() {
+ go func() {
+ if err := a.server.Serve(a.config.ListenAddress, a.config.ListenPort); err != nil {
+ if a.state != state.Closing {
+ a.logger.Fatal("server error", logKeyErr, err)
+ }
+ }
+ }()
+}
+
+func (a *App) startWatcher() {
+ if err := a.watcher.Watch(); err != nil {
+ a.logger.Fatal("error starting watcher", logKeyErr, err)
+ }
+}
+
+func (a *App) runTests(pkgs ...string) { //nolint:revive // logic is easy enough to follow.
+ if a.state != state.Ready {
+ a.logger.Debug("skipping test run when state is not ready", "state", a.state)
+ return
+ }
+
+ go func() {
+ a.setState(state.Running)
+ defer a.setState(state.Ready)
+
+ result, err := a.runner.Run(pkgs...)
+ if err != nil {
+ a.logErrorAndNotify(err, "test runner failed with error")
+
+ return
+ }
+
+ if result.Error != "" {
+ a.logger.Info("skipping coverage report parsing for result with error")
+ a.addResult(result)
+
+ if err = result.Close(); err != nil {
+ a.logger.Error("error closing test result", logKeyErr, err)
+ }
+
+ return
+ }
+
+ if result.Tests == 0 {
+ a.logger.Info("skipping coverage report parsing for result with no tests")
+ a.addResult(result)
+
+ if err = result.Close(); err != nil {
+ a.logger.Error("error closing test result", logKeyErr, err)
+ }
+
+ return
+ }
+
+ rdir := result.Dir()
+
+ f, err := os.Open(filepath.Join(rdir, "coverprofile.out"))
+ if err != nil {
+ a.logErrorAndNotify(err, "failed to open coverage profile for test result")
+
+ return
+ }
+
+ defer f.Close()
+
+ report, err := a.parser.Parse(f)
+ if err != nil {
+ a.logErrorAndNotify(err, "coverage profile parser failed with error")
+
+ return
+ }
+
+ data, err := json.Marshal(report)
+ if err != nil {
+ a.logErrorAndNotify(err, "failed to encode coverage report to JSON")
+
+ return
+ }
+
+ if err := os.WriteFile(filepath.Join(rdir, "report.json"), data, 0o600); err != nil {
+ a.logErrorAndNotify(err, "failed writing coverage report to file")
+
+ return
+ }
+
+ a.addResult(result)
+ }()
+}
+
+func (a *App) addResult(r *runner.Result) {
+ if r == nil {
+ return
+ }
+
+ a.server.AddResult(r)
+}
+
+func (a *App) logErrorAndNotify(err error, format string, args ...any) {
+ msg := fmt.Sprintf(format, args...)
+
+ a.logger.Error(msg, logKeyErr, err)
+ a.server.SendNotification("error", msg)
+}
diff --git a/internal/gokiburi/inits.go b/internal/gokiburi/inits.go
new file mode 100644
index 0000000..ae791d7
--- /dev/null
+++ b/internal/gokiburi/inits.go
@@ -0,0 +1,39 @@
+package gokiburi
+
+import "github.com/charmbracelet/log"
+
+// Initializer initializes part of an [App].
+type Initializer func(*App) error
+
+// InitLogger for [App].
+//
+// Initializes the app logger configured according to the app configuration.
+func InitLogger(app *App) error {
+ log.InfoLevelStyle = logInfoLevelStyle
+ log.WarnLevelStyle = logWarnLevelStyle
+ log.ErrorLevelStyle = logErrorLevelStyle
+ log.FatalLevelStyle = logFatalLevelStyle
+ log.KeyStyle = logKeyStyle
+ log.PrefixStyle = logPrefixStyle
+
+ level := log.InfoLevel
+
+ if app.config.Debug {
+ level = log.DebugLevel
+ } else if app.config.Quiet {
+ level = log.WarnLevel
+ }
+
+ app.logger = log.NewWithOptions(app.stdout, log.Options{
+ Level: level,
+ ReportCaller: app.config.Debug,
+ ReportTimestamp: true,
+ Prefix: "gokiburi",
+ })
+
+ if app.config.JSON {
+ app.logger.SetFormatter(log.JSONFormatter)
+ }
+
+ return nil
+}
diff --git a/internal/gokiburi/styles.go b/internal/gokiburi/styles.go
new file mode 100644
index 0000000..8887da5
--- /dev/null
+++ b/internal/gokiburi/styles.go
@@ -0,0 +1,28 @@
+package gokiburi
+
+import (
+ "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/log"
+)
+
+var (
+ logInfoLevelStyle = log.InfoLevelStyle.Copy().
+ Foreground(lipgloss.AdaptiveColor{Light: "#7D79F6", Dark: "#514DC1"})
+
+ logWarnLevelStyle = log.WarnLevelStyle.Copy().
+ Foreground(lipgloss.AdaptiveColor{Light: "#FF8700", Dark: "#FFAF00"})
+
+ logErrorLevelStyle = log.ErrorLevelStyle.Copy().
+ Foreground(lipgloss.AdaptiveColor{Light: "#FF6F91", Dark: "#C74665"})
+
+ logFatalLevelStyle = log.FatalLevelStyle.Copy().
+ Foreground(lipgloss.AdaptiveColor{Light: "#FF4672", Dark: "#ED567A"})
+
+ logKeyStyle = log.KeyStyle.Copy().
+ Foreground(lipgloss.AdaptiveColor{Light: "#FFFDF5", Dark: "#FFFDF5"}).
+ Bold(false)
+
+ logPrefixStyle = log.PrefixStyle.Copy().
+ Foreground(lipgloss.AdaptiveColor{Light: "#FFFDF5", Dark: "#FFFDF5"}).
+ Bold(true)
+)
diff --git a/internal/gokiburi/version.go b/internal/gokiburi/version.go
new file mode 100644
index 0000000..ad5bcf8
--- /dev/null
+++ b/internal/gokiburi/version.go
@@ -0,0 +1,74 @@
+package gokiburi
+
+import (
+ "fmt"
+ "runtime"
+ "time"
+)
+
+const versionTemplate = `{{with .Name}}{{printf "%%s:" .}}{{end}}
+ Version: {{printf "%%s" .Version}}
+ Go Version: %s
+ Git Commit: %s
+ Released: %s
+ OS/Arch: %s
+`
+
+// Build information set by the compiler.
+var (
+ buildVersion = ""
+ buildTime = ""
+ buildCommit = ""
+ buildGoVersion = ""
+ buildGoOSArch = ""
+)
+
+// Version of the application.
+//
+// Returns `0.0.0-dev` if no version is set.
+func Version() string {
+ if buildVersion == "" {
+ return "0.0.0-dev"
+ }
+
+ return buildVersion
+}
+
+// VersionTemplate for the Cobra CLI framework.
+func VersionTemplate() string {
+ return fmt.Sprintf(versionTemplate,
+ BuildGoVersion(), BuildCommit(), BuildTime(), BuildGoOSArch(),
+ )
+}
+
+func BuildTime() string {
+ if buildTime == "" {
+ return time.Now().UTC().Format(time.RFC3339)
+ }
+
+ return buildTime
+}
+
+func BuildCommit() string {
+ if buildCommit == "" {
+ return "HEAD"
+ }
+
+ return buildCommit
+}
+
+func BuildGoVersion() string {
+ if buildGoVersion == "" {
+ return runtime.Version()
+ }
+
+ return buildGoVersion
+}
+
+func BuildGoOSArch() string {
+ if buildGoOSArch == "" {
+ return runtime.GOOS + "/" + runtime.GOARCH
+ }
+
+ return buildGoOSArch
+}
diff --git a/internal/pkg/command/runner.go b/internal/pkg/command/runner.go
new file mode 100644
index 0000000..15f1e1a
--- /dev/null
+++ b/internal/pkg/command/runner.go
@@ -0,0 +1,40 @@
+package command
+
+import (
+ "context"
+ "errors"
+ "os/exec"
+)
+
+// Runner function runs an external command.
+//
+// Command is executed in a content-aware fashion and with `dir` set as its
+// working directory.
+//
+// Returns the combined output from stdout and stderr, exit code, or error
+// if execution fails.
+//
+// If exit code indicates failure (anything other than 0), it is not treated
+// as an execution error, and returned error will be nil.
+type Runner func(ctx context.Context, dir, name string, args ...string) (out []byte, exitCode int, err error)
+
+// DefaultRunner for running an external command.
+//
+// Uses the `os/exec` package to run the command.
+var DefaultRunner = func(ctx context.Context, dir, name string, args ...string) (out []byte, exitCode int, err error) {
+ cmd := exec.CommandContext(ctx, name, args...) //#nosec:G204 // no security risk.
+ cmd.Dir = dir
+
+ out, err = cmd.CombinedOutput()
+ if err != nil {
+ var exitError *exec.ExitError
+
+ if errors.As(err, &exitError) {
+ return out, cmd.ProcessState.ExitCode(), nil
+ }
+
+ return out, cmd.ProcessState.ExitCode(), err
+ }
+
+ return out, cmd.ProcessState.ExitCode(), nil
+}
diff --git a/internal/pkg/command/runner_test.go b/internal/pkg/command/runner_test.go
new file mode 100644
index 0000000..981b8ee
--- /dev/null
+++ b/internal/pkg/command/runner_test.go
@@ -0,0 +1,32 @@
+package command_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/michenriksen/gokiburi/internal/pkg/command"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestDefaultRunner(t *testing.T) {
+ out, exitCode, err := command.DefaultRunner(context.Background(), ".", "echo", "Hello, World!")
+
+ require.NoError(t, err)
+ require.Equal(t, "Hello, World!\n", string(out))
+ require.Equal(t, 0, exitCode)
+}
+
+func TestDefaultRunner_ExitCode(t *testing.T) {
+ _, exitCode, err := command.DefaultRunner(context.Background(), ".", "false")
+
+ require.NoError(t, err)
+ require.Equal(t, 1, exitCode)
+}
+
+func TestDefaultRunner_NotFound(t *testing.T) {
+ _, exitCode, err := command.DefaultRunner(context.Background(), ".", "5tgh52s7g7o")
+
+ require.Error(t, err)
+ require.Equal(t, -1, exitCode)
+}
diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go
new file mode 100644
index 0000000..a0e6fcb
--- /dev/null
+++ b/internal/pkg/config/config.go
@@ -0,0 +1,150 @@
+package config
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/invopop/validation"
+ "github.com/spf13/cobra"
+ "github.com/spf13/viper"
+)
+
+const (
+ configName = "gokiburi"
+ envPrefix = "gokiburi"
+ envVPDebug = "GOKIBURI_DEBUG_VIPER"
+)
+
+var shuffleRegexp = regexp.MustCompile(`^(on|off|\d+)$`)
+
+// Config for the application.
+type Config struct {
+ CfgFile string
+ UseViper bool `mapstructure:"viper"`
+ Debug bool
+ JSON bool
+ Quiet bool
+ Shuffle string
+ Covermode string
+ RaceDetection bool `mapstructure:"race"`
+ Short bool
+ Timeout time.Duration
+ Run string
+ Skip string
+ SkipPaths []string `mapstructure:"skip-paths"`
+ ListenAddress string `mapstructure:"listen-address"`
+ ListenPort int `mapstructure:"listen-port"`
+}
+
+// Validate configuration.
+func (c Config) Validate() error {
+ return validation.ValidateStruct(&c,
+ validation.Field(&c.Shuffle, validation.Match(shuffleRegexp).
+ Error("must be on, off, or a number")),
+ validation.Field(&c.Covermode, validation.In("set", "count", "atomic").
+ Error("must be one of set, count, or atomic")),
+ validation.Field(&c.ListenPort, validation.Min(0), validation.Max(65535)),
+ )
+}
+
+// Load configuration with flag values from command.
+//
+// Looks for viper configuration files at the current location, in order:
+//
+// - Current working directory
+// - `gokiburi` directory inside directory returned by [os.UserConfigDir]
+// - /etc/gokiburi/
+//
+// the configuration file must be named `gokiburi` and have an extension
+// supported by Viper, e.g. `yml`, `toml`, or `json`.
+//
+// If a command line flag is set, it will override the value will take
+// precedence over the configuration file value.
+func Load(cmd *cobra.Command) (*Config, error) {
+ cfg := &Config{}
+
+ if err := cfg.Load(cmd); err != nil {
+ return nil, fmt.Errorf("loading configuration: %w", err)
+ }
+
+ if err := cfg.Validate(); err != nil {
+ return nil, fmt.Errorf("invalid configuration: %w", err)
+ }
+
+ return cfg, nil
+}
+
+// Load configuration with flag values from command.
+//
+// Looks for viper configuration files at the current location, in order:
+//
+// - Current working directory
+// - `gokiburi` directory inside directory returned by [os.UserConfigDir]
+// - /etc/gokiburi/
+//
+// the configuration file must be named `gokiburi` and have an extension
+// supported by Viper, e.g. `yml`, `toml`, or `json`.
+//
+// If a command line flag is set, the value will take precedence over the
+// configuration file value.
+func (c *Config) Load(cmd *cobra.Command) error {
+ v := viper.New()
+
+ if err := v.BindPFlag("useViper", cmd.Flag("viper")); err != nil {
+ return fmt.Errorf("binding viper flag: %w", err)
+ }
+
+ if err := v.BindPFlags(cmd.PersistentFlags()); err != nil {
+ return fmt.Errorf("binding persistent command flags: %w", err)
+ }
+
+ if err := v.BindPFlags(cmd.Flags()); err != nil {
+ return fmt.Errorf("binding command flags: %w", err)
+ }
+
+ if err := c.readInConfig(v); err != nil {
+ return err
+ }
+
+ c.CfgFile = v.ConfigFileUsed()
+
+ if _, ok := os.LookupEnv(envVPDebug); ok {
+ v.Debug()
+ }
+
+ if err := v.Unmarshal(c); err != nil {
+ return fmt.Errorf("unmarshaling viper: %w", err)
+ }
+
+ return nil
+}
+
+func (*Config) readInConfig(v *viper.Viper) error {
+ cfgDir, err := os.UserConfigDir()
+ if err != nil {
+ return fmt.Errorf("getting user configuration directory: %w", err)
+ }
+
+ v.SetConfigName(configName)
+ v.AddConfigPath(".")
+ v.AddConfigPath(filepath.Join(cfgDir, configName))
+ v.AddConfigPath(filepath.Join("/etc", configName))
+
+ v.SetEnvPrefix(envPrefix)
+ v.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
+
+ if err = v.ReadInConfig(); err != nil {
+ if errors.As(err, &viper.ConfigFileNotFoundError{}) {
+ return nil
+ }
+
+ return fmt.Errorf("reading configuration file %q: %w", v.ConfigFileUsed(), err)
+ }
+
+ return nil
+}
diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go
new file mode 100644
index 0000000..e074d45
--- /dev/null
+++ b/internal/pkg/config/config_test.go
@@ -0,0 +1,112 @@
+package config_test
+
+import (
+ "testing"
+ "time"
+
+ "github.com/invopop/validation"
+
+ "github.com/michenriksen/gokiburi/internal/pkg/config"
+
+ "github.com/spf13/cobra"
+ "github.com/stretchr/testify/require"
+)
+
+func TestLoad(t *testing.T) {
+ cmd := &cobra.Command{}
+ cmd.PersistentFlags().Bool("viper", false, "")
+ cmd.PersistentFlags().Bool("debug", true, "")
+ cmd.PersistentFlags().Bool("json", true, "")
+ cmd.PersistentFlags().Bool("quiet", true, "")
+ cmd.Flags().String("shuffle", "on", "")
+ cmd.Flags().String("covermode", "count", "")
+ cmd.Flags().Bool("race", true, "")
+ cmd.Flags().Bool("short", true, "")
+ cmd.Flags().Duration("timeout", 42*time.Second, "")
+ cmd.Flags().StringSlice("skip-paths", []string{"path1", "path2"}, "")
+ cmd.Flags().String("listen-address", "127.0.0.1", "")
+ cmd.Flags().Int("listen-port", 8080, "")
+
+ cfg, err := config.Load(cmd)
+
+ require.NoError(t, err)
+ require.True(t, cfg.Debug)
+ require.True(t, cfg.JSON)
+ require.True(t, cfg.Quiet)
+ require.Equal(t, "on", cfg.Shuffle)
+ require.Equal(t, "count", cfg.Covermode)
+ require.True(t, cfg.RaceDetection)
+ require.True(t, cfg.Short)
+ require.Equal(t, 42*time.Second, cfg.Timeout)
+ require.Equal(t, []string{"path1", "path2"}, cfg.SkipPaths)
+ require.Equal(t, "127.0.0.1", cfg.ListenAddress)
+ require.Equal(t, 8080, cfg.ListenPort)
+}
+
+func TestLoad_NumericShuffleValue(t *testing.T) {
+ cmd := &cobra.Command{}
+ cmd.Flags().Bool("viper", false, "")
+ cmd.Flags().String("shuffle", "1337", "")
+
+ cfg, err := config.Load(cmd)
+
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+ require.Equal(t, "1337", cfg.Shuffle)
+}
+
+func TestLoad_InvalidShuffleValue(t *testing.T) {
+ cmd := &cobra.Command{}
+ cmd.Flags().Bool("viper", false, "")
+ cmd.Flags().String("shuffle", "wut", "")
+
+ cfg, err := config.Load(cmd)
+
+ var errs validation.Errors
+ require.ErrorAs(t, err, &errs)
+
+ require.Equal(t, "Shuffle: must be on, off, or a number.", errs.Error())
+ require.Nil(t, cfg)
+}
+
+func TestLoad_InvalidCovermodeValue(t *testing.T) {
+ cmd := &cobra.Command{}
+ cmd.Flags().Bool("viper", false, "")
+ cmd.Flags().String("covermode", "lol", "")
+
+ cfg, err := config.Load(cmd)
+
+ var errs validation.Errors
+ require.ErrorAs(t, err, &errs)
+
+ require.Equal(t, "Covermode: must be one of set, count, or atomic.", errs.Error())
+ require.Nil(t, cfg)
+}
+
+func TestLoad_ListenPortLessThanZero(t *testing.T) {
+ cmd := &cobra.Command{}
+ cmd.Flags().Bool("viper", false, "")
+ cmd.Flags().String("listen-port", "-1", "")
+
+ cfg, err := config.Load(cmd)
+
+ var errs validation.Errors
+ require.ErrorAs(t, err, &errs)
+
+ require.Equal(t, "ListenPort: must be no less than 0.", errs.Error())
+ require.Nil(t, cfg)
+}
+
+func TestLoad_ListenPortTooHigh(t *testing.T) {
+ cmd := &cobra.Command{}
+ cmd.Flags().Bool("viper", false, "")
+ cmd.Flags().String("listen-port", "65536", "")
+
+ cfg, err := config.Load(cmd)
+
+ var errs validation.Errors
+ require.ErrorAs(t, err, &errs)
+
+ require.Equal(t, "ListenPort: must be no greater than 65535.", errs.Error())
+ require.Nil(t, cfg)
+}
diff --git a/internal/pkg/coverparser/cover.go b/internal/pkg/coverparser/cover.go
new file mode 100644
index 0000000..956be06
--- /dev/null
+++ b/internal/pkg/coverparser/cover.go
@@ -0,0 +1,58 @@
+package coverparser
+
+import (
+ "time"
+)
+
+// Mode of a coverage report.
+type Mode string
+
+// Coverage modes supported by Go.
+const (
+ ModeSet Mode = "set" // does this statement run?
+ ModeCount Mode = "count" // how many times does this statement run?
+ ModeAtomic Mode = "atomic" // like count, but correct in multithreaded tests.
+)
+
+// Report of code coverage in a test run.
+//
+// Holds coverage profile data and contents of tested files.
+type Report struct {
+ Mode Mode `json:"mode"`
+ Profiles []Profile `json:"profiles"`
+ Time time.Time `json:"time"`
+}
+
+// Profile of a single file in coverage report.
+type Profile struct {
+ FileName string `json:"filename"` // Name of file.
+ Package string `json:"package"` // Package where file belongs.
+ Path string `json:"path"` // Absolute path to file.
+ Content []byte `json:"content"` // Content of file at time of testing.
+ Size int `json:"size"` // File size.
+ Coverage float64 `json:"coverage"` // Coverage percentage of file.
+ LineCount int `json:"lineCount"` // Number of lines in file.
+ Boundaries []ProfileBoundary `json:"boundaries"` // Boundaries map coverage of the content.
+}
+
+// ProfileBoundary represents the position in a source file of the beginning
+// and end of a block as reported by the coverage profile.
+//
+// User interfaces can use this to mark coverage of a file, e.g. by wrapping
+// file content in HTML tags between start and end offsets.
+type ProfileBoundary struct {
+ Offset int `json:"offset"` // Location as a byte offset in the content.
+ Start bool `json:"start"` // Is this the start of a block?
+ Count int `json:"count"` // Amount of times block was invoked in tests.
+ Norm float64 `json:"norm"` // Coverage normalized to [0..1].
+ Index int `json:"index"` // Order in content.
+}
+
+// pkg describes a single package, compatible with the JSON output from 'go list'; see 'go help list'.
+type pkg struct {
+ ImportPath string
+ Dir string
+ Error *struct {
+ Err string
+ }
+}
diff --git a/internal/pkg/coverparser/parser.go b/internal/pkg/coverparser/parser.go
new file mode 100644
index 0000000..2b5a81e
--- /dev/null
+++ b/internal/pkg/coverparser/parser.go
@@ -0,0 +1,266 @@
+package coverparser
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/log"
+ "golang.org/x/tools/cover"
+
+ "github.com/michenriksen/gokiburi/internal/pkg/command"
+)
+
+const defaultGoBin = "go"
+
+var (
+ // ErrNoProfiles is returned by [Parser.Parse] if profile contains no data.
+ ErrNoProfiles = errors.New("cover profile contains no coverage data")
+
+ // ErrNoPackages is returned by [Parser.Parse] if profile contains no package data.
+ ErrNoPackages = errors.New("cover profile contains no package coverage data")
+)
+
+// Option configures a [Parser].
+type Option func(*Parser)
+
+// Parser parses Go test coverage profile data.
+type Parser struct {
+ ctx context.Context
+ root string
+ logger *log.Logger
+ goBin string
+ cmdRunner command.Runner
+}
+
+// New parser for Go test coverage profile data.
+func New(ctx context.Context, rootDir string, opts ...Option) *Parser {
+ p := &Parser{
+ ctx: ctx,
+ root: rootDir,
+ logger: log.New(io.Discard),
+ goBin: defaultGoBin,
+ cmdRunner: command.DefaultRunner,
+ }
+
+ for _, opt := range opts {
+ opt(p)
+ }
+
+ return p
+}
+
+// Parse test coverage data from Go coverprofile data.
+//
+// Parses the content of a file created by the `go test -coverprofile=...`
+// command and returns a report with coverage data and contents of tested files.
+//
+// Uses [pkg.go.dev/golang.org/x/tools/cover] under the hood to parse the
+// coverprofile data.
+func (p *Parser) Parse(coverprofile io.Reader) (*Report, error) {
+ profiles, err := cover.ParseProfilesFromReader(coverprofile)
+ if err != nil {
+ return nil, fmt.Errorf("parsing cover profile data: %w", err)
+ }
+
+ if len(profiles) == 0 {
+ return nil, ErrNoProfiles
+ }
+
+ report := &Report{
+ Mode: Mode(profiles[0].Mode),
+ Time: time.Now(),
+ }
+
+ pkgs, err := p.profilePkgs(profiles)
+ if err != nil {
+ return nil, fmt.Errorf("getting packages from profiles: %w", err)
+ }
+
+ if len(pkgs) == 0 {
+ return nil, ErrNoPackages
+ }
+
+ for _, cp := range profiles {
+ profile := Profile{
+ FileName: cp.FileName,
+ Package: path.Dir(cp.FileName),
+ }
+
+ profile.Path, err = p.pkgFile(pkgs, cp.FileName)
+ if err != nil {
+ return nil, fmt.Errorf("getting absolute path for %s: %w", cp.FileName, err)
+ }
+
+ profile.Content, err = os.ReadFile(profile.Path)
+ if err != nil {
+ return nil, fmt.Errorf("reading file %s: %w", profile.Path, err)
+ }
+
+ profile.Size = len(profile.Content)
+ profile.Boundaries = p.profileBoundaries(profile.Content, cp)
+ profile.Coverage = p.percentCovered(cp)
+ profile.LineCount = p.lineCount(profile.Content)
+
+ report.Profiles = append(report.Profiles, profile)
+ }
+
+ return report, nil
+}
+
+// profilePkgs returns a map of packages in cover profiles.
+//
+// Adapted from `findPkgs` function in go `src/cmd/cover/func.go:180` at
+// commit `d922c0a`.
+func (p *Parser) profilePkgs(profiles []*cover.Profile) (map[string]*pkg, error) {
+ pkgs := make(map[string]*pkg)
+
+ var list []string
+
+ for _, profile := range profiles {
+ if strings.HasPrefix(profile.FileName, ".") || filepath.IsAbs(profile.FileName) {
+ // Relative or absolute path.
+ continue
+ }
+
+ pkg := path.Dir(profile.FileName)
+
+ if _, ok := pkgs[pkg]; !ok {
+ pkgs[pkg] = nil
+
+ list = append(list, pkg)
+ }
+ }
+
+ if len(list) == 0 {
+ return pkgs, nil
+ }
+
+ args := append([]string{"list", "-e", "-json"}, list...)
+
+ out, exitCode, err := p.cmdRunner(p.ctx, p.root, p.goBin, args...)
+ if err != nil {
+ return nil, fmt.Errorf("running go list command: %w", err)
+ }
+
+ if exitCode != 0 {
+ return nil, fmt.Errorf("non-zero exit code from go list command: %d", exitCode)
+ }
+
+ dec := json.NewDecoder(bytes.NewReader(out))
+
+ for {
+ var pkg pkg
+
+ err := dec.Decode(&pkg)
+ if err == io.EOF {
+ break
+ }
+
+ if err != nil {
+ return nil, fmt.Errorf("decoding go list json: %w", err)
+ }
+
+ pkgs[pkg.ImportPath] = &pkg
+ }
+
+ return pkgs, nil
+}
+
+// pkgFile returns absolute path to a file inside a package.
+//
+// Adapted from `findFile` function in go `src/cmd/cover/func.go:226` at
+// commit `d922c0a`.
+func (*Parser) pkgFile(pkgs map[string]*pkg, name string) (string, error) {
+ if strings.HasPrefix(name, ".") || filepath.IsAbs(name) {
+ // Relative or absolute path.
+ return name, nil
+ }
+
+ pkg := pkgs[path.Dir(name)]
+
+ if pkg != nil {
+ if pkg.Dir != "" {
+ return filepath.Join(pkg.Dir, path.Base(name)), nil
+ }
+
+ if pkg.Error != nil {
+ return "", errors.New(pkg.Error.Err)
+ }
+ }
+
+ return "", fmt.Errorf("no package for %s in go list output", name)
+}
+
+// profileBoundaries converts [cover.Boundary] structs in a profile to
+// [ProfileBoundary] structs from this package.
+//
+// This is done to control how a report is marshalled to JSON.
+func (*Parser) profileBoundaries(src []byte, profile *cover.Profile) []ProfileBoundary {
+ cbs := profile.Boundaries(src)
+ boundaries := make([]ProfileBoundary, len(cbs))
+
+ for i, cb := range cbs {
+ boundaries[i] = ProfileBoundary{
+ Offset: cb.Offset,
+ Start: cb.Start,
+ Count: cb.Count,
+ Norm: cb.Norm,
+ Index: cb.Index,
+ }
+ }
+
+ return boundaries
+}
+
+// percentCovered calculates the file coverage percentage from a profile.
+func (*Parser) percentCovered(profile *cover.Profile) float64 {
+ var total, covered int64
+
+ for _, b := range profile.Blocks {
+ total += int64(b.NumStmt)
+
+ if b.Count > 0 {
+ covered += int64(b.NumStmt)
+ }
+ }
+
+ if total == 0 {
+ return 0
+ }
+
+ percent := float64(covered) / float64(total) * 100
+ ratio := math.Pow(10, float64(1))
+
+ return math.Round(percent*ratio) / ratio
+}
+
+// lineCount returns the number of lines in a byte slice.
+func (*Parser) lineCount(b []byte) int {
+ return bytes.Count(b, []byte{'\n'}) + 1
+}
+
+// WithLogger configures [Parser] with a logger.
+func WithLogger(logger *log.Logger) Option {
+ return func(p *Parser) {
+ p.logger = logger
+ }
+}
+
+// WithGoBinPath configures [Parser] to use path as go binary for commands.
+//
+// By default, `go` is used.
+func WithGoBinPath(goBinPath string) Option {
+ return func(p *Parser) {
+ p.goBin = goBinPath
+ }
+}
diff --git a/internal/pkg/coverparser/parser_test.go b/internal/pkg/coverparser/parser_test.go
new file mode 100644
index 0000000..7dbedd1
--- /dev/null
+++ b/internal/pkg/coverparser/parser_test.go
@@ -0,0 +1,103 @@
+package coverparser_test
+
+import (
+ "context"
+ "math"
+ "os"
+ "path"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/michenriksen/gokiburi/internal/pkg/coverparser"
+ "github.com/michenriksen/gokiburi/internal/pkg/util/testutil"
+)
+
+func TestParser_Parse(t *testing.T) {
+ f := testutil.OpenFile(t, "testdata", "numbers", "coverprofile.out")
+ defer f.Close()
+
+ parser := coverparser.New(context.Background(), "./testdata")
+ report, err := parser.Parse(f)
+ require.NoError(t, err)
+
+ expectedContent := testutil.ReadFile(t, "testdata", "numbers", "numbers.go")
+
+ require.Equal(t, coverparser.ModeAtomic, report.Mode)
+ require.NotZero(t, report.Time)
+ require.Len(t, report.Profiles, 1)
+
+ profile := report.Profiles[0]
+ require.True(t, strings.HasSuffix(profile.FileName, "testdata/numbers/numbers.go"))
+ require.True(t, strings.HasSuffix(profile.Path, "testdata/numbers/numbers.go"))
+ require.True(t, strings.HasSuffix(profile.Package, "testdata/numbers"))
+ require.Equal(t, float64(100), profile.Coverage)
+ require.Equal(t, 11, profile.LineCount)
+ require.Equal(t, len(expectedContent), profile.Size)
+ require.Equal(t, expectedContent, profile.Content)
+ require.Len(t, profile.Boundaries, 6) // 3 pairs of start/end boundaries.
+
+ bb := profile.Boundaries
+
+ // First boundary starts at opening `{` for `IntMin` function.
+ require.Equal(t, 0, bb[0].Index)
+ require.True(t, bb[0].Start)
+ require.Equal(t, 156, bb[0].Offset)
+ require.Equal(t, 5, bb[0].Count)
+ require.Equal(t, 1.0, bb[0].Norm)
+
+ // First boundary ends at opening `{` for `if a < b` condition.
+ require.Equal(t, 1, bb[1].Index)
+ require.False(t, bb[1].Start)
+ require.Equal(t, 168, bb[1].Offset)
+ require.Equal(t, 0, bb[1].Count)
+ require.Equal(t, 0.0, bb[1].Norm)
+
+ // Second boundary starts at opening `{` for `if a < b` condition.
+ require.Equal(t, 2, bb[2].Index)
+ require.True(t, bb[2].Start)
+ require.Equal(t, 168, bb[2].Offset)
+ require.Equal(t, 2, bb[2].Count)
+ require.Equal(t, 0.43, math.Round(bb[2].Norm*100)/100)
+
+ // Second boundary ends after `return a` statement.
+ require.Equal(t, 3, bb[3].Index)
+ require.False(t, bb[3].Start)
+ require.Equal(t, 183, bb[3].Offset)
+ require.Equal(t, 0, bb[3].Count)
+ require.Equal(t, 0.0, bb[3].Norm)
+
+ // Third boundary starts closing `}` for `if a < b` condition.
+ require.Equal(t, 4, bb[4].Index)
+ require.True(t, bb[4].Start)
+ require.Equal(t, 185, bb[4].Offset)
+ require.Equal(t, 3, bb[4].Count)
+ require.Equal(t, 0.68, math.Round(bb[4].Norm*100)/100)
+
+ // Third boundary ends after `return b` statement.
+ require.Equal(t, 5, bb[5].Index)
+ require.False(t, bb[5].Start)
+ require.Equal(t, 193, bb[5].Offset)
+ require.Equal(t, 0, bb[5].Count)
+ require.Equal(t, 0.0, bb[5].Norm)
+}
+
+func TestParser_Parse_BadCoverprofile(t *testing.T) {
+ f, err := os.Open(path.Join("testdata", "coverprofile.out.bad"))
+ require.NoError(t, err)
+
+ parser := coverparser.New(context.Background(), "./testdata")
+ report, err := parser.Parse(f)
+
+ require.ErrorContains(t, err, "parsing cover profile data:")
+ require.Nil(t, report)
+}
+
+func TestParser_Parse_NoProfiles(t *testing.T) {
+ parser := coverparser.New(context.Background(), "./testdata")
+ report, err := parser.Parse(strings.NewReader("mode: atomic\n"))
+
+ require.ErrorIs(t, err, coverparser.ErrNoProfiles)
+ require.Nil(t, report)
+}
diff --git a/internal/pkg/coverparser/testdata/coverprofile.out.bad b/internal/pkg/coverparser/testdata/coverprofile.out.bad
new file mode 100644
index 0000000..8b1592b
--- /dev/null
+++ b/internal/pkg/coverparser/testdata/coverprofile.out.bad
@@ -0,0 +1,3 @@
+mode: atomic
+github.com/michenriksen/gokiburi/internal/pkg/coverparser/testdata/main.go:Oops,what am I doing here!?,6.11 1 5
+
diff --git a/internal/pkg/coverparser/testdata/numbers/coverprofile.out b/internal/pkg/coverparser/testdata/numbers/coverprofile.out
new file mode 100644
index 0000000..3a9f7d4
--- /dev/null
+++ b/internal/pkg/coverparser/testdata/numbers/coverprofile.out
@@ -0,0 +1,4 @@
+mode: atomic
+github.com/michenriksen/gokiburi/internal/pkg/coverparser/testdata/numbers/numbers.go:5.27,6.11 1 5
+github.com/michenriksen/gokiburi/internal/pkg/coverparser/testdata/numbers/numbers.go:6.11,8.3 1 2
+github.com/michenriksen/gokiburi/internal/pkg/coverparser/testdata/numbers/numbers.go:9.2,9.10 1 3
diff --git a/internal/pkg/coverparser/testdata/numbers/numbers.go b/internal/pkg/coverparser/testdata/numbers/numbers.go
new file mode 100644
index 0000000..72127e6
--- /dev/null
+++ b/internal/pkg/coverparser/testdata/numbers/numbers.go
@@ -0,0 +1,10 @@
+// Package numbers is a test package for the coverparser package.
+package numbers
+
+// IntMin returns the minimum of two integers.
+func IntMin(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/internal/pkg/coverparser/testdata/numbers/numbers_test.go b/internal/pkg/coverparser/testdata/numbers/numbers_test.go
new file mode 100644
index 0000000..17f94c8
--- /dev/null
+++ b/internal/pkg/coverparser/testdata/numbers/numbers_test.go
@@ -0,0 +1,29 @@
+package numbers
+
+import (
+ "fmt"
+ "testing"
+)
+
+func TestIntMin(t *testing.T) {
+ tests := []struct {
+ a, b int
+ want int
+ }{
+ {0, 1, 0},
+ {1, 0, 0},
+ {2, -2, -2},
+ {0, -1, -1},
+ {-1, 0, -1},
+ }
+
+ for _, tt := range tests {
+ testname := fmt.Sprintf("%d,%d", tt.a, tt.b)
+ t.Run(testname, func(t *testing.T) {
+ ans := IntMin(tt.a, tt.b)
+ if ans != tt.want {
+ t.Errorf("got %d, want %d", ans, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/pkg/runner/parser.go b/internal/pkg/runner/parser.go
new file mode 100644
index 0000000..60abb47
--- /dev/null
+++ b/internal/pkg/runner/parser.go
@@ -0,0 +1,209 @@
+package runner
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/log"
+)
+
+const floatBitSize = 64
+
+var percentageRegexp = regexp.MustCompile(`(\d+(?:\.\d+)?)%`)
+
+// Package is the result of tests in a single package.
+type Package struct {
+ Time time.Time `json:"time"`
+ Name string `json:"name"`
+ Pass bool `json:"pass"`
+ Passed int `json:"passed"`
+ Skipped int `json:"skipped"`
+ Failed int `json:"failed"`
+ Coverage float64 `json:"coverage"`
+ Elapsed float64 `json:"elapsed"`
+ Tests []*Test `json:"tests"`
+ testMap map[string]*Test
+}
+
+// Test result of a single test.
+type Test struct {
+ Time time.Time `json:"time"`
+ Name string `json:"name"`
+ Package string `json:"package"`
+ Pass bool `json:"pass"`
+ Skip bool `json:"skip"`
+ Elapsed float64 `json:"elapsed"`
+ Output string `json:"output"`
+}
+
+type parser struct {
+ logger *log.Logger
+}
+
+func (p *parser) parse(out []byte) ([]*Package, error) {
+ pkgMap := make(map[string]*Package)
+ lineNum := 0
+ scanner := bufio.NewScanner(bytes.NewReader(out))
+ scanner.Split(bufio.ScanLines)
+
+ for scanner.Scan() {
+ lineNum++
+
+ line := scanner.Bytes()
+ event := goTestEvent{}
+
+ if err := json.Unmarshal(line, &event); err != nil {
+ p.logger.Error("error decoding line as Go test event", logKeyErr, err, logKeyLineNum, lineNum)
+ p.logger.Debug(string(line), logKeyLineNum, lineNum)
+
+ continue
+ }
+
+ if event.Package == "" {
+ p.logger.Debug("ignoring non-test related event", "event", event)
+
+ continue
+ }
+
+ p.ensurePkgAndTest(event, pkgMap)
+
+ if err := p.handleEvent(event, pkgMap); err != nil {
+ p.logger.Error("error handling event", logKeyErr, err, logKeyLineNum, lineNum)
+ p.logger.Debug(string(line), logKeyLineNum, lineNum)
+ }
+ }
+
+ return p.makePkgSlice(pkgMap), nil
+}
+
+func (p *parser) ensurePkgAndTest(e goTestEvent, pkgMap map[string]*Package) {
+ if _, ok := pkgMap[e.Package]; !ok {
+ p.logger.Debug("new package", "pkg", e.Package)
+
+ pkgMap[e.Package] = &Package{
+ Time: e.Time,
+ Name: e.Package,
+ testMap: make(map[string]*Test),
+ }
+ }
+
+ if e.Test != "" {
+ if _, ok := pkgMap[e.Package].testMap[e.Test]; !ok {
+ p.logger.Debug("new test", "pkg", e.Package, "test", e.Test)
+
+ pkgMap[e.Package].testMap[e.Test] = &Test{
+ Name: e.Test,
+ Package: e.Package,
+ Time: e.Time,
+ }
+ }
+ }
+}
+
+func (*parser) makePkgSlice(pkgMap map[string]*Package) []*Package {
+ pkgs := make([]*Package, 0, len(pkgMap))
+
+ for _, pkg := range pkgMap {
+ tests := make([]*Test, 0, len(pkg.testMap))
+
+ for _, test := range pkg.testMap {
+ tests = append(tests, test)
+ }
+
+ pkg.Tests = tests
+ pkg.testMap = nil
+
+ pkgs = append(pkgs, pkg)
+ }
+
+ return pkgs
+}
+
+func (p *parser) handleEvent(e goTestEvent, pkgMap map[string]*Package) error {
+ if e.Test == "" {
+ return p.handlePackageEvent(e, pkgMap)
+ }
+
+ return p.handleTestEvent(e, pkgMap)
+}
+
+func (p *parser) handlePackageEvent(e goTestEvent, pkgMap map[string]*Package) error {
+ pkg, ok := pkgMap[e.Package]
+ if !ok {
+ return fmt.Errorf("unknown package: %s", e.Package)
+ }
+
+ switch e.Action {
+ case "pass":
+ pkg.Pass = true
+ pkg.Elapsed = e.Elapsed
+ case "fail":
+ pkg.Pass = false
+ pkg.Elapsed = e.Elapsed
+ case "output":
+ if !strings.Contains(e.Output, "coverage:") {
+ return nil
+ }
+
+ if match := percentageRegexp.FindStringSubmatch(e.Output); len(match) == 2 {
+ c, err := strconv.ParseFloat(match[1], floatBitSize)
+ if err != nil {
+ return fmt.Errorf("parsing %q as float64: %w", match[1], err)
+ }
+
+ pkg.Coverage = c
+ }
+ default:
+ p.logger.Debug("ignoring package event", "action", e.Action)
+ }
+
+ return nil
+}
+
+func (p *parser) handleTestEvent(e goTestEvent, pkgMap map[string]*Package) error {
+ pkg, ok := pkgMap[e.Package]
+ if !ok {
+ return fmt.Errorf("unknown package: %s", e.Package)
+ }
+
+ test, ok := pkgMap[e.Package].testMap[e.Test]
+ if !ok {
+ return fmt.Errorf("unknown test for package %s: %s", e.Package, e.Test)
+ }
+
+ switch e.Action {
+ case "pass":
+ test.Pass = true
+ test.Elapsed = e.Elapsed
+ pkg.Passed++
+ case "fail":
+ test.Pass = false
+ test.Elapsed = e.Elapsed
+ pkg.Failed++
+ case "skip":
+ test.Skip = true
+ test.Elapsed = e.Elapsed
+ pkg.Skipped++
+ case "output":
+ test.Output += e.Output
+ default:
+ p.logger.Debug("ignoring test event", "action", e.Action)
+ }
+
+ return nil
+}
+
+type goTestEvent struct {
+ Time time.Time
+ Action string
+ Package string
+ Test string
+ Elapsed float64
+ Output string
+}
diff --git a/internal/pkg/runner/runner.go b/internal/pkg/runner/runner.go
new file mode 100644
index 0000000..ccdbfef
--- /dev/null
+++ b/internal/pkg/runner/runner.go
@@ -0,0 +1,435 @@
+package runner
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/aidarkhanov/nanoid/v2"
+ "github.com/charmbracelet/log"
+
+ "github.com/michenriksen/gokiburi/internal/pkg/command"
+)
+
+const (
+ logKeyLineNum = "lineNum"
+ logKeyErr = "error"
+
+ defaultCovermode = "atomic"
+ defaultTimeout = 10 * time.Minute
+ defaultGoBin = "go"
+
+ coverDirPerm = 0o755
+)
+
+// Result of a `go test` run.
+type Result struct {
+ UUID string `json:"uuid"`
+ Error string `json:"error"`
+ Pass bool `json:"pass"`
+ Start time.Time `json:"start"`
+ Duration time.Duration `json:"duration"`
+ ExitCode int `json:"exitCode"`
+ Targets []string `json:"targets"`
+ Passed int `json:"passed"`
+ Failed int `json:"failed"`
+ Skipped int `json:"skipped"`
+ Tests int `json:"tests"`
+ Packages []*Package `json:"packages"`
+ dir string
+}
+
+// Close Result.
+//
+// Deletes the associated coverage directory if it exists.
+func (r *Result) Close() error {
+ if r.dir == "" {
+ return nil
+ }
+
+ if err := os.RemoveAll(r.dir); err != nil {
+ return fmt.Errorf("deleting test result directory %q: %w", r.dir, err)
+ }
+
+ r.dir = ""
+
+ return nil
+}
+
+// Dir containing files relevant for the test result.
+//
+// Directory contains `coverprofile.out` generated by [Runner.Run] as well
+// as `report.json` which is a code coverage report generated by
+// [coverparser.Parser].
+func (r Result) Dir() string {
+ return r.dir
+}
+
+// UUIDFunc for generating a UUID for a [Result].
+//
+// By default, [nanoid.New] is used for UUID generation, but function can be
+// replaced for testing purposes.
+type UUIDFunc func() (string, error)
+
+// Option configures a [Runner].
+type Option func(*Runner)
+
+// Runner runs tests and parses the results.
+type Runner struct {
+ ctx context.Context
+ root string
+ logger *log.Logger
+ parser *parser
+ shuffle string
+ covermode string
+ raceDetection bool
+ short bool
+ timeout time.Duration
+ goBin string
+ coverDir string
+ coverDirOnce sync.Once
+ runnerFunc command.Runner
+ uuidFunc UUIDFunc
+}
+
+// New Runner for running tests.
+//
+// Runs `go test` on directories for Go source files and parses the output for
+// easy consumption.
+//
+// The root directory should be the root directory of a Go project with tests.
+func New(ctx context.Context, rootDir string, opts ...Option) *Runner {
+ r := &Runner{
+ ctx: ctx,
+ root: rootDir,
+ logger: log.New(io.Discard),
+ parser: &parser{},
+ covermode: defaultCovermode,
+ timeout: defaultTimeout,
+ goBin: defaultGoBin,
+ runnerFunc: command.DefaultRunner,
+ uuidFunc: nanoid.New,
+ }
+
+ for _, opt := range opts {
+ opt(r)
+ }
+
+ r.parser.logger = r.logger
+
+ return r
+}
+
+// Run tests for packages.
+//
+// Runs `go test` on given packages and parses the results.
+func (r *Runner) Run(pkgs ...string) (*Result, error) {
+ var (
+ out []byte
+ err error
+ )
+
+ result := &Result{
+ Pass: true,
+ Start: time.Now(),
+ }
+
+ if result.UUID, err = r.uuidFunc(); err != nil {
+ return nil, fmt.Errorf("generating UUID for test run result: %w", err)
+ }
+
+ if result.Targets, err = r.processTargets(pkgs); err != nil {
+ return nil, err
+ }
+
+ if result.dir, err = r.mkdirCover(result.UUID); err != nil {
+ return nil, err
+ }
+
+ r.logger.Info("running tests...")
+
+ args := r.mkArgs(result)
+
+ r.logger.Debug("command", "cmd", r.goBin, "args", strings.Join(args, " "))
+
+ out, result.ExitCode, err = r.runnerFunc(r.ctx, r.root, r.goBin, args...)
+ result.Duration = time.Since(result.Start)
+
+ if err != nil || result.ExitCode != 0 {
+ if err := r.handleError(err, out); err != nil {
+ result.Error = err.Error()
+ result.Pass = false
+
+ return result, err
+ }
+ }
+
+ r.logger.Debug("command finished", "dur", result.Duration, "exitCode", result.ExitCode)
+
+ result.Packages, err = r.parser.parse(out)
+ if err != nil {
+ return nil, fmt.Errorf("parsing output of go test command: %w", err)
+ }
+
+ for _, pkg := range result.Packages {
+ for _, test := range pkg.Tests {
+ result.Tests++
+
+ switch {
+ case test.Pass:
+ result.Passed++
+ case test.Skip:
+ result.Skipped++
+ case !test.Pass:
+ result.Failed++
+ }
+ }
+ }
+
+ if result.Failed != 0 {
+ result.Pass = false
+ }
+
+ r.handleResult(result)
+
+ return result, nil
+}
+
+func (r *Runner) PackageForFile(name string) (string, error) {
+ args := []string{"list", "-find", "-f", "{{.ImportPath}}", filepath.Dir(name)}
+
+ r.logger.Debug("command", "cmd", r.goBin, "args", strings.Join(args, " "))
+
+ out, exitCode, err := r.runnerFunc(r.ctx, r.root, r.goBin, args...)
+ if err != nil {
+ return "", fmt.Errorf("running go list command: %w", err)
+ }
+
+ if exitCode != 0 {
+ return "", fmt.Errorf("running go list command: exit code: %s", out)
+ }
+
+ return strings.TrimSpace(string(out)), nil
+}
+
+func (r *Runner) handleResult(result *Result) {
+ logger := r.logger.With(
+ "pass", result.Pass,
+ "tests", result.Tests,
+ "packages", len(result.Packages),
+ "passed", result.Passed,
+ "skipped", result.Skipped,
+ "failed", result.Failed,
+ "dur", result.Duration.Round(time.Millisecond),
+ )
+
+ switch {
+ case result.Tests == 0:
+ logger.Info("no tests found")
+ case result.Pass:
+ logger.Info("tests passed")
+ default:
+ logger.With("exitCode", result.ExitCode).Warn("tests failed")
+ }
+}
+
+func (r *Runner) handleError(err error, out []byte) error {
+ var reason string
+
+ switch {
+ case errors.Is(err, context.Canceled):
+ reason = "canceled"
+ case errors.Is(err, context.DeadlineExceeded):
+ reason = "timeout"
+ case errors.Is(err, exec.ErrNotFound):
+ reason = fmt.Sprintf("go binary %q was not found", r.goBin)
+ case bytes.Contains(out, []byte("panic: test timed out")):
+ reason = "timeout"
+ case bytes.Contains(out, []byte("[build failed]")):
+ reason = "build failed"
+ default:
+ return nil
+ }
+
+ return fmt.Errorf(reason)
+}
+
+func (r *Runner) mkdirCover(uuid string) (string, error) {
+ r.coverDirOnce.Do(func() {
+ if r.coverDir == "" {
+ var err error
+
+ r.coverDir, err = os.MkdirTemp("", "gokiburi-coverage")
+ if err != nil {
+ panic(fmt.Errorf("creating temporary directory for test coverage files: %w", err))
+ }
+ }
+ })
+
+ name := filepath.Join(r.coverDir, uuid)
+
+ if err := os.MkdirAll(name, coverDirPerm); err != nil {
+ return "", fmt.Errorf("creating coverage dir %q: %w", name, err)
+ }
+
+ return name, nil
+}
+
+func (r *Runner) mkArgs(result *Result) []string {
+ args := []string{
+ "test", "-json", "-cover",
+ "-coverprofile", path.Join(result.dir, "coverprofile.out"),
+ }
+
+ if r.shuffle != "" {
+ args = append(args, "-shuffle", r.shuffle)
+ }
+
+ if r.raceDetection {
+ args = append(args, "-race", "-covermode", "atomic")
+ } else {
+ args = append(args, "-covermode", r.covermode)
+ }
+
+ if r.short {
+ args = append(args, "-short")
+ }
+
+ if r.timeout != 0 {
+ args = append(args, "-timeout", r.timeout.String())
+ }
+
+ return append(args, result.Targets...)
+}
+
+func (r *Runner) processTargets(targets []string) ([]string, error) {
+ targetMap := make(map[string]struct{})
+
+ validPkgs, err := r.validPackages()
+ if err != nil {
+ return nil, fmt.Errorf("determining valid packages: %w", err)
+ }
+
+ for _, t := range targets {
+ if _, ok := validPkgs[t]; ok {
+ targetMap[t] = struct{}{}
+ continue
+ }
+
+ r.logger.Warnf("invalid package: %q", t)
+ }
+
+ tt := make([]string, 0, len(targetMap))
+
+ for t := range targetMap {
+ tt = append(tt, t)
+ }
+
+ return tt, nil
+}
+
+func (r *Runner) validPackages() (map[string]struct{}, error) {
+ pkgs := make(map[string]struct{})
+ pkgs["./..."] = struct{}{}
+
+ args := []string{"list", "./..."}
+
+ r.logger.Debug("command", "cmd", r.goBin, "args", strings.Join(args, " "))
+
+ out, exitCode, err := r.runnerFunc(r.ctx, r.root, r.goBin, args...)
+ if err != nil {
+ return nil, fmt.Errorf("running go list command: %w", err)
+ }
+
+ if exitCode != 0 {
+ return nil, fmt.Errorf("running go list command: exit code %d", exitCode)
+ }
+
+ for _, pkg := range strings.Fields(string(out)) {
+ pkgs[pkg] = struct{}{}
+ }
+
+ return pkgs, nil
+}
+
+// WithLogger configures [Runner] with a logger.
+func WithLogger(logger *log.Logger) Option {
+ return func(r *Runner) {
+ r.logger = logger
+ }
+}
+
+// WithGoBinPath configures [Runner] to use path as go binary for running tests.
+//
+// By default, `go` is used.
+func WithGoBinPath(goBinPath string) Option {
+ return func(r *Runner) {
+ r.goBin = goBinPath
+ }
+}
+
+// WithRunnerFunc configures [Runner] to use function for running commands.
+//
+// By default, commands are run with the `os/exec` package, but the runner
+// function can be replaced for testing purposes.
+func WithRunnerFunc(rf command.Runner) Option {
+ return func(r *Runner) {
+ r.runnerFunc = rf
+ }
+}
+
+// WithUUIDFunc configures [Runner] to use function for UUID generation.
+//
+// By default, [nanoid.New] is used for UUID generation, but function can be
+// replaced for testing purposes.
+func WithUUIDFunc(uf UUIDFunc) Option {
+ return func(r *Runner) {
+ r.uuidFunc = uf
+ }
+}
+
+// WithShuffle configures [Runner] to run tests with `-shuffle` flag.
+func WithShuffle(val string) Option {
+ return func(r *Runner) {
+ r.shuffle = val
+ }
+}
+
+// WithCovermode configures [Runner] to run tests with `-covermode` flag.
+func WithCovermode(val string) Option {
+ return func(r *Runner) {
+ r.covermode = val
+ }
+}
+
+// WithRaceDetection configures [Runner] to run tests with `-race` flag.
+//
+// Note: Enabling race detection will force the covermode to be `atomic`.
+func WithRaceDetection(enabled bool) Option {
+ return func(r *Runner) {
+ r.raceDetection = enabled
+ }
+}
+
+// WithShort configures [Runner] to run tests with `-short` flag.
+func WithShort(enabled bool) Option {
+ return func(r *Runner) {
+ r.short = enabled
+ }
+}
+
+// WithTimeout configures [Runner] to use duration for test run timeout.
+func WithTimeout(dur time.Duration) Option {
+ return func(r *Runner) {
+ r.timeout = dur
+ }
+}
diff --git a/internal/pkg/runner/runner_test.go b/internal/pkg/runner/runner_test.go
new file mode 100644
index 0000000..6af2d99
--- /dev/null
+++ b/internal/pkg/runner/runner_test.go
@@ -0,0 +1,424 @@
+package runner_test
+
+import (
+ "context"
+ "os"
+ "path"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/michenriksen/gokiburi/internal/pkg/command"
+ "github.com/michenriksen/gokiburi/internal/pkg/runner"
+ "github.com/michenriksen/gokiburi/internal/pkg/util/testutil"
+)
+
+const testTarget = "./..."
+
+func TestRunner_Run(t *testing.T) {
+ runnerFunc := mockRunnerFunc(t, testutil.ReadFile(t, "testdata", "testoutput.json"), 1, nil)
+ r := runner.New(context.Background(), "./testdata",
+ runner.WithRunnerFunc(runnerFunc),
+ runner.WithUUIDFunc(mockUUIDFunc),
+ )
+
+ result, err := r.Run(testTarget)
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+
+ defer func() {
+ if dir := result.Dir(); dir != "" {
+ os.RemoveAll(dir)
+ }
+ }()
+
+ require.Equal(t, "deadbeef", result.UUID)
+ require.Equal(t, []string{testTarget}, result.Targets)
+ require.NotZero(t, result.Duration)
+ require.Empty(t, result.Error)
+ require.False(t, result.Pass)
+ require.Equal(t, 3, result.Tests)
+ require.Equal(t, 1, result.Passed)
+ require.Equal(t, 1, result.Skipped)
+ require.Equal(t, 1, result.Failed)
+ require.Len(t, result.Packages, 1)
+
+ pkg := result.Packages[0]
+
+ require.True(t, strings.HasSuffix(pkg.Name, "testdata/numbers"))
+ require.False(t, pkg.Pass)
+ require.Equal(t, 1, pkg.Passed)
+ require.Equal(t, 1, pkg.Skipped)
+ require.Equal(t, 1, pkg.Failed)
+ require.Len(t, pkg.Tests, 3)
+ require.Equal(t, 0.29, pkg.Elapsed)
+ require.Equal(t, 66.7, pkg.Coverage)
+
+ passed := testByName(t, "TestIntMinBasic", pkg.Tests)
+ skipped := testByName(t, "TestIntMinTableDriven", pkg.Tests)
+ failed := testByName(t, "TestIntMinFailing", pkg.Tests)
+
+ require.Equal(t, pkg.Name, passed.Package)
+ require.True(t, passed.Pass)
+ require.False(t, passed.Skip)
+ require.Equal(t, 0.0, passed.Elapsed)
+ require.Equal(t, "=== RUN TestIntMinBasic\n--- PASS: TestIntMinBasic (0.00s)\n", passed.Output)
+
+ require.Equal(t, pkg.Name, skipped.Package)
+ require.False(t, skipped.Pass)
+ require.True(t, skipped.Skip)
+ require.Equal(t, 0.0, skipped.Elapsed)
+ require.Equal(t, "=== RUN TestIntMinTableDriven\n numbers_test.go:16: skipping table driven test\n--- SKIP: TestIntMinTableDriven (0.00s)\n", skipped.Output)
+
+ require.Equal(t, pkg.Name, failed.Package)
+ require.False(t, failed.Pass)
+ require.False(t, failed.Skip)
+ require.Equal(t, 0.0, failed.Elapsed)
+ require.Equal(t, "=== RUN TestIntMinFailing\n numbers_test.go:41: failing test\n--- FAIL: TestIntMinFailing (0.00s)\n", failed.Output)
+}
+
+// TestRunner_Run_Integration runs the actual tests inside ./testdata/ and
+// asserts that results are as expected.
+func TestRunner_Run_Integration(t *testing.T) {
+ testutil.SetIntegrationTest(t)
+
+ r := runner.New(context.Background(), "./testdata",
+ runner.WithUUIDFunc(mockUUIDFunc),
+ )
+
+ result, err := r.Run(testTarget)
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+
+ defer func() {
+ if dir := result.Dir(); dir != "" {
+ os.RemoveAll(dir)
+ }
+ }()
+
+ require.Equal(t, "deadbeef", result.UUID)
+ require.Equal(t, []string{testTarget}, result.Targets)
+ require.NotZero(t, result.Duration)
+ require.Empty(t, result.Error)
+ require.False(t, result.Pass)
+ require.Equal(t, 3, result.Tests)
+ require.Equal(t, 1, result.Passed)
+ require.Equal(t, 1, result.Skipped)
+ require.Equal(t, 1, result.Failed)
+ require.Len(t, result.Packages, 1)
+
+ pkg := result.Packages[0]
+
+ require.True(t, strings.HasSuffix(pkg.Name, "testdata/numbers"))
+ require.False(t, pkg.Pass)
+ require.Equal(t, 1, pkg.Passed)
+ require.Equal(t, 1, pkg.Skipped)
+ require.Equal(t, 1, pkg.Failed)
+ require.Len(t, pkg.Tests, 3)
+ require.NotZero(t, pkg.Elapsed)
+ require.Equal(t, 66.7, pkg.Coverage)
+
+ passed := testByName(t, "TestIntMinBasic", pkg.Tests)
+ skipped := testByName(t, "TestIntMinTableDriven", pkg.Tests)
+ failed := testByName(t, "TestIntMinFailing", pkg.Tests)
+
+ require.Equal(t, pkg.Name, passed.Package)
+ require.True(t, passed.Pass)
+ require.False(t, passed.Skip)
+ require.Equal(t, "=== RUN TestIntMinBasic\n--- PASS: TestIntMinBasic (0.00s)\n", passed.Output)
+
+ require.Equal(t, pkg.Name, skipped.Package)
+ require.False(t, skipped.Pass)
+ require.True(t, skipped.Skip)
+ require.Equal(t, "=== RUN TestIntMinTableDriven\n numbers_test.go:16: skipping table driven test\n--- SKIP: TestIntMinTableDriven (0.00s)\n", skipped.Output)
+
+ require.Equal(t, pkg.Name, failed.Package)
+ require.False(t, failed.Pass)
+ require.False(t, failed.Skip)
+ require.Equal(t, "=== RUN TestIntMinFailing\n numbers_test.go:41: failing test\n--- FAIL: TestIntMinFailing (0.00s)\n", failed.Output)
+}
+
+func TestRunner_Run_NoTests(t *testing.T) {
+ runnerFunc := mockRunnerFunc(t, testutil.ReadFile(t, "testdata", "testoutput-no-tests.json"), 1, nil)
+ r := runner.New(context.Background(), "./testdata",
+ runner.WithRunnerFunc(runnerFunc),
+ runner.WithUUIDFunc(mockUUIDFunc),
+ )
+
+ result, err := r.Run(testTarget)
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+
+ defer func() {
+ if dir := result.Dir(); dir != "" {
+ os.RemoveAll(dir)
+ }
+ }()
+
+ require.Equal(t, "deadbeef", result.UUID)
+ require.Equal(t, []string{testTarget}, result.Targets)
+ require.NotZero(t, result.Duration)
+ require.Empty(t, result.Error)
+ require.True(t, result.Pass)
+ require.Equal(t, 0, result.Tests)
+ require.Equal(t, 0, result.Passed)
+ require.Equal(t, 0, result.Skipped)
+ require.Equal(t, 0, result.Failed)
+ require.Len(t, result.Packages, 1)
+
+ pkg := result.Packages[0]
+
+ require.True(t, strings.HasSuffix(pkg.Name, "testdata/numbers"))
+ require.True(t, pkg.Pass)
+ require.Equal(t, 0, pkg.Passed)
+ require.Equal(t, 0, pkg.Skipped)
+ require.Equal(t, 0, pkg.Failed)
+ require.Len(t, pkg.Tests, 0)
+ require.Equal(t, 0.0, pkg.Elapsed)
+ require.Equal(t, 0.0, pkg.Coverage)
+}
+
+func TestRunner_Run_AllPass(t *testing.T) {
+ runnerFunc := mockRunnerFunc(t, testutil.ReadFile(t, "testdata", "testoutput-all-pass.json"), 0, nil)
+ r := runner.New(context.Background(), "./testdata",
+ runner.WithRunnerFunc(runnerFunc),
+ runner.WithUUIDFunc(mockUUIDFunc),
+ )
+
+ result, err := r.Run(testTarget)
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+
+ defer func() {
+ if dir := result.Dir(); dir != "" {
+ os.RemoveAll(dir)
+ }
+ }()
+
+ require.Equal(t, "deadbeef", result.UUID)
+ require.Equal(t, []string{testTarget}, result.Targets)
+ require.NotZero(t, result.Duration)
+ require.Empty(t, result.Error)
+ require.True(t, result.Pass)
+ require.Equal(t, 7, result.Tests)
+ require.Equal(t, 7, result.Passed)
+ require.Equal(t, 0, result.Skipped)
+ require.Equal(t, 0, result.Failed)
+ require.Len(t, result.Packages, 1)
+
+ pkg := result.Packages[0]
+
+ require.True(t, strings.HasSuffix(pkg.Name, "testdata/numbers"))
+ require.True(t, pkg.Pass)
+ require.Equal(t, 7, pkg.Passed)
+ require.Equal(t, 0, pkg.Skipped)
+ require.Equal(t, 0, pkg.Failed)
+ require.Len(t, pkg.Tests, 7)
+ require.Equal(t, 0.0, pkg.Elapsed)
+ require.Equal(t, 100.0, pkg.Coverage)
+}
+
+func TestRunner_Run_DataRace(t *testing.T) {
+ runnerFunc := mockRunnerFunc(t, testutil.ReadFile(t, "testdata", "testoutput-data-race.json"), 66, nil)
+ r := runner.New(context.Background(), "./testdata",
+ runner.WithRunnerFunc(runnerFunc),
+ runner.WithUUIDFunc(mockUUIDFunc),
+ )
+
+ result, err := r.Run(testTarget)
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+
+ defer func() {
+ if dir := result.Dir(); dir != "" {
+ os.RemoveAll(dir)
+ }
+ }()
+
+ require.Equal(t, "deadbeef", result.UUID)
+ require.Equal(t, []string{testTarget}, result.Targets)
+ require.NotZero(t, result.Duration)
+ require.Empty(t, result.Error)
+ require.False(t, result.Pass)
+ require.Equal(t, 1, result.Tests)
+ require.Equal(t, 0, result.Passed)
+ require.Equal(t, 0, result.Skipped)
+ require.Equal(t, 1, result.Failed)
+ require.Len(t, result.Packages, 1)
+
+ pkg := result.Packages[0]
+
+ require.True(t, strings.HasSuffix(pkg.Name, "testdata/numbers"))
+ require.False(t, pkg.Pass)
+ require.Equal(t, 0, pkg.Passed)
+ require.Equal(t, 0, pkg.Skipped)
+ require.Equal(t, 1, pkg.Failed)
+ require.Len(t, pkg.Tests, 1)
+ require.Equal(t, 0.258, pkg.Elapsed)
+ require.Equal(t, 40.0, pkg.Coverage)
+
+ failed := testByName(t, "TestCounterIncrement", pkg.Tests)
+
+ require.Equal(t, pkg.Name, failed.Package)
+ require.False(t, failed.Pass)
+ require.False(t, failed.Skip)
+ require.Contains(t, failed.Output, "WARNING: DATA RACE")
+ require.Contains(t, failed.Output, "Read at 0x00c0000182e8")
+ require.Contains(t, failed.Output, "Previous write at 0x00c0000182e8")
+ require.Contains(t, failed.Output, "race detected during execution of test")
+}
+
+func TestRunner_Run_BuildFailed(t *testing.T) {
+ runnerFunc := mockRunnerFunc(t, testutil.ReadFile(t, "testdata", "testoutput-build-failed.txt"), 1, nil)
+ r := runner.New(context.Background(), "./testdata",
+ runner.WithRunnerFunc(runnerFunc),
+ runner.WithUUIDFunc(mockUUIDFunc),
+ )
+
+ result, err := r.Run(testTarget)
+
+ require.ErrorContains(t, err, "build failed")
+ require.NotNil(t, result)
+
+ defer func() {
+ if dir := result.Dir(); dir != "" {
+ os.RemoveAll(dir)
+ }
+ }()
+
+ require.Equal(t, "deadbeef", result.UUID)
+ require.Equal(t, []string{testTarget}, result.Targets)
+ require.NotZero(t, result.Duration)
+ require.Equal(t, "build failed", result.Error)
+ require.False(t, result.Pass)
+ require.Equal(t, 0, result.Tests)
+ require.Equal(t, 0, result.Passed)
+ require.Equal(t, 0, result.Skipped)
+ require.Equal(t, 0, result.Failed)
+ require.Len(t, result.Packages, 0)
+}
+
+func TestRunner_Run_FlagConfig(t *testing.T) {
+ mockRf := func(ctx context.Context, dir, name string, args ...string) ([]byte, int, error) {
+ if args[0] == "list" {
+ return []byte(testTarget), 0, nil
+ }
+
+ argStr := strings.Join(args, " ")
+ require.Contains(t, argStr, " -short ")
+ require.Contains(t, argStr, " -shuffle on ")
+ require.Contains(t, argStr, " -race ")
+ require.Contains(t, argStr, " -timeout 10s ")
+
+ return testutil.ReadFile(t, "testdata", "testoutput.json"), 0, nil
+ }
+
+ r := runner.New(context.Background(), "./testdata",
+ runner.WithRunnerFunc(mockRf),
+ runner.WithShort(true),
+ runner.WithShuffle("on"),
+ runner.WithRaceDetection(true),
+ runner.WithTimeout(10*time.Second),
+ )
+
+ result, err := r.Run(testTarget)
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+}
+
+func TestResult_Close(t *testing.T) {
+ runnerFunc := mockRunnerFunc(t, testutil.ReadFile(t, "testdata", "testoutput.json"), 1, nil)
+ r := runner.New(context.Background(), "./testdata",
+ runner.WithRunnerFunc(runnerFunc),
+ runner.WithUUIDFunc(mockUUIDFunc),
+ )
+
+ result, err := r.Run(testTarget)
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+
+ require.DirExists(t, result.Dir())
+ require.NoError(t, result.Close())
+ require.NoDirExists(t, result.Dir())
+ require.Empty(t, result.Dir())
+}
+
+func TestResult_Close_DirAlreadyRemoved(t *testing.T) {
+ runnerFunc := mockRunnerFunc(t, testutil.ReadFile(t, "testdata", "testoutput.json"), 1, nil)
+ r := runner.New(context.Background(), "./testdata",
+ runner.WithRunnerFunc(runnerFunc),
+ runner.WithUUIDFunc(mockUUIDFunc),
+ )
+
+ result, err := r.Run(testTarget)
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+
+ require.DirExists(t, result.Dir())
+ require.NoError(t, os.RemoveAll(result.Dir()))
+ require.NoError(t, result.Close())
+ require.Empty(t, result.Dir())
+}
+
+func mockUUIDFunc() (string, error) {
+ return "deadbeef", nil
+}
+
+func mockRunnerFunc(t *testing.T, out []byte, exitCode int, returnErr error) command.Runner {
+ t.Helper()
+
+ return func(ctx context.Context, dir, name string, args ...string) ([]byte, int, error) {
+ if args[0] == "list" {
+ return []byte(testTarget), 0, nil
+ }
+
+ var cpDest string
+
+ // Find destination for the coverage profile.
+ for i, arg := range args {
+ if arg == "-coverprofile" {
+ cpDest = args[i+1]
+ break
+ }
+ }
+
+ if cpDest == "" {
+ t.Fatal("coverprofile arg not found")
+ }
+
+ cp, err := os.ReadFile(path.Join("testdata", "coverprofile.out"))
+ if err != nil {
+ t.Fatalf("error reading testdata cover profile: %v", err)
+ }
+
+ if err := os.WriteFile(cpDest, cp, 0o644); err != nil {
+ t.Fatalf("error writing coverage file to %q: %v", cpDest, err)
+ }
+
+ return out, exitCode, returnErr
+ }
+}
+
+func testByName(t *testing.T, name string, tests []*runner.Test) *runner.Test {
+ t.Helper()
+
+ for _, test := range tests {
+ if test.Name == name {
+ return test
+ }
+ }
+
+ t.Fatalf("could not find test %q", name)
+
+ return nil
+}
diff --git a/internal/pkg/runner/testdata/coverprofile.out b/internal/pkg/runner/testdata/coverprofile.out
new file mode 100644
index 0000000..9687cb5
--- /dev/null
+++ b/internal/pkg/runner/testdata/coverprofile.out
@@ -0,0 +1,4 @@
+mode: atomic
+github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers/numbers.go:5.27,6.11 1 1
+github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers/numbers.go:6.11,8.3 1 0
+github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers/numbers.go:9.2,9.10 1 1
diff --git a/internal/pkg/runner/testdata/numbers/counter.go b/internal/pkg/runner/testdata/numbers/counter.go
new file mode 100644
index 0000000..827d11d
--- /dev/null
+++ b/internal/pkg/runner/testdata/numbers/counter.go
@@ -0,0 +1,13 @@
+package numbers
+
+type Counter struct {
+ value int
+}
+
+func (c *Counter) Increment() {
+ c.value++
+}
+
+func (c *Counter) Value() int {
+ return c.value
+}
diff --git a/internal/pkg/runner/testdata/numbers/counter_test.go b/internal/pkg/runner/testdata/numbers/counter_test.go
new file mode 100644
index 0000000..8d74ff6
--- /dev/null
+++ b/internal/pkg/runner/testdata/numbers/counter_test.go
@@ -0,0 +1,25 @@
+package numbers
+
+import (
+ "sync"
+ "testing"
+)
+
+func TestCounterIncrement(t *testing.T) {
+ c := &Counter{}
+ var wg sync.WaitGroup
+
+ for i := 0; i < 100; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ c.Increment()
+ }()
+ }
+
+ wg.Wait()
+
+ if c.Value() != 100 {
+ t.Errorf("Expected counter value to be 100, got %d", c.Value())
+ }
+}
diff --git a/internal/pkg/runner/testdata/numbers/numbers.go b/internal/pkg/runner/testdata/numbers/numbers.go
new file mode 100644
index 0000000..4dd318f
--- /dev/null
+++ b/internal/pkg/runner/testdata/numbers/numbers.go
@@ -0,0 +1,10 @@
+// Package Numbers is a test package for testing the runner package.
+package numbers
+
+// IntMin returns the minimum of two integers.
+func IntMin(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/internal/pkg/runner/testdata/numbers/numbers_test.go b/internal/pkg/runner/testdata/numbers/numbers_test.go
new file mode 100644
index 0000000..47f804f
--- /dev/null
+++ b/internal/pkg/runner/testdata/numbers/numbers_test.go
@@ -0,0 +1,38 @@
+package numbers
+
+import (
+ "fmt"
+ "testing"
+)
+
+func TestIntMinBasic(t *testing.T) {
+ ans := IntMin(2, -2)
+ if ans != -2 {
+ t.Errorf("IntMin(2, -2) = %d; want -2", ans)
+ }
+}
+
+func TestIntMinTableDriven(t *testing.T) {
+ t.Skip("skipping table driven test")
+
+ tests := []struct {
+ a, b int
+ want int
+ }{
+ {0, 1, 0},
+ {1, 0, 0},
+ {2, -2, -2},
+ {0, -1, -1},
+ {-1, 0, -1},
+ }
+
+ for _, tt := range tests {
+ testname := fmt.Sprintf("%d,%d", tt.a, tt.b)
+ t.Run(testname, func(t *testing.T) {
+ ans := IntMin(tt.a, tt.b)
+ if ans != tt.want {
+ t.Errorf("got %d, want %d", ans, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/pkg/runner/testdata/testoutput-all-pass.json b/internal/pkg/runner/testdata/testoutput-all-pass.json
new file mode 100644
index 0000000..9723ec5
--- /dev/null
+++ b/internal/pkg/runner/testdata/testoutput-all-pass.json
@@ -0,0 +1,33 @@
+{"Time":"2023-01-01T13:37:00.557861Z","Action":"start","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers"}
+{"Time":"2023-01-01T13:37:00.557925Z","Action":"run","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinBasic"}
+{"Time":"2023-01-01T13:37:00.557931Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinBasic","Output":"=== RUN TestIntMinBasic\n"}
+{"Time":"2023-01-01T13:37:00.557939Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinBasic","Output":"--- PASS: TestIntMinBasic (0.00s)\n"}
+{"Time":"2023-01-01T13:37:00.557945Z","Action":"pass","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinBasic","Elapsed":0}
+{"Time":"2023-01-01T13:37:00.557952Z","Action":"run","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven"}
+{"Time":"2023-01-01T13:37:00.557956Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven","Output":"=== RUN TestIntMinTableDriven\n"}
+{"Time":"2023-01-01T13:37:00.55796Z","Action":"run","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven/0,1"}
+{"Time":"2023-01-01T13:37:00.557963Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven/0,1","Output":"=== RUN TestIntMinTableDriven/0,1\n"}
+{"Time":"2023-01-01T13:37:00.557969Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven/0,1","Output":"--- PASS: TestIntMinTableDriven/0,1 (0.00s)\n"}
+{"Time":"2023-01-01T13:37:00.557973Z","Action":"pass","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven/0,1","Elapsed":0}
+{"Time":"2023-01-01T13:37:00.557977Z","Action":"run","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven/1,0"}
+{"Time":"2023-01-01T13:37:00.55798Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven/1,0","Output":"=== RUN TestIntMinTableDriven/1,0\n"}
+{"Time":"2023-01-01T13:37:00.557984Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven/1,0","Output":"--- PASS: TestIntMinTableDriven/1,0 (0.00s)\n"}
+{"Time":"2023-01-01T13:37:00.557987Z","Action":"pass","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven/1,0","Elapsed":0}
+{"Time":"2023-01-01T13:37:00.557991Z","Action":"run","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven/2,-2"}
+{"Time":"2023-01-01T13:37:00.558004Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven/2,-2","Output":"=== RUN TestIntMinTableDriven/2,-2\n"}
+{"Time":"2023-01-01T13:37:00.558008Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven/2,-2","Output":"--- PASS: TestIntMinTableDriven/2,-2 (0.00s)\n"}
+{"Time":"2023-01-01T13:37:00.558012Z","Action":"pass","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven/2,-2","Elapsed":0}
+{"Time":"2023-01-01T13:37:00.558015Z","Action":"run","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven/0,-1"}
+{"Time":"2023-01-01T13:37:00.558019Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven/0,-1","Output":"=== RUN TestIntMinTableDriven/0,-1\n"}
+{"Time":"2023-01-01T13:37:00.558033Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven/0,-1","Output":"--- PASS: TestIntMinTableDriven/0,-1 (0.00s)\n"}
+{"Time":"2023-01-01T13:37:00.558036Z","Action":"pass","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven/0,-1","Elapsed":0}
+{"Time":"2023-01-01T13:37:00.558038Z","Action":"run","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven/-1,0"}
+{"Time":"2023-01-01T13:37:00.55804Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven/-1,0","Output":"=== RUN TestIntMinTableDriven/-1,0\n"}
+{"Time":"2023-01-01T13:37:00.558043Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven/-1,0","Output":"--- PASS: TestIntMinTableDriven/-1,0 (0.00s)\n"}
+{"Time":"2023-01-01T13:37:00.558046Z","Action":"pass","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven/-1,0","Elapsed":0}
+{"Time":"2023-01-01T13:37:00.558049Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven","Output":"--- PASS: TestIntMinTableDriven (0.00s)\n"}
+{"Time":"2023-01-01T13:37:00.558051Z","Action":"pass","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven","Elapsed":0}
+{"Time":"2023-01-01T13:37:00.558054Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Output":"PASS\n"}
+{"Time":"2023-01-01T13:37:00.558056Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Output":"\tgithub.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers\tcoverage: 100.0% of statements\n"}
+{"Time":"2023-01-01T13:37:00.558067Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Output":"ok \tgithub.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers\t(cached)\tcoverage: 100.0% of statements\n"}
+{"Time":"2023-01-01T13:37:00.55807Z","Action":"pass","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Elapsed":0}
diff --git a/internal/pkg/runner/testdata/testoutput-build-failed.txt b/internal/pkg/runner/testdata/testoutput-build-failed.txt
new file mode 100644
index 0000000..7a4f035
--- /dev/null
+++ b/internal/pkg/runner/testdata/testoutput-build-failed.txt
@@ -0,0 +1,3 @@
+# github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers [github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers.test]
+./numbers_test.go:9:16: cannot use "2" (untyped string constant) as int value in argument to IntMin
+FAIL github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers [build failed]
diff --git a/internal/pkg/runner/testdata/testoutput-data-race.json b/internal/pkg/runner/testdata/testoutput-data-race.json
new file mode 100644
index 0000000..97c23ed
--- /dev/null
+++ b/internal/pkg/runner/testdata/testoutput-data-race.json
@@ -0,0 +1,43 @@
+{"Time":"2023-01-01T13:37:00.295491Z","Action":"start","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers"}
+{"Time":"2023-01-01T13:37:00.547881Z","Action":"run","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement"}
+{"Time":"2023-01-01T13:37:00.548031Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":"=== RUN TestCounterIncrement\n"}
+{"Time":"2023-01-01T13:37:00.548739Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":"==================\n"}
+{"Time":"2023-01-01T13:37:00.548772Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":"WARNING: DATA RACE\n"}
+{"Time":"2023-01-01T13:37:00.548783Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":"Read at 0x00c0000182e8 by goroutine 21:\n"}
+{"Time":"2023-01-01T13:37:00.548806Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":" github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers.(*Counter).Increment()\n"}
+{"Time":"2023-01-01T13:37:00.548864Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":" /Users/michael/src/github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers/counter.go:8 +0xb4\n"}
+{"Time":"2023-01-01T13:37:00.548881Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":" github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers.TestCounterIncrement.func1()\n"}
+{"Time":"2023-01-01T13:37:00.548891Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":" /Users/michael/src/github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers/counter_test.go:16 +0x64\n"}
+{"Time":"2023-01-01T13:37:00.5489Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":"\n"}
+{"Time":"2023-01-01T13:37:00.548918Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":"Previous write at 0x00c0000182e8 by goroutine 7:\n"}
+{"Time":"2023-01-01T13:37:00.548926Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":" github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers.(*Counter).Increment()\n"}
+{"Time":"2023-01-01T13:37:00.548937Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":" /Users/michael/src/github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers/counter.go:8 +0xc4\n"}
+{"Time":"2023-01-01T13:37:00.548947Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":" github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers.TestCounterIncrement.func1()\n"}
+{"Time":"2023-01-01T13:37:00.548955Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":" /Users/michael/src/github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers/counter_test.go:16 +0x64\n"}
+{"Time":"2023-01-01T13:37:00.548965Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":"\n"}
+{"Time":"2023-01-01T13:37:00.549006Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":"Goroutine 21 (running) created at:\n"}
+{"Time":"2023-01-01T13:37:00.549013Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":" github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers.TestCounterIncrement()\n"}
+{"Time":"2023-01-01T13:37:00.54902Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":" /Users/michael/src/github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers/counter_test.go:14 +0x6c\n"}
+{"Time":"2023-01-01T13:37:00.549082Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":" testing.tRunner()\n"}
+{"Time":"2023-01-01T13:37:00.54915Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":" /usr/local/go/src/testing/testing.go:1576 +0x180\n"}
+{"Time":"2023-01-01T13:37:00.549182Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":" testing.(*T).Run.func1()\n"}
+{"Time":"2023-01-01T13:37:00.549192Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":" /usr/local/go/src/testing/testing.go:1629 +0x40\n"}
+{"Time":"2023-01-01T13:37:00.549201Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":"\n"}
+{"Time":"2023-01-01T13:37:00.549268Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":"Goroutine 7 (finished) created at:\n"}
+{"Time":"2023-01-01T13:37:00.549288Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":" github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers.TestCounterIncrement()\n"}
+{"Time":"2023-01-01T13:37:00.5493Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":" /Users/michael/src/github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers/counter_test.go:14 +0x6c\n"}
+{"Time":"2023-01-01T13:37:00.54931Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":" testing.tRunner()\n"}
+{"Time":"2023-01-01T13:37:00.549333Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":" /usr/local/go/src/testing/testing.go:1576 +0x180\n"}
+{"Time":"2023-01-01T13:37:00.549364Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":" testing.(*T).Run.func1()\n"}
+{"Time":"2023-01-01T13:37:00.549407Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":" /usr/local/go/src/testing/testing.go:1629 +0x40\n"}
+{"Time":"2023-01-01T13:37:00.549428Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":"==================\n"}
+{"Time":"2023-01-01T13:37:00.549672Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":" counter_test.go:23: Expected counter value to be 100, got 99\n"}
+{"Time":"2023-01-01T13:37:00.549708Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":" testing.go:1446: race detected during execution of test\n"}
+{"Time":"2023-01-01T13:37:00.549788Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Output":"--- FAIL: TestCounterIncrement (0.00s)\n"}
+{"Time":"2023-01-01T13:37:00.549804Z","Action":"fail","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestCounterIncrement","Elapsed":0}
+{"Time":"2023-01-01T13:37:00.549833Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Output":" testing.go:1446: race detected during execution of test\n"}
+{"Time":"2023-01-01T13:37:00.549845Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Output":"FAIL\n"}
+{"Time":"2023-01-01T13:37:00.552453Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Output":"\tgithub.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers\tcoverage: 40.0% of statements\n"}
+{"Time":"2023-01-01T13:37:00.553484Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Output":"exit status 1\n"}
+{"Time":"2023-01-01T13:37:00.553517Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Output":"FAIL\tgithub.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers\t0.258s\n"}
+{"Time":"2023-01-01T13:37:00.553535Z","Action":"fail","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Elapsed":0.258}
diff --git a/internal/pkg/runner/testdata/testoutput-no-tests.json b/internal/pkg/runner/testdata/testoutput-no-tests.json
new file mode 100644
index 0000000..380ea03
--- /dev/null
+++ b/internal/pkg/runner/testdata/testoutput-no-tests.json
@@ -0,0 +1,6 @@
+{"Time":"2023-01-01T13:37:00.879004Z","Action":"start","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers"}
+{"Time":"2023-01-01T13:37:00.879054Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Output":"testing: warning: no tests to run\n"}
+{"Time":"2023-01-01T13:37:00.879063Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Output":"PASS\n"}
+{"Time":"2023-01-01T13:37:00.879068Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Output":"\tgithub.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers\tcoverage: 0.0% of statements\n"}
+{"Time":"2023-01-01T13:37:00.879074Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Output":"ok \tgithub.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers\t(cached)\tcoverage: 0.0% of statements [no tests to run]\n"}
+{"Time":"2023-01-01T13:37:00.87908Z","Action":"pass","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Elapsed":0}
diff --git a/internal/pkg/runner/testdata/testoutput.json b/internal/pkg/runner/testdata/testoutput.json
new file mode 100644
index 0000000..297df0d
--- /dev/null
+++ b/internal/pkg/runner/testdata/testoutput.json
@@ -0,0 +1,19 @@
+{"Time":"2023-01-01T13:37:00.636011Z","Action":"start","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers"}
+{"Time":"2023-01-01T13:37:00.920877Z","Action":"run","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinBasic"}
+{"Time":"2023-01-01T13:37:00.921056Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinBasic","Output":"=== RUN TestIntMinBasic\n"}
+{"Time":"2023-01-01T13:37:00.921157Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinBasic","Output":"--- PASS: TestIntMinBasic (0.00s)\n"}
+{"Time":"2023-01-01T13:37:00.921198Z","Action":"pass","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinBasic","Elapsed":0}
+{"Time":"2023-01-01T13:37:00.921249Z","Action":"run","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven"}
+{"Time":"2023-01-01T13:37:00.92127Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven","Output":"=== RUN TestIntMinTableDriven\n"}
+{"Time":"2023-01-01T13:37:00.921288Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven","Output":" numbers_test.go:16: skipping table driven test\n"}
+{"Time":"2023-01-01T13:37:00.921323Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven","Output":"--- SKIP: TestIntMinTableDriven (0.00s)\n"}
+{"Time":"2023-01-01T13:37:00.921344Z","Action":"skip","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinTableDriven","Elapsed":0}
+{"Time":"2023-01-01T13:37:00.921363Z","Action":"run","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinFailing"}
+{"Time":"2023-01-01T13:37:00.921381Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinFailing","Output":"=== RUN TestIntMinFailing\n"}
+{"Time":"2023-01-01T13:37:00.921403Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinFailing","Output":" numbers_test.go:41: failing test\n"}
+{"Time":"2023-01-01T13:37:00.921424Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinFailing","Output":"--- FAIL: TestIntMinFailing (0.00s)\n"}
+{"Time":"2023-01-01T13:37:00.921444Z","Action":"fail","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Test":"TestIntMinFailing","Elapsed":0}
+{"Time":"2023-01-01T13:37:00.921462Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Output":"FAIL\n"}
+{"Time":"2023-01-01T13:37:00.924682Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Output":"\tgithub.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers\tcoverage: 66.7% of statements\n"}
+{"Time":"2023-01-01T13:37:00.92586Z","Action":"output","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Output":"FAIL\tgithub.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers\t0.289s\n"}
+{"Time":"2023-01-01T13:37:00.925885Z","Action":"fail","Package":"github.com/michenriksen/gokiburi/internal/pkg/runner/testdata/numbers","Elapsed":0.29}
diff --git a/internal/pkg/server/api.go b/internal/pkg/server/api.go
new file mode 100644
index 0000000..b7f1f1d
--- /dev/null
+++ b/internal/pkg/server/api.go
@@ -0,0 +1,92 @@
+package server
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+
+ "github.com/labstack/echo/v4"
+
+ "github.com/michenriksen/gokiburi/internal/pkg/state"
+)
+
+func (s *Server) resultsHandler(c echo.Context) error {
+ return c.JSON(http.StatusOK, s.getResults())
+}
+
+func (s *Server) pauseHandler(c echo.Context) error {
+ if s.state != state.Ready {
+ // Only allow client to pause if current state is Ready.
+ return c.NoContent(http.StatusForbidden)
+ }
+
+ s.sendCommand(newCommand(Pause, ""))
+
+ return c.NoContent(http.StatusAccepted)
+}
+
+func (s *Server) resumeHandler(c echo.Context) error {
+ if s.state != state.Paused {
+ // Only allow client to resume if current state is Paused.
+ return c.NoContent(http.StatusForbidden)
+ }
+
+ s.sendCommand(newCommand(Resume, ""))
+
+ return c.NoContent(http.StatusAccepted)
+}
+
+func (s *Server) clearResultsHandler(c echo.Context) error {
+ s.clearResults()
+ return c.NoContent(http.StatusOK)
+}
+
+func (s *Server) runHandler(c echo.Context) error {
+ req := runRequest{}
+
+ if ok, err := bindAndValidate(c, &req); !ok {
+ return err
+ }
+
+ s.logger.Debug("emitting RunTests command", "data", req.Package)
+
+ s.Commands <- newCommand(RunTests, req.Package)
+
+ return c.NoContent(http.StatusAccepted)
+}
+
+func (s *Server) reportHandler(c echo.Context) error {
+ uuid := c.Param("uuid")
+ if !validUUID(uuid) {
+ return c.NoContent(http.StatusBadRequest)
+ }
+
+ result := s.resultByUUID(uuid)
+ if result == nil {
+ return c.JSON(http.StatusNotFound, fmt.Errorf("no test result with UUID %s", uuid))
+ }
+
+ rpath := filepath.Join(result.Dir(), "report.json")
+
+ report, err := os.Open(rpath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ s.logger.Error("can't find coverage report for result", logKeyUUID, uuid, "path", rpath)
+
+ return c.JSON(
+ http.StatusNotFound,
+ fmt.Errorf("coverage report for result with UUID %s was not found", uuid),
+ )
+ }
+
+ s.logger.Error("failed to read coverage report for result", logKeyUUID, uuid, logKeyError, err)
+
+ return c.JSON(
+ http.StatusInternalServerError,
+ fmt.Errorf("failed to read coverage report for result with UUID %s", uuid),
+ )
+ }
+
+ return c.Stream(200, "application/json", report)
+}
diff --git a/internal/pkg/server/commands.go b/internal/pkg/server/commands.go
new file mode 100644
index 0000000..7cb6696
--- /dev/null
+++ b/internal/pkg/server/commands.go
@@ -0,0 +1,23 @@
+package server
+
+// Instruction from user to the App.
+type Instruction int
+
+const (
+ Pause Instruction = iota
+ Resume
+ RunTests
+)
+
+// Command from user to the App.
+type Command struct {
+ Instruction Instruction
+ Data string
+}
+
+func newCommand(instruction Instruction, data string) *Command {
+ return &Command{
+ Instruction: instruction,
+ Data: data,
+ }
+}
diff --git a/internal/pkg/server/middleware.go b/internal/pkg/server/middleware.go
new file mode 100644
index 0000000..d862bf3
--- /dev/null
+++ b/internal/pkg/server/middleware.go
@@ -0,0 +1,21 @@
+package server
+
+import (
+ "github.com/labstack/echo/v4"
+)
+
+// apiHeaders adds response headers to disable browser caching for API
+// endpoints.
+func apiHeaders() echo.MiddlewareFunc {
+ return func(next echo.HandlerFunc) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ res := c.Response()
+
+ res.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
+ res.Header().Set("Pragma", "no-cache")
+ res.Header().Set("Expires", "0")
+
+ return next(c)
+ }
+ }
+}
diff --git a/internal/pkg/server/requests.go b/internal/pkg/server/requests.go
new file mode 100644
index 0000000..8c06e17
--- /dev/null
+++ b/internal/pkg/server/requests.go
@@ -0,0 +1,47 @@
+package server
+
+import (
+ "net/http"
+ "regexp"
+
+ "github.com/invopop/validation"
+ "github.com/labstack/echo/v4"
+)
+
+var (
+ uuidRegexp = regexp.MustCompile(`^[\w_-]{21}$`)
+ pkgRegexp = regexp.MustCompile(`^[\w\.\/_-]+$`)
+)
+
+type runRequest struct {
+ Package string `json:"package"`
+}
+
+func (r runRequest) Validate() error {
+ validation.ErrorTag = "json"
+
+ return validation.ValidateStruct(&r,
+ validation.Field(&r.Package,
+ validation.Required,
+ validation.Match(pkgRegexp).Error("must be a valid package name"),
+ ),
+ )
+}
+
+func validUUID(s string) bool {
+ return uuidRegexp.MatchString(s)
+}
+
+func bindAndValidate(c echo.Context, i any) (ok bool, err error) {
+ if err = c.Bind(i); err != nil {
+ return false, c.NoContent(http.StatusBadRequest)
+ }
+
+ if vi, ok := i.(validation.Validatable); ok {
+ if err = vi.Validate(); err != nil {
+ return false, c.JSON(http.StatusUnprocessableEntity, err)
+ }
+ }
+
+ return true, nil
+}
diff --git a/internal/pkg/server/responses.go b/internal/pkg/server/responses.go
new file mode 100644
index 0000000..c292619
--- /dev/null
+++ b/internal/pkg/server/responses.go
@@ -0,0 +1,7 @@
+package server
+
+type notification struct {
+ Type string `json:"type"`
+ Body string `json:"body"`
+ Tag string `json:"tag"`
+}
diff --git a/internal/pkg/server/server.go b/internal/pkg/server/server.go
new file mode 100644
index 0000000..bbdc599
--- /dev/null
+++ b/internal/pkg/server/server.go
@@ -0,0 +1,308 @@
+package server
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "crypto/sha1" // #nosec:G505 // SHA1 is used for non-sensitive notification tags.
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "mime"
+ "net"
+ "net/http"
+ "path"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/charmbracelet/log"
+ "github.com/labstack/echo/v4"
+ "github.com/labstack/echo/v4/middleware"
+ "golang.org/x/net/websocket"
+
+ "github.com/michenriksen/gokiburi/internal/pkg/runner"
+ "github.com/michenriksen/gokiburi/internal/pkg/state"
+ "github.com/michenriksen/gokiburi/web"
+)
+
+const (
+ defaultMaxResults = 50
+
+ logKeyUUID = "uuid"
+ logKeyError = "error"
+
+ cspFmt = "default-src 'self' 'nonce-%s'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
+)
+
+// Option configures a [Server].
+type Option func(*Server)
+
+// Server for serving web UI and API.
+type Server struct {
+ Commands chan *Command
+ ctx context.Context
+ root string
+ router *echo.Echo
+ state state.State
+ logger *log.Logger
+ results []*runner.Result
+ resultsMu sync.RWMutex
+ wsConn *websocket.Conn
+ wsMu sync.Mutex
+ maxResults int
+}
+
+// New Server for serving web UI and API.
+func New(ctx context.Context, rootDir string, opts ...Option) *Server {
+ router := echo.New()
+ router.HideBanner = true
+ router.HidePort = true
+
+ router.Use(middleware.Recover())
+ router.Use(middleware.Secure())
+
+ s := &Server{
+ Commands: make(chan *Command, 1),
+ ctx: ctx,
+ root: rootDir,
+ router: router,
+ state: state.Init,
+ logger: log.New(io.Discard),
+ maxResults: defaultMaxResults,
+ }
+
+ for _, opt := range opts {
+ opt(s)
+ }
+
+ return s
+}
+
+// Serve requests on host and port.
+func (s *Server) Serve(host string, port int) error {
+ if err := s.registerAPIHandlers(); err != nil {
+ return fmt.Errorf("registering API handlers: %w", err)
+ }
+
+ if err := s.registerHandlers(); err != nil {
+ return fmt.Errorf("registering static asset handler: %w", err)
+ }
+
+ address := net.JoinHostPort(host, strconv.Itoa(port))
+
+ s.logger.Info(fmt.Sprintf("web server listening on %s", address), "url", "http://"+address+"/")
+
+ if err := s.router.Start(address); err != nil {
+ return fmt.Errorf("starting router on %s: %w", address, err)
+ }
+
+ return nil
+}
+
+// Close server.
+func (s *Server) Close() error {
+ s.logger.Info("shutting down")
+
+ close(s.Commands)
+
+ s.clearResults()
+
+ if err := s.router.Shutdown(context.Background()); err != nil {
+ return fmt.Errorf("closing router: %w", err)
+ }
+
+ return nil
+}
+
+// AddResult to be served via the API.
+//
+// Prepends the result to the internal result slice. Slice is trimmed if its
+// length exceeds configured max results.
+func (s *Server) AddResult(r *runner.Result) {
+ if r.Error != "" {
+ s.sendClientMessage(newClientMessage("resultError", r))
+ return
+ }
+
+ if r.Tests == 0 {
+ s.sendClientMessage(newClientMessage("resultEmpty", r))
+ return
+ }
+
+ s.resultsMu.Lock()
+ defer s.resultsMu.Unlock()
+
+ // Prepend result to beginning of slice.
+ s.results = append(s.results, nil)
+ copy(s.results[1:], s.results)
+ s.results[0] = r
+
+ // Trim slice if length has become too big.
+ if len(s.results) > s.maxResults {
+ for i := s.maxResults; i < len(s.results); i++ {
+ s.logger.Debug(fmt.Sprintf("closing and trimming old result at index %d", i), "uuid", s.results[i].UUID)
+ s.results[i].Close()
+ }
+
+ s.results = s.results[0:s.maxResults]
+ }
+
+ s.sendClientMessage(newClientMessage("result", r))
+}
+
+// SetState for server.
+func (s *Server) SetState(newState state.State) {
+ s.state = newState
+ s.sendClientMessage(newClientMessage("state", s.state))
+}
+
+// SendNotification to display as a toaster message in the web UI.
+func (s *Server) SendNotification(kind, bodyFmt string, args ...any) {
+ body := fmt.Sprintf(bodyFmt, args...)
+ sha := sha1.New() //#nosec:G401 // We don't need a cryptographically secure hash notification tags.
+ sha.Sum([]byte(time.Now().String()))
+ sha.Sum([]byte(kind))
+ sha.Sum([]byte(body))
+
+ s.sendClientMessage(newClientMessage("notification", ¬ification{
+ kind,
+ body,
+ base64.RawURLEncoding.EncodeToString(sha.Sum(nil)),
+ }))
+}
+
+// Reset server to initial state.
+//
+// Sets the state to [state.Init], clears results, and closes all long poll
+// subscriptions.
+//
+// Note: This is mainly used for testing purposes.
+func (s *Server) Reset() {
+ s.state = state.Init
+ s.clearResults()
+}
+
+func (s *Server) getResults() []*runner.Result {
+ s.resultsMu.RLock()
+ defer s.resultsMu.RUnlock()
+
+ c := make([]*runner.Result, len(s.results))
+ copy(c, s.results)
+
+ return c
+}
+
+func (s *Server) resultByUUID(uuid string) *runner.Result {
+ for _, r := range s.getResults() {
+ if r.UUID == uuid {
+ return r
+ }
+ }
+
+ return nil
+}
+
+func (s *Server) clearResults() {
+ s.resultsMu.Lock()
+ defer s.resultsMu.Unlock()
+
+ for _, result := range s.results {
+ result.Close()
+ }
+
+ s.logger.Info("cleared current test results")
+
+ s.results = nil
+}
+
+func (s *Server) sendCommand(cmd *Command) {
+ s.Commands <- cmd
+}
+
+func (*Server) indexHandler(c echo.Context) error {
+ index, err := web.StaticAssetFS.ReadFile("app/build/index.html")
+ if err != nil {
+ return fmt.Errorf("reading index.html: %w", err)
+ }
+
+ nonce, err := genNonce()
+ if err != nil {
+ return fmt.Errorf("generating nonce: %w", err)
+ }
+
+ modified := bytes.ReplaceAll(index, []byte("`)).toEqual(
+ `<script>alert('xss')</script>`
+ );
+ expect(escapeHtml(`Hello\nWorld! `)).toEqual(`Hello\n<strong>World!</strong>`);
+ });
+});
+
+describe('formatDuration', () => {
+ it('formats nanoseconds to human readable format', () => {
+ expect(formatDuration(92_000_000_000)).toEqual('1m32s');
+ expect(formatDuration(16_000_000_000)).toEqual('16s');
+ expect(formatDuration(8_000_000)).toEqual('8ms');
+ });
+});
+
+describe('pluralize', () => {
+ it('pluralizes a word correctly from a number', () => {
+ expect(pluralize(-1, 'test', 'tests')).toEqual('-1 tests');
+ expect(pluralize(0, 'test', 'tests')).toEqual('0 tests');
+ expect(pluralize(1, 'test', 'tests')).toEqual('1 test');
+ expect(pluralize(2, 'test', 'tests')).toEqual('2 tests');
+ });
+});
+
+describe('sortByTime', () => {
+ it('sorts an array of timestamped objects by time in ascending order', () => {
+ const objs = [
+ { time: '2023-04-08T17:20:18.605327' },
+ { time: '2023-04-08T17:20:17.605327' },
+ { time: '2023-04-07T17:20:18.605327' }
+ ];
+
+ const sorted = objs.sort(sortByTime);
+
+ expect(sorted[0].time).toEqual('2023-04-07T17:20:18.605327');
+ expect(sorted[1].time).toEqual('2023-04-08T17:20:17.605327');
+ expect(sorted[2].time).toEqual('2023-04-08T17:20:18.605327');
+ });
+});
+
+describe('formatBytes', () => {
+ it('formats bytes as human-readable text', () => {
+ expect(formatBytes(0)).toEqual('0 B');
+ expect(formatBytes(500)).toEqual('500 B');
+ expect(formatBytes(1024)).toEqual('1.0 KiB');
+ expect(formatBytes(1024 * 1024)).toEqual('1.0 MiB');
+ expect(formatBytes(1_572_864)).toEqual('1.5 MiB');
+ });
+});
+
+describe('truncateFilepath', () => {
+ it('truncates a filepath to a maximum length', () => {
+ const tt = [
+ {
+ s: 'github.com/johndoe/project/warpcore/breach.go',
+ l: 40,
+ want: 'github.com/john…roject/warpcore/breach.go'
+ },
+ {
+ s: 'github.com/johndoe/project/main.go',
+ l: 40,
+ want: 'github.com/johndoe/project/main.go'
+ },
+ {
+ s: 'github.com/johndoe/project/main.go',
+ l: 0,
+ want: 'github.com/johndoe/project/main.go'
+ },
+ {
+ s: 'github.com/johndoe/project/main.go',
+ l: -1,
+ want: 'github.com/johndoe/project/main.go'
+ }
+ ];
+
+ for (const tc of tt) {
+ expect(truncateFilepath(tc.s, tc.l)).toEqual(tc.want);
+ }
+ });
+});
diff --git a/web/app/src/lib/common/utils.ts b/web/app/src/lib/common/utils.ts
new file mode 100644
index 0000000..44d8956
--- /dev/null
+++ b/web/app/src/lib/common/utils.ts
@@ -0,0 +1,266 @@
+import { get } from 'svelte/store';
+
+import { metadata } from '$lib/stores/metadata';
+
+import type { Timestamped } from '$lib/common/types';
+
+const meta = get(metadata);
+
+/**
+ * Pattern for matching characters that need
+ * escaping in a HTML context.
+ */
+const unsafeHtmlCharRegexp = /[&<>"'/]/g;
+
+/**
+ * Special HTML characters mapped to their
+ * HTML entities.
+ */
+const entityMap: Record = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ '/': '/'
+};
+
+/**
+ * Format nanoseconds to a human readable duration.
+ *
+ * @param nanoseconds - Duration in nanoseconds.
+ *
+ * @returns A human-readable string representation of duration like `1m32s`.
+ *
+ * @throws Error if given a negative number.
+ */
+export function formatDuration(nanoseconds: number): string {
+ if (nanoseconds < 0) {
+ throw new Error('duration must be a positive number');
+ }
+
+ const timeUnits = [
+ { unit: 'h', value: 60 * 60 * 1000 * 1000 * 1000 },
+ { unit: 'm', value: 60 * 1000 * 1000 * 1000 },
+ { unit: 's', value: 1000 * 1000 * 1000 },
+ { unit: 'ms', value: 1000 * 1000 }
+ ];
+
+ let result = '';
+
+ for (const { unit, value } of timeUnits) {
+ if (nanoseconds >= value) {
+ const count = Math.floor(nanoseconds / value);
+ nanoseconds %= value;
+ result += `${count}${unit}`;
+ }
+ }
+
+ if (!result) {
+ result = '0ms';
+ }
+
+ return result;
+}
+
+/**
+ * Pluralize a word with number.
+ *
+ * Pluralizes a word correctly depending on the number given.
+ *
+ * @param num - A number of something.
+ * @param singular - Word in its singular form.
+ * @param plural - Word in its plural form.
+ *
+ * @returns A string consisting of number followed by the singular
+ * or plural word, like `0 bugs`, `1 feature`, `4 files`.
+ */
+export function pluralize(num: number, singular: string, plural: string): string {
+ let word = plural;
+
+ if (num === 1) {
+ word = singular;
+ }
+
+ return num + ' ' + word;
+}
+
+/**
+ * Escape special HTML characters in a string.
+ *
+ * Makes an unsafe string safe to render directly in HTML by escaping
+ * special characters to their HTML entities.
+ *
+ * @param unsafe - Untrusted string.
+ *
+ * @returns A safe string with special HTML characters like `<`, `>`, `"`, etc.
+ * converted to their HTML entities.
+ */
+export function escapeHtml(unsafe: string): string {
+ return unsafe.replace(unsafeHtmlCharRegexp, (s: string) => {
+ return entityMap[s];
+ });
+}
+
+/**
+ * Escape special characters in a string for use in a RegExp.
+ *
+ * Makes an unsafe string safe to use in a RegExp by escaping special
+ * characters.
+ *
+ * @param unsafe - Untrusted string.
+ *
+ * @returns A safe string with special RegExp characters like `*`, `+`, `?`,
+ * etc. converted to their escaped form.
+ */
+export function escapeRegExp(unsafe: string): string {
+ return unsafe.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string.
+}
+
+/**
+ * Sort array of timestamped objects by time in descending order.
+ *
+ * Can be given to `Array.sort` as the sorter function.
+ *
+ * @param a - Object with a `time` field.
+ * @param b - Object with a `time` field.
+ *
+ * @returns
+ * -1 if a.time is older than b.time, 1 if a.time is earlier than
+ * b.time, and 0 if times are equal.
+ */
+export function sortByTime(a: Timestamped, b: Timestamped): number {
+ if (a.time < b.time) {
+ return -1;
+ } else if (a.time > b.time) {
+ return 1;
+ } else {
+ return 0;
+ }
+}
+
+/**
+ * Format bytes as human-readable text.
+ *
+ * @remarks Adapted from {@link https://stackoverflow.com/a/14919494}.
+ *
+ * @param bytes - Number of bytes.
+ * @param si - True to use metric (SI) units, aka powers of 1000. False to use
+ * binary (IEC), aka powers of 1024.
+ * @param dp - Number of decimal places to display.
+ *
+ * @returns Formatted string.
+ */
+export function formatBytes(bytes: number, si = false, dp = 1): string {
+ const thresh = si ? 1000 : 1024;
+
+ if (Math.abs(bytes) < thresh) {
+ return bytes + ' B';
+ }
+
+ const units = si
+ ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
+ : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
+ let u = -1;
+ const r = 10 ** dp;
+
+ do {
+ bytes /= thresh;
+ ++u;
+ } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
+
+ return bytes.toFixed(dp) + ' ' + units[u];
+}
+
+/**
+ * Truncate a file path with an ellipsis in the middle if its length exceeds a
+ * specified maximum, ensuring that the base filename is never truncated.
+ *
+ * @param path - The file path to be truncated.
+ * @param maxLength - The maximum allowed length for the truncated file path.
+ *
+ * @returns
+ * Truncated file path with an ellipsis in the middle if the input length
+ * exceeds maxLength, or the original file path if it doesn't exceed maxLength
+ * or if the base filename's length is equal to or greater than maxLength.
+ */
+export function truncateFilepath(path: string, maxLength: number): string {
+ const lastSlashIndex = path.lastIndexOf('/');
+ const dirname = path.slice(0, lastSlashIndex);
+ const basename = path.slice(lastSlashIndex + 1);
+
+ if (path.length <= maxLength || basename.length >= maxLength) {
+ return path;
+ }
+
+ const remainingLength = maxLength - basename.length - 1; // Subtract 1 for the ellipsis character
+ const halfRemainingLength = remainingLength / 2;
+ const start = dirname.slice(0, Math.ceil(halfRemainingLength));
+ const end = dirname.slice(-Math.floor(halfRemainingLength));
+
+ return `${start}…${end}/${basename}`;
+}
+
+/**
+ * Build a query string from key-value pairs.
+ *
+ * @param params - Key-value pairs to be encoded as a query string.
+ *
+ * @returns A query string, like `foo=bar&baz=qux`.
+ */
+export function buildQueryString(params: { [key: string]: string | number | boolean }): string {
+ const searchParams = new URLSearchParams();
+
+ for (const key in params) {
+ if (Object.prototype.hasOwnProperty.call(params, key)) {
+ searchParams.append(key, params[key].toString());
+ }
+ }
+
+ return searchParams.toString();
+}
+
+/**
+ * Debounce a function by the specified wait time.
+ *
+ * @param func - Function to debounce.
+ * @param wait - Milliseconds to wait before invoking the function.
+ *
+ * @returns A debounced version of the given function.
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function debounce any>(func: T, wait: number): (...args: Parameters) => void {
+ let timeoutId: ReturnType | null = null;
+
+ return (...args: Parameters) => {
+ if (timeoutId !== null) {
+ clearTimeout(timeoutId);
+ }
+
+ timeoutId = setTimeout(() => {
+ func(...args);
+ timeoutId = null;
+ }, wait);
+ };
+}
+
+/**
+ * Sleep for the specified duration.
+ *
+ * @param duration - Milliseconds to sleep.
+ * @returns A promise that resolves after the specified duration.
+ */
+export function sleep(duration: number): Promise {
+ return new Promise((resolve) => {
+ setTimeout(resolve, duration);
+ });
+}
+
+/**
+ * Set the page title.
+ *
+ * @param title - Page title.
+ */
+export function setPageTitle(title: string): void {
+ document.title = `${title} | ${meta.appName}`;
+}
diff --git a/web/app/src/lib/components/AppBar.svelte b/web/app/src/lib/components/AppBar.svelte
new file mode 100644
index 0000000..6a97a9d
--- /dev/null
+++ b/web/app/src/lib/components/AppBar.svelte
@@ -0,0 +1,177 @@
+
+
+
+
+
+
{$metadata.appName}
+ {#key rootVal}
+ {#if rootVal}
+
+ {rootVal}
+
+ {:else}
+
initializing...
+ {/if}
+ {/key}
+
+
+
+
+ {#if audioContextUnlockedVal}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+ {#if stateVal === 'paused'}
+
+ Paused
+ {:else if stateVal === 'offline' || stateVal === 'closing'}
+
+ Offline
+ {:else if stateVal === 'init'}
+
+ Init
+ {:else}
+
+ Live
+ {/if}
+
+
+
+ {#if stateVal === 'paused'}
+ dispatch('resume')}
+ data-tip="Resume automatic test runs"
+ >
+
+
+ {:else if stateVal === 'offline' || stateVal === 'closing'}
+
+
+
+ {:else if stateVal === 'init'}
+
+
+
+ {:else}
+ dispatch('pause')}
+ data-tip="Pause automatic test runs"
+ >
+
+
+ {/if}
+
+
+
+
+ dispatchRunTests('./...')}
+ >
+
+
+
+
+
+ dispatchOpenSettings()}
+ >
+
+
+
+
+
diff --git a/web/app/src/lib/components/CodeBlock.spec.ts b/web/app/src/lib/components/CodeBlock.spec.ts
new file mode 100644
index 0000000..25b3f3c
--- /dev/null
+++ b/web/app/src/lib/components/CodeBlock.spec.ts
@@ -0,0 +1,63 @@
+import { tick } from 'svelte';
+
+import { render, screen } from '@testing-library/svelte';
+import { describe, expect, it } from 'vitest';
+
+import CodeBlock from '$lib/components/CodeBlock.svelte';
+
+import { coverageTheme } from '$lib/stores/settings';
+
+describe('CodeBlock', () => {
+ describe('color legend', () => {
+ it('has a coverage scale', () => {
+ render(CodeBlock, { props: { code: '' } });
+
+ expect(screen.getByText('lowest')).toHaveClass('gokiburi-cov-1');
+
+ const scale = screen.getAllByText('※');
+ expect(scale.length).toBe(10);
+ scale.forEach((el, i) => {
+ expect(el).toHaveClass(`gokiburi-cov-${i + 1}`);
+ });
+
+ expect(screen.getByText('highest')).toHaveClass('gokiburi-cov-10');
+ });
+
+ it('has an example for no coverage', () => {
+ render(CodeBlock, { props: { code: '' } });
+
+ expect(screen.getByText('no coverage')).toHaveClass('gokiburi-cov-0');
+ });
+
+ it('has an example for non-runnable code', () => {
+ render(CodeBlock, { props: { code: '' } });
+
+ expect(screen.getByText('non-runnable')).toHaveClass('!text-surface-400');
+ });
+ });
+
+ describe('code display', () => {
+ it('renders code as raw HTML', () => {
+ const { getByRole } = render(CodeBlock, {
+ props: { code: 'code ' }
+ });
+
+ const code = getByRole('code');
+
+ expect(code.innerHTML).toEqual(
+ 'code '
+ );
+ });
+
+ describe('when theme setting is changed', () => {
+ it('changes the theme', async () => {
+ const { getByRole } = render(CodeBlock, { props: { code: '' } });
+
+ coverageTheme.set('heatmap');
+ await tick();
+
+ expect(getByRole('code')).toHaveClass('heatmap');
+ });
+ });
+ });
+});
diff --git a/web/app/src/lib/components/CodeBlock.svelte b/web/app/src/lib/components/CodeBlock.svelte
new file mode 100644
index 0000000..362f3b7
--- /dev/null
+++ b/web/app/src/lib/components/CodeBlock.svelte
@@ -0,0 +1,176 @@
+
+
+
+
+
colors:
+
+ lowest
+ ※
+ ※
+ ※
+ ※
+ ※
+ ※
+ ※
+ ※
+ ※
+ ※
+ highest
+
+
no coverage
+
non-runnable
+
+
{@html code}
+
+
+
diff --git a/web/app/src/lib/components/Combobox.svelte b/web/app/src/lib/components/Combobox.svelte
new file mode 100644
index 0000000..5c5ce74
--- /dev/null
+++ b/web/app/src/lib/components/Combobox.svelte
@@ -0,0 +1,101 @@
+
+
+
+
+ {#if icon} {/if}
+ {label}
+
+
+ {#if showCount}{values.length}/{options.length} {/if}
+
+
+
+
+
+
+ {#if multiple && showToggleAll}
+
+
+ {#if values.length === 0}
+
+ {:else}
+
+ {/if}
+
+ {toggleAllLabel}
+
+ {/if}
+ {#each options as option (option.value)}
+
+
+ {#if values.includes(option.value)}
+
+ {:else}
+
+ {/if}
+
+ {option.label}
+
+ {/each}
+
+
diff --git a/web/app/src/lib/components/CoverageIcon.spec.ts b/web/app/src/lib/components/CoverageIcon.spec.ts
new file mode 100644
index 0000000..dd9bcbb
--- /dev/null
+++ b/web/app/src/lib/components/CoverageIcon.spec.ts
@@ -0,0 +1,208 @@
+import { tick } from 'svelte';
+
+import {
+ mdiCircleOutline,
+ mdiCircleSlice1,
+ mdiCircleSlice2,
+ mdiCircleSlice3,
+ mdiCircleSlice4,
+ mdiCircleSlice5,
+ mdiCircleSlice6,
+ mdiCircleSlice7,
+ mdiCircleSlice8
+} from '@mdi/js';
+import { render } from '@testing-library/svelte';
+import { renderComponentToString } from '$lib/tests/helpers';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import CoverageIcon from '$lib/components/CoverageIcon.svelte';
+import SvgIcon from '$lib/components/SvgIcon.svelte';
+
+import { coverageHighMin, coverageMediumMin, coverageUseColor } from '$lib/stores/settings';
+
+afterEach(() => {
+ coverageUseColor.set('true');
+});
+
+describe('CoverageIcon', () => {
+ describe('when coverage is 100 and above', () => {
+ it('renders coverage icon eight', async () => {
+ const results = render(CoverageIcon, { props: { percentage: 100 } });
+ const icon = renderComponentToString(SvgIcon, { path: mdiCircleSlice8, size: '1.5em', class: 'inline-block' });
+ expect(results.container.innerHTML).toContain(icon);
+ results.component.$set({ percentage: 105 });
+ await tick();
+ expect(results.container.innerHTML).toContain(icon);
+ });
+ });
+
+ describe('when coverage is 87.5 and above', () => {
+ it('renders coverage icon seven', async () => {
+ const results = render(CoverageIcon, { props: { percentage: 87.5 } });
+ const icon = renderComponentToString(SvgIcon, { path: mdiCircleSlice7, size: '1.5em', class: 'inline-block' });
+ expect(results.container.innerHTML).toContain(icon);
+ results.component.$set({ percentage: 92 });
+ await tick();
+ expect(results.container.innerHTML).toContain(icon);
+ });
+ });
+
+ describe('when coverage is 75 and above', () => {
+ it('renders coverage icon six', async () => {
+ const results = render(CoverageIcon, { props: { percentage: 75 } });
+ const icon = renderComponentToString(SvgIcon, { path: mdiCircleSlice6, size: '1.5em', class: 'inline-block' });
+ expect(results.container.innerHTML).toContain(icon);
+ results.component.$set({ percentage: 80 });
+ await tick();
+ expect(results.container.innerHTML).toContain(icon);
+ });
+ });
+
+ describe('when coverage is 62.5 and above', () => {
+ it('renders coverage icon five', async () => {
+ const results = render(CoverageIcon, { props: { percentage: 62.5 } });
+ const icon = renderComponentToString(SvgIcon, { path: mdiCircleSlice5, size: '1.5em', class: 'inline-block' });
+ expect(results.container.innerHTML).toContain(icon);
+ results.component.$set({ percentage: 67 });
+ await tick();
+ expect(results.container.innerHTML).toContain(icon);
+ });
+ });
+
+ describe('when coverage is 50 and above', () => {
+ it('renders coverage icon four', async () => {
+ const results = render(CoverageIcon, { props: { percentage: 50 } });
+ const icon = renderComponentToString(SvgIcon, { path: mdiCircleSlice4, size: '1.5em', class: 'inline-block' });
+ expect(results.container.innerHTML).toContain(icon);
+ results.component.$set({ percentage: 55 });
+ await tick();
+ expect(results.container.innerHTML).toContain(icon);
+ });
+ });
+
+ describe('when coverage is 37.5 and above', () => {
+ it('renders coverage icon three', async () => {
+ const results = render(CoverageIcon, { props: { percentage: 37.5 } });
+ const icon = renderComponentToString(SvgIcon, { path: mdiCircleSlice3, size: '1.5em', class: 'inline-block' });
+ expect(results.container.innerHTML).toContain(icon);
+ results.component.$set({ percentage: 42 });
+ await tick();
+ expect(results.container.innerHTML).toContain(icon);
+ });
+ });
+
+ describe('when coverage is 25 and above', () => {
+ it('renders coverage icon two', async () => {
+ const results = render(CoverageIcon, { props: { percentage: 25 } });
+ const icon = renderComponentToString(SvgIcon, { path: mdiCircleSlice2, size: '1.5em', class: 'inline-block' });
+ expect(results.container.innerHTML).toContain(icon);
+ results.component.$set({ percentage: 30 });
+ await tick();
+ expect(results.container.innerHTML).toContain(icon);
+ });
+ });
+
+ describe('when coverage is 12.5 and above', () => {
+ it('renders coverage icon one', async () => {
+ const results = render(CoverageIcon, { props: { percentage: 12.5 } });
+ const icon = renderComponentToString(SvgIcon, { path: mdiCircleSlice1, size: '1.5em', class: 'inline-block' });
+ expect(results.container.innerHTML).toContain(icon);
+ results.component.$set({ percentage: 17 });
+ await tick();
+ expect(results.container.innerHTML).toContain(icon);
+ });
+ });
+
+ describe('when coverage is 0 and above', () => {
+ it('renders coverage icon zero', async () => {
+ const results = render(CoverageIcon, { props: { percentage: 0 } });
+ const icon = renderComponentToString(SvgIcon, { path: mdiCircleOutline, size: '1.5em', class: 'inline-block' });
+ expect(results.container.innerHTML).toContain(icon);
+ results.component.$set({ percentage: 5 });
+ await tick();
+ expect(results.container.innerHTML).toContain(icon);
+ });
+ });
+
+ describe('positive coverage', () => {
+ describe('when coverage is equal to or above positive coverage minimum', () => {
+ it('renders a green coverage icon', async () => {
+ coverageHighMin.set('80');
+ const results = render(CoverageIcon, { props: { percentage: 80 } });
+ expect(results.container.innerHTML).toContain('');
+ results.component.$set({ percentage: 85 });
+ await tick();
+ expect(results.container.innerHTML).toContain('');
+ });
+
+ describe('when use color setting is false', () => {
+ it('renders a neutral coverage icon', () => {
+ coverageHighMin.set('80');
+ coverageUseColor.set('false');
+ const { container } = render(CoverageIcon, { props: { percentage: 80 } });
+ expect(container.innerHTML).toContain('');
+ });
+ });
+ });
+
+ describe('when coverage is below positive coverage minimum', () => {
+ it('does not render a green coverage icon', () => {
+ coverageHighMin.set('80');
+ const { container } = render(CoverageIcon, { props: { percentage: 75 } });
+ expect(container.innerHTML).not.toContain('');
+ });
+ });
+ });
+
+ describe('warning coverage', () => {
+ describe('when coverage is equal to or above warning coverage minimum', () => {
+ it('renders a yellow coverage icon', async () => {
+ coverageMediumMin.set('65');
+ const results = render(CoverageIcon, { props: { percentage: 65 } });
+ expect(results.container.innerHTML).toContain('');
+ results.component.$set({ percentage: 65 });
+ await tick();
+ expect(results.container.innerHTML).toContain('');
+ });
+
+ describe('when use color setting is false', () => {
+ it('renders a neutral coverage icon', () => {
+ coverageMediumMin.set('65');
+ coverageUseColor.set('false');
+ const { container } = render(CoverageIcon, { props: { percentage: 65 } });
+ expect(container.innerHTML).toContain('');
+ });
+ });
+ });
+
+ describe('when coverage is below warning coverage minimum', () => {
+ it('does not render a yellow coverage icon', () => {
+ coverageMediumMin.set('65');
+ const { container } = render(CoverageIcon, { props: { percentage: 60 } });
+ expect(container.innerHTML).not.toContain('');
+ });
+ });
+ });
+
+ describe('negative coverage', () => {
+ describe('when coverage is below warning coverage minimum', () => {
+ it('renders a red coverage icon', async () => {
+ coverageMediumMin.set('65');
+ const results = render(CoverageIcon, { props: { percentage: 40 } });
+ expect(results.container.innerHTML).toContain('');
+ results.component.$set({ percentage: 60 });
+ await tick();
+ expect(results.container.innerHTML).toContain('');
+ });
+
+ describe('when use color setting is false', () => {
+ it('renders a neutral coverage icon', () => {
+ coverageMediumMin.set('65');
+ coverageUseColor.set('false');
+ const { container } = render(CoverageIcon, { props: { percentage: 40 } });
+ expect(container.innerHTML).toContain('');
+ });
+ });
+ });
+ });
+});
diff --git a/web/app/src/lib/components/CoverageIcon.svelte b/web/app/src/lib/components/CoverageIcon.svelte
new file mode 100644
index 0000000..65c2592
--- /dev/null
+++ b/web/app/src/lib/components/CoverageIcon.svelte
@@ -0,0 +1,89 @@
+
+
+
+ {#if percentage >= 100}
+
+ {:else if percentage >= 87.5}
+
+ {:else if percentage >= 75}
+
+ {:else if percentage >= 62.5}
+
+ {:else if percentage >= 50}
+
+ {:else if percentage >= 37.5}
+
+ {:else if percentage >= 25}
+
+ {:else if percentage >= 12.5}
+
+ {:else}
+
+ {/if}
+
diff --git a/web/app/src/lib/components/CoverageReport.spec.ts b/web/app/src/lib/components/CoverageReport.spec.ts
new file mode 100644
index 0000000..ed9b94d
--- /dev/null
+++ b/web/app/src/lib/components/CoverageReport.spec.ts
@@ -0,0 +1,22 @@
+import { tick } from 'svelte';
+
+import { render, screen } from '@testing-library/svelte';
+import coverageReportResponse from '$lib/tests/apiResponses/coverageReport.json' assert { type: 'JSON' };
+import { describe, expect, it, vi } from 'vitest';
+import createFetchMock from 'vitest-fetch-mock';
+
+import CoverageReport from '$lib/components/CoverageReport.svelte';
+
+const fetchMocker = createFetchMock(vi);
+
+fetchMocker.enableMocks();
+
+afterEach(() => {
+ fetchMocker.resetMocks();
+});
+
+describe('CoverageReport', () => {
+ it('fetches coverage report from the API', () => {
+ fetchMocker.mockResponseOnce(JSON.stringify(coverageReportResponse));
+ });
+});
diff --git a/web/app/src/lib/components/CoverageReport.svelte b/web/app/src/lib/components/CoverageReport.svelte
new file mode 100644
index 0000000..ff62a5f
--- /dev/null
+++ b/web/app/src/lib/components/CoverageReport.svelte
@@ -0,0 +1,156 @@
+
+
+
+ {#if loading}
+
+
+
Loading coverage profile...
+
+ {:else if error}
+
+
+
Failed to load coverage report
+
+ Please check if {$metadata.appName} is still running and
+
try again .
+
+
+
error details
+ {#if showErrorDetails}
+
+
+ Error: {errorMsg}
+ Result UUID: {uuid}
+ Package: {pkg}
+
+
+ {/if}
+
+
+ {:else if profiles}
+
+
+ {#if profile}
+
+
{profile.filename}
+
{profile.lineCount}L
+
{formatBytes(profile.size)}
+
+
+ {profile.coverage}% ({report?.mode})
+
+
+ {/if}
+
+
+
+ {#if profile}
+
+ {/if}
+
+ {/if}
+
diff --git a/web/app/src/lib/components/OfflineAlert.spec.ts b/web/app/src/lib/components/OfflineAlert.spec.ts
new file mode 100644
index 0000000..88f7107
--- /dev/null
+++ b/web/app/src/lib/components/OfflineAlert.spec.ts
@@ -0,0 +1,65 @@
+import { tick } from 'svelte';
+
+import { render, screen } from '@testing-library/svelte';
+import { describe, expect, it } from 'vitest';
+
+import OfflineAlert from '$lib/components/OfflineAlert.svelte';
+
+import { state } from '$lib/stores/status';
+
+describe('OfflineAlert', () => {
+ describe('when application state is init', () => {
+ it('is does not render', () => {
+ state.set('init');
+ render(OfflineAlert, {});
+ expect(screen.queryByText('Backend API Unresponsive')).toBeNull();
+ });
+ });
+
+ describe('when application state is ready', () => {
+ it('is does not render', () => {
+ state.set('ready');
+ render(OfflineAlert, {});
+ expect(screen.queryByText('Backend API Unresponsive')).toBeNull();
+ });
+ });
+
+ describe('when application state is running', () => {
+ it('is does not render', () => {
+ state.set('running');
+ render(OfflineAlert, {});
+ expect(screen.queryByText('Backend API Unresponsive')).toBeNull();
+ });
+ });
+
+ describe('when application state is paused', () => {
+ it('is does not render', () => {
+ state.set('paused');
+ render(OfflineAlert, {});
+ expect(screen.queryByText('Backend API Unresponsive')).toBeNull();
+ });
+ });
+
+ describe('when application state is offline', () => {
+ it('renders', () => {
+ state.set('offline');
+ render(OfflineAlert, {});
+ expect(screen.getByText('Backend API Unresponsive')).toBeInTheDocument();
+ });
+
+ describe('when the application state is offline but changes to ready', () => {
+ it('does not render', async () => {
+ render(OfflineAlert, {});
+ state.set('offline');
+ await tick();
+
+ expect(screen.queryByText('Backend API Unresponsive')).toBeInTheDocument();
+
+ state.set('ready');
+ await tick();
+
+ expect(screen.queryByText('Backend API Unresponsive')).toBeNull();
+ });
+ });
+ });
+});
diff --git a/web/app/src/lib/components/OfflineAlert.svelte b/web/app/src/lib/components/OfflineAlert.svelte
new file mode 100644
index 0000000..6d85f30
--- /dev/null
+++ b/web/app/src/lib/components/OfflineAlert.svelte
@@ -0,0 +1,30 @@
+
+
+{#if stateVal === 'offline'}
+
+
+
Backend API Unresponsive
+
+{/if}
diff --git a/web/app/src/lib/components/ResultButton.spec.ts b/web/app/src/lib/components/ResultButton.spec.ts
new file mode 100644
index 0000000..0e32da8
--- /dev/null
+++ b/web/app/src/lib/components/ResultButton.spec.ts
@@ -0,0 +1,82 @@
+import { fireEvent, render } from '@testing-library/svelte';
+import { describe, expect, it, vi } from 'vitest';
+
+import ResultButton from '$lib/components/ResultButton.svelte';
+
+describe('ResultButton', () => {
+ describe('when given a passing result', () => {
+ it('renders as success variant', () => {
+ const { getByRole } = render(ResultButton, { props: { result: { uuid: 'deadbeef', pass: true } } });
+
+ expect(getByRole('button')).toHaveClass('variant-soft-success');
+ });
+
+ describe('when set as active', () => {
+ it('renders as bordered success variant', () => {
+ const { getByRole } = render(ResultButton, {
+ props: { active: true, result: { uuid: 'deadbeef', pass: true } }
+ });
+
+ expect(getByRole('button')).toHaveClass('variant-ghost-success');
+ });
+ });
+
+ describe('when clicked', () => {
+ it('emits a click event with result UUID', () => {
+ const handleClick = vi.fn();
+ const { component, getByRole } = render(ResultButton, {
+ props: { result: { uuid: 'deadbeef', pass: true } }
+ });
+ component.$on('click', handleClick);
+
+ const btn = getByRole('button');
+
+ fireEvent.click(btn);
+
+ const expectedEvent = new CustomEvent('click', {
+ detail: 'deadbeef'
+ });
+
+ expect(handleClick).toHaveBeenCalledWith(expectedEvent);
+ });
+ });
+ });
+
+ describe('when given a failing result', () => {
+ it('renders as error variant', () => {
+ const { getByRole } = render(ResultButton, { props: { result: { uuid: 'deadbeef', pass: false } } });
+
+ expect(getByRole('button')).toHaveClass('variant-soft-error');
+ });
+
+ describe('when set as active', () => {
+ it('renders as bordered error variant', () => {
+ const { getByRole } = render(ResultButton, {
+ props: { active: true, result: { uuid: 'deadbeef', pass: false } }
+ });
+
+ expect(getByRole('button')).toHaveClass('variant-ghost-error');
+ });
+ });
+
+ describe('when clicked', () => {
+ it('emits a click event with result UUID', () => {
+ const handleClick = vi.fn();
+ const { component, getByRole } = render(ResultButton, {
+ props: { result: { uuid: 'deadbeef', pass: false } }
+ });
+ component.$on('click', handleClick);
+
+ const btn = getByRole('button');
+
+ fireEvent.click(btn);
+
+ const expectedEvent = new CustomEvent('click', {
+ detail: 'deadbeef'
+ });
+
+ expect(handleClick).toHaveBeenCalledWith(expectedEvent);
+ });
+ });
+ });
+});
diff --git a/web/app/src/lib/components/ResultButton.svelte b/web/app/src/lib/components/ResultButton.svelte
new file mode 100644
index 0000000..45c2a55
--- /dev/null
+++ b/web/app/src/lib/components/ResultButton.svelte
@@ -0,0 +1,40 @@
+
+
+ dispatch('click', result.uuid)}
+>
+
+ {#if result.pass}
+
+ {:else if result.error}
+ {#if result.error == 'timeout'}
+
+ {:else}
+
+ {/if}
+ {:else}
+
+ {/if}
+
+
diff --git a/web/app/src/lib/components/ResultFilter.svelte b/web/app/src/lib/components/ResultFilter.svelte
new file mode 100644
index 0000000..4963c74
--- /dev/null
+++ b/web/app/src/lib/components/ResultFilter.svelte
@@ -0,0 +1,191 @@
+
+
+
+
+
+
+
+ filterPackageStatus.set(JSON.stringify(settings.packageStatus))}
+ />
+
+ filterTestStatus.set(JSON.stringify(settings.testStatus))}
+ />
+
+ filterCoverageLevel.set(JSON.stringify(settings.coverage))}
+ />
+
+
+
+
+
+
+
diff --git a/web/app/src/lib/components/ResultListBoxItem.spec.ts b/web/app/src/lib/components/ResultListBoxItem.spec.ts
new file mode 100644
index 0000000..5824c89
--- /dev/null
+++ b/web/app/src/lib/components/ResultListBoxItem.spec.ts
@@ -0,0 +1,84 @@
+import { render } from '@testing-library/svelte';
+import { describe, expect, it, vi } from 'vitest';
+
+import ResultListBoxItem from '$lib/components/ResultListBoxItem.svelte';
+
+const startTime = '2023-01-01T13:37:00.511Z';
+
+describe('ResultListBoxItem', () => {
+ describe('when given a passing result', () => {
+ it('renders as success variant', () => {
+ const { getByRole } = render(ResultListBoxItem, {
+ props: { result: { uuid: 'deadbeef', pass: true, start: startTime }, group: 'beefdead' }
+ });
+
+ expect(getByRole('option')).toHaveClass('variant-soft-success');
+ });
+
+ it('displays result relative time', () => {
+ vi.setSystemTime('2023-01-01T11:37:00.511Z');
+
+ const { getByRole } = render(ResultListBoxItem, {
+ props: { result: { uuid: 'deadbeef', pass: true, start: startTime }, group: 'beefdead' }
+ });
+
+ expect(getByRole('option')).toHaveTextContent('2 hours');
+ });
+
+ it('displays number of passing and total tests', () => {
+ const { getByRole } = render(ResultListBoxItem, {
+ props: { result: { uuid: 'deadbeef', pass: true, start: startTime, passed: 13, tests: 37 }, group: 'beefdead' }
+ });
+
+ expect(getByRole('option')).toHaveTextContent('13/37');
+ });
+
+ describe('when selected', () => {
+ it('renders as bordered success variant', () => {
+ const { getByRole } = render(ResultListBoxItem, {
+ props: { result: { uuid: 'deadbeef', pass: true, start: startTime }, group: 'deadbeef' }
+ });
+
+ expect(getByRole('option')).toHaveClass('variant-ghost-success');
+ });
+ });
+ });
+
+ describe('when given failed result', () => {
+ it('renders as error variant', () => {
+ const { getByRole } = render(ResultListBoxItem, {
+ props: { result: { uuid: 'deadbeef', pass: false, start: startTime }, group: 'beefdead' }
+ });
+
+ expect(getByRole('option')).toHaveClass('variant-soft-error');
+ });
+
+ it('displays result relative time', () => {
+ vi.setSystemTime('2023-01-01T11:37:00.511Z');
+
+ const { getByRole } = render(ResultListBoxItem, {
+ props: { result: { uuid: 'deadbeef', pass: false, start: startTime }, group: 'beefdead' }
+ });
+
+ expect(getByRole('option')).toHaveTextContent('2 hours');
+ });
+
+ it('displays number of failing and total tests', () => {
+ const { getByRole } = render(ResultListBoxItem, {
+ props: { result: { uuid: 'deadbeef', pass: false, start: startTime, failed: 13, tests: 37 }, group: 'beefdead' }
+ });
+
+ expect(getByRole('option')).toHaveTextContent('13/37');
+ });
+
+ describe('when selected', () => {
+ it('renders as bordered error variant', () => {
+ const { getByRole } = render(ResultListBoxItem, {
+ props: { result: { uuid: 'deadbeef', pass: false, start: startTime }, group: 'deadbeef' }
+ });
+
+ expect(getByRole('option')).toHaveClass('variant-ghost-error');
+ });
+ });
+ });
+});
diff --git a/web/app/src/lib/components/ResultListBoxItem.svelte b/web/app/src/lib/components/ResultListBoxItem.svelte
new file mode 100644
index 0000000..1d8a50f
--- /dev/null
+++ b/web/app/src/lib/components/ResultListBoxItem.svelte
@@ -0,0 +1,72 @@
+
+
+
+
+ {#if result.pass}
+
+ {:else if result.error}
+ {#if result.error == 'timeout'}
+
+ {:else}j
+
+ {/if}
+ {:else}
+
+ {/if}
+
+
+
+
+
+ {#if result.pass}
+ {result.passed}/{result.tests}
+ {:else if result.error}
+ 0/0
+ {:else}
+ {result.failed}/{result.tests}
+ {/if}
+
+
+ {relativeTime}
+
diff --git a/web/app/src/lib/components/ResultPackageTable.svelte b/web/app/src/lib/components/ResultPackageTable.svelte
new file mode 100644
index 0000000..44cb114
--- /dev/null
+++ b/web/app/src/lib/components/ResultPackageTable.svelte
@@ -0,0 +1,159 @@
+
+
+
+
+
+ {#if pkg.pass}
+
+ {:else}
+
+ {/if}
+
+
+
+ {pkg.name}
+ {#if !hasTests}has no tests{/if}
+
+
+
+ {#key pkg.coverage}
+ {#if hasTests}
+
+ {pluralize(pkg.tests.length, 'test', 'tests')}
+ {#if pkg.failed}{pkg.failed} failing {/if}
+ {#if pkg.skipped}{pkg.skipped} skipped {/if}
+
+ {#if coverageShowBadgesVal}
+
openCoverageReport(pkg.name)} class="btn btn-sm variant-ghost-surface" in:scale>
+
+
+ {pkg.coverage}%
+
+
+ {:else}
+
openCoverageReport(pkg.name)} class="btn btn-sm variant-ghost-surface"
+ >view code coverage
+ {/if}
+ {/if}
+ {/key}
+
+
+
+
+ {#if failingTests.length > 0 && filter.testStatus.includes('failing')}
+
+ {/if}
+
+ {#if passingTests.length > 0 && filter.testStatus.includes('passing')}
+
+ {/if}
+
+ {#if skippedTests.length > 0 && filter.testStatus.includes('skipped')}
+
+ {/if}
+
+
+ {#if hasTests}
+
+ {#key pkg.tests.length}
+
{pluralize(pkg.tests.length, 'test', 'tests')}
+ {/key}
+
+ {#key pkg.failed}
+
{pkg.failed} failing
+ {/key}
+
+ {#key pkg.skipped}
+
{pkg.skipped} skipped
+ {/key}
+
+ {#key pkg.elapsed}
+
{pkg.elapsed}s
+ {/key}
+
+ {/if}
+
diff --git a/web/app/src/lib/components/ResultPackageTableGroup.svelte b/web/app/src/lib/components/ResultPackageTableGroup.svelte
new file mode 100644
index 0000000..e5d5fdb
--- /dev/null
+++ b/web/app/src/lib/components/ResultPackageTableGroup.svelte
@@ -0,0 +1,89 @@
+
+
+
+
(collapsed = !collapsed)}
+ on:keydown
+ >
+
+ {header}
+
+
+
+
+
+
+ {#if showTests}
+
+ {#if preview.length > 0}
+ {#each preview as test}
+
+ {/each}
+
+
+
+ Load {pluralize(tests.length - preview.length, 'more test', 'more tests')}
+
+
+
+
+
+ {:else}
+ {#each tests as test (test.package + test.name)}
+
+ {/each}
+ {/if}
+
+ {/if}
+
diff --git a/web/app/src/lib/components/ResultPackageTableRow.svelte b/web/app/src/lib/components/ResultPackageTableRow.svelte
new file mode 100644
index 0000000..b9586d2
--- /dev/null
+++ b/web/app/src/lib/components/ResultPackageTableRow.svelte
@@ -0,0 +1,113 @@
+
+
+
+
(showDetails = !showDetails)}
+ on:keyup
+ >
+
+ {#if test.skip}
+
+ {:else if test.pass}
+
+ {:else}
+
+ {/if}
+
+
+
+ {@html safeHighlightedTestName}
+
+
+
+ {#key test.elapsed}
+
+ {test.elapsed}s
+
+ {/key}
+
+
+ {#if showDetails}
+ {@const time = new Date(test.time)}
+
+
+
+
+
+
+ at {format(time, 'HH:mm:ss')}
+ {#if !isToday(time)}
+ on {format(time, 'PP')}
+ {/if}
+ ({formatDistanceToNow(time)} ago)
+
+
+ {/if}
+
+
+
diff --git a/web/app/src/lib/components/ResultStats.svelte b/web/app/src/lib/components/ResultStats.svelte
new file mode 100644
index 0000000..2e8f194
--- /dev/null
+++ b/web/app/src/lib/components/ResultStats.svelte
@@ -0,0 +1,41 @@
+
+
+
+
+
+ {pluralize(result.packages?.length || 0, 'Package', 'Packages')}
+
+
+
+ {pluralize(result.tests, 'Test', 'Tests')}
+
+
0} class:!text-primary-600={result.failed == 0}>
+
+ {result.failed} Failing
+
+
0} class:!text-primary-600={result.skipped == 0}>
+
+ {result.skipped} Skipped
+
+
+
+ {formatDuration(result.duration)}
+
+
diff --git a/web/app/src/lib/components/Settings.svelte b/web/app/src/lib/components/Settings.svelte
new file mode 100644
index 0000000..e84482a
--- /dev/null
+++ b/web/app/src/lib/components/Settings.svelte
@@ -0,0 +1,341 @@
+
+
+
+
+
Settings
+
+
runAllOnInit.set(runAllOnInitVal ? 'true' : 'false')}
+ active="bg-success-500"
+ >
+ Run all tests on initial load and resume
+
+
+
+
+
Sound notifications
+ {#if !audio.hasAudio()}
+
Your browser does not support audio playback.
+ {:else}
+
+ Play sound notifications
+
+
+
audioNotifyOn.set(audioNotifyOnVal)}
+ disabled={!audioContextUnlockedVal}
+ >
+ on test results, errors, and warnings
+ on test passes, errors, and warnings
+ on test failures, errors, and warnings
+ on errors and warnings
+
+
+
+
+ Volume
+ {#if audioVolumeVal >= 0.6}
+
+ {:else if audioVolumeVal >= 0.25}
+
+ {:else if audioVolumeVal > 0}
+
+ {:else}
+
+ {/if}
+
+ audioVolume.set(audioVolumeVal.toString())}
+ disabled={!audioContextUnlockedVal}
+ />
+
+
+
+ Preview sounds:
+ audio.play('pass', true)}> Tests pass
+ audio.play('fail', true)}
+ > Tests fail / error
+ audio.play('warning', true)}> Warning
+
+ {/if}
+
+
+
+
Browser notifications
+
+
notificationsActive.set(notificationsActiveVal ? 'true' : 'false')}
+ active="bg-success-500"
+ >
+ Send browser notifications
+
+
+
notifyOn.set(notifyOnVal)}
+ disabled={!notificationsActiveVal}
+ >
+ on test results, errors, and warnings
+ on test passes, errors, and warnings
+ on test failures, errors, and warnings
+ on test errors and warnings
+
+
+
+
+
Code coverage
+
+
+ Coverage scale theme
+ coverageTheme.set(coverageThemeVal)}>
+ white to green, red for no coverage
+ white to blue, yellow for no coverage (color blind friendly)
+ white to berry red, blue for no coverage (heat map)
+
+
+
+
coverageShowBadges.set(coverageShowBadgesVal ? 'true' : 'false')}
+ active="bg-success-500"
+ >
+ Show coverage badges
+
+
+
+ coverageUseColor.set(coverageUseColorVal ? 'true' : 'false')}
+ active="bg-success-500"
+ disabled={!coverageShowBadgesVal}
+ >
+ Colorize coverage badges
+
+
+
+
+
+ Show high coverage badges
+
+ coverageHighMin.set(coverageHighMinVal.toString())}
+ >
+ {#each coverageSteps as step}
+ when coverage is at least {step}%
+ {/each}
+
+
+
+
+ {coverageHighMinVal}%
+
+
+
+
+
+
+
+
+ Show medium coverage badges
+
+ coverageMediumMin.set(coverageMediumMinVal.toString())}
+ >
+ {#each coverageSteps as step}
+ = coverageHighMinVal}>when coverage is at least {step}%
+ {/each}
+
+
+
+
+ {coverageMediumMinVal}%
+
+
+
+
+
+
+
+
+ Low coverage badges when coverage is below {coverageMediumMinVal}%
+
+
+
+
+ {coverageMediumMinVal - 5}%
+
+
+
+
+
diff --git a/web/app/src/lib/components/Sidebar.svelte b/web/app/src/lib/components/Sidebar.svelte
new file mode 100644
index 0000000..64c8fd7
--- /dev/null
+++ b/web/app/src/lib/components/Sidebar.svelte
@@ -0,0 +1,191 @@
+
+
+
+ {#if sidebarCollapsedVal}
+
+
+
+
+
+ {#if resultsVal && resultsVal.length > 0}
+
dispatch('clearResults')}
+ type="button"
+ class="btn btn-sm btn-icon variant-ghost-surface"
+ class:hover:variant-ghost-warning={!disableClearResults}
+ class:variant-ghost-error={stateVal === 'offline'}
+ disabled={disableClearResults || stateVal === 'offline'}
+ >
+
+
+
+ {#each resultsVal as result (result.uuid)}
+
+ (selectedResultUuid = e.detail)}
+ />
+
+ {/each}
+ {/if}
+
+ {:else}
+
+
Results
+ {#if resultsVal && resultsVal.length > 0}
+
+ dispatch('clearResults')}
+ type="button"
+ class="btn btn-sm btn-icon tooltip"
+ class:variant-ghost-surface={!disableClearResults}
+ class:hover:variant-ghost-warning={!disableClearResults}
+ class:variant-ghost-error={stateVal === 'offline'}
+ disabled={disableClearResults || stateVal === 'offline'}
+ data-tip="Clear all results"
+ >
+
+
+
+ {/if}
+
+
+
+
+
+ {#if resultsVal && resultsVal.length > 0}
+
+ {#each resultsVal as result (result.uuid)}
+
+ {/each}
+
+ {:else}
+
+
+
+
+
+
+
+
+ Tip:
+
+ need more screen space? Collapse this sidebar by pressing the
+ « button at the top.
+
+
+ {/if}
+ {/if}
+
diff --git a/web/app/src/lib/components/SvgIcon.svelte b/web/app/src/lib/components/SvgIcon.svelte
new file mode 100644
index 0000000..67745ea
--- /dev/null
+++ b/web/app/src/lib/components/SvgIcon.svelte
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/web/app/src/lib/conditionalTransitions.spec.ts b/web/app/src/lib/conditionalTransitions.spec.ts
new file mode 100644
index 0000000..87c37db
--- /dev/null
+++ b/web/app/src/lib/conditionalTransitions.spec.ts
@@ -0,0 +1,116 @@
+import { get } from 'svelte/store';
+
+import {
+ conditionalScale,
+ transitionsOff,
+ transitionsOffForDuration,
+ transitionsOn
+} from '$lib/conditionalTransitions';
+import { afterEach, describe, expect, it, type Mock, vi } from 'vitest';
+
+import { transitionsActive } from '$lib/stores/transitions';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function mockLockWith(returnVal: Lock | null): Mock {
+ const mockLockRequest = vi.fn((_name: string, _options: LockOptions, callback: (lock: Lock | null) => void) => {
+ callback(returnVal);
+ });
+
+ vi.stubGlobal('navigator', {
+ locks: {
+ request: mockLockRequest
+ }
+ });
+
+ return mockLockRequest;
+}
+
+beforeEach(() => {
+ vi.useRealTimers();
+});
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('transitionsOff', () => {
+ it('acquires lock and sets transitionsActive store to false', async () => {
+ const mock = mockLockWith({} as Lock);
+ transitionsActive.set(true);
+ expect(await transitionsOff()).toBe(true);
+ expect(get(transitionsActive)).toBe(false);
+ expect(mock).toHaveBeenCalledWith('stores.transitionsActive', { ifAvailable: true }, expect.any(Function));
+ });
+
+ describe('when lock is not available', () => {
+ it('does not set transitionsActive store to false', async () => {
+ const mock = mockLockWith(null);
+ transitionsActive.set(true);
+ expect(await transitionsOff()).toBe(false);
+ expect(get(transitionsActive)).toBe(true);
+ expect(mock).toHaveBeenCalledWith('stores.transitionsActive', { ifAvailable: true }, expect.any(Function));
+ });
+ });
+});
+
+describe('transitionsOn', () => {
+ it('acquires lock and sets transitionsActive store to true', async () => {
+ const mock = mockLockWith({} as Lock);
+ transitionsActive.set(false);
+ expect(await transitionsOn()).toBe(true);
+ expect(get(transitionsActive)).toBe(true);
+ expect(mock).toHaveBeenCalledWith('stores.transitionsActive', { ifAvailable: true }, expect.any(Function));
+ });
+
+ describe('when lock is not available', () => {
+ it('does not set transitionsActive store to true', async () => {
+ const mock = mockLockWith(null);
+ transitionsActive.set(false);
+ expect(await transitionsOn()).toBe(false);
+ expect(get(transitionsActive)).toBe(false);
+ expect(mock).toHaveBeenCalledWith('stores.transitionsActive', { ifAvailable: true }, expect.any(Function));
+ });
+ });
+});
+
+describe('transitionsOffForDuration', () => {
+ it('acquires lock and sets transitionsActive store to false for duration', async () => {
+ vi.useFakeTimers();
+ const mock = mockLockWith({} as Lock);
+ transitionsActive.set(true);
+ expect(transitionsOffForDuration(1000)).resolves.toBe(true);
+ await new Promise(process.nextTick);
+ expect(get(transitionsActive)).toBe(false);
+ vi.advanceTimersByTime(1000);
+ await new Promise(process.nextTick);
+ expect(mock).toHaveBeenCalledWith('stores.transitionsActive', { ifAvailable: true }, expect.any(Function));
+ });
+
+ describe('when lock is not available', () => {
+ it('does not set transitionsActive store to true', async () => {
+ const mock = mockLockWith(null);
+ transitionsActive.set(true);
+ expect(transitionsOffForDuration(1000)).resolves.toBe(false);
+ expect(get(transitionsActive)).toBe(true);
+ expect(mock).toHaveBeenCalledWith('stores.transitionsActive', { ifAvailable: true }, expect.any(Function));
+ });
+ });
+});
+
+describe('conditionalScale', () => {
+ describe('when transitionsActive is true', () => {
+ it('returns a scale transition', () => {
+ transitionsActive.set(true);
+ const transition = conditionalScale(document.createElement('div'));
+ expect(transition.css).toBeTypeOf('function');
+ });
+ });
+
+ describe('when transitionsActive is false', () => {
+ it('returns empty object', () => {
+ transitionsActive.set(false);
+ const transition = conditionalScale(document.createElement('div'));
+ expect(transition.css).toBeUndefined;
+ });
+ });
+});
diff --git a/web/app/src/lib/conditionalTransitions.ts b/web/app/src/lib/conditionalTransitions.ts
new file mode 100644
index 0000000..a41bc63
--- /dev/null
+++ b/web/app/src/lib/conditionalTransitions.ts
@@ -0,0 +1,97 @@
+import type { ScaleParams, TransitionConfig } from 'svelte/transition';
+import { scale } from 'svelte/transition';
+
+import { transitionsActive } from '$lib/stores/transitions';
+
+import { sleep } from '$lib/common/utils';
+
+const lockName = 'stores.transitionsActive';
+
+let transitionsActiveVal = true;
+
+transitionsActive.subscribe((newSetting) => {
+ transitionsActiveVal = newSetting;
+});
+
+/**
+ * Turn transitions off.
+ *
+ * Instructs aware components to not use transitions.
+ * until enabled again with {@line transitionsOn()} or
+ * {@link transitionsOffForDuration}.
+ *
+ * @returns - Promise that resolves to true if lock was acquired and
+ * transitions were turned off.
+ */
+export function transitionsOff(): Promise {
+ return new Promise((resolve) => {
+ navigator.locks.request(lockName, { ifAvailable: true }, (lock) => {
+ if (lock) {
+ transitionsActive.set(false);
+ return resolve(true);
+ }
+ resolve(false);
+ });
+ });
+}
+
+/**
+ * Turn transitions on.
+ *
+ * Instructs aware components to use transitions.
+ *
+ * @returns - Promise that resolves to true if lock was acquired and
+ * transitions were turned on.
+ */
+export function transitionsOn(): Promise {
+ return new Promise((resolve) => {
+ navigator.locks.request(lockName, { ifAvailable: true }, (lock) => {
+ if (lock) {
+ transitionsActive.set(true);
+ return resolve(true);
+ }
+ resolve(false);
+ });
+ });
+}
+
+/**
+ * Turn transitions off for a duration.
+ *
+ * Instructs aware components to not use transitions for the given duration,
+ * which is useful for situations where many transitions would otherwise happen
+ * at once.
+ *
+ * Calling this function will lock the transitions off until the duration has
+ * passed, to prevent another call to {@link transitionsOn} or
+ * {@link transitionsOffForDuration} with a shorter duration from prematurely
+ * re-enabling transitions.
+ *
+ * @param duration - Duration in milliseconds to turn transitions off for.
+ *
+ * @returns - Promise that resolves to true if lock was acquired and
+ * transitions were turned off for duration.
+ */
+export function transitionsOffForDuration(duration: number): Promise {
+ return new Promise((resolve) => {
+ navigator.locks.request(lockName, { ifAvailable: true }, async (lock) => {
+ if (lock) {
+ transitionsActive.set(false);
+ await sleep(duration);
+ transitionsActive.set(true);
+ return resolve(true);
+ }
+ resolve(false);
+ });
+ });
+}
+
+/**
+ * Conditional scale transition.
+ *
+ * Returns a scale transition if transitions are enabled, otherwise returns
+ * an empty object.
+ */
+export function conditionalScale(node: Element, params?: ScaleParams | undefined): TransitionConfig {
+ return transitionsActiveVal ? scale(node, params) : {};
+}
diff --git a/web/app/src/lib/coverage.spec.ts b/web/app/src/lib/coverage.spec.ts
new file mode 100644
index 0000000..2ab6950
--- /dev/null
+++ b/web/app/src/lib/coverage.spec.ts
@@ -0,0 +1,115 @@
+import { renderProfile } from '$lib/coverage';
+import { describe, expect, it } from 'vitest';
+
+import type { Profile } from '$lib/common/types';
+
+const subject = `
+package shapes
+
+// Rect represents a rectangle shape.
+type Rect struct {
+ width, height int
+}
+
+// Area of the rectangle.
+func (r *Rect) Area() int {
+ return r.width * r.height
+}
+
+// Perimeter of the rectangle.
+//
+// a bit of HTML for some reason.
+func (r Rect) Perimeter() int {
+ return 2*r.width + 2*r.height
+}
+
+// Angles returns number of angles in a rectangle.
+func (r Rect) Angles() int {
+ return 4;
+}
+`;
+
+const expected = `
+package shapes
+
+// Rect represents a rectangle shape.
+type Rect struct {
+ width, height int
+}
+
+// Area of the rectangle.
+func (r *Rect) Area() int {
+ return r.width * r.height
+}
+
+// Perimeter of the rectangle.
+//
+// a bit of <strong>HTML</strong> for some reason.
+func (r Rect) Perimeter() int {
+ return 2*r.width + 2*r.height
+}
+
+// Angles returns number of angles in a rectangle.
+func (r Rect) Angles() int {
+ return 4;
+}
+`;
+
+describe('renderProfile', () => {
+ it('renders file contents with coverage blocks', () => {
+ const profile: Profile = {
+ filename: 'shapes/rectangle.go',
+ package: 'shapes',
+ path: '~/src/project/shapes/rectangle.go',
+ content: window.btoa(subject),
+ size: subject.length,
+ coverage: 100,
+ boundaries: [
+ {
+ offset: 151, // Opening `{` of Area method.
+ start: true,
+ count: 2,
+ norm: 0.3,
+ index: 0
+ },
+ {
+ offset: 184, // Closing `}` of Area method.
+ start: false,
+ count: 0,
+ norm: 0,
+ index: 1
+ },
+ {
+ offset: 301, // Opening `{` of Perimiter method.
+ start: true,
+ count: 1,
+ norm: 0.2,
+ index: 2
+ },
+ {
+ offset: 338, // Closing `}` of Perimeter method.
+ start: false,
+ count: 0,
+ norm: 0,
+ index: 3
+ },
+ {
+ offset: 418, // Opening `{` of Angles method.
+ start: true,
+ count: 0,
+ norm: 0,
+ index: 4
+ },
+ {
+ offset: 435, // Closing `}` of Angles method.
+ start: false,
+ count: 0,
+ norm: 0,
+ index: 5
+ }
+ ]
+ };
+
+ expect(renderProfile(profile)).toEqual(expected);
+ });
+});
diff --git a/web/app/src/lib/coverage.ts b/web/app/src/lib/coverage.ts
new file mode 100644
index 0000000..c6593b1
--- /dev/null
+++ b/web/app/src/lib/coverage.ts
@@ -0,0 +1,73 @@
+import { escapeHtml } from '$lib/common/utils';
+import type { Profile, ProfileBoundary } from '$lib/common/types';
+
+/**
+ * Render a code coverage profile for a file as safe HTML.
+ *
+ * Renders the file's content with code coverage blocks wrapped in `span`
+ * tags like so:
+ *
+ * ...
+ *
+ * The number in the `gokiburi-cov-*` class will be between 0 and 10 and describes
+ * the level of relative coverage, where 0 means no coverage and 10 means the
+ * highest coverage. The level will also be available in the `data-cov-level`
+ * attribute.
+ *
+ * The value in the `data-cov-count` is the number of times the wrapped code
+ * block was invoked in tests.
+ *
+ * All special HTML characters will be escaped, except for the safe code block
+ * coverage tags.
+ */
+export function renderProfile(profile: Profile): string {
+ // Decode Base64 encoded file content from profile.
+ const content = window.atob(profile.content);
+
+ // Sort the profile boundaries by index in ascending order.
+ const sortedBoundaries = profile.boundaries.sort((a: ProfileBoundary, b: ProfileBoundary): number => {
+ return a.index - b.index;
+ });
+
+ // Initialize result array.
+ const result: string[] = [];
+ let offset = 0;
+
+ // Iterate through boundaries and create result array.
+ for (const boundary of sortedBoundaries) {
+ // Add escaped content between current offset and boundary offset.
+ result.push(escapeHtml(content.slice(offset, boundary.offset)));
+
+ if (boundary.start) {
+ const level = normLevel(boundary.count, boundary.norm);
+ // Add opening span tag.
+ result.push(
+ ``
+ );
+ } else {
+ // Add closing span tag.
+ result.push(' ');
+ }
+
+ // Update the offset.
+ offset = boundary.offset;
+ }
+
+ // Add remaining content.
+ result.push(escapeHtml(content.slice(offset)));
+
+ // Join the result array into a single string.
+ return result.join('');
+}
+
+/**
+ * Converts a profile block norm float value to number between 0 - 10 as a string.
+ */
+function normLevel(count: number, norm: number): string {
+ if (count === 0) {
+ return '0';
+ }
+
+ const level = Math.floor(norm * 9) + 1;
+ return level > 10 ? '10' : level.toString();
+}
diff --git a/web/app/src/lib/filtering.ts b/web/app/src/lib/filtering.ts
new file mode 100644
index 0000000..f85882c
--- /dev/null
+++ b/web/app/src/lib/filtering.ts
@@ -0,0 +1,121 @@
+import { get } from 'svelte/store';
+
+import { coverageHighMin, coverageMediumMin } from './stores/settings';
+
+import type { FilterSettings, Package, Test } from '$lib/common/types';
+
+const coverageHighMinVal = parseFloat(get(coverageHighMin));
+const coverageMediumMinVal = parseFloat(get(coverageMediumMin));
+
+/**
+ * Default filter settings.
+ */
+export const defaultFilterSettings: FilterSettings = {
+ search: '',
+ packageStatus: ['failing', 'passing'],
+ testStatus: ['failing', 'passing', 'skipped'],
+ coverage: ['high', 'medium', 'low', 'none']
+};
+
+/**
+ * Filter packages according to the provided settings.
+ *
+ * @remarks If a search term is provided, all other settings are ignored.
+ *
+ * @param pkgs - Packages to filter.
+ * @param settings - Settings to filter by.
+ *
+ * @returns Filtered packages.
+ */
+export function filterPackages(pkgs: Package[], settings: FilterSettings): Package[] {
+ if (pkgs === undefined) {
+ return [];
+ }
+
+ if (!settings) {
+ return pkgs;
+ }
+
+ if (settings.search !== '') {
+ const search = settings.search.toLowerCase();
+
+ return pkgs.filter((pkg: Package): boolean => {
+ return pkg.tests.some((test: Test): boolean => test.name.toLowerCase().includes(search));
+ });
+ }
+
+ return pkgs.filter((pkg: Package): boolean => {
+ return Object.keys(settings).every((key: string): boolean => {
+ switch (key) {
+ case 'search':
+ return (
+ pkg.name.toLowerCase().includes(settings.search.toLowerCase()) ||
+ pkg.tests.some((test: Test): boolean => test.name.toLowerCase().includes(settings.search.toLowerCase()))
+ );
+ case 'packageStatus':
+ if (pkg.tests.length === 0) {
+ return settings.packageStatus.includes('noTests');
+ }
+
+ return settings.packageStatus.includes(pkg.pass ? 'passing' : 'failing');
+ case 'coverage':
+ if (pkg.coverage >= coverageHighMinVal) {
+ return settings.coverage.includes('high');
+ } else if (pkg.coverage >= coverageMediumMinVal) {
+ return settings.coverage.includes('medium');
+ } else if (pkg.coverage > 0) {
+ return settings.coverage.includes('low');
+ } else {
+ return settings.coverage.includes('none');
+ }
+ default:
+ return true;
+ }
+ });
+ });
+}
+
+/**
+ * Filter tests according to the provided settings.
+ *
+ * @remarks If a search term is provided, all other settings are ignored.
+ *
+ * @param tests - Tests to filter.
+ * @param settings - Settings to filter by.
+ *
+ * @returns Filtered tests.
+ */
+export function filterTests(tests: Test[], settings: FilterSettings): Test[] {
+ if (tests === undefined) {
+ return [];
+ }
+
+ if (!settings) {
+ return tests;
+ }
+
+ if (settings.search !== '') {
+ const search = settings.search.toLowerCase();
+
+ return tests.filter((test: Test): boolean => {
+ return test.name.toLowerCase().includes(search);
+ });
+ }
+
+ return tests.filter((test: Test): boolean => {
+ return Object.keys(settings).every((key: string): boolean => {
+ switch (key) {
+ case 'search':
+ return test.name.toLowerCase().includes(settings[key].toLowerCase());
+ case 'testStatus':
+ if (test.skip) {
+ return settings.testStatus.includes('skipped');
+ }
+
+ return settings.testStatus.includes(test.pass ? 'passing' : 'failing');
+ default:
+ return true;
+ }
+ });
+ });
+}
diff --git a/web/app/src/lib/services/api.spec.ts b/web/app/src/lib/services/api.spec.ts
new file mode 100644
index 0000000..12e0e94
--- /dev/null
+++ b/web/app/src/lib/services/api.spec.ts
@@ -0,0 +1,63 @@
+import coverageReportResponse from '$lib/tests/apiResponses/coverageReport.json';
+import WS from 'jest-websocket-mock';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import createFetchMock from 'vitest-fetch-mock';
+
+import ApiService from '$lib/services/api';
+
+const fetchMocker = createFetchMock(vi);
+
+fetchMocker.enableMocks();
+
+afterEach(() => {
+ fetchMocker.resetMocks();
+ WS.clean();
+ vi.clearAllMocks();
+});
+
+describe('ApiService', () => {
+ describe('results', () => {
+ it('fetches test results from API', async () => {
+ fetchMocker.mockResponseOnce(JSON.stringify([{ uuid: 'deadbeef', pass: true }]));
+
+ const results = await ApiService.results();
+
+ expect(results).not.toBeNull;
+ expect(results?.length).toBe(1);
+
+ const result = results?.[0];
+
+ expect(result?.uuid).toBe('deadbeef');
+ expect(result?.pass).toBe(true);
+
+ const req = fetchMocker.requests()[0];
+ expect(req.url).toBe('/api/results');
+ expect(req.method).toBe('GET');
+ });
+
+ describe('when there are no test results', () => {
+ it('returns null', async () => {
+ fetchMocker.mockResponseOnce(JSON.stringify(null));
+
+ const results = await ApiService.results();
+
+ expect(results).toBeNull;
+ });
+ });
+ });
+
+ describe('report', () => {
+ it('fetches coverage report from API', async () => {
+ fetchMocker.mockResponseOnce(JSON.stringify(coverageReportResponse));
+
+ const report = await ApiService.report('deadbeef');
+
+ expect(report.profiles.length).toBe(1);
+ expect(report.profiles[0].path).toBe('/Users/johndoe/src/github.com/johndoe/project/pkg/warpcore/warpcore.go');
+
+ const req = fetchMocker.requests()[0];
+ expect(req.url).toBe('/api/results/deadbeef/report');
+ expect(req.method).toBe('GET');
+ });
+ });
+});
diff --git a/web/app/src/lib/services/api.ts b/web/app/src/lib/services/api.ts
new file mode 100644
index 0000000..3134664
--- /dev/null
+++ b/web/app/src/lib/services/api.ts
@@ -0,0 +1,227 @@
+import { toastStore } from '../../skeleton';
+import type { ToastSettings } from '../../skeleton';
+
+import { state } from '$lib/stores/status';
+
+import type { ErrorType, Report, Result } from '$lib/common/types';
+
+/**
+ * Custom error for unexpected API responses.
+ */
+export class StatusError extends Error {
+ status: number;
+ statusText: string;
+
+ /**
+ * Constructs a new StatusError.
+ *
+ * @param status - HTTP status code from server
+ * @param statusText HTTP status text from server
+ */
+ constructor(status: number, statusText: string) {
+ super(`${status} ${statusText}`);
+ this.name = 'StatusError';
+ this.status = status;
+ this.statusText = statusText;
+ }
+}
+
+/**
+ * Custom error for invalid data responses from the API.
+ *
+ * API responds with `422 Unprocessable Entity` and a JSON body containing
+ * validation messages if request data validation fails.
+ */
+export class UnprocessableEntityError extends StatusError {
+ messages: Record;
+
+ /**
+ * Constructs a new UnprocessableEntityError.
+ *
+ * @param messages - the JSON response body
+ */
+ constructor(messages: Record) {
+ super(422, 'Unprocessable Entity');
+ this.name = 'UnprocessableEntityError';
+ this.messages = messages;
+ }
+}
+
+/**
+ * Custom error for API connection errors.
+ */
+export class ConnectionError extends Error {
+ constructor(message: string | undefined) {
+ super(message);
+ this.name = 'ConnectionError';
+ }
+}
+
+export default {
+ /**
+ * Fetches test run results from the API.
+ *
+ * Performs a GET request to `/api/results`.
+ *
+ * @returns A list of test run results.
+ *
+ * @throws {@link StatusError}
+ * Thrown if API responds with a non-2xx status.
+ *
+ * @throws {@link ConnectionError}
+ * Thrown if API is unresponsive.
+ */
+ async results(): Promise {
+ const resp = await performRequest(new Request('/api/results'));
+
+ return resp.json().catch(() => ({}));
+ },
+
+ /**
+ * Fetches a coverage report for a test run from the API.
+ *
+ * Performs a GET request to `/api/results/:uuid/report`.
+ *
+ * @returns A coverage report for all files in a test.
+ *
+ * @throws {@link StatusError}
+ * Thrown if API responds with a non-2xx status.
+ *
+ * @throws {@link ConnectionError}
+ * Thrown if API is unresponsive.
+ */
+ async report(uuid: string): Promise {
+ const resp = await performRequest(new Request(`/api/results/${uuid}/report`));
+
+ return resp.json().catch(() => ({}));
+ },
+
+ /**
+ * Instruct the backend to pause automatic test runs.
+ *
+ * Performs a PUT request to `/api/state/pause`.
+ *
+ * @throws {@link StatusError}
+ * Thrown if API responds with a non-2xx status.
+ *
+ * @throws {@link ConnectionError}
+ * Thrown if API is unresponsive.
+ */
+ async pause() {
+ const req = new Request('/api/state/pause', {
+ method: 'PUT'
+ });
+
+ await performRequest(req);
+ },
+
+ /**
+ * Instruct the backend to resume automatic test runs.
+ *
+ * Performs a PUT request to `/api/state/resume`.
+ *
+ * @throws {@link StatusError}
+ * Thrown if API responds with a non-2xx status.
+ *
+ * @throws {@link ConnectionError}
+ * Thrown if API is unresponsive.
+ */
+ async resume() {
+ const req = new Request('/api/state/resume', {
+ method: 'PUT'
+ });
+
+ await performRequest(req);
+ },
+
+ /**
+ * Instruct the backend to run tests for a Go package.
+ *
+ * Performs a POST request to `/api/run`.
+ *
+ * @param pkg - Go package target (e.g. `./...`)
+ *
+ * @throws {@link UnprocessableEntityError}
+ * Thrown if the Go package target is invalid.
+ *
+ * @throws {@link StatusError}
+ * Thrown if API responds with a non-2xx status.
+ *
+ * @throws {@link ConnectionError}
+ * Thrown if API is unresponsive.
+ */
+ async run(pkg: string) {
+ const req = new Request('/api/run', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ package: pkg })
+ });
+
+ await performRequest(req);
+ },
+
+ /**
+ * Clear all test run data on the server.
+ *
+ * @throws {@link StatusError}
+ * Thrown if API responds with a non-2xx status.
+ *
+ * @throws {@link ConnectionError}
+ * Thrown if API is unresponsive.
+ */
+ async clearResults() {
+ const req = new Request('/api/results', {
+ method: 'DELETE'
+ });
+
+ await performRequest(req);
+ }
+};
+
+export async function apiErrorHandler(fn: () => Promise): Promise<[ErrorType, T | undefined]> {
+ try {
+ const result = await fn();
+ return [null, result];
+ } catch (error) {
+ if (error instanceof ConnectionError) {
+ state.set('offline');
+ } else if (error instanceof StatusError) {
+ toastStore.trigger({
+ message: `Received bad response from API: ${error.message}`,
+ background: 'variant-soft-error'
+ });
+ } else {
+ toastStore.trigger({
+ message: `Unexpected error when fetching data from API: ${error}`,
+ background: 'variant-soft-error'
+ });
+ }
+
+ return [error, undefined];
+ }
+}
+
+const performRequest = async (req: Request): Promise => {
+ let resp: Response;
+
+ try {
+ resp = await fetch(req);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ throw new ConnectionError(message);
+ }
+
+ if (!resp.ok) {
+ if (resp.status == 422) {
+ throw new UnprocessableEntityError(await resp.json().catch(() => ({})));
+ }
+
+ throw new StatusError(resp.status, resp.statusText);
+ }
+
+ return new Promise((resolve) => {
+ resolve(resp);
+ });
+};
diff --git a/web/app/src/lib/services/audio.ts b/web/app/src/lib/services/audio.ts
new file mode 100644
index 0000000..792c806
--- /dev/null
+++ b/web/app/src/lib/services/audio.ts
@@ -0,0 +1,226 @@
+import { get } from 'svelte/store';
+
+import { type ToastSettings, toastStore } from '../../skeleton';
+import { Howl, Howler } from 'howler';
+
+import { audioContextUnlocked, audioNotifyOn, audioVolume } from '$lib/stores/settings';
+
+import type { AudioNotifyOnSetting } from '$lib/common/types';
+
+Howler.autoUnlock = false;
+Howler.autoSuspend = false;
+
+/**
+ * The type of sound to play.
+ */
+type SoundType = 'pass' | 'fail' | 'error' | 'warning';
+
+const passSound = new Howl({
+ src: ['/sounds/pass.webm'],
+ mute: true
+})
+ .on('loaderror', (_: number, errorCode: unknown) => {
+ toastStore.trigger({
+ message: 'Failed to load pass notification sound: ' + translateMediaErrorCode(errorCode),
+ background: 'variant-soft-error'
+ });
+ })
+ .on('playerror', (_: number, errorCode: unknown) => {
+ toastStore.trigger({
+ message: 'Failed to play pass notification sound: ' + translateMediaErrorCode(errorCode),
+ background: 'variant-soft-error'
+ });
+ });
+
+const failSound = new Howl({
+ src: ['/sounds/fail.webm'],
+ mute: true
+})
+ .on('loaderror', (_: number, errorCode: unknown) => {
+ toastStore.trigger({
+ message: 'Failed to load fail notification sound: ' + translateMediaErrorCode(errorCode),
+ background: 'variant-soft-error'
+ });
+ })
+ .on('playerror', (_: number, errorCode: unknown) => {
+ toastStore.trigger({
+ message: 'Failed to play fail notification sound: ' + translateMediaErrorCode(errorCode),
+ background: 'variant-soft-error'
+ });
+ });
+
+const warningSound = new Howl({
+ src: ['/sounds/warning.webm'],
+ mute: true
+})
+ .on('loaderror', (_: number, errorCode: unknown) => {
+ toastStore.trigger({
+ message: 'Failed to load warning notification sound: ' + translateMediaErrorCode(errorCode),
+ background: 'variant-soft-error'
+ });
+ })
+ .on('playerror', (_: number, errorCode: unknown) => {
+ toastStore.trigger({
+ message: 'Failed to play warning notification sound: ' + translateMediaErrorCode(errorCode),
+ background: 'variant-soft-error'
+ });
+ });
+
+let audioNotifyOnVal: AudioNotifyOnSetting = 'all';
+let audioVolumeVal = 1;
+
+audioNotifyOn.subscribe((newSetting) => {
+ audioNotifyOnVal = newSetting;
+});
+
+audioVolume.subscribe((newSetting) => {
+ const f = parseFloat(newSetting);
+
+ if (f >= 0 && f <= 1) {
+ audioVolumeVal = f;
+ } else {
+ audioVolumeVal = 1;
+ }
+
+ Howler.volume(audioVolumeVal);
+});
+
+export default {
+ /**
+ * Determine if a sound is currently playing.
+ *
+ * @returns true if a sound is playing, and false otherwise.
+ */
+ playing(): boolean {
+ return passSound.playing() || failSound.playing();
+ },
+
+ /**
+ * Activate audio.
+ *
+ * Because of browser security restrictions, audio must be activated before
+ * it can be played. This function will load the audio files and unlock the
+ * audio context.
+ *
+ * The function must be called from a user interaction event handler, such as
+ * a click or keypress event.
+ */
+ async activate() {
+ await Howler.ctx.resume();
+
+ if (passSound.state() === 'unloaded') {
+ passSound.load();
+ }
+ if (failSound.state() === 'unloaded') {
+ failSound.load();
+ }
+ if (warningSound.state() === 'unloaded') {
+ warningSound.load();
+ }
+
+ passSound.mute(false);
+ failSound.mute(false);
+ warningSound.mute(false);
+
+ audioContextUnlocked.set(true);
+ },
+
+ /**
+ * Deactivate audio.
+ *
+ * This function will unload the audio files and suspend the audio context.
+ *
+ * The {@link activate} function must be called again before audio can be
+ * played.
+ */
+ async deactivate() {
+ await Howler.ctx.suspend();
+ Howler.unload();
+ audioContextUnlocked.set(false);
+ },
+
+ /**
+ * Play a sound.
+ *
+ * By default, plays a sound if the sound type is enabled, and if a sound is
+ * not already playing.
+ *
+ * @param type - Type of sound to play.
+ * @param force - If true, the sound will play regardless of current settings.
+ *
+ * @returns true if the sound was played, and false otherwise.
+ */
+ play(type: SoundType, force = false): boolean {
+ if (!this.hasAudio() || get(audioContextUnlocked) === false || !this.shouldPlay(type, force)) {
+ return false;
+ }
+
+ switch (type) {
+ case 'pass':
+ passSound.play();
+ break;
+ case 'warning':
+ warningSound.play();
+ break;
+ default:
+ failSound.play();
+ break;
+ }
+
+ return true;
+ },
+
+ /**
+ * Determine if a sound should play.
+ *
+ * @param type - Type of sound to play.
+ * @param force - If true, the sound will play regardless of current settings.
+ *
+ * @returns true if the sound should play, and false otherwise.
+ */
+ shouldPlay(type: SoundType, force = false): boolean {
+ if (force) {
+ return true;
+ }
+
+ if (audioNotifyOnVal === 'all' || type === 'error' || type === 'warning') {
+ return true;
+ }
+
+ if (this.playing()) {
+ return false;
+ }
+
+ return audioNotifyOnVal === type;
+ },
+
+ /**
+ * Determine if the browser supports audio.
+ *
+ * @returns true if the browser supports audio, and false otherwise.
+ */
+ hasAudio(): boolean {
+ return !Howler.noAudio;
+ }
+};
+
+function translateMediaErrorCode(errorCode: unknown): string {
+ // If error code is a string, we assume it's a readable error message from
+ // Howler.
+ if (typeof errorCode === 'string') {
+ return errorCode;
+ }
+
+ switch (errorCode) {
+ case MediaError.MEDIA_ERR_ABORTED:
+ return 'audio playback was aborted.';
+ case MediaError.MEDIA_ERR_NETWORK:
+ return 'network error when fetching sound file.';
+ case MediaError.MEDIA_ERR_DECODE:
+ return 'sound file could not be decoded.';
+ case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
+ return 'sound file format is not supported by your browser.';
+ default:
+ return 'unknown error.';
+ }
+}
diff --git a/web/app/src/lib/services/notifications.ts b/web/app/src/lib/services/notifications.ts
new file mode 100644
index 0000000..01ce647
--- /dev/null
+++ b/web/app/src/lib/services/notifications.ts
@@ -0,0 +1,138 @@
+import { get } from 'svelte/store';
+
+import { toastStore } from '../../skeleton';
+
+import { metadata } from '$lib/stores/metadata';
+import { notificationsActive, notifyOn } from '$lib/stores/settings';
+
+import AudioService from '$lib/services/audio';
+
+import type { NotificationType } from '$lib/common/types';
+
+const appName = get(metadata).appName;
+const alwaysNotifyOn: NotificationType[] = ['error', 'warning', 'info'];
+
+const toastVariantMap: Record = {
+ pass: 'variant-soft-success',
+ fail: 'variant-soft-error',
+ error: 'variant-soft-error',
+ warning: 'variant-soft-warning',
+ info: 'variant-soft-primary'
+};
+
+let lastTag: string;
+
+export default {
+ /**
+ * Check if browser notification permission has been granted.
+ *
+ * @returns boolean true if granted, and false otherwise.
+ */
+ browserNotificationsGranted(): boolean {
+ if (typeof Notification === 'undefined') {
+ return false;
+ }
+
+ return Notification.permission === 'granted';
+ },
+
+ /**
+ * Request browser notification permission from the user.
+ *
+ * If permission has already been granted, a call to this method is a no-op.
+ *
+ * @returns boolean true if user granted permission, and false otherwise.
+ *
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Notification/requestPermission}
+ */
+ async requestBrowserNotifications(): Promise {
+ return new Promise((resolve, reject) => {
+ if (this.browserNotificationsGranted()) {
+ return resolve(true);
+ }
+
+ Notification.requestPermission()
+ .then((result) => {
+ if (result === 'granted') {
+ return resolve(true);
+ }
+ return resolve(false);
+ })
+ .catch((error) => {
+ return reject(error);
+ });
+ });
+ },
+
+ /**
+ * Notify user about an event.
+ *
+ * Notifies the user with a browser notification (if allowed and enabled),
+ * audio notification (if allowed and enabled), and with a toast notification.
+ *
+ * @param body - The message content.
+ * @param notificationType - The message type (one of 'pass', 'fail', 'error', 'warning', 'info').
+ * @param tag - A tag for the notification, used to avoid double notifications.
+ */
+ notify(body: string, notificationType: NotificationType, tag: string) {
+ if (tag === lastTag) {
+ console.debug('Skipping duplicate notification with tag:', tag);
+ return;
+ }
+
+ lastTag = tag;
+
+ if (this.browserNotificationsGranted() && this.shouldBrowserNotify(notificationType)) {
+ new Notification(`${appName.toUpperCase()}: ${notificationType.toUpperCase()}`, {
+ body: body,
+ icon: `/${notificationType}.png`,
+ lang: 'en-US',
+ tag: tag
+ });
+ }
+
+ switch (notificationType) {
+ case 'pass':
+ AudioService.play('pass');
+ break;
+ case 'fail':
+ AudioService.play('fail');
+ break;
+ case 'error':
+ AudioService.play('fail');
+ break;
+ case 'warning':
+ AudioService.play('warning');
+ break;
+ }
+
+ toastStore.trigger({ message: body, background: toastVariantMap[notificationType] });
+ },
+
+ /**
+ * Determine if a notification should be emitted.
+ *
+ * @param notificationType - Type of notification.
+ *
+ * @returns
+ * True if notification should be emitted according to the current
+ * settings, and false otherwise.
+ */
+ shouldBrowserNotify(notificationType: NotificationType): boolean {
+ if (get(notificationsActive) === 'false') {
+ return false;
+ }
+
+ const notifyOnSetting = get(notifyOn);
+
+ if (notifyOnSetting === 'all') {
+ return true;
+ }
+
+ if (!alwaysNotifyOn.includes(notificationType)) {
+ return false;
+ }
+
+ return notificationType === notifyOnSetting;
+ }
+};
diff --git a/web/app/src/lib/services/websocket.spec.ts b/web/app/src/lib/services/websocket.spec.ts
new file mode 100644
index 0000000..da50a91
--- /dev/null
+++ b/web/app/src/lib/services/websocket.spec.ts
@@ -0,0 +1,156 @@
+import { get } from 'svelte/store';
+
+import { toastStore } from '../../skeleton';
+import WS from 'jest-websocket-mock';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { lastResult, root, state } from '$lib/stores/status';
+
+import NotificationService from '$lib/services/notifications';
+import { WSMessageManager } from '$lib/services/websocket';
+
+import { sleep } from '$lib/common/utils';
+
+let manager: WSMessageManager;
+let server: WS;
+
+beforeEach(() => {
+ server = new WS('ws://localhost:3000/ws', { jsonProtocol: true });
+ manager = WSMessageManager.getInstance('ws://localhost:3000/ws');
+});
+
+afterEach(() => {
+ manager.stop();
+ WS.clean();
+ vi.clearAllMocks();
+ vi.useRealTimers();
+});
+
+describe('WSMessageManager', () => {
+ describe('when message kind is init', () => {
+ it('updates state and root stores', async () => {
+ manager.start();
+ await server.connected;
+
+ server.send({ kind: 'init', data: { state: 'ready', root: '~/src/github.com/johndoe/project' } });
+
+ expect(get(state)).toBe('ready');
+ expect(get(root)).toBe('~/src/github.com/johndoe/project');
+ });
+ });
+
+ describe('when message kind is state', () => {
+ it('updates state store', async () => {
+ manager.start();
+ await server.connected;
+
+ server.send({ kind: 'state', data: 'running' });
+
+ expect(get(state)).toBe('running');
+ });
+ });
+
+ describe('when message kind is result', () => {
+ it('updates lastResult store', async () => {
+ manager.start();
+ await server.connected;
+
+ server.send({ kind: 'result', data: { uuid: 'deadbeef', pass: true, tests: 10 } });
+
+ const result = get(lastResult);
+
+ expect(result?.uuid).toBe('deadbeef');
+ expect(result?.pass).toBe(true);
+ expect(result?.tests).toBe(10);
+ });
+ });
+
+ describe('when message kind is resultError', () => {
+ it('notifies the user', async () => {
+ NotificationService.notify = vi.fn();
+
+ manager.start();
+ await server.connected;
+
+ server.send({ kind: 'resultError', data: { uuid: 'deadbeef', pass: false, error: 'warp core breach' } });
+
+ expect(NotificationService.notify).toHaveBeenCalledWith(
+ 'Tests failed with error: warp core breach',
+ 'error',
+ 'deadbeef'
+ );
+
+ expect(document.title).toContain('ERROR: warp core breach');
+ });
+ });
+
+ describe('when message kind is resultEmpty', () => {
+ it('notifies the user', async () => {
+ NotificationService.notify = vi.fn();
+
+ manager.start();
+ await server.connected;
+
+ server.send({ kind: 'resultEmpty', data: { uuid: 'deadbeef', pass: false, packages: [{}, {}] } });
+
+ expect(NotificationService.notify).toHaveBeenCalledWith('No tests found for 2 packages', 'warning', 'deadbeef');
+
+ expect(document.title).toContain('NO TESTS FOUND');
+ });
+ });
+
+ describe('when message kind is notification', () => {
+ it('notifies the user with information', async () => {
+ NotificationService.notify = vi.fn();
+
+ manager.start();
+ await server.connected;
+
+ server.send({ kind: 'notification', data: { body: 'Hello, World!', type: 'info', tag: 'helloworld' } });
+
+ expect(NotificationService.notify).toHaveBeenCalledWith('Hello, World!', 'info', 'helloworld');
+ });
+ });
+
+ describe('when message kind is unknown', () => {
+ it('notifies the user', async () => {
+ toastStore.trigger = vi.fn();
+
+ manager.start();
+ await server.connected;
+
+ server.send({ kind: 'lolwut' });
+
+ expect(toastStore.trigger).toHaveBeenCalledWith({
+ message: `Received unknown "lolwut" message from server`,
+ background: 'variant-soft-error'
+ });
+ });
+ });
+
+ describe('when connection closes', () => {
+ describe('abnormally', async () => {
+ it('updates state store and notifies the user', async () => {
+ const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
+ NotificationService.notify = vi.fn();
+
+ state.set('ready');
+
+ manager.start();
+ await server.connected;
+
+ server.error();
+
+ sleep(50);
+
+ expect(get(state)).toBe('offline');
+ expect(NotificationService.notify).toHaveBeenCalledWith(
+ 'Server connection lost. Trying to reconnect in 5 seconds...',
+ 'warning',
+ 'websocketReconnect'
+ );
+ expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5000);
+ });
+ });
+ });
+});
diff --git a/web/app/src/lib/services/websocket.ts b/web/app/src/lib/services/websocket.ts
new file mode 100644
index 0000000..b8e10bb
--- /dev/null
+++ b/web/app/src/lib/services/websocket.ts
@@ -0,0 +1,243 @@
+import { toastStore } from '../../skeleton';
+
+import { lastResult, root, state } from '$lib/stores/status';
+
+import NotificationService from '$lib/services/notifications';
+
+import { pluralize, setPageTitle } from '$lib/common/utils';
+import type { Result, ServerNotification, State, WSMessage } from '$lib/common/types';
+
+const CODE_CLOSE_NORMAL = 1000;
+const CODE_CLOSE_GOING_AWAY = 1001;
+
+function isAbnormalClose(code: number): boolean {
+ return code !== CODE_CLOSE_NORMAL && code !== CODE_CLOSE_GOING_AWAY;
+}
+
+/**
+ * WSMessageManager handles WebSocket connection and messages.
+ */
+export class WSMessageManager {
+ private static instance: WSMessageManager;
+ private url: string;
+ private _connection: WebSocket | null = null;
+ private stopCalled = false;
+
+ private constructor(url: string) {
+ this.url = url;
+ }
+
+ /**
+ * Get WebSocket Manager instance.
+ *
+ * Instantiates a new singleton Websocket Manager with given URL if one is not
+ * already instantiated.
+ *
+ * @param url - WebSocket URL.
+ *
+ * @throws Error if WebSocket Manager is already instantiated with a different
+ * URL than the one provided.
+ */
+ public static getInstance(url: string): WSMessageManager {
+ if (!WSMessageManager.instance) {
+ WSMessageManager.instance = new WSMessageManager(url);
+ }
+
+ if (WSMessageManager.instance.url !== url) {
+ throw new Error('WebSocket Manager already instantiated with different URL');
+ }
+
+ return WSMessageManager.instance;
+ }
+
+ /**
+ * Subscribe to WebSocket messages and handle them.
+ */
+ public start() {
+ this.stopCalled = false;
+ const conn = this.connection();
+
+ this.removeEventListeners(conn);
+
+ conn.addEventListener('open', () => this.log('connection opened'));
+ conn.addEventListener('message', (event) => this.handleMessage(event));
+ conn.addEventListener('close', (event) => this.handleClose(event));
+ }
+
+ /**
+ * Unsubscribe from WebSocket messages and close connection.
+ */
+ public stop() {
+ this.stopCalled = true;
+
+ if (this._connection) {
+ this.removeEventListeners(this._connection);
+
+ if (this._connection.readyState === WebSocket.OPEN) {
+ this._connection.close();
+ }
+ }
+
+ this._connection = null;
+ }
+
+ private handleMessage(event: MessageEvent) {
+ const message = JSON.parse(event.data) as WSMessage;
+
+ switch (message.kind) {
+ case 'keepalive':
+ break;
+ case 'init':
+ this.handleInit(message);
+ break;
+ case 'state':
+ this.handleState(message);
+ break;
+ case 'result':
+ this.handleResult(message);
+ break;
+ case 'resultError':
+ this.handleResultError(message);
+ break;
+ case 'resultEmpty':
+ this.handleResultEmpty(message);
+ break;
+ case 'notification':
+ this.handleNotification(message);
+ break;
+ default:
+ toastStore.trigger({
+ message: `Received unknown "${message.kind}" message from server`,
+ background: 'variant-soft-error'
+ });
+ break;
+ }
+ }
+
+ private handleInit(message: WSMessage) {
+ this.log(`received init message`);
+
+ if (!message.data) {
+ this.log('init message missing data, ignoring');
+ return;
+ }
+
+ const data = message.data as InitData;
+
+ state.set(data.state);
+ root.set(data.root);
+ }
+
+ private handleState(message: WSMessage) {
+ this.log(`received state message`);
+
+ if (!message.data) {
+ this.log('state message missing data, ignoring');
+ return;
+ }
+
+ state.set(message.data as State);
+ }
+
+ private handleResult(message: WSMessage) {
+ this.log(`received result message`);
+
+ if (!message.data) {
+ this.log('result message missing data, ignoring');
+ return;
+ }
+
+ lastResult.set(message.data as Result);
+ }
+
+ private handleResultError(message: WSMessage) {
+ this.log(`received resultError message`);
+
+ if (!message.data) {
+ this.log('resultError message missing data, ignoring');
+ return;
+ }
+
+ const result = message.data as Result;
+
+ NotificationService.notify(`Tests failed with error: ${result.error}`, 'error', result.uuid);
+ setPageTitle(`✖ ERROR: ${result.error}`);
+ }
+
+ private handleResultEmpty(message: WSMessage) {
+ this.log(`received resultEmpty message`);
+
+ if (!message.data) {
+ this.log('resultEmpty message missing data, ignoring');
+ return;
+ }
+
+ const result = message.data as Result;
+
+ NotificationService.notify(
+ `No tests found for ${pluralize(result.packages?.length || 0, 'package', 'packages')}`,
+ 'warning',
+ result.uuid
+ );
+ setPageTitle('? NO TESTS FOUND');
+ }
+
+ private handleNotification(message: WSMessage) {
+ this.log(`received notification message`);
+
+ if (!message.data) {
+ this.log('notification message missing data, ignoring');
+ return;
+ }
+
+ const notification = message.data as ServerNotification;
+
+ NotificationService.notify(notification.body, notification.type, notification.tag);
+ }
+
+ private handleClose(event: CloseEvent) {
+ state.set('offline');
+
+ if (!this.stopCalled && isAbnormalClose(event.code)) {
+ this.stop();
+
+ this.log(`connection closed abnormally with code ${event.code}: ${event.reason || ''}`);
+ this.log(`resetting connection and trying again`);
+
+ NotificationService.notify(
+ 'Server connection lost. Trying to reconnect in 5 seconds...',
+ 'warning',
+ 'websocketReconnect'
+ );
+
+ setTimeout(() => this.start(), 5000);
+ return;
+ }
+
+ this.log(`connection closed with code ${event.code}: ${event.reason || ''}`);
+ }
+
+ private connection(): WebSocket {
+ if (!this._connection || this._connection.readyState !== WebSocket.OPEN) {
+ this._connection = new WebSocket(this.url);
+ }
+
+ return this._connection;
+ }
+
+ private log(msg: string) {
+ console.debug(`%c[WebSocket Message Manager] %c${msg}`, 'color: #a2a2a2', '');
+ }
+
+ private removeEventListeners(conn: WebSocket) {
+ conn.onopen = null;
+ conn.onmessage = null;
+ conn.onclose = null;
+ conn.onerror = null;
+ }
+}
+
+interface InitData {
+ state: State;
+ root: string;
+}
diff --git a/web/app/src/lib/stores/events.ts b/web/app/src/lib/stores/events.ts
new file mode 100644
index 0000000..c1faa8d
--- /dev/null
+++ b/web/app/src/lib/stores/events.ts
@@ -0,0 +1,20 @@
+import { writable } from 'svelte/store';
+
+export const runTestsEvent = writable('');
+export const openSettingsEvent = writable(false);
+
+/**
+ * Dispatch event to run tests for given package.
+ *
+ * @param pkg - Go package name.
+ */
+export function dispatchRunTests(pkg: string) {
+ runTestsEvent.set(pkg);
+}
+
+/**
+ * Dispatch event to open settings drawer.
+ */
+export function dispatchOpenSettings() {
+ openSettingsEvent.set(true);
+}
diff --git a/web/app/src/lib/stores/metadata.ts b/web/app/src/lib/stores/metadata.ts
new file mode 100644
index 0000000..e87cca7
--- /dev/null
+++ b/web/app/src/lib/stores/metadata.ts
@@ -0,0 +1,21 @@
+import { readable } from 'svelte/store';
+
+import { dev } from '$app/environment';
+import { PUBLIC_APP_NAME, PUBLIC_APP_VERSION } from '$env/static/public';
+
+import type { Metadata } from '$lib/common/types';
+
+const appName = PUBLIC_APP_NAME || 'gokiburi';
+const version = PUBLIC_APP_VERSION || '0.0.0-dev';
+const projectUrl = 'https://github.com/michenriksen/gokiburi';
+
+const data: Metadata = {
+ appName: appName,
+ version: version,
+ isDevVersion: version.endsWith('-dev'),
+ environment: dev ? 'development' : 'production',
+ projectUrl: projectUrl,
+ issuesUrl: `${projectUrl}/issues/new`
+};
+
+export const metadata = readable(data);
diff --git a/web/app/src/lib/stores/results.ts b/web/app/src/lib/stores/results.ts
new file mode 100644
index 0000000..dda02b8
--- /dev/null
+++ b/web/app/src/lib/stores/results.ts
@@ -0,0 +1,7 @@
+import { writable } from 'svelte/store';
+
+import type { Result } from '$lib/common/types';
+
+export const results = writable(null);
+
+export const currentResult = writable(null);
diff --git a/web/app/src/lib/stores/settings.ts b/web/app/src/lib/stores/settings.ts
new file mode 100644
index 0000000..fddfe28
--- /dev/null
+++ b/web/app/src/lib/stores/settings.ts
@@ -0,0 +1,62 @@
+import { type Writable, writable } from 'svelte/store';
+
+import { localStorageStore } from '../../skeleton';
+
+import type {
+ AudioNotifyOnSetting,
+ CoverageThemeSetting,
+ FilterCoverageLevel,
+ FilterPackageStatus,
+ FilterTestStatus,
+ NotifyOnSetting
+} from '$lib/common/types';
+
+const defaultFilterPackageStatus: FilterPackageStatus[] = ['failing', 'passing'];
+const defaultFilterTestStatus: FilterTestStatus[] = ['failing', 'passing', 'skipped'];
+const defaultFilterCoverageLevel: FilterCoverageLevel[] = ['high', 'medium', 'low', 'none'];
+
+export const runAllOnInit: Writable = localStorageStore('settings.runAllOnLoad', 'true');
+
+export const filterPackageStatus: Writable = localStorageStore(
+ 'settings.filterPackageStatus',
+ JSON.stringify(defaultFilterPackageStatus)
+);
+
+export const filterTestStatus: Writable = localStorageStore(
+ 'settings.filterTestStatus',
+ JSON.stringify(defaultFilterTestStatus)
+);
+
+export const filterCoverageLevel: Writable = localStorageStore(
+ 'settings.filterCoverageLevel',
+ JSON.stringify(defaultFilterCoverageLevel)
+);
+
+export const testGroupsCollapsed: Writable = localStorageStore('settings.testGroupsCollapsed', 'false');
+
+export const packagesPerPage: Writable = localStorageStore('settings.packagesPerPage', '50');
+
+export const sidebarCollapsed: Writable = localStorageStore('settings.sidebarCollapsed', 'false');
+
+export const audioContextUnlocked = writable(false);
+
+export const audioNotifyOn: Writable = localStorageStore('settings.audioNotifyOn', 'all');
+
+export const audioVolume: Writable = localStorageStore('settings.audioVolume', '1');
+
+export const notificationsActive: Writable = localStorageStore('settings.notificationsActive', 'false');
+
+export const notifyOn: Writable = localStorageStore('settings.notifyOn', 'all');
+
+export const coverageShowBadges: Writable = localStorageStore('settings.coverageShowBadges', 'true');
+
+export const coverageUseColor: Writable = localStorageStore('settings.coverageUseColor', 'true');
+
+export const coverageHighMin: Writable = localStorageStore('settings.coveragePositiveMin', '75');
+
+export const coverageMediumMin: Writable = localStorageStore('settings.coverageWarningMin', '50');
+
+export const coverageTheme: Writable = localStorageStore(
+ 'settings.coverageTheme',
+ 'white-to-green'
+);
diff --git a/web/app/src/lib/stores/status.ts b/web/app/src/lib/stores/status.ts
new file mode 100644
index 0000000..91b5834
--- /dev/null
+++ b/web/app/src/lib/stores/status.ts
@@ -0,0 +1,9 @@
+import { writable } from 'svelte/store';
+
+import type { Result, State } from '$lib/common/types';
+
+export const state = writable('init');
+
+export const lastResult = writable(null);
+
+export const root = writable(null);
diff --git a/web/app/src/lib/stores/transitions.ts b/web/app/src/lib/stores/transitions.ts
new file mode 100644
index 0000000..e2cc505
--- /dev/null
+++ b/web/app/src/lib/stores/transitions.ts
@@ -0,0 +1,3 @@
+import { writable } from 'svelte/store';
+
+export const transitionsActive = writable(true);
diff --git a/web/app/src/lib/tests/apiResponses/coverageReport.json b/web/app/src/lib/tests/apiResponses/coverageReport.json
new file mode 100644
index 0000000..6951754
--- /dev/null
+++ b/web/app/src/lib/tests/apiResponses/coverageReport.json
@@ -0,0 +1,30 @@
+{
+ "mode": "atomic",
+ "profiles": [
+ {
+ "filename": "github.com/johndoe/project/pkg/warpcore/warpcore.go",
+ "package": "github.com/johndoe/project/pkg/warpcore",
+ "path": "/Users/johndoe/src/github.com/johndoe/project/pkg/warpcore/warpcore.go",
+ "content": "...",
+ "size": 0,
+ "coverage": 88.88888888888889,
+ "boundaries": [
+ {
+ "offset": 137,
+ "start": true,
+ "count": 3,
+ "norm": 0.5645750340535796,
+ "index": 0
+ },
+ {
+ "offset": 202,
+ "start": false,
+ "count": 0,
+ "norm": 0,
+ "index": 1
+ }
+ ]
+ }
+ ],
+ "time": "2023-04-11T21:23:12.604891+02:00"
+}
diff --git a/web/app/src/lib/tests/helpers.ts b/web/app/src/lib/tests/helpers.ts
new file mode 100644
index 0000000..b613edf
--- /dev/null
+++ b/web/app/src/lib/tests/helpers.ts
@@ -0,0 +1,24 @@
+import type { SvelteComponent } from 'svelte';
+
+type Constructor = new (...args: any[]) => T;
+
+/**
+ * Render a Svelte component to string.
+ *
+ * Temporarily renders the given component in the DOM and returns
+ * the resulting HTML.
+ *
+ * @param component - Component Constructor.
+ * @param props - Optional props to give the component.
+ * @returns HTML of rendered component.
+ */
+export function renderComponentToString(component: Constructor, props?: any): string {
+ const host = document.createElement('div');
+ document.body.append(host);
+ const instance = new component({ target: host, props: props });
+ const result = host.innerHTML;
+ instance.$destroy();
+ host.remove();
+
+ return result;
+}
diff --git a/web/app/src/routes/+error.svelte b/web/app/src/routes/+error.svelte
new file mode 100644
index 0000000..f651e29
--- /dev/null
+++ b/web/app/src/routes/+error.svelte
@@ -0,0 +1,23 @@
+
+
+
+
+ {#if $page.status === 404}
+
+
Page not found
+ {:else}
+
+
Something went wrong
+
{$page.status} {$page.error?.message}
+ {/if}
+
+
diff --git a/web/app/src/routes/+layout.svelte b/web/app/src/routes/+layout.svelte
new file mode 100644
index 0000000..462c666
--- /dev/null
+++ b/web/app/src/routes/+layout.svelte
@@ -0,0 +1,272 @@
+
+
+
+ {#if $drawerStore.id === 'coverageReport'}
+
+ {:else if $drawerStore.id === 'settings'}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ run(e.detail)}
+ on:showingResult={() => stateVal === 'running' && state.set('ready')}
+ disableClearResults={isClearingResults || stateVal === 'offline' || stateVal === 'running'}
+ />
+
+
+
+
+
+
+ {meta.appName} v{meta.version}
+
+ |
+
+ |
+
+ |
+
+
+
+
+
diff --git a/web/app/src/routes/+page.svelte b/web/app/src/routes/+page.svelte
new file mode 100644
index 0000000..285a06b
--- /dev/null
+++ b/web/app/src/routes/+page.svelte
@@ -0,0 +1,237 @@
+
+
+
+ {#if resultVal}
+ {#key resultVal.uuid}
+
+ {/key}
+ {/if}
+
+ {#if resultVal && paginatedPackages}
+
+
+ {#each paginatedPackages as pkg (pkg.name)}
+
+ {/each}
+
+
packagesPerPage.set(val.detail.toString())}
+ bind:settings={pageSettings}
+ />
+ {:else}
+
+
+
+
+
+
+
+
+
+
+ {#each Array(5) as _}
+ {@const titleW = Math.floor(Math.random() * 30) + 10}
+
+
+ {/each}
+
+
+
+
+
+
+
+
No test results are available at the moment
+
+ To view results, either modify a Go source file within your project or
+ dispatchRunTests('./...')}
+ >run all tests .
+
+
+
+
+
+
+ Tip:
+
+ you can activate automatic run of all tests on page load in
+
dispatchOpenSettings()}>the settings .
+
+
+ {/if}
+
diff --git a/web/app/src/routes/error.ts b/web/app/src/routes/error.ts
new file mode 100644
index 0000000..4d57097
--- /dev/null
+++ b/web/app/src/routes/error.ts
@@ -0,0 +1,8 @@
+import type { PageLoad } from './$types';
+
+export const load = (({ error }) => {
+ return {
+ message: error.message,
+ stack: error.stack
+ };
+}) satisfies PageLoad;
diff --git a/web/app/src/setupTest.ts b/web/app/src/setupTest.ts
new file mode 100644
index 0000000..7b0828b
--- /dev/null
+++ b/web/app/src/setupTest.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom';
diff --git a/web/app/src/skeleton/actions/Clipboard/clipboard.d.ts b/web/app/src/skeleton/actions/Clipboard/clipboard.d.ts
new file mode 100644
index 0000000..81c24eb
--- /dev/null
+++ b/web/app/src/skeleton/actions/Clipboard/clipboard.d.ts
@@ -0,0 +1,7 @@
+export declare function clipboard(
+ node: HTMLElement,
+ args: any
+): {
+ update(newArgs: any): void;
+ destroy(): void;
+};
diff --git a/web/app/src/skeleton/actions/Clipboard/clipboard.js b/web/app/src/skeleton/actions/Clipboard/clipboard.js
new file mode 100644
index 0000000..cdd186a
--- /dev/null
+++ b/web/app/src/skeleton/actions/Clipboard/clipboard.js
@@ -0,0 +1,37 @@
+// Action: Clipboard
+export function clipboard(node, args) {
+ const onClick = () => {
+ // Handle `data-clipboard` target based on object key
+ if (typeof args === 'object') {
+ // Element Inner HTML
+ if (Object.prototype.hasOwnProperty.call(args, 'element')) {
+ const element = document.querySelector(`[data-clipboard="${args.element}"]`);
+ copyToClipboard(element?.innerHTML);
+ return;
+ }
+ // Form Input Value
+ if (Object.prototype.hasOwnProperty.call(args, 'input')) {
+ const input = document.querySelector(`[data-clipboard="${args.input}"]`);
+ copyToClipboard(input?.value);
+ return;
+ }
+ }
+ // Handle everything else.
+ copyToClipboard(args);
+ };
+ // Event Listener
+ node.addEventListener('click', onClick);
+ // Lifecycle
+ return {
+ update(newArgs) {
+ args = newArgs;
+ },
+ destroy() {
+ node.removeEventListener('click', onClick);
+ }
+ };
+}
+// Shared copy method
+function copyToClipboard(data) {
+ navigator.clipboard.writeText(String(data));
+}
diff --git a/web/app/src/skeleton/actions/Filters/filter.d.ts b/web/app/src/skeleton/actions/Filters/filter.d.ts
new file mode 100644
index 0000000..c4de8d5
--- /dev/null
+++ b/web/app/src/skeleton/actions/Filters/filter.d.ts
@@ -0,0 +1,8 @@
+export declare function filter(
+ node: HTMLElement,
+ filterName: string
+):
+ | {
+ update(newArgs: any): void;
+ }
+ | undefined;
diff --git a/web/app/src/skeleton/actions/Filters/filter.js b/web/app/src/skeleton/actions/Filters/filter.js
new file mode 100644
index 0000000..50ad789
--- /dev/null
+++ b/web/app/src/skeleton/actions/Filters/filter.js
@@ -0,0 +1,18 @@
+// Action: Filter
+export function filter(node, filterName) {
+ // Return if Firefox browser
+ const isFirefox = navigator.userAgent.indexOf('Firefox') > -1;
+ if (isFirefox) return;
+ // Return if no filterName provided
+ if (filterName === undefined) return;
+ const applyFilter = () => {
+ node.setAttribute('style', `filter: url("${filterName}")`);
+ };
+ applyFilter();
+ return {
+ update(newArgs) {
+ filterName = newArgs;
+ applyFilter();
+ }
+ };
+}
diff --git a/web/app/src/skeleton/actions/Filters/filter.test.d.ts b/web/app/src/skeleton/actions/Filters/filter.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/actions/Filters/filter.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/actions/Filters/filter.test.js b/web/app/src/skeleton/actions/Filters/filter.test.js
new file mode 100644
index 0000000..0bd4465
--- /dev/null
+++ b/web/app/src/skeleton/actions/Filters/filter.test.js
@@ -0,0 +1,36 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+// Action
+import { filter } from './filter';
+// SVG Filters
+import Emerald from './svg-filters/Emerald.svelte';
+import BlueNight from './svg-filters/BlueNight.svelte';
+import XPro from './svg-filters/XPro.svelte';
+import Summer84 from './svg-filters/Summer84.svelte';
+import Rustic from './svg-filters/Rustic.svelte';
+import Apollo from './svg-filters/Apollo.svelte';
+import GreenFall from './svg-filters/GreenFall.svelte';
+import Noir from './svg-filters/Noir.svelte';
+import NoirLight from './svg-filters/NoirLight.svelte';
+describe('Actions: Filter', () => {
+ it('Tests all SVGs have class of "filter"', async () => {
+ render(Emerald);
+ render(BlueNight);
+ render(XPro);
+ render(Summer84);
+ render(Rustic);
+ render(Apollo);
+ render(GreenFall);
+ render(NoirLight, Noir);
+ const elements = document.getElementsByClassName('filter');
+ for (let i = 0; i < elements.length; ++i) {
+ const el = elements[i];
+ expect(el.getAttribute('class').includes('filter'));
+ }
+ });
+ it('Test the node gets the filter url', async () => {
+ const elem = document.createElement('div');
+ filter(elem, 'XPro');
+ expect(elem.getAttribute('style').includes('filter: url("#Emerald")'));
+ });
+});
diff --git a/web/app/src/skeleton/actions/Filters/svg-filters/Apollo.svelte b/web/app/src/skeleton/actions/Filters/svg-filters/Apollo.svelte
new file mode 100644
index 0000000..fd77f69
--- /dev/null
+++ b/web/app/src/skeleton/actions/Filters/svg-filters/Apollo.svelte
@@ -0,0 +1,18 @@
+
+
+
+
+
+
diff --git a/web/app/src/skeleton/actions/Filters/svg-filters/Apollo.svelte.d.ts b/web/app/src/skeleton/actions/Filters/svg-filters/Apollo.svelte.d.ts
new file mode 100644
index 0000000..b32efb5
--- /dev/null
+++ b/web/app/src/skeleton/actions/Filters/svg-filters/Apollo.svelte.d.ts
@@ -0,0 +1,26 @@
+/** @typedef {typeof __propDef.props} ApolloProps */
+/** @typedef {typeof __propDef.events} ApolloEvents */
+/** @typedef {typeof __propDef.slots} ApolloSlots */
+export default class Apollo extends SvelteComponentTyped<
+ {
+ [x: string]: never;
+ },
+ {
+ [evt: string]: CustomEvent;
+ },
+ {}
+> {}
+export type ApolloProps = typeof __propDef.props;
+export type ApolloEvents = typeof __propDef.events;
+export type ApolloSlots = typeof __propDef.slots;
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: never;
+ };
+ events: {
+ [evt: string]: CustomEvent;
+ };
+ slots: {};
+};
+export {};
diff --git a/web/app/src/skeleton/actions/Filters/svg-filters/BlueNight.svelte b/web/app/src/skeleton/actions/Filters/svg-filters/BlueNight.svelte
new file mode 100644
index 0000000..966f45b
--- /dev/null
+++ b/web/app/src/skeleton/actions/Filters/svg-filters/BlueNight.svelte
@@ -0,0 +1,17 @@
+
+
+
+
+
+
diff --git a/web/app/src/skeleton/actions/Filters/svg-filters/BlueNight.svelte.d.ts b/web/app/src/skeleton/actions/Filters/svg-filters/BlueNight.svelte.d.ts
new file mode 100644
index 0000000..e5a630c
--- /dev/null
+++ b/web/app/src/skeleton/actions/Filters/svg-filters/BlueNight.svelte.d.ts
@@ -0,0 +1,26 @@
+/** @typedef {typeof __propDef.props} BlueNightProps */
+/** @typedef {typeof __propDef.events} BlueNightEvents */
+/** @typedef {typeof __propDef.slots} BlueNightSlots */
+export default class BlueNight extends SvelteComponentTyped<
+ {
+ [x: string]: never;
+ },
+ {
+ [evt: string]: CustomEvent;
+ },
+ {}
+> {}
+export type BlueNightProps = typeof __propDef.props;
+export type BlueNightEvents = typeof __propDef.events;
+export type BlueNightSlots = typeof __propDef.slots;
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: never;
+ };
+ events: {
+ [evt: string]: CustomEvent;
+ };
+ slots: {};
+};
+export {};
diff --git a/web/app/src/skeleton/actions/Filters/svg-filters/Emerald.svelte b/web/app/src/skeleton/actions/Filters/svg-filters/Emerald.svelte
new file mode 100644
index 0000000..feef317
--- /dev/null
+++ b/web/app/src/skeleton/actions/Filters/svg-filters/Emerald.svelte
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/app/src/skeleton/actions/Filters/svg-filters/Emerald.svelte.d.ts b/web/app/src/skeleton/actions/Filters/svg-filters/Emerald.svelte.d.ts
new file mode 100644
index 0000000..03f25e1
--- /dev/null
+++ b/web/app/src/skeleton/actions/Filters/svg-filters/Emerald.svelte.d.ts
@@ -0,0 +1,26 @@
+/** @typedef {typeof __propDef.props} EmeraldProps */
+/** @typedef {typeof __propDef.events} EmeraldEvents */
+/** @typedef {typeof __propDef.slots} EmeraldSlots */
+export default class Emerald extends SvelteComponentTyped<
+ {
+ [x: string]: never;
+ },
+ {
+ [evt: string]: CustomEvent;
+ },
+ {}
+> {}
+export type EmeraldProps = typeof __propDef.props;
+export type EmeraldEvents = typeof __propDef.events;
+export type EmeraldSlots = typeof __propDef.slots;
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: never;
+ };
+ events: {
+ [evt: string]: CustomEvent;
+ };
+ slots: {};
+};
+export {};
diff --git a/web/app/src/skeleton/actions/Filters/svg-filters/GreenFall.svelte b/web/app/src/skeleton/actions/Filters/svg-filters/GreenFall.svelte
new file mode 100644
index 0000000..b3716a2
--- /dev/null
+++ b/web/app/src/skeleton/actions/Filters/svg-filters/GreenFall.svelte
@@ -0,0 +1,27 @@
+
+
+
+
+
+
diff --git a/web/app/src/skeleton/actions/Filters/svg-filters/GreenFall.svelte.d.ts b/web/app/src/skeleton/actions/Filters/svg-filters/GreenFall.svelte.d.ts
new file mode 100644
index 0000000..20c1217
--- /dev/null
+++ b/web/app/src/skeleton/actions/Filters/svg-filters/GreenFall.svelte.d.ts
@@ -0,0 +1,26 @@
+/** @typedef {typeof __propDef.props} GreenFallProps */
+/** @typedef {typeof __propDef.events} GreenFallEvents */
+/** @typedef {typeof __propDef.slots} GreenFallSlots */
+export default class GreenFall extends SvelteComponentTyped<
+ {
+ [x: string]: never;
+ },
+ {
+ [evt: string]: CustomEvent;
+ },
+ {}
+> {}
+export type GreenFallProps = typeof __propDef.props;
+export type GreenFallEvents = typeof __propDef.events;
+export type GreenFallSlots = typeof __propDef.slots;
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: never;
+ };
+ events: {
+ [evt: string]: CustomEvent;
+ };
+ slots: {};
+};
+export {};
diff --git a/web/app/src/skeleton/actions/Filters/svg-filters/Noir.svelte b/web/app/src/skeleton/actions/Filters/svg-filters/Noir.svelte
new file mode 100644
index 0000000..08fb2c5
--- /dev/null
+++ b/web/app/src/skeleton/actions/Filters/svg-filters/Noir.svelte
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/app/src/skeleton/actions/Filters/svg-filters/Noir.svelte.d.ts b/web/app/src/skeleton/actions/Filters/svg-filters/Noir.svelte.d.ts
new file mode 100644
index 0000000..12a1752
--- /dev/null
+++ b/web/app/src/skeleton/actions/Filters/svg-filters/Noir.svelte.d.ts
@@ -0,0 +1,26 @@
+/** @typedef {typeof __propDef.props} NoirProps */
+/** @typedef {typeof __propDef.events} NoirEvents */
+/** @typedef {typeof __propDef.slots} NoirSlots */
+export default class Noir extends SvelteComponentTyped<
+ {
+ [x: string]: never;
+ },
+ {
+ [evt: string]: CustomEvent;
+ },
+ {}
+> {}
+export type NoirProps = typeof __propDef.props;
+export type NoirEvents = typeof __propDef.events;
+export type NoirSlots = typeof __propDef.slots;
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: never;
+ };
+ events: {
+ [evt: string]: CustomEvent;
+ };
+ slots: {};
+};
+export {};
diff --git a/web/app/src/skeleton/actions/Filters/svg-filters/NoirLight.svelte b/web/app/src/skeleton/actions/Filters/svg-filters/NoirLight.svelte
new file mode 100644
index 0000000..e31b5c2
--- /dev/null
+++ b/web/app/src/skeleton/actions/Filters/svg-filters/NoirLight.svelte
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/web/app/src/skeleton/actions/Filters/svg-filters/NoirLight.svelte.d.ts b/web/app/src/skeleton/actions/Filters/svg-filters/NoirLight.svelte.d.ts
new file mode 100644
index 0000000..5c50198
--- /dev/null
+++ b/web/app/src/skeleton/actions/Filters/svg-filters/NoirLight.svelte.d.ts
@@ -0,0 +1,26 @@
+/** @typedef {typeof __propDef.props} NoirLightProps */
+/** @typedef {typeof __propDef.events} NoirLightEvents */
+/** @typedef {typeof __propDef.slots} NoirLightSlots */
+export default class NoirLight extends SvelteComponentTyped<
+ {
+ [x: string]: never;
+ },
+ {
+ [evt: string]: CustomEvent;
+ },
+ {}
+> {}
+export type NoirLightProps = typeof __propDef.props;
+export type NoirLightEvents = typeof __propDef.events;
+export type NoirLightSlots = typeof __propDef.slots;
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: never;
+ };
+ events: {
+ [evt: string]: CustomEvent;
+ };
+ slots: {};
+};
+export {};
diff --git a/web/app/src/skeleton/actions/Filters/svg-filters/Rustic.svelte b/web/app/src/skeleton/actions/Filters/svg-filters/Rustic.svelte
new file mode 100644
index 0000000..7389fc1
--- /dev/null
+++ b/web/app/src/skeleton/actions/Filters/svg-filters/Rustic.svelte
@@ -0,0 +1,19 @@
+
+
+
+
+
+
diff --git a/web/app/src/skeleton/actions/Filters/svg-filters/Rustic.svelte.d.ts b/web/app/src/skeleton/actions/Filters/svg-filters/Rustic.svelte.d.ts
new file mode 100644
index 0000000..41bc9ce
--- /dev/null
+++ b/web/app/src/skeleton/actions/Filters/svg-filters/Rustic.svelte.d.ts
@@ -0,0 +1,26 @@
+/** @typedef {typeof __propDef.props} RusticProps */
+/** @typedef {typeof __propDef.events} RusticEvents */
+/** @typedef {typeof __propDef.slots} RusticSlots */
+export default class Rustic extends SvelteComponentTyped<
+ {
+ [x: string]: never;
+ },
+ {
+ [evt: string]: CustomEvent;
+ },
+ {}
+> {}
+export type RusticProps = typeof __propDef.props;
+export type RusticEvents = typeof __propDef.events;
+export type RusticSlots = typeof __propDef.slots;
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: never;
+ };
+ events: {
+ [evt: string]: CustomEvent;
+ };
+ slots: {};
+};
+export {};
diff --git a/web/app/src/skeleton/actions/Filters/svg-filters/Summer84.svelte b/web/app/src/skeleton/actions/Filters/svg-filters/Summer84.svelte
new file mode 100644
index 0000000..ddcbc53
--- /dev/null
+++ b/web/app/src/skeleton/actions/Filters/svg-filters/Summer84.svelte
@@ -0,0 +1,17 @@
+
+
+
+
+
+
diff --git a/web/app/src/skeleton/actions/Filters/svg-filters/Summer84.svelte.d.ts b/web/app/src/skeleton/actions/Filters/svg-filters/Summer84.svelte.d.ts
new file mode 100644
index 0000000..2ca4d81
--- /dev/null
+++ b/web/app/src/skeleton/actions/Filters/svg-filters/Summer84.svelte.d.ts
@@ -0,0 +1,26 @@
+/** @typedef {typeof __propDef.props} Summer84Props */
+/** @typedef {typeof __propDef.events} Summer84Events */
+/** @typedef {typeof __propDef.slots} Summer84Slots */
+export default class Summer84 extends SvelteComponentTyped<
+ {
+ [x: string]: never;
+ },
+ {
+ [evt: string]: CustomEvent;
+ },
+ {}
+> {}
+export type Summer84Props = typeof __propDef.props;
+export type Summer84Events = typeof __propDef.events;
+export type Summer84Slots = typeof __propDef.slots;
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: never;
+ };
+ events: {
+ [evt: string]: CustomEvent;
+ };
+ slots: {};
+};
+export {};
diff --git a/web/app/src/skeleton/actions/Filters/svg-filters/XPro.svelte b/web/app/src/skeleton/actions/Filters/svg-filters/XPro.svelte
new file mode 100644
index 0000000..9a62680
--- /dev/null
+++ b/web/app/src/skeleton/actions/Filters/svg-filters/XPro.svelte
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/web/app/src/skeleton/actions/Filters/svg-filters/XPro.svelte.d.ts b/web/app/src/skeleton/actions/Filters/svg-filters/XPro.svelte.d.ts
new file mode 100644
index 0000000..c216cc9
--- /dev/null
+++ b/web/app/src/skeleton/actions/Filters/svg-filters/XPro.svelte.d.ts
@@ -0,0 +1,26 @@
+/** @typedef {typeof __propDef.props} XProProps */
+/** @typedef {typeof __propDef.events} XProEvents */
+/** @typedef {typeof __propDef.slots} XProSlots */
+export default class XPro extends SvelteComponentTyped<
+ {
+ [x: string]: never;
+ },
+ {
+ [evt: string]: CustomEvent;
+ },
+ {}
+> {}
+export type XProProps = typeof __propDef.props;
+export type XProEvents = typeof __propDef.events;
+export type XProSlots = typeof __propDef.slots;
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: never;
+ };
+ events: {
+ [evt: string]: CustomEvent;
+ };
+ slots: {};
+};
+export {};
diff --git a/web/app/src/skeleton/actions/FocusTrap/focusTrap.d.ts b/web/app/src/skeleton/actions/FocusTrap/focusTrap.d.ts
new file mode 100644
index 0000000..1fcc441
--- /dev/null
+++ b/web/app/src/skeleton/actions/FocusTrap/focusTrap.d.ts
@@ -0,0 +1,7 @@
+export declare function focusTrap(
+ node: HTMLElement,
+ enabled: boolean
+): {
+ update(newArgs: boolean): void;
+ destroy(): void;
+};
diff --git a/web/app/src/skeleton/actions/FocusTrap/focusTrap.js b/web/app/src/skeleton/actions/FocusTrap/focusTrap.js
new file mode 100644
index 0000000..6daac33
--- /dev/null
+++ b/web/app/src/skeleton/actions/FocusTrap/focusTrap.js
@@ -0,0 +1,50 @@
+// Action: Focus Trap
+export function focusTrap(node, enabled) {
+ const elemWhitelist = 'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])';
+ let elemFirst;
+ let elemLast;
+ // When the first element is selected, shift+tab pressed, jump to the last selectable item.
+ function onFirstElemKeydown(e) {
+ if (e.shiftKey && e.code === 'Tab') {
+ e.preventDefault();
+ elemLast.focus();
+ }
+ }
+ // When the last item selected, tab pressed, jump to the first selectable item.
+ function onLastElemKeydown(e) {
+ if (!e.shiftKey && e.code === 'Tab') {
+ e.preventDefault();
+ elemFirst.focus();
+ }
+ }
+ const onInit = () => {
+ if (enabled === false) return;
+ // Gather all focusable elements
+ const focusableElems = Array.from(node.querySelectorAll(elemWhitelist));
+ if (focusableElems.length) {
+ // Set first/last focusable elements
+ elemFirst = focusableElems[0];
+ elemLast = focusableElems[focusableElems.length - 1];
+ // Auto-focus first focusable element
+ elemFirst.focus();
+ // Listen for keydown on first & last element
+ elemFirst.addEventListener('keydown', onFirstElemKeydown);
+ elemLast.addEventListener('keydown', onLastElemKeydown);
+ }
+ };
+ onInit();
+ function onDestroy() {
+ if (elemFirst) elemFirst.removeEventListener('keydown', onFirstElemKeydown);
+ if (elemLast) elemLast.removeEventListener('keydown', onLastElemKeydown);
+ }
+ // Lifecycle
+ return {
+ update(newArgs) {
+ enabled = newArgs;
+ newArgs ? onInit() : onDestroy();
+ },
+ destroy() {
+ onDestroy();
+ }
+ };
+}
diff --git a/web/app/src/skeleton/components/Accordion/Accordion.svelte b/web/app/src/skeleton/components/Accordion/Accordion.svelte
new file mode 100644
index 0000000..3aa1bde
--- /dev/null
+++ b/web/app/src/skeleton/components/Accordion/Accordion.svelte
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
diff --git a/web/app/src/skeleton/components/Accordion/Accordion.svelte.d.ts b/web/app/src/skeleton/components/Accordion/Accordion.svelte.d.ts
new file mode 100644
index 0000000..9545384
--- /dev/null
+++ b/web/app/src/skeleton/components/Accordion/Accordion.svelte.d.ts
@@ -0,0 +1,42 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Set the auto-collapse mode.*/
+ autocollapse?: boolean | undefined;
+ /** Set the drawer animation duration in milliseconds.*/
+ duration?: number | undefined;
+ /** Provide classes to set the vertical spacing between items.*/
+ spacing?: string | undefined;
+ /** Set the accordion disabled state for all items.*/
+ disabled?: boolean | undefined;
+ /** Provide classes to set the accordion item padding styles.*/
+ padding?: string | undefined;
+ /** Provide classes to set the accordion item hover styles.*/
+ hover?: string | undefined;
+ /** Provide classes to set the accordion item rounded styles.*/
+ rounded?: string | undefined;
+ /** Set the rotation of the item caret in the open state.*/
+ caretOpen?: string | undefined;
+ /** Set the rotation of the item caret in the closed state.*/
+ caretClosed?: string | undefined;
+ /** Provide arbitrary classes to the trigger button region.*/
+ regionControl?: string | undefined;
+ /** Provide arbitrary classes to the content panel region.*/
+ regionPanel?: string | undefined;
+ /** Provide arbitrary classes to the caret icon region.*/
+ regionCaret?: string | undefined;
+ };
+ events: {
+ [evt: string]: CustomEvent;
+ };
+ slots: {
+ default: {};
+ };
+};
+export type AccordionProps = typeof __propDef.props;
+export type AccordionEvents = typeof __propDef.events;
+export type AccordionSlots = typeof __propDef.slots;
+/** The Accordion parent element. */
+export default class Accordion extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/components/Accordion/Accordion.test.d.ts b/web/app/src/skeleton/components/Accordion/Accordion.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/Accordion/Accordion.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/Accordion/Accordion.test.js b/web/app/src/skeleton/components/Accordion/Accordion.test.js
new file mode 100644
index 0000000..2b54dfa
--- /dev/null
+++ b/web/app/src/skeleton/components/Accordion/Accordion.test.js
@@ -0,0 +1,26 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import Accordion from './Accordion.svelte';
+describe('Accordion.svelte', () => {
+ it('Renders with minimal props', async () => {
+ const { getByTestId } = render(Accordion);
+ expect(getByTestId('accordion')).toBeTruthy();
+ });
+ it('Renders with all props', async () => {
+ const { getByTestId } = render(Accordion, {
+ props: {
+ autocollapse: true,
+ duration: 200,
+ spacing: 'space-y-1',
+ padding: 'py-2 px-4',
+ hover: 'hover:bg-primary-hover-token',
+ rounded: 'rounded-container-token',
+ regionControl: '',
+ regionPanel: 'space-y-4',
+ regionCaret: ''
+ }
+ });
+ expect(getByTestId('accordion')).toBeTruthy();
+ expect(getByTestId('accordion').className).to.contain('space-y-1');
+ });
+});
diff --git a/web/app/src/skeleton/components/Accordion/AccordionItem.svelte b/web/app/src/skeleton/components/Accordion/AccordionItem.svelte
new file mode 100644
index 0000000..56b1c6c
--- /dev/null
+++ b/web/app/src/skeleton/components/Accordion/AccordionItem.svelte
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+ {#if $$slots.lead}
+
+
+
+ {/if}
+
+
+ (summary)
+
+
+
+
+
+ {#if openState}
+
+ (content)
+
+ {/if}
+
diff --git a/web/app/src/skeleton/components/Accordion/AccordionItem.svelte.d.ts b/web/app/src/skeleton/components/Accordion/AccordionItem.svelte.d.ts
new file mode 100644
index 0000000..07dafec
--- /dev/null
+++ b/web/app/src/skeleton/components/Accordion/AccordionItem.svelte.d.ts
@@ -0,0 +1,58 @@
+import { SvelteComponentTyped } from 'svelte';
+import type { Writable } from 'svelte/store';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Set open by default on load.*/
+ open?: boolean | undefined;
+ /** Provide a unique input id. Auto-generated by default*/
+ id?: string | undefined;
+ /** Set the auto-collapse mode.*/
+ autocollapse?: boolean | undefined;
+ /** The writable store that houses the auto-collapse active item UUID.*/
+ active?: Writable | undefined;
+ /** Set the drawer animation duration.*/
+ duration?: number | undefined;
+ /** Set the disabled state for this item.*/
+ disabled?: boolean | undefined;
+ /** Provide classes to set the accordion item padding styles.*/
+ padding?: string | undefined;
+ /** Provide classes to set the accordion item hover styles.*/
+ hover?: string | undefined;
+ /** Provide classes to set the accordion item rounded styles.*/
+ rounded?: string | undefined;
+ /** Provide arbitrary classes to the trigger button region.*/
+ caretOpen?: string | undefined;
+ /** Provide arbitrary classes to content panel region.*/
+ caretClosed?: string | undefined;
+ /** Provide arbitrary classes to the trigger button region.*/
+ regionControl?: string | undefined;
+ /** Provide arbitrary classes to content panel region.*/
+ regionPanel?: string | undefined;
+ /** Provide arbitrary classes caret icon region.*/
+ regionCaret?: string | undefined;
+ };
+ events: {
+ click: MouseEvent;
+ keydown: KeyboardEvent;
+ keyup: KeyboardEvent;
+ keypress: KeyboardEvent;
+ } & {
+ [evt: string]: CustomEvent;
+ };
+ slots: {
+ lead: {};
+ summary: {};
+ content: {};
+ };
+};
+export type AccordionItemProps = typeof __propDef.props;
+export type AccordionItemEvents = typeof __propDef.events;
+export type AccordionItemSlots = typeof __propDef.slots;
+/** The Accordion child element. */
+export default class AccordionItem extends SvelteComponentTyped<
+ AccordionItemProps,
+ AccordionItemEvents,
+ AccordionItemSlots
+> {}
+export {};
diff --git a/web/app/src/skeleton/components/Accordion/AccordionItem.test.d.ts b/web/app/src/skeleton/components/Accordion/AccordionItem.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/Accordion/AccordionItem.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/Accordion/AccordionItem.test.js b/web/app/src/skeleton/components/Accordion/AccordionItem.test.js
new file mode 100644
index 0000000..6c41038
--- /dev/null
+++ b/web/app/src/skeleton/components/Accordion/AccordionItem.test.js
@@ -0,0 +1,22 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import AccordionItem from './AccordionItem.svelte';
+describe('AccordionItem.svelte', () => {
+ it('Renders with minimal props', async () => {
+ const { getByTestId } = render(AccordionItem);
+ expect(getByTestId('accordion-item')).toBeTruthy();
+ });
+ it('Renders with all props', async () => {
+ const { getByTestId } = render(AccordionItem, {
+ open: true,
+ padding: 'py-2 px-4',
+ hover: 'hover:bg-primary-hover-token',
+ rounded: 'rounded-container-token',
+ regionControl: '',
+ regionPanel: 'space-y-4',
+ regionCaret: ''
+ });
+ expect(getByTestId('accordion-item')).toBeTruthy();
+ expect(getByTestId('accordion-item').querySelector('.accordion-control')?.className).to.contain('py-2 px-4');
+ });
+});
diff --git a/web/app/src/skeleton/components/AppBar/AppBar.svelte b/web/app/src/skeleton/components/AppBar/AppBar.svelte
new file mode 100644
index 0000000..ec4a876
--- /dev/null
+++ b/web/app/src/skeleton/components/AppBar/AppBar.svelte
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+ {#if $$slots.lead}
+
+ {/if}
+
+
+
+ {#if $$slots.trail}
+
+ {/if}
+
+
+ {#if $$slots.headline}
+
+ {/if}
+
diff --git a/web/app/src/skeleton/components/AppBar/AppBar.svelte.d.ts b/web/app/src/skeleton/components/AppBar/AppBar.svelte.d.ts
new file mode 100644
index 0000000..155bc9e
--- /dev/null
+++ b/web/app/src/skeleton/components/AppBar/AppBar.svelte.d.ts
@@ -0,0 +1,48 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Provide classes to set background color.*/
+ background?: string | undefined;
+ /** Provide classes to set border styles.*/
+ border?: string | undefined;
+ /** Provide classes to set padding.*/
+ padding?: string | undefined;
+ /** Provide classes to define a box shadow.*/
+ shadow?: string | undefined;
+ /** Provide classes to set the vertical spacing between rows.*/
+ spacing?: string | undefined;
+ /** Provide classes to set grid columns for the main row.*/
+ gridColumns?: string | undefined;
+ /** Provide classes to set gap spacing for the main row.*/
+ gap?: string | undefined;
+ /** Provide arbitrary classes to style the top (main) row.*/
+ regionRowMain?: string | undefined;
+ /** Provide arbitrary classes to style the bottom (headline) row.*/
+ regionRowHeadline?: string | undefined;
+ /** Classes to apply to the lead slot container element*/
+ slotLead?: string | undefined;
+ /** Classes to apply to the default slot container element*/
+ slotDefault?: string | undefined;
+ /** Classes to apply to the trail slot container element*/
+ slotTrail?: string | undefined;
+ /** Provide a semantic ID for the ARIA label.*/
+ label?: string | undefined;
+ /** Provide the ID of the element that labels the toolbar.*/
+ labelledby?: string | undefined;
+ };
+ events: {
+ [evt: string]: CustomEvent;
+ };
+ slots: {
+ lead: {};
+ default: {};
+ trail: {};
+ headline: {};
+ };
+};
+export type AppBarProps = typeof __propDef.props;
+export type AppBarEvents = typeof __propDef.events;
+export type AppBarSlots = typeof __propDef.slots;
+export default class AppBar extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/components/AppBar/AppBar.test.d.ts b/web/app/src/skeleton/components/AppBar/AppBar.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/AppBar/AppBar.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/AppBar/AppBar.test.js b/web/app/src/skeleton/components/AppBar/AppBar.test.js
new file mode 100644
index 0000000..c37ad83
--- /dev/null
+++ b/web/app/src/skeleton/components/AppBar/AppBar.test.js
@@ -0,0 +1,28 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import AppBar from './AppBar.svelte';
+describe('AppBar.svelte', () => {
+ it('Renders with minimal props', async () => {
+ const { getByTestId } = render(AppBar);
+ expect(getByTestId('app-bar')).toBeTruthy();
+ });
+ it('Renders with all props', async () => {
+ const { getByTestId } = render(AppBar, {
+ props: {
+ background: 'bg-primary-500',
+ border: 'border border-secondary-500',
+ padding: 'p-4',
+ shadow: 'shadow',
+ space: 'space-x-2',
+ // Slots
+ slotLead: 'bg-red-500',
+ slotCenter: 'bg-green-500',
+ slotTrail: 'bg-blue-500',
+ // a11y
+ label: 'TestAppShell',
+ labelledby: 'TestLabelAppShell'
+ }
+ });
+ expect(getByTestId('app-bar')).toBeTruthy();
+ });
+});
diff --git a/web/app/src/skeleton/components/AppRail/AppRail.svelte b/web/app/src/skeleton/components/AppRail/AppRail.svelte
new file mode 100644
index 0000000..0395adb
--- /dev/null
+++ b/web/app/src/skeleton/components/AppRail/AppRail.svelte
@@ -0,0 +1,31 @@
+
+
+
+
+
diff --git a/web/app/src/skeleton/components/AppRail/AppRail.svelte.d.ts b/web/app/src/skeleton/components/AppRail/AppRail.svelte.d.ts
new file mode 100644
index 0000000..beb8961
--- /dev/null
+++ b/web/app/src/skeleton/components/AppRail/AppRail.svelte.d.ts
@@ -0,0 +1,43 @@
+import { SvelteComponentTyped } from 'svelte';
+import { type Writable } from 'svelte/store';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Provide a writable store to maintain navigation selection.*/
+ selected?: Writable | undefined;
+ /** Provide classes to set the background color.*/
+ background?: string | undefined;
+ /** Provide classes to set the background color.*/
+ border?: string | undefined;
+ /** Provide classes to set the tile active tile background.*/
+ active?: string | undefined;
+ /** Provide classes to set the tile hover background color.*/
+ hover?: string | undefined;
+ /** Provide classes to set the width.*/
+ width?: string | undefined;
+ /** Provide classes to set the height.*/
+ height?: string | undefined;
+ /** Provide a class to set the grid gap.*/
+ gap?: string | undefined;
+ /** Provide arbitrary classes to the lead region.*/
+ regionLead?: string | undefined;
+ /** Provide arbitrary classes to the default region.*/
+ regionDefault?: string | undefined;
+ /** Provide arbitrary classes to the trail region.*/
+ regionTrail?: string | undefined;
+ };
+ events: {
+ [evt: string]: CustomEvent;
+ };
+ slots: {
+ lead: {};
+ default: {};
+ trail: {};
+ };
+};
+export type AppRailProps = typeof __propDef.props;
+export type AppRailEvents = typeof __propDef.events;
+export type AppRailSlots = typeof __propDef.slots;
+/** A vertical navigation rail component. */
+export default class AppRail extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/components/AppRail/AppRailTile.svelte b/web/app/src/skeleton/components/AppRail/AppRailTile.svelte
new file mode 100644
index 0000000..1511f14
--- /dev/null
+++ b/web/app/src/skeleton/components/AppRail/AppRailTile.svelte
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+ {#if $$slots.default}
+
+ {/if}
+
+ {#if label}
+ {label}
+ {/if}
+
+
diff --git a/web/app/src/skeleton/components/AppRail/AppRailTile.svelte.d.ts b/web/app/src/skeleton/components/AppRail/AppRailTile.svelte.d.ts
new file mode 100644
index 0000000..ff11659
--- /dev/null
+++ b/web/app/src/skeleton/components/AppRail/AppRailTile.svelte.d.ts
@@ -0,0 +1,38 @@
+import { SvelteComponentTyped } from 'svelte';
+import type { Writable } from 'svelte/store';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Provide a unique value, active tiles will be highlighted.*/
+ value?: any | undefined;
+ /** Provide the element tag. Button or Anchor recommended.*/
+ tag?: string | undefined;
+ /** Provide the visible text label.*/
+ label?: string | undefined;
+ /** Provide arbitrary classes to style the icon region.*/
+ regionIcon?: string | undefined;
+ /** Provide arbitrary classes to style the label region.*/
+ regionLabel?: string | undefined;
+ selected?: Writable | undefined;
+ active?: Writable | undefined;
+ hover?: Writable | undefined;
+ };
+ events: {
+ keydown: KeyboardEvent;
+ keyup: KeyboardEvent;
+ keypress: KeyboardEvent;
+ /** {{ event }} click - Fires when the component is clicked.*/
+ click: CustomEvent;
+ } & {
+ [evt: string]: CustomEvent;
+ };
+ slots: {
+ default: {};
+ };
+};
+export type AppRailTileProps = typeof __propDef.props;
+export type AppRailTileEvents = typeof __propDef.events;
+export type AppRailTileSlots = typeof __propDef.slots;
+/** A navigation tile for the App Rail component. */
+export default class AppRailTile extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/components/AppShell/AppShell.svelte b/web/app/src/skeleton/components/AppShell/AppShell.svelte
new file mode 100644
index 0000000..6eb4770
--- /dev/null
+++ b/web/app/src/skeleton/components/AppShell/AppShell.svelte
@@ -0,0 +1,68 @@
+
+
+
+
+ {#if $$slots.header}
+
+ {/if}
+
+
+
+
+ {#if $$slots.sidebarLeft}
+
+ {/if}
+
+
+
+
+ {#if $$slots.pageHeader}
+
+ {/if}
+
+
+
+
+
+ {#if $$slots.pageFooter}
+
+ {/if}
+
+
+
+ {#if $$slots.sidebarRight}
+
+ {/if}
+
+
+
+ {#if $$slots.footer}
+
+ {/if}
+
diff --git a/web/app/src/skeleton/components/AppShell/AppShell.svelte.d.ts b/web/app/src/skeleton/components/AppShell/AppShell.svelte.d.ts
new file mode 100644
index 0000000..f7ea13d
--- /dev/null
+++ b/web/app/src/skeleton/components/AppShell/AppShell.svelte.d.ts
@@ -0,0 +1,41 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Apply arbitrary classes to the entire `#page` region.*/
+ regionPage?: string | undefined;
+ /** Apply arbitrary classes to the `header` slot container element*/
+ slotHeader?: string | undefined;
+ /** Apply arbitrary classes to the `sidebarLeft` slot container element*/
+ slotSidebarLeft?: string | undefined;
+ /** Apply arbitrary classes to the `sidebarRight` slot container element*/
+ slotSidebarRight?: string | undefined;
+ /** Apply arbitrary classes to the `pageHeader` slot container element*/
+ slotPageHeader?: string | undefined;
+ /** Apply arbitrary classes to the `pageContent` slot container element*/
+ slotPageContent?: string | undefined;
+ /** Apply arbitrary classes to the `pageFooter` slot container element*/
+ slotPageFooter?: string | undefined;
+ /** Apply arbitrary classes to the `footer` slot container element*/
+ slotFooter?: string | undefined;
+ };
+ events: {
+ scroll: Event;
+ } & {
+ [evt: string]: CustomEvent;
+ };
+ slots: {
+ header: {};
+ sidebarLeft: {};
+ pageHeader: {};
+ default: {};
+ pageFooter: {};
+ sidebarRight: {};
+ footer: {};
+ };
+};
+export type AppShellProps = typeof __propDef.props;
+export type AppShellEvents = typeof __propDef.events;
+export type AppShellSlots = typeof __propDef.slots;
+export default class AppShell extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/components/AppShell/AppShell.test.d.ts b/web/app/src/skeleton/components/AppShell/AppShell.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/AppShell/AppShell.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/AppShell/AppShell.test.js b/web/app/src/skeleton/components/AppShell/AppShell.test.js
new file mode 100644
index 0000000..af29be1
--- /dev/null
+++ b/web/app/src/skeleton/components/AppShell/AppShell.test.js
@@ -0,0 +1,23 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import AppShell from './AppShell.svelte';
+describe('AppShell.svelte', () => {
+ it('Renders with minimal props', async () => {
+ const { getByTestId } = render(AppShell);
+ expect(getByTestId('app-shell')).toBeTruthy();
+ });
+ it('Renders with all props', async () => {
+ const { getByTestId } = render(AppShell, {
+ props: {
+ slotHeader: 'bg-red-500',
+ slotSidebarLeft: 'w-auto',
+ slotSidebarRight: 'w-auto',
+ slotPageHeader: 'bg-green-500',
+ slotPageContent: 'bg-blue-500',
+ slotPageFooter: 'bg-yellow-500',
+ slotFooter: 'bg-purple-500'
+ }
+ });
+ expect(getByTestId('app-shell')).toBeTruthy();
+ });
+});
diff --git a/web/app/src/skeleton/components/Autocomplete/Autocomplete.svelte b/web/app/src/skeleton/components/Autocomplete/Autocomplete.svelte
new file mode 100644
index 0000000..3731580
--- /dev/null
+++ b/web/app/src/skeleton/components/Autocomplete/Autocomplete.svelte
@@ -0,0 +1,80 @@
+
+
+
+
+ {#if optionsFiltered.length > 0}
+
+
+ {#each optionsFiltered as option, i (option)}
+
+ onSelection(option)}
+ on:click
+ on:keypress
+ >
+ {@html option.label}
+
+
+ {/each}
+
+
+ {:else}
+
{emptyState}
+ {/if}
+
diff --git a/web/app/src/skeleton/components/Autocomplete/Autocomplete.svelte.d.ts b/web/app/src/skeleton/components/Autocomplete/Autocomplete.svelte.d.ts
new file mode 100644
index 0000000..62c3d15
--- /dev/null
+++ b/web/app/src/skeleton/components/Autocomplete/Autocomplete.svelte.d.ts
@@ -0,0 +1,51 @@
+import { SvelteComponentTyped } from 'svelte';
+import type { AutocompleteOption } from './types';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Bind the input value.*/
+ input?: unknown;
+ /** Define values for the list*/
+ options?: AutocompleteOption[] | undefined;
+ /** Provide allowlist values*/
+ allowlist?: unknown[] | undefined;
+ /** Provide denylist values*/
+ denylist?: unknown[] | undefined;
+ /** Provide a HTML markup to display when no match is found.*/
+ emptyState?: string | undefined;
+ /** Provide arbitrary classes to nav element.*/
+ regionNav?: string | undefined;
+ /** Provide arbitrary classes to each list.*/
+ regionList?: string | undefined;
+ /** Provide arbitrary classes to each list item.*/
+ regionItem?: string | undefined;
+ /** Provide arbitrary classes to each button.*/
+ regionButton?: string | undefined;
+ /** Provide arbitrary classes to empty message.*/
+ regionEmpty?: string | undefined;
+ /** DEPRECATED: replace with allowlist*/
+ whitelist?: unknown[] | undefined;
+ /** DEPRECATED: replace with denylist*/
+ blacklist?: unknown[] | undefined;
+ /** DEPRECATED: Set the animation duration. Use zero to disable.*/
+ duration?: number | undefined;
+ };
+ events: {
+ click: MouseEvent;
+ keypress: KeyboardEvent;
+ /** {AutocompleteOption} selection - Fire on option select.*/
+ selection: CustomEvent;
+ } & {
+ [evt: string]: CustomEvent;
+ };
+ slots: {};
+};
+export type AutocompleteProps = typeof __propDef.props;
+export type AutocompleteEvents = typeof __propDef.events;
+export type AutocompleteSlots = typeof __propDef.slots;
+export default class Autocomplete extends SvelteComponentTyped<
+ AutocompleteProps,
+ AutocompleteEvents,
+ AutocompleteSlots
+> {}
+export {};
diff --git a/web/app/src/skeleton/components/Autocomplete/Autocomplete.test.d.ts b/web/app/src/skeleton/components/Autocomplete/Autocomplete.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/Autocomplete/Autocomplete.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/Autocomplete/Autocomplete.test.js b/web/app/src/skeleton/components/Autocomplete/Autocomplete.test.js
new file mode 100644
index 0000000..edc8f24
--- /dev/null
+++ b/web/app/src/skeleton/components/Autocomplete/Autocomplete.test.js
@@ -0,0 +1,128 @@
+import { describe, expect, it, vi } from 'vitest';
+import { Autocomplete } from '../../index.ts';
+import { render } from '@testing-library/svelte';
+// keeping this as an array of `as const`s gives us autocompletion, and type safety
+const options = [
+ { label: 'Vanilla', value: 'vanilla', keywords: 'plain, basic', meta: { healthy: false } },
+ { label: 'Chocolate', value: 'chocolate', keywords: 'dark, white', meta: { healthy: false } },
+ { label: 'Strawberry', value: 'strawberry', keywords: 'fruit', meta: { healthy: true } },
+ {
+ label: 'Neapolitan',
+ value: 'neapolitan',
+ keywords: 'mix, strawberry, chocolate, vanilla',
+ meta: { healthy: false }
+ },
+ { label: 'Pineapple', value: 'pineapple', keywords: 'fruit', meta: { healthy: true } },
+ { label: 'Peach', value: 'peach', keywords: 'fruit', meta: { healthy: true } }
+];
+// NB: be very careful picking options. Make sure none of the search terms match the keywords of unintended options.
+describe('Autocomplete.svelte', () => {
+ it('Shows all the options when no search term is provided', () => {
+ const { getByText } = render(Autocomplete, { props: { options, input: '' } });
+ options.forEach((option) => {
+ expect(getByText(option.label)).toBeTruthy();
+ });
+ });
+ it('Shows only the options that match the search term', () => {
+ const matchingOptions = ['Neapolitan', 'Pineapple'];
+ const notMatchingOptions = options.filter((option) => !matchingOptions.includes(option.label));
+ const { getByText, queryByText } = render(Autocomplete, { props: { options, input: 'ne' } });
+ matchingOptions.forEach((option) => {
+ expect(getByText(option)).toBeTruthy();
+ });
+ notMatchingOptions.forEach((option) => {
+ expect(queryByText(option.label)).toBeFalsy();
+ });
+ });
+ it('Searches in keywords', () => {
+ const matchingOptions = ['Strawberry', 'Peach', 'Pineapple'];
+ const notMatchingOptions = options.filter((option) => !matchingOptions.includes(option.label));
+ const { getByText, queryByText } = render(Autocomplete, { props: { options, input: 'fruit' } });
+ matchingOptions.forEach((option) => {
+ expect(getByText(option)).toBeTruthy();
+ });
+ notMatchingOptions.forEach((option) => {
+ expect(queryByText(option.label)).toBeFalsy();
+ });
+ });
+ it('Shows a message when no options match the search term', () => {
+ const { getByText, rerender } = render(Autocomplete, { props: { options, input: 'nonexistent' } });
+ expect(getByText('No Results Found.')).toBeTruthy();
+ // ensures custom message is set
+ rerender({ options, input: 'nonexistent', emptyState: 'custom message' });
+ expect(getByText('custom message')).toBeTruthy();
+ });
+ it('Fires the selection event when an option is selected', () => {
+ let selectedOption;
+ const selectionHandler = vi.fn((e) => (selectedOption = e.detail));
+ const { getByText, component } = render(Autocomplete, {
+ props: { options, input: 'ne' }
+ });
+ component.$on('selection', selectionHandler);
+ getByText('Neapolitan').click();
+ expect(selectionHandler).toHaveBeenCalled();
+ expect(selectedOption).toEqual(options.find((o) => o.label === 'Neapolitan'));
+ });
+ describe('whitelist', () => {
+ it('only shows items in the whitelist when no search term is present', () => {
+ // doesn't include 'pineapple'
+ const whitelist = ['neapolitan', 'chocolate', 'peach', 'strawberry', 'vanilla'];
+ const { getByText, queryByText } = render(Autocomplete, { props: { options, input: '', whitelist } });
+ options.forEach((option) => {
+ if (!whitelist.includes(option.value)) {
+ expect(queryByText(option.label)).toBeFalsy();
+ } else {
+ expect(getByText(option.label)).toBeTruthy();
+ }
+ });
+ });
+ it('only shows items in the whitelist when searching', () => {
+ // doesn't include 'pineapple'
+ const whitelist = ['neapolitan', 'chocolate', 'peach', 'strawberry', 'vanilla'];
+ const { getByText, queryByText } = render(Autocomplete, { props: { options, input: 'ne', whitelist } });
+ const matchingOptions = ['Neapolitan'];
+ const notMatchingOptions = options.filter((option) => !matchingOptions.includes(option.label));
+ matchingOptions.forEach((option) => {
+ expect(getByText(option)).toBeTruthy();
+ });
+ notMatchingOptions.forEach((option) => {
+ expect(queryByText(option.label)).toBeFalsy();
+ });
+ });
+ it('shows the empty message if the only matching elements are not in the whitelist', () => {
+ const whitelist = ['neapolitan', 'chocolate', 'peach', 'strawberry', 'vanilla'];
+ const { queryByText } = render(Autocomplete, { props: { options, input: 'pineapple', whitelist } });
+ expect(queryByText('No Results Found.')).toBeTruthy();
+ });
+ });
+ describe('blacklist', () => {
+ it('does not show items in the blacklist when no search term is present', () => {
+ const blacklist = ['pineapple'];
+ const { getByText, queryByText } = render(Autocomplete, { props: { options, input: '', blacklist } });
+ options.forEach((option) => {
+ if (blacklist.includes(option.value)) {
+ expect(queryByText(option.label)).toBeFalsy();
+ } else {
+ expect(getByText(option.label)).toBeTruthy();
+ }
+ });
+ });
+ it('does not show items in the blacklist when searching', () => {
+ const blacklist = ['pineapple'];
+ const { getByText, queryByText } = render(Autocomplete, { props: { options, input: 'ne', blacklist } });
+ const matchingOptions = ['Neapolitan'];
+ const notMatchingOptions = options.filter((option) => !matchingOptions.includes(option.label));
+ matchingOptions.forEach((option) => {
+ expect(getByText(option)).toBeTruthy();
+ });
+ notMatchingOptions.forEach((option) => {
+ expect(queryByText(option.label)).toBeFalsy();
+ });
+ });
+ it('shows the empty message if the only matching options are in the blacklist', () => {
+ const blacklist = ['pineapple'];
+ const { queryByText } = render(Autocomplete, { props: { options, input: 'pineapple', blacklist } });
+ expect(queryByText('No Results Found.')).toBeTruthy();
+ });
+ });
+});
diff --git a/web/app/src/skeleton/components/Autocomplete/types.d.ts b/web/app/src/skeleton/components/Autocomplete/types.d.ts
new file mode 100644
index 0000000..b7486db
--- /dev/null
+++ b/web/app/src/skeleton/components/Autocomplete/types.d.ts
@@ -0,0 +1,10 @@
+export interface AutocompleteOption {
+ /** provide a unique display label per option. Supports HTML. */
+ label: string;
+ /** Provide a unique option value. */
+ value: unknown;
+ /** Provide a comma separated list of keywords. */
+ keywords?: any;
+ /** Pass arbitrary data per option. */
+ meta?: any;
+}
diff --git a/web/app/src/skeleton/components/Autocomplete/types.js b/web/app/src/skeleton/components/Autocomplete/types.js
new file mode 100644
index 0000000..5c6dfd2
--- /dev/null
+++ b/web/app/src/skeleton/components/Autocomplete/types.js
@@ -0,0 +1,2 @@
+// Autocomplete Types
+export {};
diff --git a/web/app/src/skeleton/components/Avatar/Avatar.svelte b/web/app/src/skeleton/components/Avatar/Avatar.svelte
new file mode 100644
index 0000000..4705c45
--- /dev/null
+++ b/web/app/src/skeleton/components/Avatar/Avatar.svelte
@@ -0,0 +1,47 @@
+
+
+
+ {#if src}
+
+ {:else}
+
+
+ {String(initials).substring(0, 2).toUpperCase()}
+
+
+ {/if}
+
diff --git a/web/app/src/skeleton/components/Avatar/Avatar.svelte.d.ts b/web/app/src/skeleton/components/Avatar/Avatar.svelte.d.ts
new file mode 100644
index 0000000..e44f2f5
--- /dev/null
+++ b/web/app/src/skeleton/components/Avatar/Avatar.svelte.d.ts
@@ -0,0 +1,42 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Initials only - Provide up to two text characters.*/
+ initials?: string | undefined;
+ /** Initials only - Provide classes to set the SVG text fill color.*/
+ fill?: string | undefined;
+ /** Provide the avatar image element source.*/
+ src?: string | undefined;
+ /** Image only. Provide an Svelte action reference, such as `filter`.*/
+ action?: any;
+ /** Image only. Provide Svelte action params, such as Apollo.*/
+ actionParams?: string | undefined;
+ /** Provide classes to set background styles.*/
+ background?: string | undefined;
+ /** Provide classes to set avatar width.*/
+ width?: string | undefined;
+ /** Provide classes to set border styles.*/
+ border?: string | undefined;
+ /** Provide classes to set rounded style.*/
+ rounded?: string | undefined;
+ /** Provide classes to set shadow styles.*/
+ shadow?: string | undefined;
+ /** Provide classes to set cursor styles.*/
+ cursor?: string | undefined;
+ };
+ events: {
+ click: MouseEvent;
+ keydown: KeyboardEvent;
+ keyup: KeyboardEvent;
+ keypress: KeyboardEvent;
+ } & {
+ [evt: string]: CustomEvent;
+ };
+ slots: {};
+};
+export type AvatarProps = typeof __propDef.props;
+export type AvatarEvents = typeof __propDef.events;
+export type AvatarSlots = typeof __propDef.slots;
+export default class Avatar extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/components/Avatar/Avatar.test.d.ts b/web/app/src/skeleton/components/Avatar/Avatar.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/Avatar/Avatar.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/Avatar/Avatar.test.js b/web/app/src/skeleton/components/Avatar/Avatar.test.js
new file mode 100644
index 0000000..13f9e8a
--- /dev/null
+++ b/web/app/src/skeleton/components/Avatar/Avatar.test.js
@@ -0,0 +1,35 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import Avatar from './Avatar.svelte';
+const imgTextSrc = 'https://i.pravatar.cc/512?img=48';
+describe('Avatar.svelte', () => {
+ it('Renders with minimal props', async () => {
+ const { getByTestId } = render(Avatar);
+ expect(getByTestId('avatar')).toBeTruthy();
+ });
+ it('Renders with all props', async () => {
+ const { getByTestId } = render(Avatar, {
+ props: {
+ background: 'bg-surface-500',
+ width: 'w-12',
+ border: '',
+ rounded: 'rounded-full',
+ shadow: 'shadow-xl',
+ // Props (initials)
+ initials: 'AB',
+ text: 'text-xl',
+ color: 'text-white'
+ }
+ });
+ expect(getByTestId('avatar')).toBeTruthy();
+ });
+ it('Image shown when src prop set', async () => {
+ const { getByTestId } = render(Avatar, { props: { src: imgTextSrc } });
+ const elemImage = getByTestId('avatar').querySelector('.avatar-image');
+ expect(elemImage.src).to.eq(imgTextSrc);
+ });
+ it('Initials shown when no image source provided', async () => {
+ const { getByTestId } = render(Avatar);
+ expect(getByTestId('avatar').querySelector('.avatar-initials')?.textContent).eq('AB');
+ });
+});
diff --git a/web/app/src/skeleton/components/ConicGradient/ConicGradient.svelte b/web/app/src/skeleton/components/ConicGradient/ConicGradient.svelte
new file mode 100644
index 0000000..614d034
--- /dev/null
+++ b/web/app/src/skeleton/components/ConicGradient/ConicGradient.svelte
@@ -0,0 +1,69 @@
+
+
+
+
+ {#if $$slots.default}
+
+ {/if}
+
+ {#if cone}
+
+ {/if}
+
+ {#if legend && generatedLegendList}
+
+ {#each generatedLegendList as { color, label, value }}
+
+
+ {label}
+ {value}%
+
+ {/each}
+
+ {/if}
+
diff --git a/web/app/src/skeleton/components/ConicGradient/ConicGradient.svelte.d.ts b/web/app/src/skeleton/components/ConicGradient/ConicGradient.svelte.d.ts
new file mode 100644
index 0000000..98bac49
--- /dev/null
+++ b/web/app/src/skeleton/components/ConicGradient/ConicGradient.svelte.d.ts
@@ -0,0 +1,43 @@
+import { SvelteComponentTyped } from 'svelte';
+import type { ConicStop } from './types';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Provide a data set of color stops and labels.*/
+ stops?: ConicStop[] | undefined;
+ /** Enable a contextual legend.*/
+ legend?: boolean | undefined;
+ /** When enabled, the conic gradient will spin.*/
+ spin?: boolean | undefined;
+ /** Style the conic gradient width.*/
+ width?: string | undefined;
+ /** Style the legend hover effect.*/
+ hover?: string | undefined;
+ /** Style the caption region above the gradient.*/
+ regionCaption?: string | undefined;
+ /** Style the conic gradient region.*/
+ regionCone?: string | undefined;
+ /** Style the legend region below the gradient.*/
+ regionLegend?: string | undefined;
+ };
+ events: {
+ click: MouseEvent;
+ keydown: KeyboardEvent;
+ keyup: KeyboardEvent;
+ keypress: KeyboardEvent;
+ } & {
+ [evt: string]: CustomEvent;
+ };
+ slots: {
+ default: {};
+ };
+};
+export type ConicGradientProps = typeof __propDef.props;
+export type ConicGradientEvents = typeof __propDef.events;
+export type ConicGradientSlots = typeof __propDef.slots;
+export default class ConicGradient extends SvelteComponentTyped<
+ ConicGradientProps,
+ ConicGradientEvents,
+ ConicGradientSlots
+> {}
+export {};
diff --git a/web/app/src/skeleton/components/ConicGradient/ConicGradient.test.d.ts b/web/app/src/skeleton/components/ConicGradient/ConicGradient.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/ConicGradient/ConicGradient.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/ConicGradient/ConicGradient.test.js b/web/app/src/skeleton/components/ConicGradient/ConicGradient.test.js
new file mode 100644
index 0000000..3a47a5e
--- /dev/null
+++ b/web/app/src/skeleton/components/ConicGradient/ConicGradient.test.js
@@ -0,0 +1,23 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import ConicGradient from './ConicGradient.svelte';
+describe('ConicGradient.svelte', () => {
+ it('Renders with minimal props', async () => {
+ const { getByTestId } = render(ConicGradient);
+ expect(getByTestId('conic-gradient')).toBeTruthy();
+ });
+ it('Renders with props', () => {
+ const { getByTestId } = render(ConicGradient, {
+ props: {
+ data: [
+ { label: 'Emerald', color: ['emerald', 500], start: 0, end: 33 },
+ { label: 'Indigo', color: ['indigo', 500], start: 33, end: 66 },
+ { label: 'Rose', color: ['rose', 500], start: 66, end: 100 }
+ ],
+ legend: true,
+ width: 'w-8'
+ }
+ });
+ expect(getByTestId('conic-gradient')).toBeTruthy();
+ });
+});
diff --git a/web/app/src/skeleton/components/ConicGradient/settings.d.ts b/web/app/src/skeleton/components/ConicGradient/settings.d.ts
new file mode 100644
index 0000000..5486de9
--- /dev/null
+++ b/web/app/src/skeleton/components/ConicGradient/settings.d.ts
@@ -0,0 +1,9 @@
+export type HexRgb = {
+ hex: string;
+ rgb: string;
+};
+export type TailwindColorObject = {
+ label: string;
+ shades: Record;
+};
+export declare const tailwindDefaultColors: TailwindColorObject[];
diff --git a/web/app/src/skeleton/components/ConicGradient/settings.js b/web/app/src/skeleton/components/ConicGradient/settings.js
new file mode 100644
index 0000000..1e2b7fc
--- /dev/null
+++ b/web/app/src/skeleton/components/ConicGradient/settings.js
@@ -0,0 +1,334 @@
+// Provides the full set of Tailwind colors via Javascript
+// https://tailwindcss.com/docs/customizing-colors#default-color-palette
+export const tailwindDefaultColors = [
+ {
+ label: 'slate',
+ shades: {
+ 50: { hex: '#f8fafc', rgb: '248 250 252' },
+ 100: { hex: '#f1f5f9', rgb: '241 245 249' },
+ 200: { hex: '#e2e8f0', rgb: '226 232 240' },
+ 300: { hex: '#cbd5e1', rgb: '203 213 225' },
+ 400: { hex: '#94a3b8', rgb: '148 163 184' },
+ 500: { hex: '#64748b', rgb: '100 116 139' },
+ 600: { hex: '#475569', rgb: '71 85 105' },
+ 700: { hex: '#334155', rgb: '51 65 85' },
+ 800: { hex: '#1e293b', rgb: '30 41 59' },
+ 900: { hex: '#0f172a', rgb: '15 23 42' }
+ }
+ },
+ {
+ label: 'gray',
+ shades: {
+ 50: { hex: '#f9fafb', rgb: '249 250 251' },
+ 100: { hex: '#f3f4f6', rgb: '243 244 246' },
+ 200: { hex: '#e5e7eb', rgb: '229 231 235' },
+ 300: { hex: '#d1d5db', rgb: '209 213 219' },
+ 400: { hex: '#9ca3af', rgb: '156 163 175' },
+ 500: { hex: '#6b7280', rgb: '107 114 128' },
+ 600: { hex: '#4b5563', rgb: '75 85 99' },
+ 700: { hex: '#374151', rgb: '55 65 81' },
+ 800: { hex: '#1f2937', rgb: '31 41 55' },
+ 900: { hex: '#111827', rgb: '17 24 39' }
+ }
+ },
+ {
+ label: 'zinc',
+ shades: {
+ 50: { hex: '#fafafa', rgb: '250 250 250' },
+ 100: { hex: '#f4f4f5', rgb: '244 244 245' },
+ 200: { hex: '#e4e4e7', rgb: '228 228 231' },
+ 300: { hex: '#d4d4d8', rgb: '212 212 216' },
+ 400: { hex: '#a1a1aa', rgb: '161 161 170' },
+ 500: { hex: '#71717a', rgb: '113 113 122' },
+ 600: { hex: '#52525b', rgb: '82 82 91' },
+ 700: { hex: '#3f3f46', rgb: '63 63 70' },
+ 800: { hex: '#27272a', rgb: '39 39 42' },
+ 900: { hex: '#18181b', rgb: '24 24 27' }
+ }
+ },
+ {
+ label: 'neutral',
+ shades: {
+ 50: { hex: '#fafafa', rgb: '250 250 250' },
+ 100: { hex: '#f5f5f5', rgb: '245 245 245' },
+ 200: { hex: '#e5e5e5', rgb: '229 229 229' },
+ 300: { hex: '#d4d4d4', rgb: '212 212 212' },
+ 400: { hex: '#a3a3a3', rgb: '163 163 163' },
+ 500: { hex: '#737373', rgb: '115 115 115' },
+ 600: { hex: '#525252', rgb: '82 82 82' },
+ 700: { hex: '#404040', rgb: '64 64 64' },
+ 800: { hex: '#262626', rgb: '38 38 38' },
+ 900: { hex: '#171717', rgb: '23 23 23' }
+ }
+ },
+ {
+ label: 'stone',
+ shades: {
+ 50: { hex: '#fafaf9', rgb: '250 250 249' },
+ 100: { hex: '#f5f5f4', rgb: '245 245 244' },
+ 200: { hex: '#e7e5e4', rgb: '231 229 228' },
+ 300: { hex: '#d6d3d1', rgb: '214 211 209' },
+ 400: { hex: '#a8a29e', rgb: '168 162 158' },
+ 500: { hex: '#78716c', rgb: '120 113 108' },
+ 600: { hex: '#57534e', rgb: '87 83 78' },
+ 700: { hex: '#44403c', rgb: '68 64 60' },
+ 800: { hex: '#292524', rgb: '41 37 36' },
+ 900: { hex: '#1c1917', rgb: '28 25 23' }
+ }
+ },
+ {
+ label: 'red',
+ shades: {
+ 50: { hex: '#fef2f2', rgb: '254 242 242' },
+ 100: { hex: '#fee2e2', rgb: '254 226 226' },
+ 200: { hex: '#fecaca', rgb: '254 202 202' },
+ 300: { hex: '#fca5a5', rgb: '252 165 165' },
+ 400: { hex: '#f87171', rgb: '248 113 113' },
+ 500: { hex: '#ef4444', rgb: '239 68 68' },
+ 600: { hex: '#dc2626', rgb: '220 38 38' },
+ 700: { hex: '#b91c1c', rgb: '185 28 28' },
+ 800: { hex: '#991b1b', rgb: '153 27 27' },
+ 900: { hex: '#7f1d1d', rgb: '127 29 29' }
+ }
+ },
+ {
+ label: 'orange',
+ shades: {
+ 50: { hex: '#fff7ed', rgb: '255 247 237' },
+ 100: { hex: '#ffedd5', rgb: '255 237 213' },
+ 200: { hex: '#fed7aa', rgb: '254 215 170' },
+ 300: { hex: '#fdba74', rgb: '253 186 116' },
+ 400: { hex: '#fb923c', rgb: '251 146 60' },
+ 500: { hex: '#f97316', rgb: '249 115 22' },
+ 600: { hex: '#ea580c', rgb: '234 88 12' },
+ 700: { hex: '#c2410c', rgb: '194 65 12' },
+ 800: { hex: '#9a3412', rgb: '154 52 18' },
+ 900: { hex: '#7c2d12', rgb: '124 45 18' }
+ }
+ },
+ {
+ label: 'amber',
+ shades: {
+ 50: { hex: '#fffbeb', rgb: '255 251 235' },
+ 100: { hex: '#fef3c7', rgb: '254 243 199' },
+ 200: { hex: '#fde68a', rgb: '253 230 138' },
+ 300: { hex: '#fcd34d', rgb: '252 211 77' },
+ 400: { hex: '#fbbf24', rgb: '251 191 36' },
+ 500: { hex: '#f59e0b', rgb: '245 158 11' },
+ 600: { hex: '#d97706', rgb: '217 119 6' },
+ 700: { hex: '#b45309', rgb: '180 83 9' },
+ 800: { hex: '#92400e', rgb: '146 64 14' },
+ 900: { hex: '#78350f', rgb: '120 53 15' }
+ }
+ },
+ {
+ label: 'yellow',
+ shades: {
+ 50: { hex: '#fefce8', rgb: '254 252 232' },
+ 100: { hex: '#fef9c3', rgb: '254 249 195' },
+ 200: { hex: '#fef08a', rgb: '254 240 138' },
+ 300: { hex: '#fde047', rgb: '253 224 71' },
+ 400: { hex: '#facc15', rgb: '250 204 21' },
+ 500: { hex: '#eab308', rgb: '234 179 8' },
+ 600: { hex: '#ca8a04', rgb: '202 138 4' },
+ 700: { hex: '#a16207', rgb: '161 98 7' },
+ 800: { hex: '#854d0e', rgb: '133 77 14' },
+ 900: { hex: '#713f12', rgb: '113 63 18' }
+ }
+ },
+ {
+ label: 'lime',
+ shades: {
+ 50: { hex: '#f7fee7', rgb: '247 254 231' },
+ 100: { hex: '#ecfccb', rgb: '236 252 203' },
+ 200: { hex: '#d9f99d', rgb: '217 249 157' },
+ 300: { hex: '#bef264', rgb: '190 242 100' },
+ 400: { hex: '#a3e635', rgb: '163 230 53' },
+ 500: { hex: '#84cc16', rgb: '132 204 22' },
+ 600: { hex: '#65a30d', rgb: '101 163 13' },
+ 700: { hex: '#4d7c0f', rgb: '77 124 15' },
+ 800: { hex: '#3f6212', rgb: '63 98 18' },
+ 900: { hex: '#365314', rgb: '54 83 20' }
+ }
+ },
+ {
+ label: 'green',
+ shades: {
+ 50: { hex: '#f0fdf4', rgb: '240 253 244' },
+ 100: { hex: '#dcfce7', rgb: '220 252 231' },
+ 200: { hex: '#bbf7d0', rgb: '187 247 208' },
+ 300: { hex: '#86efac', rgb: '134 239 172' },
+ 400: { hex: '#4ade80', rgb: '74 222 128' },
+ 500: { hex: '#22c55e', rgb: '34 197 94' },
+ 600: { hex: '#16a34a', rgb: '22 163 74' },
+ 700: { hex: '#15803d', rgb: '21 128 61' },
+ 800: { hex: '#166534', rgb: '22 101 52' },
+ 900: { hex: '#14532d', rgb: '20 83 45' }
+ }
+ },
+ {
+ label: 'emerald',
+ shades: {
+ 50: { hex: '#ecfdf5', rgb: '236 253 245' },
+ 100: { hex: '#d1fae5', rgb: '209 250 229' },
+ 200: { hex: '#a7f3d0', rgb: '167 243 208' },
+ 300: { hex: '#6ee7b7', rgb: '110 231 183' },
+ 400: { hex: '#34d399', rgb: '52 211 153' },
+ 500: { hex: '#10b981', rgb: '16 185 129' },
+ 600: { hex: '#059669', rgb: '5 150 105' },
+ 700: { hex: '#047857', rgb: '4 120 87' },
+ 800: { hex: '#065f46', rgb: '6 95 70' },
+ 900: { hex: '#064e3b', rgb: '6 78 59' }
+ }
+ },
+ {
+ label: 'teal',
+ shades: {
+ 50: { hex: '#f0fdfa', rgb: '240 253 250' },
+ 100: { hex: '#ccfbf1', rgb: '204 251 241' },
+ 200: { hex: '#99f6e4', rgb: '153 246 228' },
+ 300: { hex: '#5eead4', rgb: '94 234 212' },
+ 400: { hex: '#2dd4bf', rgb: '45 212 191' },
+ 500: { hex: '#14b8a6', rgb: '20 184 166' },
+ 600: { hex: '#0d9488', rgb: '13 148 136' },
+ 700: { hex: '#0f766e', rgb: '15 118 110' },
+ 800: { hex: '#115e59', rgb: '17 94 89' },
+ 900: { hex: '#134e4a', rgb: '19 78 74' }
+ }
+ },
+ {
+ label: 'cyan',
+ shades: {
+ 50: { hex: '#ecfeff', rgb: '236 254 255' },
+ 100: { hex: '#cffafe', rgb: '207 250 254' },
+ 200: { hex: '#a5f3fc', rgb: '165 243 252' },
+ 300: { hex: '#67e8f9', rgb: '103 232 249' },
+ 400: { hex: '#22d3ee', rgb: '34 211 238' },
+ 500: { hex: '#06b6d4', rgb: '6 182 212' },
+ 600: { hex: '#0891b2', rgb: '8 145 178' },
+ 700: { hex: '#0e7490', rgb: '14 116 144' },
+ 800: { hex: '#155e75', rgb: '21 94 117' },
+ 900: { hex: '#164e63', rgb: '22 78 99' }
+ }
+ },
+ {
+ label: 'sky',
+ shades: {
+ 50: { hex: '#f0f9ff', rgb: '240 249 255' },
+ 100: { hex: '#e0f2fe', rgb: '224 242 254' },
+ 200: { hex: '#bae6fd', rgb: '186 230 253' },
+ 300: { hex: '#7dd3fc', rgb: '125 211 252' },
+ 400: { hex: '#38bdf8', rgb: '56 189 248' },
+ 500: { hex: '#0ea5e9', rgb: '14 165 233' },
+ 600: { hex: '#0284c7', rgb: '2 132 199' },
+ 700: { hex: '#0369a1', rgb: '3 105 161' },
+ 800: { hex: '#075985', rgb: '7 89 133' },
+ 900: { hex: '#0c4a6e', rgb: '12 74 110' }
+ }
+ },
+ {
+ label: 'blue',
+ shades: {
+ 50: { hex: '#eff6ff', rgb: '239 246 255' },
+ 100: { hex: '#dbeafe', rgb: '219 234 254' },
+ 200: { hex: '#bfdbfe', rgb: '191 219 254' },
+ 300: { hex: '#93c5fd', rgb: '147 197 253' },
+ 400: { hex: '#60a5fa', rgb: '96 165 250' },
+ 500: { hex: '#3b82f6', rgb: '59 130 246' },
+ 600: { hex: '#2563eb', rgb: '37 99 235' },
+ 700: { hex: '#1d4ed8', rgb: '29 78 216' },
+ 800: { hex: '#1e40af', rgb: '30 64 175' },
+ 900: { hex: '#1e3a8a', rgb: '30 58 138' }
+ }
+ },
+ {
+ label: 'indigo',
+ shades: {
+ 50: { hex: '#eef2ff', rgb: '238 242 255' },
+ 100: { hex: '#e0e7ff', rgb: '224 231 255' },
+ 200: { hex: '#c7d2fe', rgb: '199 210 254' },
+ 300: { hex: '#a5b4fc', rgb: '165 180 252' },
+ 400: { hex: '#818cf8', rgb: '129 140 248' },
+ 500: { hex: '#6366f1', rgb: '99 102 241' },
+ 600: { hex: '#4f46e5', rgb: '79 70 229' },
+ 700: { hex: '#4338ca', rgb: '67 56 202' },
+ 800: { hex: '#3730a3', rgb: '55 48 163' },
+ 900: { hex: '#312e81', rgb: '49 46 129' }
+ }
+ },
+ {
+ label: 'violet',
+ shades: {
+ 50: { hex: '#f5f3ff', rgb: '245 243 255' },
+ 100: { hex: '#ede9fe', rgb: '237 233 254' },
+ 200: { hex: '#ddd6fe', rgb: '221 214 254' },
+ 300: { hex: '#c4b5fd', rgb: '196 181 253' },
+ 400: { hex: '#a78bfa', rgb: '167 139 250' },
+ 500: { hex: '#8b5cf6', rgb: '139 92 246' },
+ 600: { hex: '#7c3aed', rgb: '124 58 237' },
+ 700: { hex: '#6d28d9', rgb: '109 40 217' },
+ 800: { hex: '#5b21b6', rgb: '91 33 182' },
+ 900: { hex: '#4c1d95', rgb: '76 29 149' }
+ }
+ },
+ {
+ label: 'purple',
+ shades: {
+ 50: { hex: '#faf5ff', rgb: '250 245 255' },
+ 100: { hex: '#f3e8ff', rgb: '243 232 255' },
+ 200: { hex: '#e9d5ff', rgb: '233 213 255' },
+ 300: { hex: '#d8b4fe', rgb: '216 180 254' },
+ 400: { hex: '#c084fc', rgb: '192 132 252' },
+ 500: { hex: '#a855f7', rgb: '168 85 247' },
+ 600: { hex: '#9333ea', rgb: '147 51 234' },
+ 700: { hex: '#7e22ce', rgb: '126 34 206' },
+ 800: { hex: '#6b21a8', rgb: '107 33 168' },
+ 900: { hex: '#581c87', rgb: '88 28 135' }
+ }
+ },
+ {
+ label: 'fuchsia',
+ shades: {
+ 50: { hex: '#fdf4ff', rgb: '253 244 255' },
+ 100: { hex: '#fae8ff', rgb: '250 232 255' },
+ 200: { hex: '#f5d0fe', rgb: '245 208 254' },
+ 300: { hex: '#f0abfc', rgb: '240 171 252' },
+ 400: { hex: '#e879f9', rgb: '232 121 249' },
+ 500: { hex: '#d946ef', rgb: '217 70 239' },
+ 600: { hex: '#c026d3', rgb: '192 38 211' },
+ 700: { hex: '#a21caf', rgb: '162 28 175' },
+ 800: { hex: '#86198f', rgb: '134 25 143' },
+ 900: { hex: '#701a75', rgb: '112 26 117' }
+ }
+ },
+ {
+ label: 'pink',
+ shades: {
+ 50: { hex: '#fdf2f8', rgb: '253 242 248' },
+ 100: { hex: '#fce7f3', rgb: '252 231 243' },
+ 200: { hex: '#fbcfe8', rgb: '251 207 232' },
+ 300: { hex: '#f9a8d4', rgb: '249 168 212' },
+ 400: { hex: '#f472b6', rgb: '244 114 182' },
+ 500: { hex: '#ec4899', rgb: '236 72 153' },
+ 600: { hex: '#db2777', rgb: '219 39 119' },
+ 700: { hex: '#be185d', rgb: '190 24 93' },
+ 800: { hex: '#9d174d', rgb: '157 23 77' },
+ 900: { hex: '#831843', rgb: '131 24 67' }
+ }
+ },
+ {
+ label: 'rose',
+ shades: {
+ 50: { hex: '#fff1f2', rgb: '255 241 242' },
+ 100: { hex: '#ffe4e6', rgb: '255 228 230' },
+ 200: { hex: '#fecdd3', rgb: '254 205 211' },
+ 300: { hex: '#fda4af', rgb: '253 164 175' },
+ 400: { hex: '#fb7185', rgb: '251 113 133' },
+ 500: { hex: '#f43f5e', rgb: '244 63 94' },
+ 600: { hex: '#e11d48', rgb: '225 29 72' },
+ 700: { hex: '#be123c', rgb: '190 18 60' },
+ 800: { hex: '#9f1239', rgb: '159 18 57' },
+ 900: { hex: '#881337', rgb: '136 19 55' }
+ }
+ }
+];
diff --git a/web/app/src/skeleton/components/ConicGradient/types.d.ts b/web/app/src/skeleton/components/ConicGradient/types.d.ts
new file mode 100644
index 0000000..46456a1
--- /dev/null
+++ b/web/app/src/skeleton/components/ConicGradient/types.d.ts
@@ -0,0 +1,10 @@
+export interface ConicStop {
+ /** The legend label value. */
+ label?: string;
+ /** The desired color value. */
+ color: string | object;
+ /** Starting stop value (0-100) */
+ start: number;
+ /** Ending stop value (0-100) */
+ end: number;
+}
diff --git a/web/app/src/skeleton/components/ConicGradient/types.js b/web/app/src/skeleton/components/ConicGradient/types.js
new file mode 100644
index 0000000..1175069
--- /dev/null
+++ b/web/app/src/skeleton/components/ConicGradient/types.js
@@ -0,0 +1,2 @@
+// Conic Gradient Types
+export {};
diff --git a/web/app/src/skeleton/components/FileButton/FileButton.svelte b/web/app/src/skeleton/components/FileButton/FileButton.svelte
new file mode 100644
index 0000000..1ef59fe
--- /dev/null
+++ b/web/app/src/skeleton/components/FileButton/FileButton.svelte
@@ -0,0 +1,36 @@
+
+
+
diff --git a/web/app/src/skeleton/components/FileButton/FileButton.svelte.d.ts b/web/app/src/skeleton/components/FileButton/FileButton.svelte.d.ts
new file mode 100644
index 0000000..a8ee099
--- /dev/null
+++ b/web/app/src/skeleton/components/FileButton/FileButton.svelte.d.ts
@@ -0,0 +1,30 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Bind FileList to the file input.*/
+ files?: FileList | undefined;
+ /** Required. Set a unique name for the file input.*/
+ name: string;
+ /** Provide classes to set the width.*/
+ width?: string | undefined;
+ /** Provide a button variant or other class styles.*/
+ button?: string | undefined;
+ };
+ events: {
+ change: Event;
+ keydown: KeyboardEvent;
+ keyup: KeyboardEvent;
+ keypress: KeyboardEvent;
+ } & {
+ [evt: string]: CustomEvent;
+ };
+ slots: {
+ default: {};
+ };
+};
+export type FileButtonProps = typeof __propDef.props;
+export type FileButtonEvents = typeof __propDef.events;
+export type FileButtonSlots = typeof __propDef.slots;
+export default class FileButton extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/components/FileButton/FileButton.test.d.ts b/web/app/src/skeleton/components/FileButton/FileButton.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/FileButton/FileButton.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/FileButton/FileButton.test.js b/web/app/src/skeleton/components/FileButton/FileButton.test.js
new file mode 100644
index 0000000..5640117
--- /dev/null
+++ b/web/app/src/skeleton/components/FileButton/FileButton.test.js
@@ -0,0 +1,29 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import FileButton from './FileButton.svelte';
+describe('FileButton.svelte', () => {
+ it('Renders with minimal props', async () => {
+ const { getByTestId } = render(FileButton, { props: { name: 'testName' } });
+ expect(getByTestId('file-button')).toBeTruthy();
+ });
+ it('Renders with all props', async () => {
+ // Create Mock FileList
+ // Reference: https://dev.to/akirakashihara/how-to-mock-filelist-on-vitest-or-jest-4494
+ // const file = new File([`foo`], `foo.txt`, { type: `text/plain` });
+ // const input = document.createElement(`input`);
+ // input.setAttribute(`type`, `file`);
+ // input.setAttribute(`name`, `file-upload`);
+ // const mockFileList = Object.create(input.files);
+ // mockFileList[0] = file;
+ // ---
+ const { getByTestId } = render(FileButton, {
+ props: {
+ // files: mockFileList,
+ name: 'testFileButtonInput',
+ accept: 'image/*',
+ multiple: false
+ }
+ });
+ expect(getByTestId('file-button')).toBeTruthy();
+ });
+});
diff --git a/web/app/src/skeleton/components/FileDropzone/FileDropzone.svelte b/web/app/src/skeleton/components/FileDropzone/FileDropzone.svelte
new file mode 100644
index 0000000..fa852d0
--- /dev/null
+++ b/web/app/src/skeleton/components/FileDropzone/FileDropzone.svelte
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+ {#if $$slots.lead}
{/if}
+
+
+ Upload a file or drag and drop
+
+
+ {#if $$slots.meta}
{/if}
+
+
+
diff --git a/web/app/src/skeleton/components/FileDropzone/FileDropzone.svelte.d.ts b/web/app/src/skeleton/components/FileDropzone/FileDropzone.svelte.d.ts
new file mode 100644
index 0000000..06bbc4c
--- /dev/null
+++ b/web/app/src/skeleton/components/FileDropzone/FileDropzone.svelte.d.ts
@@ -0,0 +1,53 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Bind FileList to the file input.*/
+ files?: FileList | undefined;
+ /** Required. Set a unique name for the file input.*/
+ name: string;
+ /** Provide classes to set the border styles.*/
+ border?: string | undefined;
+ /** Provide classes to set the padding styles.*/
+ padding?: string | undefined;
+ /** Provide classes to set the box radius styles.*/
+ rounded?: string | undefined;
+ /** Provide arbitrary styles for the UI region.*/
+ regionInterface?: string | undefined;
+ /** Provide arbitrary styles for the UI text region.*/
+ regionInterfaceText?: string | undefined;
+ /** Provide arbitrary styles for lead slot container.*/
+ slotLead?: string | undefined;
+ /** Provide arbitrary styles for message slot container.*/
+ slotMessage?: string | undefined;
+ /** Provide arbitrary styles for meta text slot container.*/
+ slotMeta?: string | undefined;
+ };
+ events: {
+ change: Event;
+ dragenter: DragEvent;
+ dragover: DragEvent;
+ dragleave: DragEvent;
+ drop: DragEvent;
+ click: MouseEvent;
+ keydown: KeyboardEvent;
+ keyup: KeyboardEvent;
+ keypress: KeyboardEvent;
+ } & {
+ [evt: string]: CustomEvent;
+ };
+ slots: {
+ lead: {};
+ message: {};
+ meta: {};
+ };
+};
+export type FileDropzoneProps = typeof __propDef.props;
+export type FileDropzoneEvents = typeof __propDef.events;
+export type FileDropzoneSlots = typeof __propDef.slots;
+export default class FileDropzone extends SvelteComponentTyped<
+ FileDropzoneProps,
+ FileDropzoneEvents,
+ FileDropzoneSlots
+> {}
+export {};
diff --git a/web/app/src/skeleton/components/FileDropzone/FileDropzone.test.d.ts b/web/app/src/skeleton/components/FileDropzone/FileDropzone.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/FileDropzone/FileDropzone.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/FileDropzone/FileDropzone.test.js b/web/app/src/skeleton/components/FileDropzone/FileDropzone.test.js
new file mode 100644
index 0000000..5b7b99c
--- /dev/null
+++ b/web/app/src/skeleton/components/FileDropzone/FileDropzone.test.js
@@ -0,0 +1,29 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import FileDropzone from './FileDropzone.svelte';
+describe('FileDropzone.svelte', () => {
+ it('Renders with minimal props', async () => {
+ const { getByTestId } = render(FileDropzone, { props: { name: 'testName' } });
+ expect(getByTestId('file-dropzone')).toBeTruthy();
+ });
+ it('Renders with all props', async () => {
+ // Create Mock FileList
+ // Reference: https://dev.to/akirakashihara/how-to-mock-filelist-on-vitest-or-jest-4494
+ // const file = new File([`foo`], `foo.txt`, { type: `text/plain` });
+ // const input = document.createElement(`input`);
+ // input.setAttribute(`type`, `file`);
+ // input.setAttribute(`name`, `file-upload`);
+ // const mockFileList = Object.create(input.files);
+ // mockFileList[0] = file;
+ // ---
+ const { getByTestId } = render(FileDropzone, {
+ props: {
+ // files: mockFileList,
+ name: 'testFileDropzoneInput',
+ accept: 'image/*',
+ multiple: false
+ }
+ });
+ expect(getByTestId('file-dropzone')).toBeTruthy();
+ });
+});
diff --git a/web/app/src/skeleton/components/InputChip/InputChip.svelte b/web/app/src/skeleton/components/InputChip/InputChip.svelte
new file mode 100644
index 0000000..368009d
--- /dev/null
+++ b/web/app/src/skeleton/components/InputChip/InputChip.svelte
@@ -0,0 +1,126 @@
+
+
+
diff --git a/web/app/src/skeleton/components/InputChip/InputChip.svelte.d.ts b/web/app/src/skeleton/components/InputChip/InputChip.svelte.d.ts
new file mode 100644
index 0000000..8a86845
--- /dev/null
+++ b/web/app/src/skeleton/components/InputChip/InputChip.svelte.d.ts
@@ -0,0 +1,54 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Bind the input value.*/
+ input?: string | undefined;
+ /** Set a unique select input name.*/
+ name: string;
+ /** An array of values.*/
+ value?: any[] | undefined;
+ /** Provide a whitelist of accepted values.*/
+ whitelist?: string[] | undefined;
+ /** Maximum number of chips. Set -1 to disable.*/
+ max?: number | undefined;
+ /** Set the minimum character length.*/
+ minlength?: number | undefined;
+ /** Set the maximum character length.*/
+ maxlength?: number | undefined;
+ /** When enabled, allows for uppercase values.*/
+ allowUpperCase?: boolean | undefined;
+ /** When enabled, allows for duplicate values.*/
+ allowDuplicates?: boolean | undefined;
+ /** Provide a custom validator function.*/
+ validation?: ((...args: any[]) => boolean) | undefined;
+ /** The duration of the animated fly effect.*/
+ duration?: number | undefined;
+ /** Set the required state for this input field.*/
+ required?: boolean | undefined;
+ /** Provide classes or a variant to style the chips.*/
+ chips?: string | undefined;
+ /** {{ event: Event, input: any }} invalid - Fires when the input value is invalid.*/
+ invalid?: string | undefined;
+ /** Provide classes to set padding styles.*/
+ padding?: string | undefined;
+ /** Provide classes to set border radius styles.*/
+ rounded?: string | undefined;
+ };
+ events: {
+ /** Bind the input value.*/
+ input: Event;
+ click: MouseEvent;
+ keypress: KeyboardEvent;
+ keydown: KeyboardEvent;
+ keyup: KeyboardEvent;
+ } & {
+ [evt: string]: CustomEvent;
+ };
+ slots: {};
+};
+export type InputChipProps = typeof __propDef.props;
+export type InputChipEvents = typeof __propDef.events;
+export type InputChipSlots = typeof __propDef.slots;
+export default class InputChip extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/components/ListBox/ListBox.svelte b/web/app/src/skeleton/components/ListBox/ListBox.svelte
new file mode 100644
index 0000000..940bde8
--- /dev/null
+++ b/web/app/src/skeleton/components/ListBox/ListBox.svelte
@@ -0,0 +1,21 @@
+
+
+
+
+
diff --git a/web/app/src/skeleton/components/ListBox/ListBox.svelte.d.ts b/web/app/src/skeleton/components/ListBox/ListBox.svelte.d.ts
new file mode 100644
index 0000000..c4ee76f
--- /dev/null
+++ b/web/app/src/skeleton/components/ListBox/ListBox.svelte.d.ts
@@ -0,0 +1,31 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Enable selection of multiple items.*/
+ multiple?: boolean | undefined;
+ /** Provide class to set the vertical spacing style.*/
+ spacing?: string | undefined;
+ /** Provide classes to set the listbox box radius styles.*/
+ rounded?: string | undefined;
+ /** Provide classes to set the listbox item active background styles.*/
+ active?: string | undefined;
+ /** Provide classes to set the listbox item hover background styles.*/
+ hover?: string | undefined;
+ /** Provide classes to set the listbox item padding styles.*/
+ padding?: string | undefined;
+ /** Provide the ARIA labelledby value.*/
+ labelledby?: string | undefined;
+ };
+ events: {
+ [evt: string]: CustomEvent;
+ };
+ slots: {
+ default: {};
+ };
+};
+export type ListBoxProps = typeof __propDef.props;
+export type ListBoxEvents = typeof __propDef.events;
+export type ListBoxSlots = typeof __propDef.slots;
+export default class ListBox extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/components/ListBox/ListBox.test.d.ts b/web/app/src/skeleton/components/ListBox/ListBox.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/ListBox/ListBox.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/ListBox/ListBox.test.js b/web/app/src/skeleton/components/ListBox/ListBox.test.js
new file mode 100644
index 0000000..4e0d87a
--- /dev/null
+++ b/web/app/src/skeleton/components/ListBox/ListBox.test.js
@@ -0,0 +1,42 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import { writable } from 'svelte/store';
+import ListBox from './ListBox.svelte';
+describe('ListBox.svelte', () => {
+ it('Renders with minimal props', async () => {
+ const { getByTestId } = render(ListBox);
+ expect(getByTestId('listbox')).toBeTruthy();
+ });
+ it('Renders with all props', async () => {
+ const { getByTestId } = render(ListBox, {
+ props: {
+ selected: writable('foobar'),
+ space: 'space-y-1',
+ height: 'h-auto',
+ // Props (Item)
+ accent: '!bg-primary-500',
+ padding: 'px-4 py-3',
+ rounded: 'rounded',
+ // Props (regions)
+ regionLabel: 'bg-red-500',
+ regionList: 'bg-green-500',
+ // Props (a11y)
+ label: 'testList1',
+ labelId: 'testListId1'
+ }
+ });
+ expect(getByTestId('listbox')).toBeTruthy();
+ });
+ it('Renders listbox, single value', async () => {
+ const { getByTestId } = render(ListBox, { props: { tag: 'nav', selected: writable('foobar') } });
+ const element = getByTestId('listbox');
+ expect(element).toBeTruthy();
+ expect(element.tagName).eq('DIV');
+ });
+ it('Renders listbox, multiple values', async () => {
+ const { getByTestId } = render(ListBox, { props: { tag: 'nav', selected: writable(['foo', 'bar']) } });
+ const element = getByTestId('listbox');
+ expect(element).toBeTruthy();
+ expect(element.tagName).eq('DIV');
+ });
+});
diff --git a/web/app/src/skeleton/components/ListBox/ListBoxItem.svelte b/web/app/src/skeleton/components/ListBox/ListBoxItem.svelte
new file mode 100644
index 0000000..8c43dee
--- /dev/null
+++ b/web/app/src/skeleton/components/ListBox/ListBoxItem.svelte
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+ {#if multiple}
+
+ {:else}
+
+ {/if}
+
+
+
+
+ {#if $$slots.lead}
{/if}
+
+
+
+ {#if $$slots.trail}
{/if}
+
+
+
diff --git a/web/app/src/skeleton/components/ListBox/ListBoxItem.svelte.d.ts b/web/app/src/skeleton/components/ListBox/ListBoxItem.svelte.d.ts
new file mode 100644
index 0000000..32bd8d5
--- /dev/null
+++ b/web/app/src/skeleton/components/ListBox/ListBoxItem.svelte.d.ts
@@ -0,0 +1,36 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Set the radio group binding value.*/
+ group: any;
+ /** Set a unique name value for the input.*/
+ name: string;
+ /** Set the input's value.*/
+ value: any;
+ multiple?: string | undefined;
+ rounded?: string | undefined;
+ active?: string | undefined;
+ hover?: string | undefined;
+ padding?: string | undefined;
+ };
+ events: {
+ keydown: KeyboardEvent;
+ keyup: KeyboardEvent;
+ keypress: KeyboardEvent;
+ click: MouseEvent;
+ change: Event;
+ } & {
+ [evt: string]: CustomEvent;
+ };
+ slots: {
+ lead: {};
+ default: {};
+ trail: {};
+ };
+};
+export type ListBoxItemProps = typeof __propDef.props;
+export type ListBoxItemEvents = typeof __propDef.events;
+export type ListBoxItemSlots = typeof __propDef.slots;
+export default class ListBoxItem extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/components/ListBox/ListBoxItem.test.d.ts b/web/app/src/skeleton/components/ListBox/ListBoxItem.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/ListBox/ListBoxItem.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/ListBox/ListBoxItem.test.js b/web/app/src/skeleton/components/ListBox/ListBoxItem.test.js
new file mode 100644
index 0000000..6bd0249
--- /dev/null
+++ b/web/app/src/skeleton/components/ListBox/ListBoxItem.test.js
@@ -0,0 +1,29 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import ListBoxItem from './ListBoxItem.svelte';
+describe('ListBoxItem.svelte', () => {
+ it('Renders with minimal props', async () => {
+ const { getByTestId } = render(ListBoxItem, {
+ props: {
+ group: 'testGroup',
+ name: 'testName',
+ value: 'testValue'
+ }
+ });
+ const element = getByTestId('listbox-item');
+ expect(element).toBeTruthy();
+ expect(element.tagName).eq('DIV');
+ });
+ // TODO: we need to define the `value` prop here, not sure the syntax
+ it('Renders selection list item, single value', async () => {
+ const { getByTestId } = render(ListBoxItem, {
+ props: {
+ group: 'testGroup',
+ name: 'testName',
+ value: 'testValue'
+ }
+ });
+ const element = getByTestId('listbox-item');
+ expect(element).toBeTruthy();
+ });
+});
diff --git a/web/app/src/skeleton/components/Paginator/Paginator.svelte b/web/app/src/skeleton/components/Paginator/Paginator.svelte
new file mode 100644
index 0000000..210b7a4
--- /dev/null
+++ b/web/app/src/skeleton/components/Paginator/Paginator.svelte
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+ { onChangeLength() }} class="paginator-select {classesSelect}" {disabled} aria-label="Select Amount">
+ {#each settings.amounts as amount}{amount} {amountText} {/each}
+
+
+
+
+ {settings.offset * settings.limit + 1} - {Math.min(settings.offset * settings.limit + settings.limit, settings.size)} / {settings.size}
+
+
+
+ { onPrev() }} disabled={disabled || settings.offset === 0}>
+ {@html buttonTextPrevious}
+
+ { onNext() }} disabled={disabled || (settings.offset + 1) * settings.limit >= settings.size}>
+ {@html buttonTextNext}
+
+
+
diff --git a/web/app/src/skeleton/components/Paginator/Paginator.svelte.d.ts b/web/app/src/skeleton/components/Paginator/Paginator.svelte.d.ts
new file mode 100644
index 0000000..65b229d
--- /dev/null
+++ b/web/app/src/skeleton/components/Paginator/Paginator.svelte.d.ts
@@ -0,0 +1,39 @@
+import { SvelteComponentTyped } from 'svelte';
+import type { PaginationSettings } from './types';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Pass the page setting object.*/
+ settings?: PaginationSettings | undefined;
+ /** Sets selection and buttons to disabled state on-demand.*/
+ disabled?: boolean | undefined;
+ /** Provide classes to style the select input.*/
+ select?: string | undefined;
+ /** Provide classes to set flexbox justification.*/
+ justify?: string | undefined;
+ /** Provide classes to style page info text.*/
+ text?: string | undefined;
+ /** Set the text for the amount selection input.*/
+ amountText?: string | undefined;
+ /** Provide arbitrary classes to the next/previous buttons.*/
+ buttonClasses?: string | undefined;
+ /** Set the text label for the Previous button.*/
+ buttonTextPrevious?: string | undefined;
+ /** Set the text label for the Next button.*/
+ buttonTextNext?: string | undefined;
+ };
+ events: {
+ /** {{ length: number }} amount - Fires when the amount selection input changes.*/
+ amount: CustomEvent;
+ /** {{ offset: number }} page Fires when the next/back buttons are pressed.*/
+ page: CustomEvent;
+ } & {
+ [evt: string]: CustomEvent;
+ };
+ slots: {};
+};
+export type PaginatorProps = typeof __propDef.props;
+export type PaginatorEvents = typeof __propDef.events;
+export type PaginatorSlots = typeof __propDef.slots;
+export default class Paginator extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/components/Paginator/Paginator.test.d.ts b/web/app/src/skeleton/components/Paginator/Paginator.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/Paginator/Paginator.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/Paginator/Paginator.test.js b/web/app/src/skeleton/components/Paginator/Paginator.test.js
new file mode 100644
index 0000000..e86917c
--- /dev/null
+++ b/web/app/src/skeleton/components/Paginator/Paginator.test.js
@@ -0,0 +1,26 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import Paginator from './Paginator.svelte';
+describe('Paginator.svelte', () => {
+ it('Renders with minimal props', async () => {
+ const { getByTestId } = render(Paginator);
+ expect(getByTestId('paginator')).toBeTruthy();
+ });
+ it('Renders with all props', async () => {
+ const { getByTestId } = render(Paginator, {
+ props: {
+ offset: 1,
+ limit: 50,
+ size: 100,
+ amounts: [1, 5, 10, 50, 100],
+ // ---
+ justify: 'justify-between',
+ text: 'text-xs',
+ select: 'bg-primary-500',
+ // ---
+ buttons: { variant: 'filled-primary' }
+ }
+ });
+ expect(getByTestId('paginator')).toBeTruthy();
+ });
+});
diff --git a/web/app/src/skeleton/components/Paginator/types.d.ts b/web/app/src/skeleton/components/Paginator/types.d.ts
new file mode 100644
index 0000000..08045d5
--- /dev/null
+++ b/web/app/src/skeleton/components/Paginator/types.d.ts
@@ -0,0 +1,10 @@
+export interface PaginationSettings {
+ /** Index of the first list item to display. */
+ offset: number;
+ /** Current number of items to display. */
+ limit: number;
+ /** The total size (length) of your source content. */
+ size: number;
+ /** List of amounts available to the select input */
+ amounts: number[];
+}
diff --git a/web/app/src/skeleton/components/Paginator/types.js b/web/app/src/skeleton/components/Paginator/types.js
new file mode 100644
index 0000000..343ea47
--- /dev/null
+++ b/web/app/src/skeleton/components/Paginator/types.js
@@ -0,0 +1,2 @@
+// Pagination Types
+export {};
diff --git a/web/app/src/skeleton/components/ProgressBar/ProgressBar.svelte b/web/app/src/skeleton/components/ProgressBar/ProgressBar.svelte
new file mode 100644
index 0000000..71d6cd3
--- /dev/null
+++ b/web/app/src/skeleton/components/ProgressBar/ProgressBar.svelte
@@ -0,0 +1,44 @@
+
+
+
+
+
+
diff --git a/web/app/src/skeleton/components/ProgressBar/ProgressBar.svelte.d.ts b/web/app/src/skeleton/components/ProgressBar/ProgressBar.svelte.d.ts
new file mode 100644
index 0000000..0faf97f
--- /dev/null
+++ b/web/app/src/skeleton/components/ProgressBar/ProgressBar.svelte.d.ts
@@ -0,0 +1,31 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Specifies the amount completed. Indeterminate when `undefined`.*/
+ value?: number | undefined;
+ /** Minimum amount the bar represents.*/
+ min?: number | undefined;
+ /** Maximum amount the bar represents.*/
+ max?: number | undefined;
+ /** Provide classes to set track height.*/
+ height?: string | undefined;
+ /** Provide classes to set rounded styles.*/
+ rounded?: string | undefined;
+ /** Provide arbitrary classes to style the meter element.*/
+ meter?: string | undefined;
+ /** Provide arbitrary classes to style the track element.*/
+ track?: string | undefined;
+ /** Provide the ARIA labelledby value.*/
+ labelledby?: string | undefined;
+ };
+ events: {
+ [evt: string]: CustomEvent;
+ };
+ slots: {};
+};
+export type ProgressBarProps = typeof __propDef.props;
+export type ProgressBarEvents = typeof __propDef.events;
+export type ProgressBarSlots = typeof __propDef.slots;
+export default class ProgressBar extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/components/ProgressBar/ProgressBar.test.d.ts b/web/app/src/skeleton/components/ProgressBar/ProgressBar.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/ProgressBar/ProgressBar.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/ProgressBar/ProgressBar.test.js b/web/app/src/skeleton/components/ProgressBar/ProgressBar.test.js
new file mode 100644
index 0000000..e6bf408
--- /dev/null
+++ b/web/app/src/skeleton/components/ProgressBar/ProgressBar.test.js
@@ -0,0 +1,24 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import ProgressBar from './ProgressBar.svelte';
+describe('ProgressBar.svelte', () => {
+ it('Renders with minimal props', async () => {
+ const { getByTestId } = render(ProgressBar);
+ expect(getByTestId('progress-bar')).toBeTruthy();
+ });
+ it('Renders with all props', () => {
+ const { getByTestId } = render(ProgressBar, {
+ props: {
+ label: 'Test',
+ min: 10,
+ value: 50,
+ max: 100,
+ height: 'h-1',
+ rounded: 'rounded-none',
+ meter: 'bg-warning-500',
+ track: 'bg-warning-500/30'
+ }
+ });
+ expect(getByTestId('progress-bar')).toBeTruthy();
+ });
+});
diff --git a/web/app/src/skeleton/components/ProgressRadial/ProgressRadial.svelte b/web/app/src/skeleton/components/ProgressRadial/ProgressRadial.svelte
new file mode 100644
index 0000000..13ee7a5
--- /dev/null
+++ b/web/app/src/skeleton/components/ProgressRadial/ProgressRadial.svelte
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if value != undefined && value >= 0 && $$slots.default}
+
+ {/if}
+
+
diff --git a/web/app/src/skeleton/components/ProgressRadial/ProgressRadial.svelte.d.ts b/web/app/src/skeleton/components/ProgressRadial/ProgressRadial.svelte.d.ts
new file mode 100644
index 0000000..34a065c
--- /dev/null
+++ b/web/app/src/skeleton/components/ProgressRadial/ProgressRadial.svelte.d.ts
@@ -0,0 +1,37 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Set the meter fill amount. Shows as indeterminate when set `undefined`.*/
+ value?: number | undefined;
+ /** Sets the base stroke width. Scales responsively.*/
+ stroke?: number | undefined;
+ /** Sets the base font size. Scales responsively.*/
+ font?: number | undefined;
+ /** Provide classes to set the width.*/
+ width?: string | undefined;
+ /** Provide classes to set meter color.*/
+ meter?: string | undefined;
+ /** Provide classes to set track color.*/
+ track?: string | undefined;
+ /** Provide classes to set the SVG text fill color.*/
+ fill?: string | undefined;
+ /** Provide the ARIA labelledby value.*/
+ labelledby?: string | undefined;
+ };
+ events: {
+ [evt: string]: CustomEvent;
+ };
+ slots: {
+ default: {};
+ };
+};
+export type ProgressRadialProps = typeof __propDef.props;
+export type ProgressRadialEvents = typeof __propDef.events;
+export type ProgressRadialSlots = typeof __propDef.slots;
+export default class ProgressRadial extends SvelteComponentTyped<
+ ProgressRadialProps,
+ ProgressRadialEvents,
+ ProgressRadialSlots
+> {}
+export {};
diff --git a/web/app/src/skeleton/components/ProgressRadial/ProgressRadial.test.d.ts b/web/app/src/skeleton/components/ProgressRadial/ProgressRadial.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/ProgressRadial/ProgressRadial.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/ProgressRadial/ProgressRadial.test.js b/web/app/src/skeleton/components/ProgressRadial/ProgressRadial.test.js
new file mode 100644
index 0000000..b7f6a1a
--- /dev/null
+++ b/web/app/src/skeleton/components/ProgressRadial/ProgressRadial.test.js
@@ -0,0 +1,23 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import ProgressRadial from './ProgressRadial.svelte';
+describe('ProgressRadial.svelte', () => {
+ it('Renders with minimal props', async () => {
+ const { getByTestId } = render(ProgressRadial);
+ expect(getByTestId('progress-radial')).toBeTruthy();
+ });
+ it('Renders with all props', () => {
+ const { getByTestId } = render(ProgressRadial, {
+ props: {
+ value: 50,
+ stroke: 20,
+ track: 'stroke-surface-300 dark:stroke-surface-700',
+ meter: 'stroke-black dark:stroke-white',
+ color: 'fill-black dark:fill-white',
+ font: 56,
+ label: 'testProgressRadial1'
+ }
+ });
+ expect(getByTestId('progress-radial')).toBeTruthy();
+ });
+});
diff --git a/web/app/src/skeleton/components/Radio/RadioGroup.svelte b/web/app/src/skeleton/components/Radio/RadioGroup.svelte
new file mode 100644
index 0000000..6953792
--- /dev/null
+++ b/web/app/src/skeleton/components/Radio/RadioGroup.svelte
@@ -0,0 +1,26 @@
+
+
+
+
+
diff --git a/web/app/src/skeleton/components/Radio/RadioGroup.svelte.d.ts b/web/app/src/skeleton/components/Radio/RadioGroup.svelte.d.ts
new file mode 100644
index 0000000..f124b1a
--- /dev/null
+++ b/web/app/src/skeleton/components/Radio/RadioGroup.svelte.d.ts
@@ -0,0 +1,39 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Provide display classes. Set `flex` to stretch full width.*/
+ display?: string | undefined;
+ /** Provide classes to set the base background color.*/
+ background?: string | undefined;
+ /** Provide classes to set the border styles.*/
+ border?: string | undefined;
+ /** Provide classes horizontal spacing between items.*/
+ spacing?: string | undefined;
+ /** Provide classes to set the border radius.*/
+ rounded?: string | undefined;
+ /** Provide classes to set the RadioItem padding.*/
+ padding?: string | undefined;
+ /** Provide classes to set the active item color.*/
+ active?: string | undefined;
+ /** Provide classes to set the hover style.*/
+ hover?: string | undefined;
+ /** Provide classes to set the highlighted text color.*/
+ color?: string | undefined;
+ /** Provide classes to set the highlighted SVG fill color.*/
+ fill?: string | undefined;
+ /** Provide the ARIA labelledby value.*/
+ labelledby?: string | undefined;
+ };
+ events: {
+ [evt: string]: CustomEvent;
+ };
+ slots: {
+ default: {};
+ };
+};
+export type RadioGroupProps = typeof __propDef.props;
+export type RadioGroupEvents = typeof __propDef.events;
+export type RadioGroupSlots = typeof __propDef.slots;
+export default class RadioGroup extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/components/Radio/RadioGroup.test.d.ts b/web/app/src/skeleton/components/Radio/RadioGroup.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/Radio/RadioGroup.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/Radio/RadioGroup.test.js b/web/app/src/skeleton/components/Radio/RadioGroup.test.js
new file mode 100644
index 0000000..b59e3e6
--- /dev/null
+++ b/web/app/src/skeleton/components/Radio/RadioGroup.test.js
@@ -0,0 +1,27 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import RadioGroup from './RadioGroup.svelte';
+describe('RadioGroup.svelte', () => {
+ it('Renders with minimal props', async () => {
+ const { getByTestId } = render(RadioGroup, {
+ props: {}
+ });
+ expect(getByTestId('radio-group')).toBeTruthy();
+ });
+ it('Renders with all props', () => {
+ const { getByTestId } = render(RadioGroup, {
+ props: {
+ display: 'inline-flex',
+ background: 'bg-surface-300 dark:bg-surface-700',
+ hover: 'hover:bg-primary-500/10',
+ accent: 'bg-primary-500 !text-white',
+ color: 'text-white',
+ fill: 'fill-white',
+ rounded: 'rounded',
+ // A11y
+ label: 'testRadioGroup'
+ }
+ });
+ expect(getByTestId('radio-group')).toBeTruthy();
+ });
+});
diff --git a/web/app/src/skeleton/components/Radio/RadioItem.svelte b/web/app/src/skeleton/components/Radio/RadioItem.svelte
new file mode 100644
index 0000000..98692b5
--- /dev/null
+++ b/web/app/src/skeleton/components/Radio/RadioItem.svelte
@@ -0,0 +1,64 @@
+
+
+
+
+
+
diff --git a/web/app/src/skeleton/components/Radio/RadioItem.svelte.d.ts b/web/app/src/skeleton/components/Radio/RadioItem.svelte.d.ts
new file mode 100644
index 0000000..bebcf02
--- /dev/null
+++ b/web/app/src/skeleton/components/Radio/RadioItem.svelte.d.ts
@@ -0,0 +1,39 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Set the radio group binding value.*/
+ group: any;
+ /** Set a unique name value for the input.*/
+ name: string;
+ /** Set the input's value.*/
+ value: any;
+ /** Set the hover title.*/
+ title?: string | undefined;
+ /** Defines a semantic ARIA label.*/
+ label?: string | undefined;
+ rounded?: string | undefined;
+ padding?: string | undefined;
+ active?: string | undefined;
+ hover?: string | undefined;
+ color?: string | undefined;
+ fill?: string | undefined;
+ };
+ events: {
+ keydown: KeyboardEvent;
+ keyup: KeyboardEvent;
+ keypress: KeyboardEvent;
+ click: MouseEvent;
+ change: Event;
+ } & {
+ [evt: string]: CustomEvent;
+ };
+ slots: {
+ default: {};
+ };
+};
+export type RadioItemProps = typeof __propDef.props;
+export type RadioItemEvents = typeof __propDef.events;
+export type RadioItemSlots = typeof __propDef.slots;
+export default class RadioItem extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/components/Radio/RadioItem.test.d.ts b/web/app/src/skeleton/components/Radio/RadioItem.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/Radio/RadioItem.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/Radio/RadioItem.test.js b/web/app/src/skeleton/components/Radio/RadioItem.test.js
new file mode 100644
index 0000000..ae05f4f
--- /dev/null
+++ b/web/app/src/skeleton/components/Radio/RadioItem.test.js
@@ -0,0 +1,24 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import RadioItem from './RadioItem.svelte';
+describe('RadioItem.svelte', () => {
+ it('Renders with minimal props', async () => {
+ const { getByTestId } = render(RadioItem, {
+ group: 'testGroup',
+ name: 'testName',
+ value: 'testValue'
+ });
+ expect(getByTestId('radio-item')).toBeTruthy();
+ });
+ it('Renders with all props', () => {
+ const { getByTestId } = render(RadioItem, {
+ props: {
+ group: 'testGroup',
+ name: 'testName',
+ value: 'testValue',
+ label: 'testLabel'
+ }
+ });
+ expect(getByTestId('radio-item')).toBeTruthy();
+ });
+});
diff --git a/web/app/src/skeleton/components/RangeSlider/RangeSlider.svelte b/web/app/src/skeleton/components/RangeSlider/RangeSlider.svelte
new file mode 100644
index 0000000..c4d069d
--- /dev/null
+++ b/web/app/src/skeleton/components/RangeSlider/RangeSlider.svelte
@@ -0,0 +1,69 @@
+
+
+
+
+ {#if $$slots.default}
{/if}
+
+
+
+
+
+
+
+ {#if ticked && tickmarks && tickmarks.length}
+
+ {#each tickmarks as tm}
+
+ {/each}
+
+ {/if}
+
+
+
+ {#if $$slots.trail}
{/if}
+
diff --git a/web/app/src/skeleton/components/RangeSlider/RangeSlider.svelte.d.ts b/web/app/src/skeleton/components/RangeSlider/RangeSlider.svelte.d.ts
new file mode 100644
index 0000000..d83448b
--- /dev/null
+++ b/web/app/src/skeleton/components/RangeSlider/RangeSlider.svelte.d.ts
@@ -0,0 +1,40 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Required. Set a unique name for the input.*/
+ name: string;
+ /** Provide a unique input id. Auto-generated by default*/
+ id?: string | undefined;
+ /** Set the input value.*/
+ value?: number | undefined;
+ /** Set the input minimum range.*/
+ min?: number | undefined;
+ /** Set the input maximum range.*/
+ max?: number | undefined;
+ /** Set the input step offset.*/
+ step?: number | undefined;
+ /** Enables tick marks. See browser support below.*/
+ ticked?: boolean | undefined;
+ /** Provide classes to set the input accent color.*/
+ accent?: string | undefined;
+ /** A semantic ARIA label.*/
+ label?: string | undefined;
+ };
+ events: {
+ click: MouseEvent;
+ change: Event;
+ blur: FocusEvent;
+ } & {
+ [evt: string]: CustomEvent;
+ };
+ slots: {
+ default: {};
+ trail: {};
+ };
+};
+export type RangeSliderProps = typeof __propDef.props;
+export type RangeSliderEvents = typeof __propDef.events;
+export type RangeSliderSlots = typeof __propDef.slots;
+export default class RangeSlider extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/components/RangeSlider/RangeSlider.test.d.ts b/web/app/src/skeleton/components/RangeSlider/RangeSlider.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/RangeSlider/RangeSlider.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/RangeSlider/RangeSlider.test.js b/web/app/src/skeleton/components/RangeSlider/RangeSlider.test.js
new file mode 100644
index 0000000..b29ab4b
--- /dev/null
+++ b/web/app/src/skeleton/components/RangeSlider/RangeSlider.test.js
@@ -0,0 +1,38 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import RangeSlider from './RangeSlider.svelte';
+describe('RangeSlider.svelte', () => {
+ it('Renders with minimal props', async () => {
+ const { getByTestId } = render(RangeSlider, { props: { name: 'testRangeSlider' } });
+ expect(getByTestId('range-slider')).toBeTruthy();
+ });
+ it('Renders with all props', async () => {
+ const { getByTestId } = render(RangeSlider, {
+ props: {
+ min: 0,
+ max: 20,
+ step: 5,
+ value: 10,
+ ticked: true,
+ accent: 'bg-primary-500',
+ // a11y
+ id: 'testRangeSlider1',
+ name: 'testRangeSlider1',
+ label: 'testRangeSliderLabel1'
+ }
+ });
+ expect(getByTestId('range-slider')).toBeTruthy();
+ });
+ it('Ticks enabled', async () => {
+ const { getByTestId } = render(RangeSlider, {
+ props: { name: 'testName', ticked: true }
+ });
+ expect(getByTestId('range-slider').querySelector('datalist')).toBeTruthy();
+ });
+ it('Disabled state', async () => {
+ const { getByTestId } = render(RangeSlider, {
+ props: { name: 'testName', disabled: true }
+ });
+ expect(getByTestId('range-slider').querySelector('input')?.disabled).eq(true);
+ });
+});
diff --git a/web/app/src/skeleton/components/SlideToggle/SlideToggle.svelte b/web/app/src/skeleton/components/SlideToggle/SlideToggle.svelte
new file mode 100644
index 0000000..d201dc9
--- /dev/null
+++ b/web/app/src/skeleton/components/SlideToggle/SlideToggle.svelte
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+ {#if $$slots.default}
{/if}
+
+
diff --git a/web/app/src/skeleton/components/SlideToggle/SlideToggle.svelte.d.ts b/web/app/src/skeleton/components/SlideToggle/SlideToggle.svelte.d.ts
new file mode 100644
index 0000000..7b987da
--- /dev/null
+++ b/web/app/src/skeleton/components/SlideToggle/SlideToggle.svelte.d.ts
@@ -0,0 +1,41 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Required. Set a unique name for the input.*/
+ name: string;
+ /** The checked state of the input element.*/
+ checked?: boolean | undefined;
+ /** Sets the size of the component.*/
+ size?: string | undefined;
+ /** Provide classes to set the checked state color.*/
+ active?: string | undefined;
+ /** Provide classes to set the border styles.*/
+ border?: string | undefined;
+ /** Provide classes to set border radius styles.*/
+ rounded?: string | undefined;
+ /** Provide a semantic label.*/
+ label?: string | undefined;
+ };
+ events: {
+ click: MouseEvent;
+ keydown: KeyboardEvent;
+ /** {{ event }} keyup Fires when the component is focused and key is pressed.*/
+ keyup: KeyboardEvent;
+ keypress: KeyboardEvent;
+ mouseover: MouseEvent;
+ change: Event;
+ focus: FocusEvent;
+ blur: FocusEvent;
+ } & {
+ [evt: string]: CustomEvent;
+ };
+ slots: {
+ default: {};
+ };
+};
+export type SlideToggleProps = typeof __propDef.props;
+export type SlideToggleEvents = typeof __propDef.events;
+export type SlideToggleSlots = typeof __propDef.slots;
+export default class SlideToggle extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/components/SlideToggle/SlideToggle.test.d.ts b/web/app/src/skeleton/components/SlideToggle/SlideToggle.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/SlideToggle/SlideToggle.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/SlideToggle/SlideToggle.test.js b/web/app/src/skeleton/components/SlideToggle/SlideToggle.test.js
new file mode 100644
index 0000000..73d9bcd
--- /dev/null
+++ b/web/app/src/skeleton/components/SlideToggle/SlideToggle.test.js
@@ -0,0 +1,21 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import SliderToggle from './SlideToggle.svelte';
+describe('SliderToggle.svelte', () => {
+ it('Renders with minimal props', async () => {
+ const { getByTestId } = render(SliderToggle, { props: { name: 'testName' } });
+ expect(getByTestId('slide-toggle')).toBeTruthy();
+ });
+ it('Renders with all props', async () => {
+ const { getByTestId } = render(SliderToggle, {
+ props: {
+ name: 'testName',
+ checked: true,
+ accent: 'bg-primary-500',
+ size: 'lg',
+ label: 'testSlideToggle1'
+ }
+ });
+ expect(getByTestId('slide-toggle')).toBeTruthy();
+ });
+});
diff --git a/web/app/src/skeleton/components/Stepper/Step.svelte b/web/app/src/skeleton/components/Stepper/Step.svelte
new file mode 100644
index 0000000..be422b0
--- /dev/null
+++ b/web/app/src/skeleton/components/Stepper/Step.svelte
@@ -0,0 +1,88 @@
+
+
+
+{#if stepIndex === $state.current}
+
+
+
+
+
+ ({stepTerm} {stepIndex + 1} Content)
+
+
+ {#if $state.total > 1}
+
+
{@html buttonBackLabel}
+ {#if stepIndex < $state.total - 1}
+
+ {#if locked}
+
+
+
+ {/if}
+ {@html buttonNextLabel}
+
+ {:else}
+
+ {@html buttonCompleteLabel}
+
+ {/if}
+
+ {/if}
+
+{/if}
diff --git a/web/app/src/skeleton/components/Stepper/Step.svelte.d.ts b/web/app/src/skeleton/components/Stepper/Step.svelte.d.ts
new file mode 100644
index 0000000..ce51e23
--- /dev/null
+++ b/web/app/src/skeleton/components/Stepper/Step.svelte.d.ts
@@ -0,0 +1,40 @@
+import { SvelteComponentTyped } from 'svelte';
+import type { Writable } from 'svelte/store';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ locked?: boolean | undefined;
+ /** Provide arbitrary classes to the step header region.*/
+ regionHeader?: string | undefined;
+ /** Provide arbitrary classes to the step content region.*/
+ regionContent?: string | undefined;
+ /** Provide arbitrary classes to the step navigation region.*/
+ regionNavigation?: string | undefined;
+ state?: Writable | undefined;
+ dispatchParent?: any;
+ stepTerm?: string | undefined;
+ gap?: string | undefined;
+ justify?: string | undefined;
+ buttonBack?: string | undefined;
+ buttonBackType?: 'button' | 'reset' | 'submit' | undefined;
+ buttonBackLabel?: string | undefined;
+ buttonNext?: string | undefined;
+ buttonNextType?: 'button' | 'reset' | 'submit' | undefined;
+ buttonNextLabel?: string | undefined;
+ buttonComplete?: string | undefined;
+ buttonCompleteType?: 'button' | 'reset' | 'submit' | undefined;
+ buttonCompleteLabel?: string | undefined;
+ };
+ events: {
+ [evt: string]: CustomEvent;
+ };
+ slots: {
+ header: {};
+ default: {};
+ };
+};
+export type StepProps = typeof __propDef.props;
+export type StepEvents = typeof __propDef.events;
+export type StepSlots = typeof __propDef.slots;
+export default class Step extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/components/Stepper/Step.test.d.ts b/web/app/src/skeleton/components/Stepper/Step.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/Stepper/Step.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/Stepper/Step.test.js b/web/app/src/skeleton/components/Stepper/Step.test.js
new file mode 100644
index 0000000..71cba39
--- /dev/null
+++ b/web/app/src/skeleton/components/Stepper/Step.test.js
@@ -0,0 +1,21 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import { writable } from 'svelte/store';
+import Step from './Step.svelte';
+let mockState = writable({ current: 1, total: 1 }); // NOTE: current/total must match!
+describe('Step.svelte', () => {
+ it('Renders with mininal props', async () => {
+ const { getByTestId } = render(Step, { props: { state: mockState, index: 0 } });
+ expect(getByTestId('step')).toBeTruthy();
+ });
+ it('Renders with all props', () => {
+ const { getByTestId } = render(Step, {
+ props: {
+ state: mockState,
+ index: 0,
+ locked: false
+ }
+ });
+ expect(getByTestId('step')).toBeTruthy();
+ });
+});
diff --git a/web/app/src/skeleton/components/Stepper/Stepper.svelte b/web/app/src/skeleton/components/Stepper/Stepper.svelte
new file mode 100644
index 0000000..d632517
--- /dev/null
+++ b/web/app/src/skeleton/components/Stepper/Stepper.svelte
@@ -0,0 +1,66 @@
+
+
+
+
+ {#if $state.total}
+
+ {/if}
+
+
+
+
+
diff --git a/web/app/src/skeleton/components/Stepper/Stepper.svelte.d.ts b/web/app/src/skeleton/components/Stepper/Stepper.svelte.d.ts
new file mode 100644
index 0000000..bf3f555
--- /dev/null
+++ b/web/app/src/skeleton/components/Stepper/Stepper.svelte.d.ts
@@ -0,0 +1,53 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Provide classes to style the stepper header gap.*/
+ gap?: string | undefined;
+ /** Provide the verbiage that represents "Step".*/
+ stepTerm?: string | undefined;
+ /** Provide classes to style the stepper header badges.*/
+ badge?: string | undefined;
+ /** Provide classes to style the stepper header active step badge.*/
+ active?: string | undefined;
+ /** Provide classes to style the stepper header border.*/
+ border?: string | undefined;
+ /** Provide the initially selected step*/
+ start?: number | undefined;
+ /** Set the justification for the step navigation buttons.*/
+ justify?: string | undefined;
+ /** Provide arbitrary classes to style the back button.*/
+ buttonBack?: string | undefined;
+ /** Set the type of the back button.*/
+ buttonBackType?: 'button' | 'reset' | 'submit' | undefined;
+ /** Provide the HTML label content for the back button.*/
+ buttonBackLabel?: string | undefined;
+ /** Provide arbitrary classes to style the next button.*/
+ buttonNext?: string | undefined;
+ /** Set the type of the next button.*/
+ buttonNextType?: 'button' | 'reset' | 'submit' | undefined;
+ /** Provide the HTML label content for the next button.*/
+ buttonNextLabel?: string | undefined;
+ /** Provide arbitrary classes to style the complete button.*/
+ buttonComplete?: string | undefined;
+ /** Set the type of the complete button.*/
+ buttonCompleteType?: 'button' | 'reset' | 'submit' | undefined;
+ /** Provide the HTML label content for the complete button.*/
+ buttonCompleteLabel?: string | undefined;
+ /** Provide arbitrary classes to the stepper header region.*/
+ regionHeader?: string | undefined;
+ /** Provide arbitrary classes to the stepper content region.*/
+ regionContent?: string | undefined;
+ };
+ events: {
+ [evt: string]: CustomEvent;
+ };
+ slots: {
+ default: {};
+ };
+};
+export type StepperProps = typeof __propDef.props;
+export type StepperEvents = typeof __propDef.events;
+export type StepperSlots = typeof __propDef.slots;
+export default class Stepper extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/components/Stepper/Stepper.test.d.ts b/web/app/src/skeleton/components/Stepper/Stepper.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/Stepper/Stepper.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/Stepper/Stepper.test.js b/web/app/src/skeleton/components/Stepper/Stepper.test.js
new file mode 100644
index 0000000..5e537e5
--- /dev/null
+++ b/web/app/src/skeleton/components/Stepper/Stepper.test.js
@@ -0,0 +1,27 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import { writable } from 'svelte/store';
+import Stepper from './Stepper.svelte';
+describe('Stepper.svelte', () => {
+ it('Renders with minimal props', () => {
+ const { getByTestId } = render(Stepper);
+ expect(getByTestId('stepper')).toBeTruthy();
+ });
+ it('Renders with all props', () => {
+ const { getByTestId } = render(Stepper, {
+ props: {
+ active: writable(0),
+ length: 0,
+ duration: 200,
+ // Props (timeline)
+ color: 'text-white',
+ background: 'bg-secondary-500 text-white',
+ // Props (buttons)
+ buttonBack: {},
+ buttonNext: {},
+ buttonComplete: {}
+ }
+ });
+ expect(getByTestId('stepper')).toBeTruthy();
+ });
+});
diff --git a/web/app/src/skeleton/components/Tab/Tab.svelte b/web/app/src/skeleton/components/Tab/Tab.svelte
new file mode 100644
index 0000000..fbb98bd
--- /dev/null
+++ b/web/app/src/skeleton/components/Tab/Tab.svelte
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+ {#if $$slots.lead}
{/if}
+
+
+
+
diff --git a/web/app/src/skeleton/components/Tab/Tab.svelte.d.ts b/web/app/src/skeleton/components/Tab/Tab.svelte.d.ts
new file mode 100644
index 0000000..cb6968e
--- /dev/null
+++ b/web/app/src/skeleton/components/Tab/Tab.svelte.d.ts
@@ -0,0 +1,44 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Set the radio group binding value.*/
+ group: any;
+ /** Set a unique name value for the input.*/
+ name: string;
+ /** Set the input's value.*/
+ value: any;
+ /** Set the ARIA controls value to define which panel this tab controls.*/
+ controls?: string | undefined;
+ /** Provide classes to style each tab's active styles.*/
+ active?: string | undefined;
+ /** Provide classes to style each tab's hover styles.*/
+ hover?: string | undefined;
+ /** Provide classes to style each tab's flex styles.*/
+ flex?: string | undefined;
+ /** Provide classes to style each tab's padding styles.*/
+ padding?: string | undefined;
+ /** Provide classes to style each tab's box radius styles.*/
+ rounded?: string | undefined;
+ /** Provide classes to set the vertical spacing between items.*/
+ spacing?: string | undefined;
+ };
+ events: {
+ keydown: KeyboardEvent;
+ keyup: KeyboardEvent;
+ keypress: KeyboardEvent;
+ click: MouseEvent;
+ change: Event;
+ } & {
+ [evt: string]: CustomEvent;
+ };
+ slots: {
+ lead: {};
+ default: {};
+ };
+};
+export type TabProps = typeof __propDef.props;
+export type TabEvents = typeof __propDef.events;
+export type TabSlots = typeof __propDef.slots;
+export default class Tab extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/components/Tab/Tab.test.d.ts b/web/app/src/skeleton/components/Tab/Tab.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/Tab/Tab.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/Tab/Tab.test.js b/web/app/src/skeleton/components/Tab/Tab.test.js
new file mode 100644
index 0000000..154bb0e
--- /dev/null
+++ b/web/app/src/skeleton/components/Tab/Tab.test.js
@@ -0,0 +1,30 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import Tab from './Tab.svelte';
+describe('Tab.svelte', () => {
+ it('Renders with minimal props', async () => {
+ const { getByTestId } = render(Tab, {
+ props: {
+ group: 'testGroup',
+ name: 'testName',
+ value: 'testValue'
+ }
+ });
+ expect(getByTestId('tab')).toBeTruthy();
+ });
+ it('Renders with all props', async () => {
+ const { getByTestId } = render(Tab, {
+ props: {
+ group: 'testGroup',
+ name: 'testName',
+ value: 'testValue',
+ // ---
+ borderWidth: 'border-b-2',
+ borderColor: 'border-primary-500',
+ color: 'text-primary-500',
+ fill: 'fill-primary-500'
+ }
+ });
+ expect(getByTestId('tab')).toBeTruthy();
+ });
+});
diff --git a/web/app/src/skeleton/components/Tab/TabGroup.svelte b/web/app/src/skeleton/components/Tab/TabGroup.svelte
new file mode 100644
index 0000000..2cb679d
--- /dev/null
+++ b/web/app/src/skeleton/components/Tab/TabGroup.svelte
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+ {#if $$slots.panel}
+
+ {/if}
+
diff --git a/web/app/src/skeleton/components/Tab/TabGroup.svelte.d.ts b/web/app/src/skeleton/components/Tab/TabGroup.svelte.d.ts
new file mode 100644
index 0000000..a68d03c
--- /dev/null
+++ b/web/app/src/skeleton/components/Tab/TabGroup.svelte.d.ts
@@ -0,0 +1,47 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Provide classes to set the tab list flex justification.*/
+ justify?: string | undefined;
+ /** Provide classes to set the tab group border styles.*/
+ border?: string | undefined;
+ /** Provide classes to style each tab's active styles.*/
+ active?: string | undefined;
+ /** Provide classes to style each tab's hover styles.*/
+ hover?: string | undefined;
+ /** Provide classes to style each tab's flex styles.*/
+ flex?: string | undefined;
+ /** Provide classes to style each tab's padding styles.*/
+ padding?: string | undefined;
+ /** Provide classes to style each tab's box radius styles.*/
+ rounded?: string | undefined;
+ /** Provide classes to set the vertical spacing between items.*/
+ spacing?: string | undefined;
+ /** Provide arbitrary classes to style the tab list region.*/
+ regionList?: string | undefined;
+ /** Provide arbitrary classes to style the tab panel region.*/
+ regionPanel?: string | undefined;
+ /** Provide the ID of the element that labels the tab list.*/
+ labelledby?: string | undefined;
+ /** Matches the tab aria-control value, pairs with the panel.*/
+ panel?: string | undefined;
+ };
+ events: {
+ click: MouseEvent;
+ keypress: KeyboardEvent;
+ keydown: KeyboardEvent;
+ keyup: KeyboardEvent;
+ } & {
+ [evt: string]: CustomEvent;
+ };
+ slots: {
+ default: {};
+ panel: {};
+ };
+};
+export type TabGroupProps = typeof __propDef.props;
+export type TabGroupEvents = typeof __propDef.events;
+export type TabGroupSlots = typeof __propDef.slots;
+export default class TabGroup extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/components/Tab/TabGroup.test.d.ts b/web/app/src/skeleton/components/Tab/TabGroup.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/Tab/TabGroup.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/Tab/TabGroup.test.js b/web/app/src/skeleton/components/Tab/TabGroup.test.js
new file mode 100644
index 0000000..0b2e1e9
--- /dev/null
+++ b/web/app/src/skeleton/components/Tab/TabGroup.test.js
@@ -0,0 +1,26 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import { writable } from 'svelte/store';
+import TabGroup from './TabGroup.svelte';
+describe('TabGroup.svelte', () => {
+ it('Renders with minimal props', async () => {
+ const { getByTestId } = render(TabGroup, { props: { selected: writable(0) } });
+ expect(getByTestId('tab-group')).toBeTruthy();
+ });
+ it('Renders with minimal props', async () => {
+ const { getByTestId } = render(TabGroup, {
+ props: {
+ selected: writable(0),
+ justify: 'justify-start',
+ borderWidth: 'border-b-2',
+ borderColor: 'border-primary-500',
+ color: 'text-primary-500',
+ fill: 'fill-primary-500',
+ hover: 'hover:bg-primary-500/10',
+ labelledby: 'testTabGroupLabel1',
+ label: 'testTabGroup1'
+ }
+ });
+ expect(getByTestId('tab-group')).toBeTruthy();
+ });
+});
diff --git a/web/app/src/skeleton/components/Table/Table.svelte b/web/app/src/skeleton/components/Table/Table.svelte
new file mode 100644
index 0000000..b7ba0d7
--- /dev/null
+++ b/web/app/src/skeleton/components/Table/Table.svelte
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+ {#each source.head as heading }
+ {@html heading}
+ {/each}
+
+
+
+
+ {#each source.body as row, rowIndex}
+
+
+ { onRowClick(e, rowIndex); }}
+ on:keydown={(e) => { onRowKeydown(e, rowIndex); }}
+ aria-rowindex={rowIndex + 1}
+ >
+ {#each row as cell, cellIndex}
+
+
+
+ {@html cell ? cell : '-'}
+
+ {/each}
+
+ {/each}
+
+
+ {#if source.foot}
+
+ {/if}
+
+
diff --git a/web/app/src/skeleton/components/Table/Table.svelte.d.ts b/web/app/src/skeleton/components/Table/Table.svelte.d.ts
new file mode 100644
index 0000000..a645a8d
--- /dev/null
+++ b/web/app/src/skeleton/components/Table/Table.svelte.d.ts
@@ -0,0 +1,41 @@
+import { SvelteComponentTyped } from 'svelte';
+import type { TableSource } from './types';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Provide the full set of table source data.*/
+ source: TableSource;
+ /** Enables row hover style and `on:selected` event when rows are clicked.*/
+ interactive?: boolean | undefined;
+ /** Override the Tailwind Element class. Replace this for a headless UI.*/
+ element?: string | undefined;
+ /** Provide classes to set the table text size.*/
+ text?: string | undefined;
+ /** Provide classes to set the table text color.*/
+ color?: string | undefined;
+ /** Provide arbitrary classes for the table head.*/
+ regionHead?: string | undefined;
+ /** Provide arbitrary classes for the table head cells.*/
+ regionHeadCell?: string | undefined;
+ /** Provide arbitrary classes for the table body.*/
+ regionBody?: string | undefined;
+ /** Provide arbitrary classes for the table cells.*/
+ regionCell?: string | undefined;
+ /** Provide arbitrary classes for the table foot.*/
+ regionFoot?: string | undefined;
+ /** Provide arbitrary classes for the table foot cells.*/
+ regionFootCell?: string | undefined;
+ };
+ events: {
+ /** {rowMetaData} selected - Fires when a table row is clicked.*/
+ selected: CustomEvent;
+ } & {
+ [evt: string]: CustomEvent;
+ };
+ slots: {};
+};
+export type TableProps = typeof __propDef.props;
+export type TableEvents = typeof __propDef.events;
+export type TableSlots = typeof __propDef.slots;
+export default class Table extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/components/Table/types.d.ts b/web/app/src/skeleton/components/Table/types.d.ts
new file mode 100644
index 0000000..1b5eeb6
--- /dev/null
+++ b/web/app/src/skeleton/components/Table/types.d.ts
@@ -0,0 +1,10 @@
+export interface TableSource {
+ /** The formatted table heading values. */
+ head: string[];
+ /** The formatted table body values. */
+ body: string[][];
+ /** The data returned when an interactive row is clicked. */
+ meta?: string[][];
+ /** The formatted table footer values. */
+ foot?: string[];
+}
diff --git a/web/app/src/skeleton/components/Table/types.js b/web/app/src/skeleton/components/Table/types.js
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/components/Table/types.js
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/components/Table/utils.d.ts b/web/app/src/skeleton/components/Table/utils.d.ts
new file mode 100644
index 0000000..1cf41e0
--- /dev/null
+++ b/web/app/src/skeleton/components/Table/utils.d.ts
@@ -0,0 +1,7 @@
+/** Wrap object key value with an HTML tag. */
+/** Map an object to a defined order. */
+export declare function tableSourceMapper(source: any[], keys: string[]): any[];
+/** Map an array of objects to an array of values. */
+export declare function tableSourceValues(source: any[]): any[];
+/** Sets object order and returns values. */
+export declare function tableMapperValues(source: any[], keys: string[]): any[];
diff --git a/web/app/src/skeleton/components/Table/utils.js b/web/app/src/skeleton/components/Table/utils.js
new file mode 100644
index 0000000..8671861
--- /dev/null
+++ b/web/app/src/skeleton/components/Table/utils.js
@@ -0,0 +1,33 @@
+// Table Component Utilities
+// Cell Formatters ---
+// NOTE: this would require `onMount`, which is too slow, so may just nix this.
+// REMINDER: if re-enabled, update `index.ts`!
+/** Wrap object key value with an HTML tag. */
+// export function tableCellFormatter(source: any[], key: string, tagName: string, classes?: string) {
+// return source.map((row) => {
+// if (row[key]) {
+// const newElem: HTMLElement = document.createElement(tagName);
+// newElem.innerHTML = row[key];
+// if (classes) newElem.setAttribute('class', classes);
+// row[key] = newElem.outerHTML;
+// }
+// return row;
+// });
+// }
+// Source Formatters ---
+/** Map an object to a defined order. */
+export function tableSourceMapper(source, keys) {
+ return source.map((row) => {
+ const mappedRow = {};
+ keys.forEach((key) => (mappedRow[key] = row[key]));
+ return mappedRow;
+ });
+}
+/** Map an array of objects to an array of values. */
+export function tableSourceValues(source) {
+ return source.map((row) => Object.values(row));
+}
+/** Sets object order and returns values. */
+export function tableMapperValues(source, keys) {
+ return tableSourceValues(tableSourceMapper(source, keys));
+}
diff --git a/web/app/src/skeleton/components/TableOfContents/TableOfContents.svelte b/web/app/src/skeleton/components/TableOfContents/TableOfContents.svelte
new file mode 100644
index 0000000..43eeee5
--- /dev/null
+++ b/web/app/src/skeleton/components/TableOfContents/TableOfContents.svelte
@@ -0,0 +1,102 @@
+
+
+
+
+{#if filteredHeadingsList.length > 0}
+
+
+ {label}
+ {#each filteredHeadingsList as headingElem, i}
+
+
+ {/each}
+
+
+{/if}
diff --git a/web/app/src/skeleton/components/TableOfContents/TableOfContents.svelte.d.ts b/web/app/src/skeleton/components/TableOfContents/TableOfContents.svelte.d.ts
new file mode 100644
index 0000000..9109eae
--- /dev/null
+++ b/web/app/src/skeleton/components/TableOfContents/TableOfContents.svelte.d.ts
@@ -0,0 +1,47 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Query selector for the scrollable page element.*/
+ scrollParent?: string | undefined;
+ /** Query selector for the element to scan for headings.*/
+ target?: string | undefined;
+ /** Query selector for the allowed headings. From H2-H6.*/
+ allowedHeadings?: string | undefined;
+ /** Set the label text.*/
+ label?: string | undefined;
+ /** Set the component width style.*/
+ width?: string | undefined;
+ /** Set the vertical spacing styles.*/
+ spacing?: string | undefined;
+ /** Set the row text color styles.*/
+ text?: string | undefined;
+ /** Set the row hover styles.*/
+ hover?: string | undefined;
+ /** Set the active row styles*/
+ active?: string | undefined;
+ /** Set the row border radius styles.*/
+ rounded?: string | undefined;
+ /** Provide arbitrary styles for the label element.*/
+ regionLabel?: string | undefined;
+ /** Provide arbitrary styles for the list element.*/
+ regionList?: string | undefined;
+ };
+ events: {
+ click: MouseEvent;
+ keypress: KeyboardEvent;
+ } & {
+ [evt: string]: CustomEvent;
+ };
+ slots: {};
+};
+export type TableOfContentsProps = typeof __propDef.props;
+export type TableOfContentsEvents = typeof __propDef.events;
+export type TableOfContentsSlots = typeof __propDef.slots;
+/** Allows you to quickly navigate the hierarchy of headings for the current page. */
+export default class TableOfContents extends SvelteComponentTyped<
+ TableOfContentsProps,
+ TableOfContentsEvents,
+ TableOfContentsSlots
+> {}
+export {};
diff --git a/web/app/src/skeleton/fonts/quicksand/Quicksand-VariableFont_wght.ttf b/web/app/src/skeleton/fonts/quicksand/Quicksand-VariableFont_wght.ttf
new file mode 100644
index 0000000..0ec2219
Binary files /dev/null and b/web/app/src/skeleton/fonts/quicksand/Quicksand-VariableFont_wght.ttf differ
diff --git a/web/app/src/skeleton/index.d.ts b/web/app/src/skeleton/index.d.ts
new file mode 100644
index 0000000..243eda0
--- /dev/null
+++ b/web/app/src/skeleton/index.d.ts
@@ -0,0 +1,80 @@
+export type { AutocompleteOption } from './components/Autocomplete/types';
+export type { ConicStop } from './components/ConicGradient/types';
+export type { DrawerSettings } from './utilities/Drawer/types';
+export type { ModalSettings, ModalComponent } from './utilities/Modal/types';
+export type { ToastSettings } from './utilities/Toast/types';
+export type { TableSource } from './components/Table/types';
+export type { PopupSettings } from './utilities/Popup/types';
+export type CssClasses = string;
+export { storeHighlightJs } from './utilities/CodeBlock/stores';
+export { storePopup } from './utilities/Popup/popup';
+export { drawerStore } from './utilities/Drawer/stores';
+export { modalStore } from './utilities/Modal/stores';
+export { toastStore } from './utilities/Toast/stores';
+export {
+ type DataTableModel,
+ type DataTableOptions,
+ createDataTableStore,
+ dataTableHandler,
+ tableInteraction,
+ tableA11y
+} from './utilities/DataTable/DataTable';
+export {
+ modeOsPrefers,
+ modeUserPrefers,
+ modeCurrent,
+ getModeOsPrefers,
+ getModeUserPrefers,
+ getModeAutoPrefers,
+ setModeUserPrefers,
+ setModeCurrent,
+ setInitialClassState,
+ autoModeWatcher
+} from './utilities/LightSwitch/lightswitch';
+export { localStorageStore } from './utilities/LocalStorageStore/LocalStorageStore';
+export { tableSourceMapper, tableSourceValues, tableMapperValues } from './components/Table/utils';
+export { clipboard } from './actions/Clipboard/clipboard';
+export { filter } from './actions/Filters/filter';
+export { focusTrap } from './actions/FocusTrap/focusTrap';
+export { popup } from './utilities/Popup/popup';
+export { default as Accordion } from './components/Accordion/Accordion.svelte';
+export { default as AccordionItem } from './components/Accordion/AccordionItem.svelte';
+export { default as AppBar } from './components/AppBar/AppBar.svelte';
+export { default as AppRail } from './components/AppRail/AppRail.svelte';
+export { default as AppRailTile } from './components/AppRail/AppRailTile.svelte';
+export { default as AppShell } from './components/AppShell/AppShell.svelte';
+export { default as Autocomplete } from './components/Autocomplete/Autocomplete.svelte';
+export { default as Avatar } from './components/Avatar/Avatar.svelte';
+export { default as ConicGradient } from './components/ConicGradient/ConicGradient.svelte';
+export { default as FileButton } from './components/FileButton/FileButton.svelte';
+export { default as FileDropzone } from './components/FileDropzone/FileDropzone.svelte';
+export { default as InputChip } from './components/InputChip/InputChip.svelte';
+export { default as ListBox } from './components/ListBox/ListBox.svelte';
+export { default as ListBoxItem } from './components/ListBox/ListBoxItem.svelte';
+export { default as Paginator } from './components/Paginator/Paginator.svelte';
+export { default as ProgressBar } from './components/ProgressBar/ProgressBar.svelte';
+export { default as ProgressRadial } from './components/ProgressRadial/ProgressRadial.svelte';
+export { default as RadioGroup } from './components/Radio/RadioGroup.svelte';
+export { default as RadioItem } from './components/Radio/RadioItem.svelte';
+export { default as RangeSlider } from './components/RangeSlider/RangeSlider.svelte';
+export { default as SlideToggle } from './components/SlideToggle/SlideToggle.svelte';
+export { default as Stepper } from './components/Stepper/Stepper.svelte';
+export { default as Step } from './components/Stepper/Step.svelte';
+export { default as Table } from './components/Table/Table.svelte';
+export { default as TabGroup } from './components/Tab/TabGroup.svelte';
+export { default as Tab } from './components/Tab/Tab.svelte';
+export { default as TableOfContents } from './components/TableOfContents/TableOfContents.svelte';
+export { default as CodeBlock } from './utilities/CodeBlock/CodeBlock.svelte';
+export { default as Modal } from './utilities/Modal/Modal.svelte';
+export { default as Drawer } from './utilities/Drawer/Drawer.svelte';
+export { default as LightSwitch } from './utilities/LightSwitch/LightSwitch.svelte';
+export { default as Toast } from './utilities/Toast/Toast.svelte';
+export { default as Apollo } from './actions/Filters/svg-filters/Apollo.svelte';
+export { default as BlueNight } from './actions/Filters/svg-filters/BlueNight.svelte';
+export { default as Emerald } from './actions/Filters/svg-filters/Emerald.svelte';
+export { default as GreenFall } from './actions/Filters/svg-filters/GreenFall.svelte';
+export { default as Noir } from './actions/Filters/svg-filters/Noir.svelte';
+export { default as NoirLight } from './actions/Filters/svg-filters/NoirLight.svelte';
+export { default as Rustic } from './actions/Filters/svg-filters/Rustic.svelte';
+export { default as Summer84 } from './actions/Filters/svg-filters/Summer84.svelte';
+export { default as XPro } from './actions/Filters/svg-filters/XPro.svelte';
diff --git a/web/app/src/skeleton/index.js b/web/app/src/skeleton/index.js
new file mode 100644
index 0000000..9e39298
--- /dev/null
+++ b/web/app/src/skeleton/index.js
@@ -0,0 +1,86 @@
+// This file defines the short path imports for the package (ex: @skeletonlabs/skeleton/*)
+// Stores ---
+export { storeHighlightJs } from './utilities/CodeBlock/stores';
+export { storePopup } from './utilities/Popup/popup';
+export { drawerStore } from './utilities/Drawer/stores';
+export { modalStore } from './utilities/Modal/stores';
+export { toastStore } from './utilities/Toast/stores';
+// Utilities ---
+// Data Table
+export {
+ // Utilities
+ createDataTableStore,
+ dataTableHandler,
+ // Svelte Actions
+ tableInteraction,
+ tableA11y
+} from './utilities/DataTable/DataTable';
+// Lightswitch
+export {
+ // Stores
+ modeOsPrefers,
+ modeUserPrefers,
+ modeCurrent,
+ // Methods
+ getModeOsPrefers,
+ getModeUserPrefers,
+ getModeAutoPrefers,
+ setModeUserPrefers,
+ setModeCurrent,
+ setInitialClassState,
+ autoModeWatcher
+} from './utilities/LightSwitch/lightswitch';
+// Local Storage Store
+export { localStorageStore } from './utilities/LocalStorageStore/LocalStorageStore';
+// Component Utilities
+export { tableSourceMapper, tableSourceValues, tableMapperValues } from './components/Table/utils';
+// Svelte Actions ---
+export { clipboard } from './actions/Clipboard/clipboard';
+export { filter } from './actions/Filters/filter';
+export { focusTrap } from './actions/FocusTrap/focusTrap';
+// Utility Actions
+export { popup } from './utilities/Popup/popup';
+// Svelte Components ---
+export { default as Accordion } from './components/Accordion/Accordion.svelte';
+export { default as AccordionItem } from './components/Accordion/AccordionItem.svelte';
+export { default as AppBar } from './components/AppBar/AppBar.svelte';
+export { default as AppRail } from './components/AppRail/AppRail.svelte';
+export { default as AppRailTile } from './components/AppRail/AppRailTile.svelte';
+export { default as AppShell } from './components/AppShell/AppShell.svelte';
+export { default as Autocomplete } from './components/Autocomplete/Autocomplete.svelte';
+export { default as Avatar } from './components/Avatar/Avatar.svelte';
+export { default as ConicGradient } from './components/ConicGradient/ConicGradient.svelte';
+export { default as FileButton } from './components/FileButton/FileButton.svelte';
+export { default as FileDropzone } from './components/FileDropzone/FileDropzone.svelte';
+export { default as InputChip } from './components/InputChip/InputChip.svelte';
+export { default as ListBox } from './components/ListBox/ListBox.svelte';
+export { default as ListBoxItem } from './components/ListBox/ListBoxItem.svelte';
+export { default as Paginator } from './components/Paginator/Paginator.svelte';
+export { default as ProgressBar } from './components/ProgressBar/ProgressBar.svelte';
+export { default as ProgressRadial } from './components/ProgressRadial/ProgressRadial.svelte';
+export { default as RadioGroup } from './components/Radio/RadioGroup.svelte';
+export { default as RadioItem } from './components/Radio/RadioItem.svelte';
+export { default as RangeSlider } from './components/RangeSlider/RangeSlider.svelte';
+export { default as SlideToggle } from './components/SlideToggle/SlideToggle.svelte';
+export { default as Stepper } from './components/Stepper/Stepper.svelte';
+export { default as Step } from './components/Stepper/Step.svelte';
+export { default as Table } from './components/Table/Table.svelte';
+export { default as TabGroup } from './components/Tab/TabGroup.svelte';
+export { default as Tab } from './components/Tab/Tab.svelte';
+export { default as TableOfContents } from './components/TableOfContents/TableOfContents.svelte';
+// Utility Components
+export { default as CodeBlock } from './utilities/CodeBlock/CodeBlock.svelte';
+export { default as Modal } from './utilities/Modal/Modal.svelte';
+export { default as Drawer } from './utilities/Drawer/Drawer.svelte';
+export { default as LightSwitch } from './utilities/LightSwitch/LightSwitch.svelte';
+export { default as Toast } from './utilities/Toast/Toast.svelte';
+// Filter Components
+export { default as Apollo } from './actions/Filters/svg-filters/Apollo.svelte';
+export { default as BlueNight } from './actions/Filters/svg-filters/BlueNight.svelte';
+export { default as Emerald } from './actions/Filters/svg-filters/Emerald.svelte';
+export { default as GreenFall } from './actions/Filters/svg-filters/GreenFall.svelte';
+export { default as Noir } from './actions/Filters/svg-filters/Noir.svelte';
+export { default as NoirLight } from './actions/Filters/svg-filters/NoirLight.svelte';
+export { default as Rustic } from './actions/Filters/svg-filters/Rustic.svelte';
+export { default as Summer84 } from './actions/Filters/svg-filters/Summer84.svelte';
+export { default as XPro } from './actions/Filters/svg-filters/XPro.svelte';
diff --git a/web/app/src/skeleton/styles/all.css b/web/app/src/skeleton/styles/all.css
new file mode 100644
index 0000000..7f3768d
--- /dev/null
+++ b/web/app/src/skeleton/styles/all.css
@@ -0,0 +1,18 @@
+/* Stylesheet: all.css */
+/* Import AFTER your theme, but BEFORE your global stylesheet. */
+/* NOTE: The order shown below is required */
+
+/* Tailwind Directives */
+@import 'tailwind.css';
+
+/* Global Styles */
+@import 'core.css';
+
+/* Typographical Settings */
+@import 'typography.css';
+
+/* Imports all Tailwind Elements */
+@import 'elements.css';
+
+/* Imports all Variant Styles */
+@import 'variants.css';
diff --git a/web/app/src/skeleton/styles/core.css b/web/app/src/skeleton/styles/core.css
new file mode 100644
index 0000000..c19af3f
--- /dev/null
+++ b/web/app/src/skeleton/styles/core.css
@@ -0,0 +1,46 @@
+/* Stylesheet: core.css */
+
+@layer base {
+ /* === Body Styles === */
+
+ body {
+ @apply bg-surface-50-900-token;
+ }
+
+ /* === Selection === */
+
+ ::selection {
+ @apply bg-primary-500/30;
+ }
+
+ /* === Focus === */
+
+ /* Outline (do not change) */
+ /* http://www.outlinenone.com/ */
+
+ /* Mobile tap highlight */
+ /* https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-tap-highlight-color */
+ html {
+ -webkit-tap-highlight-color: rgba(128, 128, 128, 0.5);
+ }
+
+ /* === Scrollbars === */
+
+ /* Hide Scrollbars */
+ .hide-scrollbar::-webkit-scrollbar {
+ display: none;
+ }
+ .hide-scrollbar {
+ -ms-overflow-style: none; /* IE/Edge */
+ scrollbar-width: none; /* Firefox */
+ }
+
+ /* === Horizontal Rules === */
+
+ hr:not(.divider) {
+ @apply block border-t border-solid border-surface-300-600-token;
+ }
+ .divider-vertical {
+ @apply inline-block border-l border-solid border-surface-300-600-token min-h-[10px] mx-auto;
+ }
+}
diff --git a/web/app/src/skeleton/styles/elements.css b/web/app/src/skeleton/styles/elements.css
new file mode 100644
index 0000000..ad28c67
--- /dev/null
+++ b/web/app/src/skeleton/styles/elements.css
@@ -0,0 +1,19 @@
+/* Stylesheet: elements.css */
+/* Import AFTER your theme, but BEFORE your global stylesheet. */
+/* Recommended as the LAST stylesheet in the set */
+
+@import 'elements/alerts.css';
+@import 'elements/badges.css';
+@import 'elements/breadcrumbs.css';
+@import 'elements/buttons.css';
+@import 'elements/cards.css';
+@import 'elements/chips.css';
+@import 'elements/forms.css';
+@import 'elements/lists.css';
+@import 'elements/logo-clouds.css';
+@import 'elements/placeholders.css';
+@import 'elements/tables.css';
+
+/* Utilities */
+@import 'elements/modals.css';
+@import 'elements/popups.css';
diff --git a/web/app/src/skeleton/styles/elements/alerts.css b/web/app/src/skeleton/styles/elements/alerts.css
new file mode 100644
index 0000000..63105bf
--- /dev/null
+++ b/web/app/src/skeleton/styles/elements/alerts.css
@@ -0,0 +1,17 @@
+/* Tailwind Elements: alerts.css */
+
+@layer components {
+ .alert {
+ @apply flex flex-col items-start lg:items-center lg:flex-row p-4 space-y-4 lg:space-y-0 lg:space-x-4;
+ /* Text */
+ @apply text-surface-900-50-token;
+ /* Rounded */
+ @apply rounded-container-token;
+ }
+ .alert-message {
+ @apply flex-auto space-y-2;
+ }
+ .alert-actions {
+ @apply flex items-center space-x-2;
+ }
+}
diff --git a/web/app/src/skeleton/styles/elements/badges.css b/web/app/src/skeleton/styles/elements/badges.css
new file mode 100644
index 0000000..f80dac7
--- /dev/null
+++ b/web/app/src/skeleton/styles/elements/badges.css
@@ -0,0 +1,31 @@
+/* Tailwind Elements: badges.css */
+
+@layer components {
+ .badge {
+ /* Core */
+ @apply inline-flex justify-center items-center space-x-2 whitespace-nowrap;
+ /* Text */
+ @apply font-semibold text-xs;
+ /* Padding */
+ @apply px-2 py-1;
+ /* Theme: Rounded */
+ @apply rounded-token;
+ }
+
+ .badge-icon {
+ /* Core */
+ @apply w-5 h-5 flex justify-center items-center rounded-full;
+ /* Text */
+ @apply font-semibold text-xs;
+ /* Shadow */
+ @apply shadow;
+ }
+
+ /* === Variants === */
+
+ /* Glass */
+ .badge-glass {
+ @apply bg-surface-500/20 dark:bg-surface-500/20 backdrop-blur-lg;
+ @apply ring-[1px] ring-neutral-900/5 dark:ring-neutral-50/5 ring-inset;
+ }
+}
diff --git a/web/app/src/skeleton/styles/elements/breadcrumbs.css b/web/app/src/skeleton/styles/elements/breadcrumbs.css
new file mode 100644
index 0000000..f1f4d6e
--- /dev/null
+++ b/web/app/src/skeleton/styles/elements/breadcrumbs.css
@@ -0,0 +1,26 @@
+/* Tailwind Elements: breadcrumbs.css */
+
+@layer components {
+ .breadcrumb,
+ .breadcrumb-nonresponsive {
+ @apply flex items-center space-x-4 w-full hide-scrollbar overflow-x-auto;
+ }
+
+ .crumb {
+ @apply flex justify-center items-center space-x-2;
+ }
+ .crumb-separator {
+ @apply flex text-surface-700-200-token opacity-50;
+ }
+
+ /* === Auto-Responsive === */
+
+ .breadcrumb li {
+ @apply hidden md:block;
+ }
+ .breadcrumb li:nth-last-child(3),
+ .breadcrumb li:nth-last-child(2),
+ .breadcrumb li:nth-last-child(1) {
+ @apply block;
+ }
+}
diff --git a/web/app/src/skeleton/styles/elements/buttons.css b/web/app/src/skeleton/styles/elements/buttons.css
new file mode 100644
index 0000000..62ce73e
--- /dev/null
+++ b/web/app/src/skeleton/styles/elements/buttons.css
@@ -0,0 +1,107 @@
+/* Tailwind Elements: button.css */
+
+@layer components {
+ /* === States === */
+
+ button:disabled {
+ @apply !opacity-50 !cursor-not-allowed active:scale-100 hover:brightness-100;
+ }
+
+ .button-base-styles {
+ /* Size (match base) */
+ @apply text-base;
+ /* Padding */
+ @apply px-5 py-[9px];
+ /* Core */
+ @apply text-center whitespace-nowrap;
+ /* Flex Columns */
+ @apply inline-flex justify-center items-center space-x-2;
+ /* States */
+ @apply hover:brightness-[1.15];
+ /* Transitions */
+ @apply transition-all;
+ }
+
+ /* === Button === */
+ /* Standard button/anchor tag elements. */
+
+ .btn {
+ @apply button-base-styles rounded-token active:scale-[95%] active:brightness-90;
+ }
+
+ /* Button: Sizes */
+ /* Note: Default values are built into `.btn` */
+ .btn-sm {
+ @apply text-sm px-3 py-1.5;
+ }
+ .btn-lg {
+ @apply text-lg px-7 py-3;
+ }
+ .btn-xl {
+ @apply text-xl px-9 py-4;
+ }
+
+ /* === Icon Button === */
+ /* A circular button meant for housing icons. */
+
+ .btn-icon {
+ /* Extend Base Button Classes */
+ @apply button-base-styles;
+ /* Padding */
+ @apply p-0;
+ /* Size (match base) */
+ @apply text-base aspect-square w-[43px];
+ /* Rounded */
+ @apply rounded-full;
+ }
+
+ /* Icon Button: Size */
+ .btn-icon-sm {
+ @apply text-sm aspect-square w-[33px];
+ }
+ .btn-icon-base {
+ @apply text-base aspect-square w-[43px];
+ }
+ .btn-icon-lg {
+ @apply text-lg aspect-square w-[53px];
+ }
+ .btn-icon-xl {
+ @apply text-xl aspect-square w-[63px];
+ }
+
+ /* File Input Button */
+ input[type='file']:not(.file-dropzone-input)::file-selector-button {
+ @apply border-0 btn variant-filled btn-sm mr-2;
+ }
+
+ /* === Button Groups === */
+
+ .btn-group {
+ @apply inline-flex flex-row space-x-0 overflow-hidden rounded-token;
+ /* Safari: hover overflow fix for border radius */
+ isolation: isolate;
+ }
+ .btn-group-vertical {
+ @apply btn-group flex-col space-y-0 rounded-container-token;
+ /* Safari: hover overflow fix for border radius */
+ isolation: isolate;
+ }
+
+ /* Button / Anchors */
+ .btn-group button,
+ .btn-group a,
+ .btn-group-vertical button,
+ .btn-group-vertical a {
+ @apply button-base-styles hover:bg-surface-50/[3%] active:bg-surface-900/[3%];
+ /* Reset Anchor Styles */
+ @apply !no-underline !text-inherit;
+ }
+
+ /* Set Neutral Divider */
+ .btn-group * + * {
+ @apply border-t-0 border-l border-surface-500/20;
+ }
+ .btn-group-vertical * + * {
+ @apply border-t border-l-0 border-surface-500/20;
+ }
+}
diff --git a/web/app/src/skeleton/styles/elements/cards.css b/web/app/src/skeleton/styles/elements/cards.css
new file mode 100644
index 0000000..2b57bb1
--- /dev/null
+++ b/web/app/src/skeleton/styles/elements/cards.css
@@ -0,0 +1,32 @@
+/* Tailwind Elements: cards.css */
+
+@layer components {
+ .card {
+ /* background */
+ @apply bg-surface-100-800-token;
+ /* Ring */
+ @apply ring-outline-token;
+ /* Theme: Rounded */
+ @apply rounded-container-token;
+ }
+
+ /* === Regions === */
+
+ .card-header {
+ @apply p-4 pb-0;
+ }
+
+ .card-footer {
+ @apply p-4 pt-0;
+ }
+
+ /* === States === */
+
+ a.card {
+ @apply transition-all hover:brightness-105;
+ }
+
+ .card-hover {
+ @apply transition-all hover:scale-[101%] hover:shadow-xl;
+ }
+}
diff --git a/web/app/src/skeleton/styles/elements/chips.css b/web/app/src/skeleton/styles/elements/chips.css
new file mode 100644
index 0000000..c2ccee6
--- /dev/null
+++ b/web/app/src/skeleton/styles/elements/chips.css
@@ -0,0 +1,22 @@
+/* Tailwind Elements: chips.css */
+
+@layer components {
+ .chip {
+ @apply px-3 py-1.5 whitespace-nowrap cursor-pointer;
+ /* Text */
+ @apply text-xs text-center;
+ /* Rounded */
+ @apply rounded;
+ /* Flex Columns */
+ @apply inline-flex justify-center items-center space-x-2;
+ /* States */
+ @apply hover:brightness-[1.15];
+ /* Transitions */
+ @apply transition-all;
+ }
+
+ .chip-disabled,
+ .chip:disabled {
+ @apply !opacity-50 !cursor-not-allowed active:scale-100;
+ }
+}
diff --git a/web/app/src/skeleton/styles/elements/forms.css b/web/app/src/skeleton/styles/elements/forms.css
new file mode 100644
index 0000000..9a7f821
--- /dev/null
+++ b/web/app/src/skeleton/styles/elements/forms.css
@@ -0,0 +1,269 @@
+/* Stylesheet: forms.css */
+
+@layer base {
+ /* === Resets === */
+
+ fieldset,
+ legend,
+ label {
+ @apply block;
+ }
+
+ /* Placeholders */
+ ::-moz-placeholder {
+ @apply text-surface-500-400-token;
+ }
+ :-ms-input-placeholder {
+ @apply text-surface-500-400-token;
+ }
+ ::placeholder {
+ @apply text-surface-500-400-token;
+ }
+
+ /* Date Calendar Picker (Webkit) */
+ input::-webkit-calendar-picker-indicator {
+ @apply dark:invert;
+ }
+
+ /* Progress Bar */
+ progress {
+ webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ @apply w-full h-2 overflow-hidden rounded-token;
+ @apply bg-surface-400-500-token;
+ }
+ progress::-webkit-progress-bar {
+ @apply bg-surface-400-500-token;
+ }
+ progress::-webkit-progress-value {
+ @apply bg-surface-900-50-token;
+ }
+ ::-moz-progress-bar {
+ @apply bg-surface-900-50-token;
+ }
+ :indeterminate::-moz-progress-bar {
+ width: 0;
+ }
+
+ /* Range Input */
+ /* https://developer.mozilla.org/en-US/docs/Web/CSS/accent-color */
+ [type='range'] {
+ @apply w-full accent-surface-900 dark:accent-surface-50;
+ }
+
+ /* === Text Labeling === */
+
+ .legend {
+ @apply font-heading-token text-xl md:text-2xl;
+ }
+
+ .label {
+ @apply space-y-1;
+ }
+
+ /* === Core Styles === */
+
+ .input,
+ .textarea,
+ .select,
+ .input-group {
+ @apply w-full transition duration-200;
+ /* Background */
+ @apply bg-surface-200-700-token focus:brightness-105 hover:brightness-105;
+ /* Ring */
+ @apply !ring-0;
+ /* Border */
+ @apply border-token border-surface-400-500-token focus-within:border-primary-500;
+ }
+
+ /* Base Inputs */
+ .input,
+ .input-group {
+ @apply rounded-token;
+ }
+
+ /* Container Inputs */
+ .textarea,
+ .select {
+ @apply rounded-container-token;
+ }
+
+ /* Select (size/multiple) */
+ .select {
+ @apply p-2 pr-8 space-y-1;
+ }
+ .select[size] {
+ @apply bg-none;
+ }
+ .select optgroup {
+ @apply space-y-1 font-bold;
+ }
+ .select optgroup option {
+ @apply ml-0 pl-0;
+ }
+ .select optgroup option:first-of-type {
+ @apply mt-3;
+ }
+ .select optgroup option:last-child {
+ @apply !mb-3;
+ }
+ .select option {
+ @apply bg-surface-200-700-token px-4 py-2 rounded-token cursor-pointer;
+ }
+ .select option:checked {
+ /* https://stackoverflow.com/questions/50618602/change-color-of-selected-option-in-select-multiple */
+ background: rgb(var(--color-primary-500))
+ linear-gradient(0deg, rgb(var(--color-primary-500)) 0%, rgb(var(--color-primary-500)) 100%);
+ @apply text-on-primary-token;
+ }
+
+ /* Checkbox & Radio */
+ .checkbox,
+ .radio {
+ @apply w-5 h-5 !ring-0 rounded cursor-pointer;
+ /* Background */
+ @apply bg-surface-200-700-token focus:brightness-105 hover:brightness-105;
+ /* Border */
+ @apply border-token border-surface-400-500-token focus:border-primary-500;
+ }
+ .checkbox:checked,
+ .radio:checked {
+ @apply bg-primary-500 hover:bg-primary-500 focus:bg-primary-500 focus:ring-0;
+ }
+ .radio {
+ @apply rounded-token;
+ }
+
+ /* === Specialized === */
+
+ /* File Inputs */
+ .input[type='file'] {
+ @apply p-1;
+ }
+
+ /* Color Picker */
+ /* https://stackoverflow.com/questions/11167281/webkit-css-to-control-the-box-around-the-color-in-an-inputtype-color */
+ .input[type='color'] {
+ @apply border-none w-10 h-10 overflow-hidden rounded-token cursor-pointer;
+ -webkit-appearance: none; /* WebKit Only */
+ }
+ .input[type='color']::-webkit-color-swatch-wrapper {
+ @apply p-0;
+ }
+ .input[type='color']::-webkit-color-swatch {
+ @apply border-none hover:brightness-110;
+ }
+ .input[type='color']::-moz-color-swatch {
+ @apply border-none;
+ }
+
+ /* === States === */
+
+ .input:disabled,
+ .textarea:disabled,
+ .select:disabled {
+ @apply !opacity-50 !cursor-not-allowed hover:!brightness-100;
+ }
+
+ .input[readonly],
+ .textarea[readonly],
+ .select[readonly] {
+ @apply !border-0 !cursor-not-allowed hover:!brightness-100;
+ }
+
+ /* === Input Groups === */
+
+ .input-group {
+ @apply grid overflow-hidden;
+ }
+ .input-group input,
+ .input-group select {
+ @apply border-0 ring-0 bg-transparent;
+ }
+ .input-group select option {
+ @apply bg-surface-200-700-token;
+ }
+ .input-group div,
+ .input-group a,
+ .input-group button {
+ @apply px-4 flex justify-between items-center;
+ }
+ .input-group-divider input,
+ .input-group-divider select,
+ .input-group-divider div,
+ .input-group-divider a {
+ @apply border-l border-surface-400-500-token focus:border-surface-400-500-token;
+ /* Disable Ring */
+ @apply !ring-0;
+ /* Prevent buttons from being squished */
+ @apply !min-w-fit;
+ }
+ .input-group-divider *:first-child {
+ @apply !border-l-0;
+ }
+ .input-group-shim {
+ @apply bg-surface-400/10 text-surface-600-300-token;
+ }
+
+ /* === Variants === */
+
+ /* success */
+ .input-success {
+ @apply !bg-success-200 !border-success-500 !text-success-700;
+ }
+ .input-success::-moz-placeholder {
+ @apply text-success-700;
+ }
+ .input-success:-ms-input-placeholder {
+ @apply text-success-700;
+ }
+ .input-success::placeholder {
+ @apply text-success-700;
+ }
+
+ /* warning */
+ .input-warning {
+ @apply !bg-warning-200 !border-warning-500 !text-warning-700;
+ }
+ .input-warning::-moz-placeholder {
+ @apply text-warning-700;
+ }
+ .input-warning:-ms-input-placeholder {
+ @apply text-warning-700;
+ }
+ .input-warning::placeholder {
+ @apply text-warning-700;
+ }
+
+ /* error */
+ .input-error {
+ @apply !bg-error-200 !border-error-500 !text-error-500;
+ }
+ .input-error::-moz-placeholder {
+ @apply text-error-500;
+ }
+ .input-error:-ms-input-placeholder {
+ @apply text-error-500;
+ }
+ .input-error::placeholder {
+ @apply text-error-500;
+ }
+
+ /* === Variants === */
+
+ /* Material */
+ .variant-form-material {
+ /* Border Radius */
+ @apply !rounded-tl !rounded-tr !rounded-bl-none !rounded-br-none;
+ /* Background */
+ @apply bg-surface-500/10 dark:bg-surface-500/20;
+ /* Border */
+ @apply border-0 border-b-2;
+ /* Blur */
+ @apply backdrop-blur;
+ }
+ .variant-form-material[type='file'] {
+ @apply !py-1.5;
+ }
+}
diff --git a/web/app/src/skeleton/styles/elements/lists.css b/web/app/src/skeleton/styles/elements/lists.css
new file mode 100644
index 0000000..078d8da
--- /dev/null
+++ b/web/app/src/skeleton/styles/elements/lists.css
@@ -0,0 +1,52 @@
+/* Tailwind Elements: button.css */
+
+@layer components {
+ /* === Lists (Parents) === */
+
+ .list,
+ .list-dl,
+ .list-nav ul {
+ /* List Style */
+ @apply list-none;
+ /* Spacing */
+ @apply space-y-1;
+ }
+
+ /* === List Items (Children) === */
+
+ .list li {
+ /* @apply bg-red-500; */
+ @apply flex items-center space-x-4;
+ /* Padding */
+ @apply p-2;
+ /* Theme: Rounded */
+ @apply rounded-token;
+ /* Wrapping */
+ @apply whitespace-normal break-words;
+ }
+
+ .list-dl div {
+ /* @apply bg-blue-500; */
+ @apply flex items-center space-x-4 whitespace-nowrap;
+ /* Padding */
+ @apply p-2;
+ /* Theme: Rounded */
+ @apply rounded-token;
+ }
+
+ .list-nav a,
+ .list-nav button,
+ .list-option {
+ @apply flex items-center space-x-4 whitespace-nowrap;
+ /* Padding */
+ @apply px-4 py-2;
+ /* Hover */
+ @apply bg-primary-hover-token;
+ /* Focus */
+ @apply focus:!variant-filled-primary outline-none;
+ /* Cursor */
+ @apply cursor-pointer;
+ /* Theme: Rounded */
+ @apply rounded-token;
+ }
+}
diff --git a/web/app/src/skeleton/styles/elements/logo-clouds.css b/web/app/src/skeleton/styles/elements/logo-clouds.css
new file mode 100644
index 0000000..d44a64d
--- /dev/null
+++ b/web/app/src/skeleton/styles/elements/logo-clouds.css
@@ -0,0 +1,29 @@
+/* Tailwind Elements: logo-clouds.css */
+
+@layer components {
+ .logo-cloud {
+ @apply grid overflow-hidden;
+ /* Theme: Rounded */
+ @apply rounded-container-token;
+ }
+
+ /* === Logo Item (Child) === */
+
+ .logo-item {
+ @apply: flex-auto text-center space-x-4 shadow;
+ /* Center Contents */
+ @apply flex justify-center items-center space-x-4;
+ /* Background */
+ @apply bg-surface-100-800-token;
+ /* Text */
+ @apply text-base font-bold text-black dark:text-white;
+ /* Padding */
+ @apply py-4 md:py-8;
+ }
+
+ /* === States === */
+
+ a.logo-item {
+ @apply hover:brightness-110;
+ }
+}
diff --git a/web/app/src/skeleton/styles/elements/modals.css b/web/app/src/skeleton/styles/elements/modals.css
new file mode 100644
index 0000000..7727410
--- /dev/null
+++ b/web/app/src/skeleton/styles/elements/modals.css
@@ -0,0 +1,15 @@
+/* Tailwind Elements: modals.css */
+
+/* === Modal (helpers) === */
+
+.w-modal-slim {
+ @apply w-full max-w-[400px];
+}
+
+.w-modal {
+ @apply w-full max-w-[640px];
+}
+
+.w-modal-wide {
+ @apply w-full max-w-[80%];
+}
diff --git a/web/app/src/skeleton/styles/elements/placeholders.css b/web/app/src/skeleton/styles/elements/placeholders.css
new file mode 100644
index 0000000..44fd056
--- /dev/null
+++ b/web/app/src/skeleton/styles/elements/placeholders.css
@@ -0,0 +1,17 @@
+/* Tailwind Elements: placeholders.css */
+
+@layer components {
+ /* === Base === */
+
+ .placeholder {
+ @apply bg-surface-300-600-token h-5;
+ /* Theme: Rounded */
+ @apply rounded-token;
+ }
+
+ /* === Shapes === */
+
+ .placeholder-circle {
+ @apply bg-surface-300-600-token aspect-square rounded-full;
+ }
+}
diff --git a/web/app/src/skeleton/styles/elements/popups.css b/web/app/src/skeleton/styles/elements/popups.css
new file mode 100644
index 0000000..c8bc057
--- /dev/null
+++ b/web/app/src/skeleton/styles/elements/popups.css
@@ -0,0 +1,16 @@
+/* Tailwind Elements: popups.css */
+
+/* === Popup === */
+
+[data-popup] {
+ /* https://floating-ui.com/docs/computeposition#usage */
+ @apply absolute top-0 left-0; /* max-w-max */
+ /* Set hidden on page load */
+ @apply hidden;
+ /* Transitions */
+ @apply transition-opacity duration-200;
+}
+
+[data-popup] .arrow {
+ @apply absolute rotate-45 w-2 h-2;
+}
diff --git a/web/app/src/skeleton/styles/elements/tables.css b/web/app/src/skeleton/styles/elements/tables.css
new file mode 100644
index 0000000..9a71f23
--- /dev/null
+++ b/web/app/src/skeleton/styles/elements/tables.css
@@ -0,0 +1,102 @@
+/* Elements: tables.css */
+
+@layer components {
+ .table-container {
+ @apply overflow-x-auto w-full rounded-container-token;
+ }
+
+ .table {
+ @apply w-full overflow-hidden table-auto;
+ /* background */
+ @apply bg-surface-100-800-token;
+ /* Theme: Rounded */
+ @apply rounded-container-token;
+ }
+
+ /* === Hover Styles ==== */
+
+ .table-hover tbody tr {
+ @apply hover:bg-surface-500/20 even:hover:bg-surface-500/20;
+ }
+
+ .table-interactive tbody tr {
+ @apply hover:bg-primary-hover-token even:hover:bg-primary-hover-token cursor-pointer;
+ }
+
+ /* === Sort Styles ==== */
+
+ [data-sort] {
+ @apply hover:bg-primary-hover-token cursor-pointer;
+ /* Sort Icon - invisible by default */
+ @apply after:opacity-0 after:!ml-2 after:!content-['↓'];
+ }
+
+ .table-sort-asc {
+ @apply after:opacity-50 after:!content-['↑'];
+ }
+
+ .table-sort-dsc {
+ @apply after:opacity-50 after:!content-['↓'];
+ }
+
+ /* === Table Head === */
+
+ .table thead {
+ @apply bg-surface-200-700-token border-b border-surface-500/20;
+ }
+
+ .table thead tr {
+ @apply capitalize text-left;
+ }
+
+ .table thead th {
+ @apply font-bold p-4;
+ }
+
+ /* === Table Body === */
+
+ .table tbody tr {
+ @apply border-b border-surface-500/20 even:bg-surface-500/5;
+ }
+
+ .table tbody td {
+ /* NOTE: removed this to allow wrapping */
+ @apply text-sm px-3 py-4 align-top whitespace-nowrap lg:whitespace-normal;
+ }
+
+ .table-compact tbody td {
+ @apply !py-3;
+ }
+
+ .table-comfortable tbody td {
+ @apply !py-5;
+ }
+
+ /* === Table Foot === */
+
+ .table tfoot {
+ @apply bg-surface-100-800-token;
+ }
+
+ .table tfoot tr {
+ @apply capitalize text-left;
+ }
+
+ .table tfoot th,
+ .table tfoot td {
+ @apply p-4;
+ }
+
+ /* === Rows Specific === */
+
+ .table-row-checked {
+ @apply !bg-secondary-500/20;
+ }
+
+ /* === Cells Specific === */
+
+ /* Source: https://stackoverflow.com/questions/11267154/fit-cell-width-to-content */
+ .table-cell-fit {
+ @apply w-[1%] whitespace-nowrap;
+ }
+}
diff --git a/web/app/src/skeleton/styles/highlight-js.css b/web/app/src/skeleton/styles/highlight-js.css
new file mode 100644
index 0000000..33ed8a9
--- /dev/null
+++ b/web/app/src/skeleton/styles/highlight-js.css
@@ -0,0 +1,116 @@
+/* Skeleton Highlight.js Theme */
+
+/* Red */
+.hljs-doctag,
+.hljs-keyword,
+.hljs-meta .hljs-keyword,
+.hljs-template-tag,
+.hljs-template-variable,
+.hljs-type,
+.hljs-variable.language_ {
+ /* color: #ff7b72; */
+ @apply text-red-400;
+}
+
+/* Purple */
+.hljs-title,
+.hljs-title.class_,
+.hljs-title.class_.inherited__,
+.hljs-title.function_ {
+ /* color: #d2a8ff; */
+ @apply text-purple-400;
+}
+
+/* Sky */
+.hljs-attr,
+.hljs-attribute,
+.hljs-literal,
+.hljs-meta,
+.hljs-number,
+.hljs-operator,
+.hljs-selector-attr,
+.hljs-selector-class,
+.hljs-selector-id,
+.hljs-variable {
+ /* color: #79c0ff; */
+ @apply text-sky-300;
+}
+
+/* Blue */
+.hljs-meta .hljs-string,
+.hljs-regexp,
+.hljs-string {
+ /* color: #a5d6ff; */
+ @apply text-blue-400;
+}
+
+/* Amber (types) */
+.hljs-built_in,
+.hljs-symbol {
+ /* color: #ffa657; */
+ @apply text-amber-400;
+}
+
+/* Gray (Medium) */
+.hljs-code,
+.hljs-comment,
+.hljs-formula {
+ /* color: #8b949e; */
+ @apply text-neutral-500;
+}
+
+/* Green (Tags) */
+.hljs-name,
+.hljs-quote,
+.hljs-selector-pseudo,
+.hljs-selector-tag {
+ /* color: #7ee787; */
+ @apply text-green-400;
+}
+
+/* Pink (Light) */
+.hljs-subst {
+ /* color: #c9d1d9; */
+ @apply text-pink-300;
+}
+
+/* Sky */
+.hljs-section {
+ /* color: #1f6feb; */
+ /* font-weight: 700; */
+ @apply font-bold text-sky-400;
+}
+
+/* Yellow */
+.hljs-bullet {
+ /* color: #f2cc60; */
+ @apply text-yellow-400;
+}
+
+/* Gray (Light) */
+.hljs-emphasis {
+ /* color: #c9d1d9; */
+ /* font-style: italic; */
+ @apply italic text-neutral-200;
+}
+
+/* Gray (Light) */
+.hljs-strong {
+ /* color: #c9d1d9; */
+ /* font-weight: 700; */
+ @apply font-bold text-neutral-200;
+}
+
+/* Lime / Green */
+.hljs-addition {
+ /* color: #aff5b4; */
+ /* background-color: #033a16; */
+ @apply text-lime-300 bg-green-700;
+}
+
+/* Pink / Red */
+.hljs-deletion {
+ /* color: #ffdcd7; */
+ /* background-color: #67060c; */
+ @apply text-rose-300 bg-rose-700;
+}
diff --git a/web/app/src/skeleton/styles/tailwind.css b/web/app/src/skeleton/styles/tailwind.css
new file mode 100644
index 0000000..46dc35d
--- /dev/null
+++ b/web/app/src/skeleton/styles/tailwind.css
@@ -0,0 +1,16 @@
+/* Stylesheet: tailwind.css */
+
+/*
+https://tailwindcss.com/docs/functions-and-directives
+
+IMPORTANT:
+Be sure to remove these directives from your global CSS stylesheet.
+
+Tailwind directives should only be included ONCE per project.
+These directives should precede ALL Skeleton stylesheets.
+*/
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+@tailwind variants;
diff --git a/web/app/src/skeleton/styles/typography.css b/web/app/src/skeleton/styles/typography.css
new file mode 100644
index 0000000..2201cd9
--- /dev/null
+++ b/web/app/src/skeleton/styles/typography.css
@@ -0,0 +1,101 @@
+/* Stylesheet: typography.css */
+
+@layer base {
+ body {
+ @apply font-token text-token;
+ }
+
+ /* === Headings === */
+
+ h1:not(.unstyled):is(:not(.prose *)) {
+ @apply font-heading-token text-3xl md:text-5xl;
+ }
+ h2:not(.unstyled):is(:not(.prose *)) {
+ @apply font-heading-token text-2xl md:text-4xl;
+ }
+ h3:not(.unstyled):is(:not(.prose *)) {
+ @apply font-heading-token text-xl md:text-2xl;
+ }
+ h4:not(.unstyled):is(:not(.prose *)) {
+ @apply font-heading-token text-lg md:text-xl;
+ }
+ h5:not(.unstyled):is(:not(.prose *)) {
+ @apply font-heading-token text-base md:text-lg;
+ }
+ h6:not(.unstyled):is(:not(.prose *)) {
+ @apply font-heading-token text-sm md:text-base;
+ }
+
+ /* === Elements === */
+
+ p:not(.unstyled):is(:not(.prose *)) {
+ /* NOTE: do not hardcode a text color style here. It makes color overrides difficult. */
+ @apply text-base;
+ }
+
+ a:not(.unstyled):not(.permalink):is(:not(.prose *)):not(.btn):not(.btn-icon):not(.app-bar a):not(.logo-item):not(
+ a.card
+ ):not(.list-nav a) {
+ @apply text-primary-700 dark:text-primary-500 hover:brightness-110 underline;
+ }
+
+ blockquote:not(.unstyled):is(:not(.prose *)) {
+ @apply text-token text-base italic border-l-8 border-l-secondary-500 px-4 pl-4;
+ }
+
+ /* Keyboard */
+ kbd:not(.unstyled):is(:not(.prose *)) {
+ @apply font-sans font-bold text-sm;
+ @apply bg-surface-300-600-token px-1.5 py-[3px] rounded;
+ @apply ring-[1px] ring-surface-900 ring-inset;
+ @apply border-b-2 border-surface-900;
+ }
+
+ /* === Code Blocks === */
+ /* For use outside of Skeleton's CodeBlock component */
+
+ pre:not(.unstyled):not(.code-block pre):is(:not(.prose *)) {
+ @apply font-mono text-base bg-neutral-900/90 text-white p-4 whitespace-pre-wrap overflow-x-auto rounded-container-token;
+ }
+
+ code:not(.unstyled):is(:not(.prose *)):is(:not(pre *)) {
+ @apply font-mono text-xs text-primary-700 dark:text-primary-400 whitespace-nowrap;
+ @apply bg-primary-500/30 dark:bg-primary-500/20;
+ @apply py-0.5 px-1 rounded;
+ }
+
+ /* === Insertions / Deletions ==== */
+ /* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/ins */
+ /* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/del */
+
+ ins:not(.unstyled):is(:not(.prose *)),
+ del:not(.unstyled):is(:not(.prose *)) {
+ @apply block relative p-0.5 pl-5;
+ text-decoration: none;
+ }
+
+ ins:not(.unstyled):is(:not(.prose *))::before,
+ del:not(.unstyled):is(:not(.prose *))::before {
+ @apply absolute left-1 font-mono;
+ }
+ ins:not(.unstyled):is(:not(.prose *))::before {
+ content: '+';
+ }
+ del:not(.unstyled):is(:not(.prose *))::before {
+ content: '−';
+ }
+
+ ins:not(.unstyled):is(:not(.prose *)) {
+ @apply font-mono bg-success-500 text-on-success-token;
+ }
+ del:not(.unstyled):is(:not(.prose *)) {
+ @apply font-mono bg-error-500 text-on-error-token;
+ }
+
+ /* === Date/Time === */
+ /* Useful for displaying timestamps */
+
+ time:not(.unstyled):is(:not(.prose *)) {
+ @apply text-sm text-surface-500 dark:text-surface-400;
+ }
+}
diff --git a/web/app/src/skeleton/styles/variants.css b/web/app/src/skeleton/styles/variants.css
new file mode 100644
index 0000000..3507521
--- /dev/null
+++ b/web/app/src/skeleton/styles/variants.css
@@ -0,0 +1,156 @@
+/* === Variants === */
+/* A canned set of reusable variant styles. */
+
+@layer components {
+ /* Outline -- supports ringed and host variants */
+ .variant-outline-primary {
+ @apply ring-[1px] ring-primary-500 dark:ring-primary-500 ring-inset;
+ }
+ .variant-outline-secondary {
+ @apply ring-[1px] ring-secondary-500 dark:ring-secondary-500 ring-inset;
+ }
+ .variant-outline-tertiary {
+ @apply ring-[1px] ring-tertiary-500 dark:ring-tertiary-500 ring-inset;
+ }
+ .variant-outline-success {
+ @apply ring-[1px] ring-success-500 dark:ring-success-500 ring-inset;
+ }
+ .variant-outline-warning {
+ @apply ring-[1px] ring-warning-500 dark:ring-warning-500 ring-inset;
+ }
+ .variant-outline-error {
+ @apply ring-[1px] ring-error-500 dark:ring-error-500 ring-inset;
+ }
+ .variant-outline,
+ .variant-outline-surface {
+ @apply ring-[1px] ring-surface-500 dark:ring-surface-500 ring-inset;
+ }
+
+ /* ------------------------ */
+
+ /* Filled */
+ .variant-filled {
+ @apply bg-surface-900-50-token text-surface-50-900-token;
+ }
+ .variant-filled-primary {
+ @apply bg-primary-500 dark:bg-primary-500 text-on-primary-token dark:text-on-primary-token;
+ }
+ .variant-filled-secondary {
+ @apply bg-secondary-500 dark:bg-secondary-500 text-on-secondary-token dark:text-on-secondary-token;
+ }
+ .variant-filled-tertiary {
+ @apply bg-tertiary-500 dark:bg-tertiary-500 text-on-tertiary-token dark:text-on-tertiary-token;
+ }
+ .variant-filled-success {
+ @apply bg-success-500 dark:bg-success-500 text-on-success-token dark:text-on-success-token;
+ }
+ .variant-filled-warning {
+ @apply bg-warning-500 dark:bg-warning-500 text-on-warning-token dark:text-on-warning-token;
+ }
+ .variant-filled-error {
+ @apply bg-error-500 dark:bg-error-500 text-on-error-token dark:text-on-error-token;
+ }
+ .variant-filled-surface {
+ @apply bg-surface-400-500-token text-on-surface-token dark:text-on-surface-token;
+ }
+
+ /* Ringed */
+ .variant-ringed {
+ @apply bg-transparent dark:bg-transparent variant-outline;
+ }
+ .variant-ringed-primary {
+ @apply bg-transparent dark:bg-transparent variant-outline-primary;
+ }
+ .variant-ringed-secondary {
+ @apply bg-transparent dark:bg-transparent variant-outline-secondary;
+ }
+ .variant-ringed-tertiary {
+ @apply bg-transparent dark:bg-transparent variant-outline-tertiary;
+ }
+ .variant-ringed-success {
+ @apply bg-transparent dark:bg-transparent variant-outline-success;
+ }
+ .variant-ringed-warning {
+ @apply bg-transparent dark:bg-transparent variant-outline-warning;
+ }
+ .variant-ringed-error {
+ @apply bg-transparent dark:bg-transparent variant-outline-error;
+ }
+ .variant-ringed-surface {
+ @apply bg-transparent dark:bg-transparent variant-outline-surface;
+ }
+
+ /* Ghost */
+ .variant-ghost-primary {
+ @apply bg-primary-500/20 dark:bg-primary-500/20 variant-outline-primary;
+ }
+ .variant-ghost-secondary {
+ @apply bg-secondary-500/20 dark:bg-secondary-500/20 variant-outline-secondary;
+ }
+ .variant-ghost-tertiary {
+ @apply bg-tertiary-500/20 dark:bg-tertiary-500/20 variant-outline-tertiary;
+ }
+ .variant-ghost-success {
+ @apply bg-success-500/20 dark:bg-success-500/20 variant-outline-success;
+ }
+ .variant-ghost-warning {
+ @apply bg-warning-500/20 dark:bg-warning-500/20 variant-outline-warning;
+ }
+ .variant-ghost-error {
+ @apply bg-error-500/20 dark:bg-error-500/20 variant-outline-error;
+ }
+ .variant-ghost,
+ .variant-ghost-surface {
+ @apply bg-surface-500/20 dark:bg-surface-500/20 variant-outline-surface;
+ }
+
+ /* Soft */
+ .variant-soft-primary {
+ @apply bg-primary-400/20 dark:bg-primary-500/20 text-primary-700-200-token !ring-0;
+ }
+ .variant-soft-secondary {
+ @apply bg-secondary-400/20 dark:bg-secondary-500/20 text-secondary-700-200-token !ring-0;
+ }
+ .variant-soft-tertiary {
+ @apply bg-tertiary-400/20 dark:bg-tertiary-500/20 text-tertiary-700-200-token !ring-0;
+ }
+ .variant-soft-success {
+ @apply bg-success-400/20 dark:bg-success-500/20 text-success-700-200-token !ring-0;
+ }
+ .variant-soft-warning {
+ @apply bg-warning-400/20 dark:bg-warning-500/20 text-warning-700-200-token !ring-0;
+ }
+ .variant-soft-error {
+ @apply bg-error-400/20 dark:bg-error-500/20 text-error-700-200-token !ring-0;
+ }
+ .variant-soft,
+ .variant-soft-surface {
+ @apply bg-surface-400/20 dark:bg-surface-500/20 text-surface-700-200-token !ring-0;
+ }
+
+ /* Glass */
+ .variant-glass-primary {
+ @apply bg-primary-500/20 dark:bg-primary-500/20 backdrop-blur-lg;
+ }
+ .variant-glass-secondary {
+ @apply bg-secondary-500/20 dark:bg-secondary-500/20 backdrop-blur-lg;
+ }
+ .variant-glass-tertiary {
+ @apply bg-tertiary-500/20 dark:bg-tertiary-500/20 backdrop-blur-lg;
+ }
+ .variant-glass-success {
+ @apply bg-success-500/20 dark:bg-success-500/20 backdrop-blur-lg;
+ }
+ .variant-glass-warning {
+ @apply bg-warning-500/20 dark:bg-warning-500/20 backdrop-blur-lg;
+ }
+ .variant-glass-error {
+ @apply bg-error-500/20 dark:bg-error-500/20 backdrop-blur-lg;
+ }
+ .variant-glass-surface {
+ @apply bg-surface-500/20 dark:bg-surface-500/20 backdrop-blur-lg;
+ }
+ .variant-glass {
+ @apply bg-surface-50/30 dark:bg-surface-900/30 backdrop-blur-lg;
+ }
+}
diff --git a/web/app/src/skeleton/tailwind/core.cjs b/web/app/src/skeleton/tailwind/core.cjs
new file mode 100644
index 0000000..1d90811
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/core.cjs
@@ -0,0 +1,37 @@
+// The Skeleton Tailwind Plugin
+// Tailwind Docs: https://tailwindcss.com/docs/plugins
+// Skeleton Docs: https://www.skeleton.dev/docs/get-started
+
+const plugin = require('tailwindcss/plugin');
+
+// Skeleton Theme Modules
+const themeColors = require('./theme/colors.cjs');
+// Skeleton Design Token Modules
+const tokensBackgrounds = require('./tokens/backgrounds.cjs');
+const tokensBorders = require('./tokens/borders.cjs');
+const tokensBorderRadius = require('./tokens/border-radius.cjs');
+const tokensFills = require('./tokens/fills.cjs');
+const tokensText = require('./tokens/text.cjs');
+const tokensRings = require('./tokens/rings.cjs');
+
+module.exports = plugin(
+ ({ addUtilities }) => {
+ addUtilities({
+ // Implement Skeleton design token classes
+ ...tokensBackgrounds(),
+ ...tokensBorders(),
+ ...tokensBorderRadius(),
+ ...tokensFills(),
+ ...tokensText(),
+ ...tokensRings()
+ });
+ },
+ {
+ theme: {
+ extend: {
+ // Implement Skeleton theme colors
+ colors: themeColors()
+ }
+ }
+ }
+);
diff --git a/web/app/src/skeleton/tailwind/core.d.cts b/web/app/src/skeleton/tailwind/core.d.cts
new file mode 100644
index 0000000..8bd9163
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/core.d.cts
@@ -0,0 +1,5 @@
+declare const _exports: {
+ handler: import('tailwindcss/types/config.js').PluginCreator;
+ config?: Partial | undefined;
+};
+export = _exports;
diff --git a/web/app/src/skeleton/tailwind/generated/intellisense-classes.cjs b/web/app/src/skeleton/tailwind/generated/intellisense-classes.cjs
new file mode 100644
index 0000000..fe2b415
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/generated/intellisense-classes.cjs
@@ -0,0 +1,1456 @@
+module.exports = {
+ '.hide-scrollbar::-webkit-scrollbar': { display: 'none' },
+ '.hide-scrollbar': { msOverflowStyle: 'none', scrollbarWidth: 'none' },
+ '.divider-vertical': {
+ marginLeft: 'auto',
+ marginRight: 'auto',
+ display: 'inline-block',
+ minHeight: '10px',
+ borderLeftWidth: '1px',
+ borderStyle: 'solid',
+ borderColor: 'rgb(var(--color-surface-300))'
+ },
+ '.dark .divider-vertical': { borderColor: 'rgb(var(--color-surface-600))' },
+ '.\\!legend': { fontSize: '1.25rem', lineHeight: '1.75rem', fontFamily: 'var(--theme-font-family-heading)' },
+ '.legend': { fontSize: '1.25rem', lineHeight: '1.75rem', fontFamily: 'var(--theme-font-family-heading)' },
+ '.label > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-y-reverse': '0',
+ marginTop: 'calc(0.25rem * calc(1 - var(--tw-space-y-reverse)))',
+ marginBottom: 'calc(0.25rem * var(--tw-space-y-reverse))'
+ },
+ '.\\!input': {
+ width: '100%',
+ transitionProperty:
+ 'color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter',
+ transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ transitionDuration: '200ms',
+ backgroundColor: 'rgb(var(--color-surface-200))',
+ '--tw-ring-offset-shadow':
+ 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color) !important',
+ '--tw-ring-shadow':
+ 'var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color) !important',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000) !important',
+ borderWidth: 'var(--theme-border-base)',
+ borderColor: 'rgb(var(--color-surface-400))',
+ borderRadius: 'var(--theme-rounded-base)'
+ },
+ '.input,\n\t.textarea,\n\t.select,\n\t.input-group': {
+ width: '100%',
+ transitionProperty:
+ 'color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter',
+ transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ transitionDuration: '200ms',
+ backgroundColor: 'rgb(var(--color-surface-200))',
+ '--tw-ring-offset-shadow':
+ 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color) !important',
+ '--tw-ring-shadow':
+ 'var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color) !important',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000) !important',
+ borderWidth: 'var(--theme-border-base)',
+ borderColor: 'rgb(var(--color-surface-400))'
+ },
+ '.dark .\\!input': { backgroundColor: 'rgb(var(--color-surface-700))', borderColor: 'rgb(var(--color-surface-500))' },
+ '.\\!input:hover': {
+ '--tw-brightness': 'brightness(1.05)',
+ 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)'
+ },
+ '.\\!input:focus': {
+ '--tw-brightness': 'brightness(1.05)',
+ 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)'
+ },
+ '.dark .input,.dark \n\t.textarea,.dark \n\t.select,.dark \n\t.input-group': {
+ backgroundColor: 'rgb(var(--color-surface-700))',
+ borderColor: 'rgb(var(--color-surface-500))'
+ },
+ '.input:hover,\n\t.textarea:hover,\n\t.select:hover,\n\t.input-group:hover': {
+ '--tw-brightness': 'brightness(1.05)',
+ 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)'
+ },
+ '.input:focus,\n\t.textarea:focus,\n\t.select:focus,\n\t.input-group:focus': {
+ '--tw-brightness': 'brightness(1.05)',
+ 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)'
+ },
+ '.\\!input:focus-within': {
+ '--tw-border-opacity': '1',
+ borderColor: 'rgb(var(--color-primary-500) / var(--tw-border-opacity))'
+ },
+ '.input:focus-within,\n\t.textarea:focus-within,\n\t.select:focus-within,\n\t.input-group:focus-within': {
+ '--tw-border-opacity': '1',
+ borderColor: 'rgb(var(--color-primary-500) / var(--tw-border-opacity))'
+ },
+ '.input,\n\t.input-group': { borderRadius: 'var(--theme-rounded-base)' },
+ '.textarea,\n\t.select': { borderRadius: 'var(--theme-rounded-container)' },
+ '.select > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-y-reverse': '0',
+ marginTop: 'calc(0.25rem * calc(1 - var(--tw-space-y-reverse)))',
+ marginBottom: 'calc(0.25rem * var(--tw-space-y-reverse))'
+ },
+ '.select': { padding: '0.5rem', paddingRight: '2rem' },
+ '.select[size]': { backgroundImage: 'none' },
+ '.select optgroup > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-y-reverse': '0',
+ marginTop: 'calc(0.25rem * calc(1 - var(--tw-space-y-reverse)))',
+ marginBottom: 'calc(0.25rem * var(--tw-space-y-reverse))'
+ },
+ '.select optgroup': { fontWeight: 700 },
+ '.select optgroup option': { marginLeft: '0px', paddingLeft: '0px' },
+ '.select optgroup option:first-of-type': { marginTop: '0.75rem' },
+ '.select optgroup option:last-child': { marginBottom: '0.75rem !important' },
+ '.select option': {
+ cursor: 'pointer',
+ paddingLeft: '1rem',
+ paddingRight: '1rem',
+ paddingTop: '0.5rem',
+ paddingBottom: '0.5rem',
+ backgroundColor: 'rgb(var(--color-surface-200))',
+ borderRadius: 'var(--theme-rounded-base)'
+ },
+ '.dark .select option': { backgroundColor: 'rgb(var(--color-surface-700))' },
+ '.select option:checked': {
+ background:
+ 'rgb(var(--color-primary-500)) linear-gradient(0deg, rgb(var(--color-primary-500)) 0%, rgb(var(--color-primary-500)) 100%)',
+ color: 'rgb(var(--on-primary))'
+ },
+ '.checkbox,\n\t.radio': {
+ height: '1.25rem',
+ width: '1.25rem',
+ cursor: 'pointer',
+ borderRadius: '0.25rem',
+ '--tw-ring-offset-shadow':
+ 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color) !important',
+ '--tw-ring-shadow':
+ 'var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color) !important',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000) !important',
+ backgroundColor: 'rgb(var(--color-surface-200))',
+ borderWidth: 'var(--theme-border-base)',
+ borderColor: 'rgb(var(--color-surface-400))'
+ },
+ '.dark .checkbox,.dark \n\t.radio': {
+ backgroundColor: 'rgb(var(--color-surface-700))',
+ borderColor: 'rgb(var(--color-surface-500))'
+ },
+ '.checkbox:hover,\n\t.radio:hover': {
+ '--tw-brightness': 'brightness(1.05)',
+ 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)'
+ },
+ '.checkbox:focus,\n\t.radio:focus': {
+ '--tw-brightness': 'brightness(1.05)',
+ 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)',
+ '--tw-border-opacity': '1',
+ borderColor: 'rgb(var(--color-primary-500) / var(--tw-border-opacity))'
+ },
+ '.checkbox:checked,\n\t.radio:checked': {
+ '--tw-bg-opacity': '1',
+ backgroundColor: 'rgb(var(--color-primary-500) / var(--tw-bg-opacity))'
+ },
+ '.checkbox:checked:hover,\n\t.radio:checked:hover': {
+ '--tw-bg-opacity': '1',
+ backgroundColor: 'rgb(var(--color-primary-500) / var(--tw-bg-opacity))'
+ },
+ '.checkbox:checked:focus,\n\t.radio:checked:focus': {
+ '--tw-bg-opacity': '1',
+ backgroundColor: 'rgb(var(--color-primary-500) / var(--tw-bg-opacity))',
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)'
+ },
+ '.radio': { borderRadius: 'var(--theme-rounded-base)' },
+ ".\\!input[type='file']": { padding: '0.25rem' },
+ ".input[type='file']": { padding: '0.25rem' },
+ ".\\!input[type='color']": {
+ height: '2.5rem',
+ width: '2.5rem',
+ cursor: 'pointer',
+ overflow: 'hidden',
+ borderStyle: 'none',
+ borderRadius: 'var(--theme-rounded-base)',
+ WebkitAppearance: 'none !important'
+ },
+ ".input[type='color']": {
+ height: '2.5rem',
+ width: '2.5rem',
+ cursor: 'pointer',
+ overflow: 'hidden',
+ borderStyle: 'none',
+ borderRadius: 'var(--theme-rounded-base)',
+ WebkitAppearance: 'none'
+ },
+ ".\\!input[type='color']::-webkit-color-swatch-wrapper": { padding: '0px' },
+ ".input[type='color']::-webkit-color-swatch-wrapper": { padding: '0px' },
+ ".\\!input[type='color']::-webkit-color-swatch": { borderStyle: 'none' },
+ ".\\!input[type='color']:hover::-webkit-color-swatch": {
+ '--tw-brightness': 'brightness(1.1)',
+ 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)'
+ },
+ ".input[type='color']::-webkit-color-swatch": { borderStyle: 'none' },
+ ".input[type='color']:hover::-webkit-color-swatch": {
+ '--tw-brightness': 'brightness(1.1)',
+ 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)'
+ },
+ ".\\!input[type='color']::-moz-color-swatch": { borderStyle: 'none' },
+ ".input[type='color']::-moz-color-swatch": { borderStyle: 'none' },
+ '.\\!input:disabled': { cursor: 'not-allowed !important', opacity: '0.5 !important' },
+ '.\\!input:disabled:hover': {
+ '--tw-brightness': 'brightness(1) !important',
+ 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) !important'
+ },
+ '.input:disabled,\n\t.textarea:disabled,\n\t.select:disabled': {
+ cursor: 'not-allowed !important',
+ opacity: '0.5 !important'
+ },
+ '.input:disabled:hover,\n\t.textarea:disabled:hover,\n\t.select:disabled:hover': {
+ '--tw-brightness': 'brightness(1) !important',
+ 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) !important'
+ },
+ '.\\!input[readonly]': { cursor: 'not-allowed !important', borderWidth: '0px !important' },
+ '.\\!input[readonly]:hover': {
+ '--tw-brightness': 'brightness(1) !important',
+ 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) !important'
+ },
+ '.input[readonly],\n\t.textarea[readonly],\n\t.select[readonly]': {
+ cursor: 'not-allowed !important',
+ borderWidth: '0px !important'
+ },
+ '.input[readonly]:hover,\n\t.textarea[readonly]:hover,\n\t.select[readonly]:hover': {
+ '--tw-brightness': 'brightness(1) !important',
+ 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) !important'
+ },
+ '.input-group': { display: 'grid', overflow: 'hidden' },
+ '.input-group input,\n\t.input-group select': {
+ borderWidth: '0px',
+ backgroundColor: 'transparent',
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)'
+ },
+ '.input-group select option': { backgroundColor: 'rgb(var(--color-surface-200))' },
+ '.dark .input-group select option': { backgroundColor: 'rgb(var(--color-surface-700))' },
+ '.input-group div,\n\t.input-group a,\n\t.input-group button': {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingLeft: '1rem',
+ paddingRight: '1rem'
+ },
+ '.input-group-divider input,\n\t.input-group-divider select,\n\t.input-group-divider div,\n\t.input-group-divider a':
+ {
+ borderLeftWidth: '1px',
+ borderColor: 'rgb(var(--color-surface-400))',
+ '--tw-ring-offset-shadow':
+ 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color) !important',
+ '--tw-ring-shadow':
+ 'var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color) !important',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000) !important',
+ minWidth: 'fit-content !important'
+ },
+ '.dark .input-group-divider input,.dark \n\t.input-group-divider select,.dark \n\t.input-group-divider div,.dark \n\t.input-group-divider a':
+ { borderColor: 'rgb(var(--color-surface-500))' },
+ '.input-group-divider input:focus,\n\t.input-group-divider select:focus,\n\t.input-group-divider div:focus,\n\t.input-group-divider a:focus':
+ { borderColor: 'rgb(var(--color-surface-400))' },
+ '.dark .input-group-divider input:focus,.dark \n\t.input-group-divider select:focus,.dark \n\t.input-group-divider div:focus,.dark \n\t.input-group-divider a:focus':
+ { borderColor: 'rgb(var(--color-surface-500))' },
+ '.input-group-divider *:first-child': { borderLeftWidth: '0px !important' },
+ '.input-group-shim': {
+ backgroundColor: 'rgb(var(--color-surface-400) / 0.1)',
+ color: 'rgb(var(--color-surface-600))'
+ },
+ '.dark .input-group-shim': { color: 'rgb(var(--color-surface-300))' },
+ '.input-success': {
+ '--tw-border-opacity': '1 !important',
+ borderColor: 'rgb(var(--color-success-500) / var(--tw-border-opacity)) !important',
+ '--tw-bg-opacity': '1 !important',
+ backgroundColor: 'rgb(var(--color-success-200) / var(--tw-bg-opacity)) !important',
+ '--tw-text-opacity': '1 !important',
+ color: 'rgb(var(--color-success-700) / var(--tw-text-opacity)) !important'
+ },
+ '.input-success::-moz-placeholder': {
+ '--tw-text-opacity': '1',
+ color: 'rgb(var(--color-success-700) / var(--tw-text-opacity))'
+ },
+ '.input-success:-ms-input-placeholder': {
+ '--tw-text-opacity': '1',
+ color: 'rgb(var(--color-success-700) / var(--tw-text-opacity))'
+ },
+ '.input-success::placeholder': {
+ '--tw-text-opacity': '1',
+ color: 'rgb(var(--color-success-700) / var(--tw-text-opacity))'
+ },
+ '.input-warning': {
+ '--tw-border-opacity': '1 !important',
+ borderColor: 'rgb(var(--color-warning-500) / var(--tw-border-opacity)) !important',
+ '--tw-bg-opacity': '1 !important',
+ backgroundColor: 'rgb(var(--color-warning-200) / var(--tw-bg-opacity)) !important',
+ '--tw-text-opacity': '1 !important',
+ color: 'rgb(var(--color-warning-700) / var(--tw-text-opacity)) !important'
+ },
+ '.input-warning::-moz-placeholder': {
+ '--tw-text-opacity': '1',
+ color: 'rgb(var(--color-warning-700) / var(--tw-text-opacity))'
+ },
+ '.input-warning:-ms-input-placeholder': {
+ '--tw-text-opacity': '1',
+ color: 'rgb(var(--color-warning-700) / var(--tw-text-opacity))'
+ },
+ '.input-warning::placeholder': {
+ '--tw-text-opacity': '1',
+ color: 'rgb(var(--color-warning-700) / var(--tw-text-opacity))'
+ },
+ '.input-error': {
+ '--tw-border-opacity': '1 !important',
+ borderColor: 'rgb(var(--color-error-500) / var(--tw-border-opacity)) !important',
+ '--tw-bg-opacity': '1 !important',
+ backgroundColor: 'rgb(var(--color-error-200) / var(--tw-bg-opacity)) !important',
+ '--tw-text-opacity': '1 !important',
+ color: 'rgb(var(--color-error-500) / var(--tw-text-opacity)) !important'
+ },
+ '.input-error::-moz-placeholder': {
+ '--tw-text-opacity': '1',
+ color: 'rgb(var(--color-error-500) / var(--tw-text-opacity))'
+ },
+ '.input-error:-ms-input-placeholder': {
+ '--tw-text-opacity': '1',
+ color: 'rgb(var(--color-error-500) / var(--tw-text-opacity))'
+ },
+ '.input-error::placeholder': {
+ '--tw-text-opacity': '1',
+ color: 'rgb(var(--color-error-500) / var(--tw-text-opacity))'
+ },
+ '.variant-form-material': {
+ borderTopLeftRadius: '0.25rem !important',
+ borderTopRightRadius: '0.25rem !important',
+ borderBottomLeftRadius: '0px !important',
+ borderBottomRightRadius: '0px !important',
+ backgroundColor: 'rgb(var(--color-surface-500) / 0.1)',
+ borderWidth: '0px',
+ borderBottomWidth: '2px',
+ '--tw-backdrop-blur': 'blur(8px)',
+ backdropFilter:
+ '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)'
+ },
+ ".variant-form-material[type='file']": { paddingTop: '0.375rem !important', paddingBottom: '0.375rem !important' },
+ '.alert': {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ padding: '1rem',
+ color: 'rgb(var(--color-surface-900))',
+ borderRadius: 'var(--theme-rounded-container)'
+ },
+ '.alert > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-y-reverse': '0',
+ marginTop: 'calc(1rem * calc(1 - var(--tw-space-y-reverse)))',
+ marginBottom: 'calc(1rem * var(--tw-space-y-reverse))'
+ },
+ '.dark .alert': { color: 'rgb(var(--color-surface-50))' },
+ '.alert-message': { flex: '1 1 auto' },
+ '.alert-message > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-y-reverse': '0',
+ marginTop: 'calc(0.5rem * calc(1 - var(--tw-space-y-reverse)))',
+ marginBottom: 'calc(0.5rem * var(--tw-space-y-reverse))'
+ },
+ '.alert-actions': { display: 'flex', alignItems: 'center' },
+ '.alert-actions > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': '0',
+ marginRight: 'calc(0.5rem * var(--tw-space-x-reverse))',
+ marginLeft: 'calc(0.5rem * calc(1 - var(--tw-space-x-reverse)))'
+ },
+ '.badge': {
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ whiteSpace: 'nowrap',
+ fontSize: '0.75rem',
+ lineHeight: '1rem',
+ fontWeight: 600,
+ paddingLeft: '0.5rem',
+ paddingRight: '0.5rem',
+ paddingTop: '0.25rem',
+ paddingBottom: '0.25rem',
+ borderRadius: 'var(--theme-rounded-base)'
+ },
+ '.badge > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': '0',
+ marginRight: 'calc(0.5rem * var(--tw-space-x-reverse))',
+ marginLeft: 'calc(0.5rem * calc(1 - var(--tw-space-x-reverse)))'
+ },
+ '.badge-icon': {
+ display: 'flex',
+ height: '1.25rem',
+ width: '1.25rem',
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: '9999px',
+ fontSize: '0.75rem',
+ lineHeight: '1rem',
+ fontWeight: 600,
+ '--tw-shadow': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
+ '--tw-shadow-colored': '0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)'
+ },
+ '.badge-glass': {
+ backgroundColor: 'rgb(var(--color-surface-500) / 0.2)',
+ '--tw-backdrop-blur': 'blur(16px)',
+ backdropFilter:
+ 'var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)',
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
+ '--tw-ring-inset': 'inset',
+ '--tw-ring-color': 'rgb(23 23 23 / 0.05)'
+ },
+ '.breadcrumb::-webkit-scrollbar,\n\t.breadcrumb-nonresponsive::-webkit-scrollbar': { display: 'none' },
+ '.breadcrumb,\n\t.breadcrumb-nonresponsive': {
+ msOverflowStyle: 'none',
+ scrollbarWidth: 'none',
+ display: 'flex',
+ width: '100%',
+ alignItems: 'center',
+ overflowX: 'auto'
+ },
+ '.breadcrumb > :not([hidden]) ~ :not([hidden]),\n\t.breadcrumb-nonresponsive > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': '0',
+ marginRight: 'calc(1rem * var(--tw-space-x-reverse))',
+ marginLeft: 'calc(1rem * calc(1 - var(--tw-space-x-reverse)))'
+ },
+ '.crumb': { display: 'flex', alignItems: 'center', justifyContent: 'center' },
+ '.crumb > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': '0',
+ marginRight: 'calc(0.5rem * var(--tw-space-x-reverse))',
+ marginLeft: 'calc(0.5rem * calc(1 - var(--tw-space-x-reverse)))'
+ },
+ '.crumb-separator': { display: 'flex', opacity: 0.5, color: 'rgb(var(--color-surface-700))' },
+ '.dark .crumb-separator': { color: 'rgb(var(--color-surface-200))' },
+ '.breadcrumb li': { display: 'none' },
+ '.breadcrumb li:nth-last-child(3),\n\t.breadcrumb li:nth-last-child(2),\n\t.breadcrumb li:nth-last-child(1)': {
+ display: 'block'
+ },
+ '.btn': {
+ fontSize: '1rem',
+ lineHeight: '1.5rem',
+ paddingLeft: '1.25rem',
+ paddingRight: '1.25rem',
+ paddingTop: '9px',
+ paddingBottom: '9px',
+ whiteSpace: 'nowrap',
+ textAlign: 'center',
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ transitionProperty: 'all',
+ transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ transitionDuration: '150ms',
+ borderRadius: 'var(--theme-rounded-base)'
+ },
+ '.btn > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': '0',
+ marginRight: 'calc(0.5rem * var(--tw-space-x-reverse))',
+ marginLeft: 'calc(0.5rem * calc(1 - var(--tw-space-x-reverse)))'
+ },
+ '.btn:hover': {
+ '--tw-brightness': 'brightness(1.15)',
+ 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)'
+ },
+ '.btn:active': {
+ '--tw-scale-x': '95%',
+ '--tw-scale-y': '95%',
+ transform:
+ 'translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))',
+ '--tw-brightness': 'brightness(.9)',
+ 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)'
+ },
+ '.btn-sm': {
+ paddingLeft: '0.75rem',
+ paddingRight: '0.75rem',
+ paddingTop: '0.375rem',
+ paddingBottom: '0.375rem',
+ fontSize: '0.875rem',
+ lineHeight: '1.25rem'
+ },
+ '.btn-lg': {
+ paddingLeft: '1.75rem',
+ paddingRight: '1.75rem',
+ paddingTop: '0.75rem',
+ paddingBottom: '0.75rem',
+ fontSize: '1.125rem',
+ lineHeight: '1.75rem'
+ },
+ '.btn-xl': {
+ paddingLeft: '2.25rem',
+ paddingRight: '2.25rem',
+ paddingTop: '1rem',
+ paddingBottom: '1rem',
+ fontSize: '1.25rem',
+ lineHeight: '1.75rem'
+ },
+ '.btn-icon': {
+ fontSize: '1rem',
+ lineHeight: '1.5rem',
+ paddingLeft: '1.25rem',
+ paddingRight: '1.25rem',
+ paddingTop: '9px',
+ paddingBottom: '9px',
+ whiteSpace: 'nowrap',
+ textAlign: 'center',
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ transitionProperty: 'all',
+ transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ transitionDuration: '150ms',
+ padding: '0px',
+ aspectRatio: '1 / 1',
+ width: '43px',
+ borderRadius: '9999px'
+ },
+ '.btn-icon > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': '0',
+ marginRight: 'calc(0.5rem * var(--tw-space-x-reverse))',
+ marginLeft: 'calc(0.5rem * calc(1 - var(--tw-space-x-reverse)))'
+ },
+ '.btn-icon:hover': {
+ '--tw-brightness': 'brightness(1.15)',
+ 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)'
+ },
+ '.btn-icon-sm': { aspectRatio: '1 / 1', width: '33px', fontSize: '0.875rem', lineHeight: '1.25rem' },
+ '.btn-icon-lg': { aspectRatio: '1 / 1', width: '53px', fontSize: '1.125rem', lineHeight: '1.75rem' },
+ '.btn-icon-xl': { aspectRatio: '1 / 1', width: '63px', fontSize: '1.25rem', lineHeight: '1.75rem' },
+ '.btn-group': {
+ display: 'inline-flex',
+ flexDirection: 'row',
+ overflow: 'hidden',
+ borderRadius: 'var(--theme-rounded-base)',
+ isolation: 'isolate'
+ },
+ '.btn-group > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': '0',
+ marginRight: 'calc(0px * var(--tw-space-x-reverse))',
+ marginLeft: 'calc(0px * calc(1 - var(--tw-space-x-reverse)))'
+ },
+ '.btn-group-vertical': {
+ display: 'inline-flex',
+ flexDirection: 'column',
+ overflow: 'hidden',
+ borderRadius: 'var(--theme-rounded-container)',
+ isolation: 'isolate'
+ },
+ '.btn-group-vertical > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': '0',
+ marginRight: 'calc(0px * var(--tw-space-x-reverse))',
+ marginLeft: 'calc(0px * calc(1 - var(--tw-space-x-reverse)))',
+ '--tw-space-y-reverse': '0',
+ marginTop: 'calc(0px * calc(1 - var(--tw-space-y-reverse)))',
+ marginBottom: 'calc(0px * var(--tw-space-y-reverse))'
+ },
+ '.btn-group-vertical button,.btn-group-vertical a': {
+ fontSize: '1rem',
+ lineHeight: '1.5rem',
+ paddingLeft: '1.25rem',
+ paddingRight: '1.25rem',
+ paddingTop: '9px',
+ paddingBottom: '9px',
+ whiteSpace: 'nowrap',
+ textAlign: 'center',
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ transitionProperty: 'all',
+ transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ transitionDuration: '150ms',
+ color: 'inherit !important',
+ textDecorationLine: 'none !important'
+ },
+ '.btn-group-vertical button > :not([hidden]) ~ :not([hidden]),.btn-group-vertical a > :not([hidden]) ~ :not([hidden])':
+ {
+ '--tw-space-x-reverse': '0',
+ marginRight: 'calc(0.5rem * var(--tw-space-x-reverse))',
+ marginLeft: 'calc(0.5rem * calc(1 - var(--tw-space-x-reverse)))'
+ },
+ '.btn-group-vertical button:hover,.btn-group-vertical a:hover': {
+ '--tw-brightness': 'brightness(1.15)',
+ 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)',
+ backgroundColor: 'rgb(var(--color-surface-50) / 3%)'
+ },
+ '.btn-group-vertical button:active,.btn-group-vertical a:active': {
+ backgroundColor: 'rgb(var(--color-surface-900) / 3%)'
+ },
+ '.btn-group-vertical * + *': {
+ borderTopWidth: '1px',
+ borderLeftWidth: '0px',
+ borderColor: 'rgb(var(--color-surface-500) / 0.2)'
+ },
+ '.btn-group button,\n\t.btn-group a,\n\t.btn-group-vertical button,\n\t.btn-group-vertical a': {
+ fontSize: '1rem',
+ lineHeight: '1.5rem',
+ paddingLeft: '1.25rem',
+ paddingRight: '1.25rem',
+ paddingTop: '9px',
+ paddingBottom: '9px',
+ whiteSpace: 'nowrap',
+ textAlign: 'center',
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ transitionProperty: 'all',
+ transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ transitionDuration: '150ms',
+ color: 'inherit !important',
+ textDecorationLine: 'none !important'
+ },
+ '.btn-group button > :not([hidden]) ~ :not([hidden]),\n\t.btn-group a > :not([hidden]) ~ :not([hidden]),\n\t.btn-group-vertical button > :not([hidden]) ~ :not([hidden]),\n\t.btn-group-vertical a > :not([hidden]) ~ :not([hidden])':
+ {
+ '--tw-space-x-reverse': '0',
+ marginRight: 'calc(0.5rem * var(--tw-space-x-reverse))',
+ marginLeft: 'calc(0.5rem * calc(1 - var(--tw-space-x-reverse)))'
+ },
+ '.btn-group button:hover,\n\t.btn-group a:hover,\n\t.btn-group-vertical button:hover,\n\t.btn-group-vertical a:hover':
+ {
+ '--tw-brightness': 'brightness(1.15)',
+ 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)',
+ backgroundColor: 'rgb(var(--color-surface-50) / 3%)'
+ },
+ '.btn-group button:active,\n\t.btn-group a:active,\n\t.btn-group-vertical button:active,\n\t.btn-group-vertical a:active':
+ { backgroundColor: 'rgb(var(--color-surface-900) / 3%)' },
+ '.btn-group * + *': {
+ borderTopWidth: '0px',
+ borderLeftWidth: '1px',
+ borderColor: 'rgb(var(--color-surface-500) / 0.2)'
+ },
+ '.card': {
+ backgroundColor: 'rgb(var(--color-surface-100))',
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
+ '--tw-ring-inset': 'inset',
+ '--tw-ring-color': 'rgb(23 23 23 / 0.05);',
+ borderRadius: 'var(--theme-rounded-container)'
+ },
+ '.dark .card': {
+ backgroundColor: 'rgb(var(--color-surface-800))',
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
+ '--tw-ring-inset': 'inset',
+ '--tw-ring-color': 'rgb(250 250 250 / 0.05)'
+ },
+ '.card-header': { padding: '1rem', paddingBottom: '0px' },
+ '.card-footer': { padding: '1rem', paddingTop: '0px' },
+ '.card-hover': {
+ transitionProperty: 'all',
+ transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ transitionDuration: '150ms'
+ },
+ '.card-hover:hover': {
+ '--tw-scale-x': '101%',
+ '--tw-scale-y': '101%',
+ transform:
+ 'translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))',
+ '--tw-shadow': '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
+ '--tw-shadow-colored': '0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)'
+ },
+ '.chip': {
+ cursor: 'pointer',
+ whiteSpace: 'nowrap',
+ paddingLeft: '0.75rem',
+ paddingRight: '0.75rem',
+ paddingTop: '0.375rem',
+ paddingBottom: '0.375rem',
+ textAlign: 'center',
+ fontSize: '0.75rem',
+ lineHeight: '1rem',
+ borderRadius: '0.25rem',
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ transitionProperty: 'all',
+ transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ transitionDuration: '150ms'
+ },
+ '.chip > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': '0',
+ marginRight: 'calc(0.5rem * var(--tw-space-x-reverse))',
+ marginLeft: 'calc(0.5rem * calc(1 - var(--tw-space-x-reverse)))'
+ },
+ '.chip:hover': {
+ '--tw-brightness': 'brightness(1.15)',
+ 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)'
+ },
+ '.chip-disabled,\n\t.chip:disabled': { cursor: 'not-allowed !important', opacity: '0.5 !important' },
+ '.chip-disabled:active,\n\t.chip:disabled:active': {
+ '--tw-scale-x': '1',
+ '--tw-scale-y': '1',
+ transform:
+ 'translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))'
+ },
+ '.list,\n\t.list-dl,\n\t.list-nav ul': { listStyleType: 'none' },
+ '.list > :not([hidden]) ~ :not([hidden]),\n\t.list-dl > :not([hidden]) ~ :not([hidden]),\n\t.list-nav ul > :not([hidden]) ~ :not([hidden])':
+ {
+ '--tw-space-y-reverse': '0',
+ marginTop: 'calc(0.25rem * calc(1 - var(--tw-space-y-reverse)))',
+ marginBottom: 'calc(0.25rem * var(--tw-space-y-reverse))'
+ },
+ '.list li': {
+ display: 'flex',
+ alignItems: 'center',
+ padding: '0.5rem',
+ borderRadius: 'var(--theme-rounded-base)',
+ whiteSpace: 'normal',
+ overflowWrap: 'break-word'
+ },
+ '.list li > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': '0',
+ marginRight: 'calc(1rem * var(--tw-space-x-reverse))',
+ marginLeft: 'calc(1rem * calc(1 - var(--tw-space-x-reverse)))'
+ },
+ '.list-dl div': {
+ display: 'flex',
+ alignItems: 'center',
+ whiteSpace: 'nowrap',
+ padding: '0.5rem',
+ borderRadius: 'var(--theme-rounded-base)'
+ },
+ '.list-dl div > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': '0',
+ marginRight: 'calc(1rem * var(--tw-space-x-reverse))',
+ marginLeft: 'calc(1rem * calc(1 - var(--tw-space-x-reverse)))'
+ },
+ '.list-nav a,\n\t.list-nav button,\n\t.list-option': {
+ display: 'flex',
+ alignItems: 'center',
+ whiteSpace: 'nowrap',
+ paddingLeft: '1rem',
+ paddingRight: '1rem',
+ paddingTop: '0.5rem',
+ paddingBottom: '0.5rem',
+ outline: '2px solid transparent',
+ outlineOffset: '2px',
+ cursor: 'pointer',
+ borderRadius: 'var(--theme-rounded-base)'
+ },
+ '.list-nav a > :not([hidden]) ~ :not([hidden]),\n\t.list-nav button > :not([hidden]) ~ :not([hidden]),\n\t.list-option > :not([hidden]) ~ :not([hidden])':
+ {
+ '--tw-space-x-reverse': '0',
+ marginRight: 'calc(1rem * var(--tw-space-x-reverse))',
+ marginLeft: 'calc(1rem * calc(1 - var(--tw-space-x-reverse)))'
+ },
+ '.list-nav a:hover,\n\t.list-nav button:hover,\n\t.list-option:hover': {
+ backgroundColor: 'rgb(var(--color-primary-500) / 0.1)'
+ },
+ '.list-nav a:focus,\n\t.list-nav button:focus,\n\t.list-option:focus': {
+ '--tw-bg-opacity': '1',
+ backgroundColor: 'rgb(var(--color-primary-500) / var(--tw-bg-opacity))',
+ color: 'rgb(var(--on-primary))'
+ },
+ '.logo-cloud': { display: 'grid', overflow: 'hidden', borderRadius: 'var(--theme-rounded-container)' },
+ '.logo-item': {
+ '@apply: flex-auto text-center space-x-4 shadow': true,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: 'rgb(var(--color-surface-100))',
+ fontSize: '1rem',
+ lineHeight: '1.5rem',
+ fontWeight: 700,
+ '--tw-text-opacity': '1',
+ color: 'rgb(0 0 0 / var(--tw-text-opacity))',
+ paddingTop: '1rem',
+ paddingBottom: '1rem'
+ },
+ '.logo-item > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': '0',
+ marginRight: 'calc(1rem * var(--tw-space-x-reverse))',
+ marginLeft: 'calc(1rem * calc(1 - var(--tw-space-x-reverse)))'
+ },
+ '.dark .logo-item': { backgroundColor: 'rgb(var(--color-surface-800))' },
+ '.placeholder': {
+ height: '1.25rem',
+ backgroundColor: 'rgb(var(--color-surface-300))',
+ borderRadius: 'var(--theme-rounded-base)'
+ },
+ '.dark .placeholder': { backgroundColor: 'rgb(var(--color-surface-600))' },
+ '.placeholder-circle': {
+ aspectRatio: '1 / 1',
+ borderRadius: '9999px',
+ backgroundColor: 'rgb(var(--color-surface-300))'
+ },
+ '.dark .placeholder-circle': { backgroundColor: 'rgb(var(--color-surface-600))' },
+ '.table-container': { width: '100%', overflowX: 'auto', borderRadius: 'var(--theme-rounded-container)' },
+ '.dark .table': { backgroundColor: 'rgb(var(--color-surface-800))' },
+ '.table-hover tbody tr:hover': { backgroundColor: 'rgb(var(--color-surface-500) / 0.2)' },
+ '.table-hover tbody tr:hover:nth-child(even)': { backgroundColor: 'rgb(var(--color-surface-500) / 0.2)' },
+ '.table-interactive tbody tr': { cursor: 'pointer' },
+ '.table-interactive tbody tr:hover:hover': { backgroundColor: 'rgb(var(--color-primary-500) / 0.1)' },
+ '.table-interactive tbody tr:hover:nth-child(even):hover': { backgroundColor: 'rgb(var(--color-primary-500) / 0.1)' },
+ '.table-sort-asc::after': { opacity: 0.5, '--tw-content': "'↑' !important", content: 'var(--tw-content) !important' },
+ '.table-sort-dsc::after': { opacity: 0.5, '--tw-content': "'↓' !important", content: 'var(--tw-content) !important' },
+ '.table thead': {
+ borderBottomWidth: '1px',
+ borderColor: 'rgb(var(--color-surface-500) / 0.2)',
+ backgroundColor: 'rgb(var(--color-surface-200))'
+ },
+ '.dark .table thead': { backgroundColor: 'rgb(var(--color-surface-700))' },
+ '.table thead tr': { textAlign: 'left', textTransform: 'capitalize' },
+ '.table thead th': { padding: '1rem', fontWeight: 700 },
+ '.table tbody tr': { borderBottomWidth: '1px', borderColor: 'rgb(var(--color-surface-500) / 0.2)' },
+ '.table tbody tr:nth-child(even)': { backgroundColor: 'rgb(var(--color-surface-500) / 0.05)' },
+ '.table tbody td': {
+ whiteSpace: 'nowrap',
+ paddingLeft: '0.75rem',
+ paddingRight: '0.75rem',
+ paddingTop: '1rem',
+ paddingBottom: '1rem',
+ verticalAlign: 'top',
+ fontSize: '0.875rem',
+ lineHeight: '1.25rem'
+ },
+ '.table-compact tbody td': { paddingTop: '0.75rem !important', paddingBottom: '0.75rem !important' },
+ '.table-comfortable tbody td': { paddingTop: '1.25rem !important', paddingBottom: '1.25rem !important' },
+ '.table tfoot': { backgroundColor: 'rgb(var(--color-surface-100))' },
+ '.dark .table tfoot': { backgroundColor: 'rgb(var(--color-surface-800))' },
+ '.table tfoot tr': { textAlign: 'left', textTransform: 'capitalize' },
+ '.table tfoot th,\n\t.table tfoot td': { padding: '1rem' },
+ '.table-row-checked': { backgroundColor: 'rgb(var(--color-secondary-500) / 0.2) !important' },
+ '.table-cell-fit': { width: '1%', whiteSpace: 'nowrap' },
+ '.variant-filled': { backgroundColor: 'rgb(var(--color-surface-900))', color: 'rgb(var(--color-surface-50))' },
+ '.dark .variant-filled': { backgroundColor: 'rgb(var(--color-surface-50))', color: 'rgb(var(--color-surface-900))' },
+ '.variant-filled-primary': {
+ '--tw-bg-opacity': '1',
+ backgroundColor: 'rgb(var(--color-primary-500) / var(--tw-bg-opacity))',
+ color: 'rgb(var(--on-primary))'
+ },
+ '.variant-filled-secondary': {
+ '--tw-bg-opacity': '1',
+ backgroundColor: 'rgb(var(--color-secondary-500) / var(--tw-bg-opacity))',
+ color: 'rgb(var(--on-secondary))'
+ },
+ '.variant-filled-tertiary': {
+ '--tw-bg-opacity': '1',
+ backgroundColor: 'rgb(var(--color-tertiary-500) / var(--tw-bg-opacity))',
+ color: 'rgb(var(--on-tertiary))'
+ },
+ '.variant-filled-success': {
+ '--tw-bg-opacity': '1',
+ backgroundColor: 'rgb(var(--color-success-500) / var(--tw-bg-opacity))',
+ color: 'rgb(var(--on-success))'
+ },
+ '.variant-filled-warning': {
+ '--tw-bg-opacity': '1',
+ backgroundColor: 'rgb(var(--color-warning-500) / var(--tw-bg-opacity))',
+ color: 'rgb(var(--on-warning))'
+ },
+ '.variant-filled-error': {
+ '--tw-bg-opacity': '1',
+ backgroundColor: 'rgb(var(--color-error-500) / var(--tw-bg-opacity))',
+ color: 'rgb(var(--on-error))'
+ },
+ '.variant-filled-surface': { backgroundColor: 'rgb(var(--color-surface-400))', color: 'rgb(var(--on-surface))' },
+ '.dark .variant-filled-surface': { backgroundColor: 'rgb(var(--color-surface-500))' },
+ '.variant-ringed': {
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
+ '--tw-ring-inset': 'inset',
+ '--tw-ring-opacity': '1',
+ '--tw-ring-color': 'rgb(var(--color-surface-500) / var(--tw-ring-opacity))',
+ backgroundColor: 'transparent'
+ },
+ '.variant-ringed-primary': {
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
+ '--tw-ring-inset': 'inset',
+ '--tw-ring-opacity': '1',
+ '--tw-ring-color': 'rgb(var(--color-primary-500) / var(--tw-ring-opacity))',
+ backgroundColor: 'transparent'
+ },
+ '.variant-ringed-secondary': {
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
+ '--tw-ring-inset': 'inset',
+ '--tw-ring-opacity': '1',
+ '--tw-ring-color': 'rgb(var(--color-secondary-500) / var(--tw-ring-opacity))',
+ backgroundColor: 'transparent'
+ },
+ '.variant-ringed-tertiary': {
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
+ '--tw-ring-inset': 'inset',
+ '--tw-ring-opacity': '1',
+ '--tw-ring-color': 'rgb(var(--color-tertiary-500) / var(--tw-ring-opacity))',
+ backgroundColor: 'transparent'
+ },
+ '.variant-ringed-success': {
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
+ '--tw-ring-inset': 'inset',
+ '--tw-ring-opacity': '1',
+ '--tw-ring-color': 'rgb(var(--color-success-500) / var(--tw-ring-opacity))',
+ backgroundColor: 'transparent'
+ },
+ '.variant-ringed-warning': {
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
+ '--tw-ring-inset': 'inset',
+ '--tw-ring-opacity': '1',
+ '--tw-ring-color': 'rgb(var(--color-warning-500) / var(--tw-ring-opacity))',
+ backgroundColor: 'transparent'
+ },
+ '.variant-ringed-error': {
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
+ '--tw-ring-inset': 'inset',
+ '--tw-ring-opacity': '1',
+ '--tw-ring-color': 'rgb(var(--color-error-500) / var(--tw-ring-opacity))',
+ backgroundColor: 'transparent'
+ },
+ '.variant-ringed-surface': {
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
+ '--tw-ring-inset': 'inset',
+ '--tw-ring-opacity': '1',
+ '--tw-ring-color': 'rgb(var(--color-surface-500) / var(--tw-ring-opacity))',
+ backgroundColor: 'transparent'
+ },
+ '.variant-ghost-primary': {
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
+ '--tw-ring-inset': 'inset',
+ '--tw-ring-opacity': '1',
+ '--tw-ring-color': 'rgb(var(--color-primary-500) / var(--tw-ring-opacity))',
+ backgroundColor: 'rgb(var(--color-primary-500) / 0.2)'
+ },
+ '.variant-ghost-secondary': {
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
+ '--tw-ring-inset': 'inset',
+ '--tw-ring-opacity': '1',
+ '--tw-ring-color': 'rgb(var(--color-secondary-500) / var(--tw-ring-opacity))',
+ backgroundColor: 'rgb(var(--color-secondary-500) / 0.2)'
+ },
+ '.variant-ghost-tertiary': {
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
+ '--tw-ring-inset': 'inset',
+ '--tw-ring-opacity': '1',
+ '--tw-ring-color': 'rgb(var(--color-tertiary-500) / var(--tw-ring-opacity))',
+ backgroundColor: 'rgb(var(--color-tertiary-500) / 0.2)'
+ },
+ '.variant-ghost-success': {
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
+ '--tw-ring-inset': 'inset',
+ '--tw-ring-opacity': '1',
+ '--tw-ring-color': 'rgb(var(--color-success-500) / var(--tw-ring-opacity))',
+ backgroundColor: 'rgb(var(--color-success-500) / 0.2)'
+ },
+ '.variant-ghost-warning': {
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
+ '--tw-ring-inset': 'inset',
+ '--tw-ring-opacity': '1',
+ '--tw-ring-color': 'rgb(var(--color-warning-500) / var(--tw-ring-opacity))',
+ backgroundColor: 'rgb(var(--color-warning-500) / 0.2)'
+ },
+ '.variant-ghost-error': {
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
+ '--tw-ring-inset': 'inset',
+ '--tw-ring-opacity': '1',
+ '--tw-ring-color': 'rgb(var(--color-error-500) / var(--tw-ring-opacity))',
+ backgroundColor: 'rgb(var(--color-error-500) / 0.2)'
+ },
+ '.variant-ghost,\n\t.variant-ghost-surface': {
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
+ '--tw-ring-inset': 'inset',
+ '--tw-ring-opacity': '1',
+ '--tw-ring-color': 'rgb(var(--color-surface-500) / var(--tw-ring-opacity))',
+ backgroundColor: 'rgb(var(--color-surface-500) / 0.2)'
+ },
+ '.variant-soft-primary': {
+ backgroundColor: 'rgb(var(--color-primary-400) / 0.2)',
+ '--tw-ring-offset-shadow':
+ 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color) !important',
+ '--tw-ring-shadow':
+ 'var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color) !important',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000) !important',
+ color: 'rgb(var(--color-primary-700))'
+ },
+ '.dark .variant-soft-primary': { color: 'rgb(var(--color-primary-200))' },
+ '.variant-soft-secondary': {
+ backgroundColor: 'rgb(var(--color-secondary-400) / 0.2)',
+ '--tw-ring-offset-shadow':
+ 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color) !important',
+ '--tw-ring-shadow':
+ 'var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color) !important',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000) !important',
+ color: 'rgb(var(--color-secondary-700))'
+ },
+ '.dark .variant-soft-secondary': { color: 'rgb(var(--color-secondary-200))' },
+ '.variant-soft-tertiary': {
+ backgroundColor: 'rgb(var(--color-tertiary-400) / 0.2)',
+ '--tw-ring-offset-shadow':
+ 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color) !important',
+ '--tw-ring-shadow':
+ 'var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color) !important',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000) !important',
+ color: 'rgb(var(--color-tertiary-700))'
+ },
+ '.dark .variant-soft-tertiary': { color: 'rgb(var(--color-tertiary-200))' },
+ '.variant-soft-success': {
+ backgroundColor: 'rgb(var(--color-success-400) / 0.2)',
+ '--tw-ring-offset-shadow':
+ 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color) !important',
+ '--tw-ring-shadow':
+ 'var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color) !important',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000) !important',
+ color: 'rgb(var(--color-success-700))'
+ },
+ '.dark .variant-soft-success': { color: 'rgb(var(--color-success-200))' },
+ '.variant-soft-warning': {
+ backgroundColor: 'rgb(var(--color-warning-400) / 0.2)',
+ '--tw-ring-offset-shadow':
+ 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color) !important',
+ '--tw-ring-shadow':
+ 'var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color) !important',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000) !important',
+ color: 'rgb(var(--color-warning-700))'
+ },
+ '.dark .variant-soft-warning': { color: 'rgb(var(--color-warning-200))' },
+ '.variant-soft-error': {
+ backgroundColor: 'rgb(var(--color-error-400) / 0.2)',
+ '--tw-ring-offset-shadow':
+ 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color) !important',
+ '--tw-ring-shadow':
+ 'var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color) !important',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000) !important',
+ color: 'rgb(var(--color-error-700))'
+ },
+ '.dark .variant-soft-error': { color: 'rgb(var(--color-error-200))' },
+ '.variant-soft,\n\t.variant-soft-surface': {
+ backgroundColor: 'rgb(var(--color-surface-400) / 0.2)',
+ '--tw-ring-offset-shadow':
+ 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color) !important',
+ '--tw-ring-shadow':
+ 'var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color) !important',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000) !important',
+ color: 'rgb(var(--color-surface-700))'
+ },
+ '.dark .variant-soft,.dark \n\t.variant-soft-surface': { color: 'rgb(var(--color-surface-200))' },
+ '.variant-glass-primary': {
+ backgroundColor: 'rgb(var(--color-primary-500) / 0.2)',
+ '--tw-backdrop-blur': 'blur(16px)',
+ backdropFilter:
+ '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)'
+ },
+ '.variant-glass-secondary': {
+ backgroundColor: 'rgb(var(--color-secondary-500) / 0.2)',
+ '--tw-backdrop-blur': 'blur(16px)',
+ backdropFilter:
+ '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)'
+ },
+ '.variant-glass-tertiary': {
+ backgroundColor: 'rgb(var(--color-tertiary-500) / 0.2)',
+ '--tw-backdrop-blur': 'blur(16px)',
+ backdropFilter:
+ '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)'
+ },
+ '.variant-glass-success': {
+ backgroundColor: 'rgb(var(--color-success-500) / 0.2)',
+ '--tw-backdrop-blur': 'blur(16px)',
+ backdropFilter:
+ '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)'
+ },
+ '.variant-glass-warning': {
+ backgroundColor: 'rgb(var(--color-warning-500) / 0.2)',
+ '--tw-backdrop-blur': 'blur(16px)',
+ backdropFilter:
+ '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)'
+ },
+ '.variant-glass-error': {
+ backgroundColor: 'rgb(var(--color-error-500) / 0.2)',
+ '--tw-backdrop-blur': 'blur(16px)',
+ backdropFilter:
+ '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)'
+ },
+ '.variant-glass-surface': {
+ backgroundColor: 'rgb(var(--color-surface-500) / 0.2)',
+ '--tw-backdrop-blur': 'blur(16px)',
+ backdropFilter:
+ '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)'
+ },
+ '.variant-glass': {
+ backgroundColor: 'rgb(var(--color-surface-50) / 0.3)',
+ '--tw-backdrop-blur': 'blur(16px)',
+ backdropFilter:
+ '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)'
+ },
+ '.left-\\[10\\%\\]': { left: '10%' },
+ '.left-\\[15\\%\\]': { left: '15%' },
+ '.left-\\[25\\%\\]': { left: '25%' },
+ '.left-\\[35\\%\\]': { left: '35%' },
+ '.left-\\[40\\%\\]': { left: '40%' },
+ '.left-\\[45\\%\\]': { left: '45%' },
+ '.left-\\[5\\%\\]': { left: '5%' },
+ '.left-\\[50\\%\\]': { left: '50%' },
+ '.left-\\[65\\%\\]': { left: '65%' },
+ '.left-\\[70\\%\\]': { left: '70%' },
+ '.top-\\[-12\\%\\]': { top: '-12%' },
+ '.top-\\[0\\%\\]': { top: '0%' },
+ '.top-\\[20\\%\\]': { top: '20%' },
+ '.top-\\[32\\%\\]': { top: '32%' },
+ '.top-\\[45\\%\\]': { top: '45%' },
+ '.top-\\[50\\%\\]': { top: '50%' },
+ '.top-\\[55\\%\\]': { top: '55%' },
+ '.top-\\[60\\%\\]': { top: '60%' },
+ '.top-\\[78\\%\\]': { top: '78%' },
+ '.top-\\[98\\%\\]': { top: '98%' },
+ '.z-\\[-1\\]': { zIndex: -1 },
+ '.z-\\[1\\]': { zIndex: 1 },
+ '.z-\\[888\\]': { zIndex: 888 },
+ '.z-\\[999\\]': { zIndex: 999 },
+ '.\\!my-6': { marginTop: '1.5rem !important', marginBottom: '1.5rem !important' },
+ '.-mt-\\[15px\\]': { marginTop: '-15px' },
+ '.mt-\\[15px\\]': { marginTop: '15px' },
+ '.\\!flex': { display: 'flex !important' },
+ '.aspect-\\[21\\/9\\]': { aspectRatio: '21/9' },
+ '.h-\\[120px\\]': { height: '120px' },
+ '.h-\\[20px\\]': { height: '20px' },
+ '.h-\\[280px\\]': { height: '280px' },
+ '.h-\\[480px\\]': { height: '480px' },
+ '.h-\\[50\\%\\]': { height: '50%' },
+ '.h-\\[72px\\]': { height: '72px' },
+ '.max-h-\\[180px\\]': { maxHeight: '180px' },
+ '.max-h-\\[200px\\]': { maxHeight: '200px' },
+ '.max-h-\\[480px\\]': { maxHeight: '480px' },
+ '.max-h-\\[90\\%\\]': { maxHeight: '90%' },
+ '.min-h-\\[320px\\]': { minHeight: '320px' },
+ '.min-h-\\[400px\\]': { minHeight: '400px' },
+ '.\\!w-full': { width: '100% !important' },
+ '.w-\\[100px\\]': { width: '100px' },
+ '.w-\\[240px\\]': { width: '240px' },
+ '.w-\\[280px\\]': { width: '280px' },
+ '.w-\\[286px\\]': { width: '286px' },
+ '.w-\\[320px\\]': { width: '320px' },
+ '.w-\\[32px\\]': { width: '32px' },
+ '.w-\\[360px\\]': { width: '360px' },
+ '.w-\\[50\\%\\]': { width: '50%' },
+ '.w-\\[70\\%\\]': { width: '70%' },
+ '.w-\\[70px\\]': { width: '70px' },
+ '.w-\\[90\\%\\]': { width: '90%' },
+ '.min-w-\\[150px\\]': { minWidth: '150px' },
+ '.max-w-\\[180px\\]': { maxWidth: '180px' },
+ '.max-w-\\[320px\\]': { maxWidth: '320px' },
+ '.max-w-\\[400px\\]': { maxWidth: '400px' },
+ '.max-w-\\[475px\\]': { maxWidth: '475px' },
+ '.max-w-\\[480px\\]': { maxWidth: '480px' },
+ '.max-w-\\[600px\\]': { maxWidth: '600px' },
+ '.max-w-\\[640px\\]': { maxWidth: '640px' },
+ '.max-w-\\[650px\\]': { maxWidth: '650px' },
+ '.max-w-\\[800px\\]': { maxWidth: '800px' },
+ '.max-w-\\[90\\%\\]': { maxWidth: '90%' },
+ '.origin-\\[50\\%_50\\%\\]': { transformOrigin: '50% 50%' },
+ '.-translate-x-\\[50\\%\\]': {
+ '--tw-translate-x': '-50%',
+ transform:
+ 'translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))'
+ },
+ '.-translate-y-\\[50\\%\\]': {
+ '--tw-translate-y': '-50%',
+ transform:
+ 'translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))'
+ },
+ '.translate-x-\\[100\\%\\]': {
+ '--tw-translate-x': '100%',
+ transform:
+ 'translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))'
+ },
+ '.translate-x-\\[50\\%\\]': {
+ '--tw-translate-x': '50%',
+ transform:
+ 'translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))'
+ },
+ '.scale-\\[0\\.8\\]': {
+ '--tw-scale-x': '0.8',
+ '--tw-scale-y': '0.8',
+ transform:
+ 'translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))'
+ },
+ '.-scale-x-\\[100\\%\\]': {
+ '--tw-scale-x': '-100%',
+ transform:
+ 'translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))'
+ },
+ '.grid-cols-\\[100px_1fr\\]': { gridTemplateColumns: '100px 1fr' },
+ '.grid-cols-\\[1fr_auto\\]': { gridTemplateColumns: '1fr auto' },
+ '.grid-cols-\\[1fr_auto_auto\\]': { gridTemplateColumns: '1fr auto auto' },
+ '.grid-cols-\\[auto_1fr\\]': { gridTemplateColumns: 'auto 1fr' },
+ '.grid-cols-\\[auto_1fr_auto\\]': { gridTemplateColumns: 'auto 1fr auto' },
+ '.grid-rows-\\[1fr_40px\\]': { gridTemplateRows: '1fr 40px' },
+ '.grid-rows-\\[auto_1fr_auto\\]': { gridTemplateRows: 'auto 1fr auto' },
+ '.space-y-\\[1px\\] > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-y-reverse': '0',
+ marginTop: 'calc(1px * calc(1 - var(--tw-space-y-reverse)))',
+ marginBottom: 'calc(1px * var(--tw-space-y-reverse))'
+ },
+ '.\\!rounded-none': { borderRadius: '0px !important' },
+ '.rounded-\\[corner\\]': { borderRadius: 'corner' },
+ '.\\!border-t-2': { borderTopWidth: '2px !important' },
+ '.\\!border-t-4': { borderTopWidth: '4px !important' },
+ '.\\!border-t-8': { borderTopWidth: '8px !important' },
+ '.\\!border-dashed': { borderStyle: 'dashed !important' },
+ '.\\!border-dotted': { borderStyle: 'dotted !important' },
+ '.\\!border-double': { borderStyle: 'double !important' },
+ '.border-primary-500': {
+ '--tw-border-opacity': '1',
+ borderColor: 'rgb(var(--color-primary-500) / var(--tw-border-opacity))'
+ },
+ '.border-primary-500\\/50': { borderColor: 'rgb(var(--color-primary-500) / 0.5)' },
+ '.border-secondary-500': {
+ '--tw-border-opacity': '1',
+ borderColor: 'rgb(var(--color-secondary-500) / var(--tw-border-opacity))'
+ },
+ '.border-surface-500': {
+ '--tw-border-opacity': '1',
+ borderColor: 'rgb(var(--color-surface-500) / var(--tw-border-opacity))'
+ },
+ '.border-surface-500\\/10': { borderColor: 'rgb(var(--color-surface-500) / 0.1)' },
+ '.border-surface-500\\/30': { borderColor: 'rgb(var(--color-surface-500) / 0.3)' },
+ '.border-surface-500\\/50': { borderColor: 'rgb(var(--color-surface-500) / 0.5)' },
+ '.from-primary-500': {
+ '--tw-gradient-from': 'rgb(var(--color-primary-500) / 1) var(--tw-gradient-from-position)',
+ '--tw-gradient-from-position': ' ',
+ '--tw-gradient-to': 'rgb(var(--color-primary-500) / 0) var(--tw-gradient-from-position)',
+ '--tw-gradient-to-position': ' ',
+ '--tw-gradient-stops': 'var(--tw-gradient-from), var(--tw-gradient-to)'
+ },
+ '.via-tertiary-500': {
+ '--tw-gradient-via-position': ' ',
+ '--tw-gradient-to': 'rgb(var(--color-tertiary-500) / 0) var(--tw-gradient-to-position)',
+ '--tw-gradient-to-position': ' ',
+ '--tw-gradient-stops':
+ 'var(--tw-gradient-from), rgb(var(--color-tertiary-500) / 1) var(--tw-gradient-via-position), var(--tw-gradient-to)'
+ },
+ '.to-secondary-500': {
+ '--tw-gradient-to': 'rgb(var(--color-secondary-500) / 1) var(--tw-gradient-to-position)',
+ '--tw-gradient-to-position': ' '
+ },
+ '.fill-primary-500': { fill: 'rgb(var(--color-primary-500) / 1)' },
+ '.fill-surface-50': { fill: 'rgb(var(--color-surface-50) / 1)' },
+ '.fill-surface-900': { fill: 'rgb(var(--color-surface-900) / 1)' },
+ '.stroke-error-500': { stroke: 'rgb(var(--color-error-500) / 1)' },
+ '.stroke-error-500\\/30': { stroke: 'rgb(var(--color-error-500) / 0.3)' },
+ '.stroke-primary-500': { stroke: 'rgb(var(--color-primary-500) / 1)' },
+ '.stroke-primary-500\\/30': { stroke: 'rgb(var(--color-primary-500) / 0.3)' },
+ '.stroke-secondary-500': { stroke: 'rgb(var(--color-secondary-500) / 1)' },
+ '.stroke-secondary-500\\/30': { stroke: 'rgb(var(--color-secondary-500) / 0.3)' },
+ '.stroke-success-500': { stroke: 'rgb(var(--color-success-500) / 1)' },
+ '.stroke-success-500\\/30': { stroke: 'rgb(var(--color-success-500) / 0.3)' },
+ '.stroke-surface-300': { stroke: 'rgb(var(--color-surface-300) / 1)' },
+ '.stroke-surface-500\\/30': { stroke: 'rgb(var(--color-surface-500) / 0.3)' },
+ '.stroke-surface-900': { stroke: 'rgb(var(--color-surface-900) / 1)' },
+ '.stroke-tertiary-500': { stroke: 'rgb(var(--color-tertiary-500) / 1)' },
+ '.stroke-tertiary-500\\/30': { stroke: 'rgb(var(--color-tertiary-500) / 0.3)' },
+ '.stroke-warning-500': { stroke: 'rgb(var(--color-warning-500) / 1)' },
+ '.stroke-warning-500\\/30': { stroke: 'rgb(var(--color-warning-500) / 0.3)' },
+ '.\\!p-0': { padding: '0px !important' },
+ '.\\!p-4': { padding: '1rem !important' },
+ '.\\!px-3': { paddingLeft: '0.75rem !important', paddingRight: '0.75rem !important' },
+ '.\\!text-center': { textAlign: 'center !important' },
+ '.\\!text-5xl': { fontSize: '3rem !important', lineHeight: '1 !important' },
+ '.\\!text-lg': { fontSize: '1.125rem !important', lineHeight: '1.75rem !important' },
+ '.\\!text-sm': { fontSize: '0.875rem !important', lineHeight: '1.25rem !important' },
+ '.\\!text-xl': { fontSize: '1.25rem !important', lineHeight: '1.75rem !important' },
+ '.text-\\[16px\\]': { fontSize: '16px' },
+ '.\\!text-current': { color: 'currentColor !important' },
+ '.\\!text-slate-900': {
+ '--tw-text-opacity': '1 !important',
+ color: 'rgb(15 23 42 / var(--tw-text-opacity)) !important'
+ },
+ '.\\!text-stone-900': {
+ '--tw-text-opacity': '1 !important',
+ color: 'rgb(28 25 23 / var(--tw-text-opacity)) !important'
+ },
+ '.\\!text-white': {
+ '--tw-text-opacity': '1 !important',
+ color: 'rgb(255 255 255 / var(--tw-text-opacity)) !important'
+ },
+ '.\\!text-zinc-900': {
+ '--tw-text-opacity': '1 !important',
+ color: 'rgb(24 24 27 / var(--tw-text-opacity)) !important'
+ },
+ '.text-primary-500': { '--tw-text-opacity': '1', color: 'rgb(var(--color-primary-500) / var(--tw-text-opacity))' },
+ '.text-primary-700': { '--tw-text-opacity': '1', color: 'rgb(var(--color-primary-700) / var(--tw-text-opacity))' },
+ '.text-secondary-500': {
+ '--tw-text-opacity': '1',
+ color: 'rgb(var(--color-secondary-500) / var(--tw-text-opacity))'
+ },
+ '.text-surface-50': { '--tw-text-opacity': '1', color: 'rgb(var(--color-surface-50) / var(--tw-text-opacity))' },
+ '.text-surface-700': { '--tw-text-opacity': '1', color: 'rgb(var(--color-surface-700) / var(--tw-text-opacity))' },
+ '.text-surface-900': { '--tw-text-opacity': '1', color: 'rgb(var(--color-surface-900) / var(--tw-text-opacity))' },
+ '.text-warning-500': { '--tw-text-opacity': '1', color: 'rgb(var(--color-warning-500) / var(--tw-text-opacity))' },
+ '.accent-\\[color\\]': { accentColor: 'color' },
+ '.accent-surface-900': { accentColor: 'rgb(var(--color-surface-900) / 1)' },
+ '.shadow-surface-500\\/10': {
+ '--tw-shadow-color': 'rgb(var(--color-surface-500) / 0.1)',
+ '--tw-shadow': 'var(--tw-shadow-colored)'
+ },
+ '.-outline-offset-\\[3px\\]': { outlineOffset: '-3px' },
+ '.\\!ring-0': {
+ '--tw-ring-offset-shadow':
+ 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color) !important',
+ '--tw-ring-shadow':
+ 'var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color) !important',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000) !important'
+ },
+ '.ring-\\[1px\\]': {
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)'
+ },
+ '.ring-surface-500\\/10': { '--tw-ring-color': 'rgb(var(--color-surface-500) / 0.1)' },
+ '.ring-surface-500\\/30': { '--tw-ring-color': 'rgb(var(--color-surface-500) / 0.3)' },
+ '.ring-surface-500\\/50': { '--tw-ring-color': 'rgb(var(--color-surface-500) / 0.5)' },
+ '.blur-\\[50px\\]': {
+ '--tw-blur': 'blur(50px)',
+ 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)'
+ },
+ '.transition-\\[stroke-dashoffset\\]': {
+ transitionProperty: 'stroke-dashoffset',
+ transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ transitionDuration: '150ms'
+ },
+ '.transition-\\[width\\]': {
+ transitionProperty: 'width',
+ transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ transitionDuration: '150ms'
+ },
+ '.duration-\\[200ms\\]': { transitionDuration: '200ms' },
+ '.hover\\:card:hover': {
+ backgroundColor: 'rgb(var(--color-surface-100))',
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
+ '--tw-ring-inset': 'inset',
+ '--tw-ring-color': 'rgb(23 23 23 / 0.05);',
+ borderRadius: 'var(--theme-rounded-container)'
+ },
+ '.dark .hover\\:card:hover': {
+ backgroundColor: 'rgb(var(--color-surface-800))',
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
+ '--tw-ring-inset': 'inset',
+ '--tw-ring-color': 'rgb(250 250 250 / 0.05)'
+ },
+ '.hover\\:card:hovera': {
+ transitionProperty: 'all',
+ transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ transitionDuration: '150ms'
+ },
+ '.hover\\:card:hovera:hover': {
+ '--tw-brightness': 'brightness(1.05)',
+ 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)'
+ },
+ '.hover\\:variant-filled:hover': {
+ backgroundColor: 'rgb(var(--color-surface-900))',
+ color: 'rgb(var(--color-surface-50))'
+ },
+ '.dark .hover\\:variant-filled:hover': {
+ backgroundColor: 'rgb(var(--color-surface-50))',
+ color: 'rgb(var(--color-surface-900))'
+ },
+ '.hover\\:variant-soft-primary:hover': {
+ backgroundColor: 'rgb(var(--color-primary-400) / 0.2)',
+ '--tw-ring-offset-shadow':
+ 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color) !important',
+ '--tw-ring-shadow':
+ 'var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color) !important',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000) !important',
+ color: 'rgb(var(--color-primary-700))'
+ },
+ '.dark .hover\\:variant-soft-primary:hover': { color: 'rgb(var(--color-primary-200))' },
+ '.hover\\:variant-soft:hover': {
+ backgroundColor: 'rgb(var(--color-surface-400) / 0.2)',
+ '--tw-ring-offset-shadow':
+ 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color) !important',
+ '--tw-ring-shadow':
+ 'var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color) !important',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000) !important',
+ color: 'rgb(var(--color-surface-700))'
+ },
+ '.dark .hover\\:variant-soft:hover': { color: 'rgb(var(--color-surface-200))' },
+ '.focus\\:\\!variant-filled-primary:focus': {
+ '--tw-bg-opacity': '1',
+ backgroundColor: 'rgb(var(--color-primary-500) / var(--tw-bg-opacity))',
+ color: 'rgb(var(--on-primary))'
+ },
+ '.\\[\\&\\>\\.logo-item\\]\\:variant-filled-secondary>.logo-item': {
+ '--tw-bg-opacity': '1',
+ backgroundColor: 'rgb(var(--color-secondary-500) / var(--tw-bg-opacity))',
+ color: 'rgb(var(--on-secondary))'
+ },
+ '.hover\\:scale-105:hover': {
+ '--tw-scale-x': '1.05',
+ '--tw-scale-y': '1.05',
+ transform:
+ 'translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))'
+ },
+ '.hover\\:\\!border-primary-500:hover': {
+ '--tw-border-opacity': '1 !important',
+ borderColor: 'rgb(var(--color-primary-500) / var(--tw-border-opacity)) !important'
+ },
+ '.hover\\:ring-surface-500\\/50:hover': { '--tw-ring-color': 'rgb(var(--color-surface-500) / 0.5)' },
+ '.hover\\:brightness-\\[105\\%\\]:hover': {
+ '--tw-brightness': 'brightness(105%)',
+ 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)'
+ },
+ '.focus\\:ring-0:focus': {
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)'
+ },
+ '.disabled\\:\\!opacity-0:disabled': { opacity: '0 !important' },
+ '.\\[\\&\\>\\*\\+\\*\\]\\:border-red-500>*+*': {
+ '--tw-border-opacity': '1',
+ borderColor: 'rgb(239 68 68 / var(--tw-border-opacity))'
+ },
+ '.\\[\\&\\>\\.foo-label\\]\\:p-4>.foo-label': { padding: '1rem' },
+ '.w-modal-slim': { width: '100%', maxWidth: '400px' },
+ '.w-modal': { width: '100%', maxWidth: '640px' },
+ '.w-modal-wide': { width: '100%', maxWidth: '80%' }
+};
diff --git a/web/app/src/skeleton/tailwind/generated/intellisense-classes.d.cts b/web/app/src/skeleton/tailwind/generated/intellisense-classes.d.cts
new file mode 100644
index 0000000..4fc420f
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/generated/intellisense-classes.d.cts
@@ -0,0 +1,1878 @@
+declare const _exports: {
+ '.hide-scrollbar::-webkit-scrollbar': {
+ display: string;
+ };
+ '.hide-scrollbar': {
+ msOverflowStyle: string;
+ scrollbarWidth: string;
+ };
+ '.divider-vertical': {
+ marginLeft: string;
+ marginRight: string;
+ display: string;
+ minHeight: string;
+ borderLeftWidth: string;
+ borderStyle: string;
+ borderColor: string;
+ };
+ '.dark .divider-vertical': {
+ borderColor: string;
+ };
+ '.\\!legend': {
+ fontSize: string;
+ lineHeight: string;
+ fontFamily: string;
+ };
+ '.legend': {
+ fontSize: string;
+ lineHeight: string;
+ fontFamily: string;
+ };
+ '.label > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-y-reverse': string;
+ marginTop: string;
+ marginBottom: string;
+ };
+ '.\\!input': {
+ width: string;
+ transitionProperty: string;
+ transitionTimingFunction: string;
+ transitionDuration: string;
+ backgroundColor: string;
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ borderWidth: string;
+ borderColor: string;
+ borderRadius: string;
+ };
+ '.input,\n\t.textarea,\n\t.select,\n\t.input-group': {
+ width: string;
+ transitionProperty: string;
+ transitionTimingFunction: string;
+ transitionDuration: string;
+ backgroundColor: string;
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ borderWidth: string;
+ borderColor: string;
+ };
+ '.dark .\\!input': {
+ backgroundColor: string;
+ borderColor: string;
+ };
+ '.\\!input:hover': {
+ '--tw-brightness': string;
+ filter: string;
+ };
+ '.\\!input:focus': {
+ '--tw-brightness': string;
+ filter: string;
+ };
+ '.dark .input,.dark \n\t.textarea,.dark \n\t.select,.dark \n\t.input-group': {
+ backgroundColor: string;
+ borderColor: string;
+ };
+ '.input:hover,\n\t.textarea:hover,\n\t.select:hover,\n\t.input-group:hover': {
+ '--tw-brightness': string;
+ filter: string;
+ };
+ '.input:focus,\n\t.textarea:focus,\n\t.select:focus,\n\t.input-group:focus': {
+ '--tw-brightness': string;
+ filter: string;
+ };
+ '.\\!input:focus-within': {
+ '--tw-border-opacity': string;
+ borderColor: string;
+ };
+ '.input:focus-within,\n\t.textarea:focus-within,\n\t.select:focus-within,\n\t.input-group:focus-within': {
+ '--tw-border-opacity': string;
+ borderColor: string;
+ };
+ '.input,\n\t.input-group': {
+ borderRadius: string;
+ };
+ '.textarea,\n\t.select': {
+ borderRadius: string;
+ };
+ '.select > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-y-reverse': string;
+ marginTop: string;
+ marginBottom: string;
+ };
+ '.select': {
+ padding: string;
+ paddingRight: string;
+ };
+ '.select[size]': {
+ backgroundImage: string;
+ };
+ '.select optgroup > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-y-reverse': string;
+ marginTop: string;
+ marginBottom: string;
+ };
+ '.select optgroup': {
+ fontWeight: number;
+ };
+ '.select optgroup option': {
+ marginLeft: string;
+ paddingLeft: string;
+ };
+ '.select optgroup option:first-of-type': {
+ marginTop: string;
+ };
+ '.select optgroup option:last-child': {
+ marginBottom: string;
+ };
+ '.select option': {
+ cursor: string;
+ paddingLeft: string;
+ paddingRight: string;
+ paddingTop: string;
+ paddingBottom: string;
+ backgroundColor: string;
+ borderRadius: string;
+ };
+ '.dark .select option': {
+ backgroundColor: string;
+ };
+ '.select option:checked': {
+ background: string;
+ color: string;
+ };
+ '.checkbox,\n\t.radio': {
+ height: string;
+ width: string;
+ cursor: string;
+ borderRadius: string;
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ backgroundColor: string;
+ borderWidth: string;
+ borderColor: string;
+ };
+ '.dark .checkbox,.dark \n\t.radio': {
+ backgroundColor: string;
+ borderColor: string;
+ };
+ '.checkbox:hover,\n\t.radio:hover': {
+ '--tw-brightness': string;
+ filter: string;
+ };
+ '.checkbox:focus,\n\t.radio:focus': {
+ '--tw-brightness': string;
+ filter: string;
+ '--tw-border-opacity': string;
+ borderColor: string;
+ };
+ '.checkbox:checked,\n\t.radio:checked': {
+ '--tw-bg-opacity': string;
+ backgroundColor: string;
+ };
+ '.checkbox:checked:hover,\n\t.radio:checked:hover': {
+ '--tw-bg-opacity': string;
+ backgroundColor: string;
+ };
+ '.checkbox:checked:focus,\n\t.radio:checked:focus': {
+ '--tw-bg-opacity': string;
+ backgroundColor: string;
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ };
+ '.radio': {
+ borderRadius: string;
+ };
+ ".\\!input[type='file']": {
+ padding: string;
+ };
+ ".input[type='file']": {
+ padding: string;
+ };
+ ".\\!input[type='color']": {
+ height: string;
+ width: string;
+ cursor: string;
+ overflow: string;
+ borderStyle: string;
+ borderRadius: string;
+ WebkitAppearance: string;
+ };
+ ".input[type='color']": {
+ height: string;
+ width: string;
+ cursor: string;
+ overflow: string;
+ borderStyle: string;
+ borderRadius: string;
+ WebkitAppearance: string;
+ };
+ ".\\!input[type='color']::-webkit-color-swatch-wrapper": {
+ padding: string;
+ };
+ ".input[type='color']::-webkit-color-swatch-wrapper": {
+ padding: string;
+ };
+ ".\\!input[type='color']::-webkit-color-swatch": {
+ borderStyle: string;
+ };
+ ".\\!input[type='color']:hover::-webkit-color-swatch": {
+ '--tw-brightness': string;
+ filter: string;
+ };
+ ".input[type='color']::-webkit-color-swatch": {
+ borderStyle: string;
+ };
+ ".input[type='color']:hover::-webkit-color-swatch": {
+ '--tw-brightness': string;
+ filter: string;
+ };
+ ".\\!input[type='color']::-moz-color-swatch": {
+ borderStyle: string;
+ };
+ ".input[type='color']::-moz-color-swatch": {
+ borderStyle: string;
+ };
+ '.\\!input:disabled': {
+ cursor: string;
+ opacity: string;
+ };
+ '.\\!input:disabled:hover': {
+ '--tw-brightness': string;
+ filter: string;
+ };
+ '.input:disabled,\n\t.textarea:disabled,\n\t.select:disabled': {
+ cursor: string;
+ opacity: string;
+ };
+ '.input:disabled:hover,\n\t.textarea:disabled:hover,\n\t.select:disabled:hover': {
+ '--tw-brightness': string;
+ filter: string;
+ };
+ '.\\!input[readonly]': {
+ cursor: string;
+ borderWidth: string;
+ };
+ '.\\!input[readonly]:hover': {
+ '--tw-brightness': string;
+ filter: string;
+ };
+ '.input[readonly],\n\t.textarea[readonly],\n\t.select[readonly]': {
+ cursor: string;
+ borderWidth: string;
+ };
+ '.input[readonly]:hover,\n\t.textarea[readonly]:hover,\n\t.select[readonly]:hover': {
+ '--tw-brightness': string;
+ filter: string;
+ };
+ '.input-group': {
+ display: string;
+ overflow: string;
+ };
+ '.input-group input,\n\t.input-group select': {
+ borderWidth: string;
+ backgroundColor: string;
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ };
+ '.input-group select option': {
+ backgroundColor: string;
+ };
+ '.dark .input-group select option': {
+ backgroundColor: string;
+ };
+ '.input-group div,\n\t.input-group a,\n\t.input-group button': {
+ display: string;
+ alignItems: string;
+ justifyContent: string;
+ paddingLeft: string;
+ paddingRight: string;
+ };
+ '.input-group-divider input,\n\t.input-group-divider select,\n\t.input-group-divider div,\n\t.input-group-divider a': {
+ borderLeftWidth: string;
+ borderColor: string;
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ minWidth: string;
+ };
+ '.dark .input-group-divider input,.dark \n\t.input-group-divider select,.dark \n\t.input-group-divider div,.dark \n\t.input-group-divider a': {
+ borderColor: string;
+ };
+ '.input-group-divider input:focus,\n\t.input-group-divider select:focus,\n\t.input-group-divider div:focus,\n\t.input-group-divider a:focus': {
+ borderColor: string;
+ };
+ '.dark .input-group-divider input:focus,.dark \n\t.input-group-divider select:focus,.dark \n\t.input-group-divider div:focus,.dark \n\t.input-group-divider a:focus': {
+ borderColor: string;
+ };
+ '.input-group-divider *:first-child': {
+ borderLeftWidth: string;
+ };
+ '.input-group-shim': {
+ backgroundColor: string;
+ color: string;
+ };
+ '.dark .input-group-shim': {
+ color: string;
+ };
+ '.input-success': {
+ '--tw-border-opacity': string;
+ borderColor: string;
+ '--tw-bg-opacity': string;
+ backgroundColor: string;
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.input-success::-moz-placeholder': {
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.input-success:-ms-input-placeholder': {
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.input-success::placeholder': {
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.input-warning': {
+ '--tw-border-opacity': string;
+ borderColor: string;
+ '--tw-bg-opacity': string;
+ backgroundColor: string;
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.input-warning::-moz-placeholder': {
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.input-warning:-ms-input-placeholder': {
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.input-warning::placeholder': {
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.input-error': {
+ '--tw-border-opacity': string;
+ borderColor: string;
+ '--tw-bg-opacity': string;
+ backgroundColor: string;
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.input-error::-moz-placeholder': {
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.input-error:-ms-input-placeholder': {
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.input-error::placeholder': {
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.variant-form-material': {
+ borderTopLeftRadius: string;
+ borderTopRightRadius: string;
+ borderBottomLeftRadius: string;
+ borderBottomRightRadius: string;
+ backgroundColor: string;
+ borderWidth: string;
+ borderBottomWidth: string;
+ '--tw-backdrop-blur': string;
+ backdropFilter: string;
+ };
+ ".variant-form-material[type='file']": {
+ paddingTop: string;
+ paddingBottom: string;
+ };
+ '.alert': {
+ display: string;
+ flexDirection: string;
+ alignItems: string;
+ padding: string;
+ color: string;
+ borderRadius: string;
+ };
+ '.alert > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-y-reverse': string;
+ marginTop: string;
+ marginBottom: string;
+ };
+ '.dark .alert': {
+ color: string;
+ };
+ '.alert-message': {
+ flex: string;
+ };
+ '.alert-message > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-y-reverse': string;
+ marginTop: string;
+ marginBottom: string;
+ };
+ '.alert-actions': {
+ display: string;
+ alignItems: string;
+ };
+ '.alert-actions > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': string;
+ marginRight: string;
+ marginLeft: string;
+ };
+ '.badge': {
+ display: string;
+ alignItems: string;
+ justifyContent: string;
+ whiteSpace: string;
+ fontSize: string;
+ lineHeight: string;
+ fontWeight: number;
+ paddingLeft: string;
+ paddingRight: string;
+ paddingTop: string;
+ paddingBottom: string;
+ borderRadius: string;
+ };
+ '.badge > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': string;
+ marginRight: string;
+ marginLeft: string;
+ };
+ '.badge-icon': {
+ display: string;
+ height: string;
+ width: string;
+ alignItems: string;
+ justifyContent: string;
+ borderRadius: string;
+ fontSize: string;
+ lineHeight: string;
+ fontWeight: number;
+ '--tw-shadow': string;
+ '--tw-shadow-colored': string;
+ boxShadow: string;
+ };
+ '.badge-glass': {
+ backgroundColor: string;
+ '--tw-backdrop-blur': string;
+ backdropFilter: string;
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ '--tw-ring-inset': string;
+ '--tw-ring-color': string;
+ };
+ '.breadcrumb::-webkit-scrollbar,\n\t.breadcrumb-nonresponsive::-webkit-scrollbar': {
+ display: string;
+ };
+ '.breadcrumb,\n\t.breadcrumb-nonresponsive': {
+ msOverflowStyle: string;
+ scrollbarWidth: string;
+ display: string;
+ width: string;
+ alignItems: string;
+ overflowX: string;
+ };
+ '.breadcrumb > :not([hidden]) ~ :not([hidden]),\n\t.breadcrumb-nonresponsive > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': string;
+ marginRight: string;
+ marginLeft: string;
+ };
+ '.crumb': {
+ display: string;
+ alignItems: string;
+ justifyContent: string;
+ };
+ '.crumb > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': string;
+ marginRight: string;
+ marginLeft: string;
+ };
+ '.crumb-separator': {
+ display: string;
+ opacity: number;
+ color: string;
+ };
+ '.dark .crumb-separator': {
+ color: string;
+ };
+ '.breadcrumb li': {
+ display: string;
+ };
+ '.breadcrumb li:nth-last-child(3),\n\t.breadcrumb li:nth-last-child(2),\n\t.breadcrumb li:nth-last-child(1)': {
+ display: string;
+ };
+ '.btn': {
+ fontSize: string;
+ lineHeight: string;
+ paddingLeft: string;
+ paddingRight: string;
+ paddingTop: string;
+ paddingBottom: string;
+ whiteSpace: string;
+ textAlign: string;
+ display: string;
+ alignItems: string;
+ justifyContent: string;
+ transitionProperty: string;
+ transitionTimingFunction: string;
+ transitionDuration: string;
+ borderRadius: string;
+ };
+ '.btn > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': string;
+ marginRight: string;
+ marginLeft: string;
+ };
+ '.btn:hover': {
+ '--tw-brightness': string;
+ filter: string;
+ };
+ '.btn:active': {
+ '--tw-scale-x': string;
+ '--tw-scale-y': string;
+ transform: string;
+ '--tw-brightness': string;
+ filter: string;
+ };
+ '.btn-sm': {
+ paddingLeft: string;
+ paddingRight: string;
+ paddingTop: string;
+ paddingBottom: string;
+ fontSize: string;
+ lineHeight: string;
+ };
+ '.btn-lg': {
+ paddingLeft: string;
+ paddingRight: string;
+ paddingTop: string;
+ paddingBottom: string;
+ fontSize: string;
+ lineHeight: string;
+ };
+ '.btn-xl': {
+ paddingLeft: string;
+ paddingRight: string;
+ paddingTop: string;
+ paddingBottom: string;
+ fontSize: string;
+ lineHeight: string;
+ };
+ '.btn-icon': {
+ fontSize: string;
+ lineHeight: string;
+ paddingLeft: string;
+ paddingRight: string;
+ paddingTop: string;
+ paddingBottom: string;
+ whiteSpace: string;
+ textAlign: string;
+ display: string;
+ alignItems: string;
+ justifyContent: string;
+ transitionProperty: string;
+ transitionTimingFunction: string;
+ transitionDuration: string;
+ padding: string;
+ aspectRatio: string;
+ width: string;
+ borderRadius: string;
+ };
+ '.btn-icon > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': string;
+ marginRight: string;
+ marginLeft: string;
+ };
+ '.btn-icon:hover': {
+ '--tw-brightness': string;
+ filter: string;
+ };
+ '.btn-icon-sm': {
+ aspectRatio: string;
+ width: string;
+ fontSize: string;
+ lineHeight: string;
+ };
+ '.btn-icon-lg': {
+ aspectRatio: string;
+ width: string;
+ fontSize: string;
+ lineHeight: string;
+ };
+ '.btn-icon-xl': {
+ aspectRatio: string;
+ width: string;
+ fontSize: string;
+ lineHeight: string;
+ };
+ '.btn-group': {
+ display: string;
+ flexDirection: string;
+ overflow: string;
+ borderRadius: string;
+ isolation: string;
+ };
+ '.btn-group > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': string;
+ marginRight: string;
+ marginLeft: string;
+ };
+ '.btn-group-vertical': {
+ display: string;
+ flexDirection: string;
+ overflow: string;
+ borderRadius: string;
+ isolation: string;
+ };
+ '.btn-group-vertical > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': string;
+ marginRight: string;
+ marginLeft: string;
+ '--tw-space-y-reverse': string;
+ marginTop: string;
+ marginBottom: string;
+ };
+ '.btn-group-vertical button,.btn-group-vertical a': {
+ fontSize: string;
+ lineHeight: string;
+ paddingLeft: string;
+ paddingRight: string;
+ paddingTop: string;
+ paddingBottom: string;
+ whiteSpace: string;
+ textAlign: string;
+ display: string;
+ alignItems: string;
+ justifyContent: string;
+ transitionProperty: string;
+ transitionTimingFunction: string;
+ transitionDuration: string;
+ color: string;
+ textDecorationLine: string;
+ };
+ '.btn-group-vertical button > :not([hidden]) ~ :not([hidden]),.btn-group-vertical a > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': string;
+ marginRight: string;
+ marginLeft: string;
+ };
+ '.btn-group-vertical button:hover,.btn-group-vertical a:hover': {
+ '--tw-brightness': string;
+ filter: string;
+ backgroundColor: string;
+ };
+ '.btn-group-vertical button:active,.btn-group-vertical a:active': {
+ backgroundColor: string;
+ };
+ '.btn-group-vertical * + *': {
+ borderTopWidth: string;
+ borderLeftWidth: string;
+ borderColor: string;
+ };
+ '.btn-group button,\n\t.btn-group a,\n\t.btn-group-vertical button,\n\t.btn-group-vertical a': {
+ fontSize: string;
+ lineHeight: string;
+ paddingLeft: string;
+ paddingRight: string;
+ paddingTop: string;
+ paddingBottom: string;
+ whiteSpace: string;
+ textAlign: string;
+ display: string;
+ alignItems: string;
+ justifyContent: string;
+ transitionProperty: string;
+ transitionTimingFunction: string;
+ transitionDuration: string;
+ color: string;
+ textDecorationLine: string;
+ };
+ '.btn-group button > :not([hidden]) ~ :not([hidden]),\n\t.btn-group a > :not([hidden]) ~ :not([hidden]),\n\t.btn-group-vertical button > :not([hidden]) ~ :not([hidden]),\n\t.btn-group-vertical a > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': string;
+ marginRight: string;
+ marginLeft: string;
+ };
+ '.btn-group button:hover,\n\t.btn-group a:hover,\n\t.btn-group-vertical button:hover,\n\t.btn-group-vertical a:hover': {
+ '--tw-brightness': string;
+ filter: string;
+ backgroundColor: string;
+ };
+ '.btn-group button:active,\n\t.btn-group a:active,\n\t.btn-group-vertical button:active,\n\t.btn-group-vertical a:active': {
+ backgroundColor: string;
+ };
+ '.btn-group * + *': {
+ borderTopWidth: string;
+ borderLeftWidth: string;
+ borderColor: string;
+ };
+ '.card': {
+ backgroundColor: string;
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ '--tw-ring-inset': string;
+ '--tw-ring-color': string;
+ borderRadius: string;
+ };
+ '.dark .card': {
+ backgroundColor: string;
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ '--tw-ring-inset': string;
+ '--tw-ring-color': string;
+ };
+ '.card-header': {
+ padding: string;
+ paddingBottom: string;
+ };
+ '.card-footer': {
+ padding: string;
+ paddingTop: string;
+ };
+ '.card-hover': {
+ transitionProperty: string;
+ transitionTimingFunction: string;
+ transitionDuration: string;
+ };
+ '.card-hover:hover': {
+ '--tw-scale-x': string;
+ '--tw-scale-y': string;
+ transform: string;
+ '--tw-shadow': string;
+ '--tw-shadow-colored': string;
+ boxShadow: string;
+ };
+ '.chip': {
+ cursor: string;
+ whiteSpace: string;
+ paddingLeft: string;
+ paddingRight: string;
+ paddingTop: string;
+ paddingBottom: string;
+ textAlign: string;
+ fontSize: string;
+ lineHeight: string;
+ borderRadius: string;
+ display: string;
+ alignItems: string;
+ justifyContent: string;
+ transitionProperty: string;
+ transitionTimingFunction: string;
+ transitionDuration: string;
+ };
+ '.chip > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': string;
+ marginRight: string;
+ marginLeft: string;
+ };
+ '.chip:hover': {
+ '--tw-brightness': string;
+ filter: string;
+ };
+ '.chip-disabled,\n\t.chip:disabled': {
+ cursor: string;
+ opacity: string;
+ };
+ '.chip-disabled:active,\n\t.chip:disabled:active': {
+ '--tw-scale-x': string;
+ '--tw-scale-y': string;
+ transform: string;
+ };
+ '.list,\n\t.list-dl,\n\t.list-nav ul': {
+ listStyleType: string;
+ };
+ '.list > :not([hidden]) ~ :not([hidden]),\n\t.list-dl > :not([hidden]) ~ :not([hidden]),\n\t.list-nav ul > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-y-reverse': string;
+ marginTop: string;
+ marginBottom: string;
+ };
+ '.list li': {
+ display: string;
+ alignItems: string;
+ padding: string;
+ borderRadius: string;
+ whiteSpace: string;
+ overflowWrap: string;
+ };
+ '.list li > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': string;
+ marginRight: string;
+ marginLeft: string;
+ };
+ '.list-dl div': {
+ display: string;
+ alignItems: string;
+ whiteSpace: string;
+ padding: string;
+ borderRadius: string;
+ };
+ '.list-dl div > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': string;
+ marginRight: string;
+ marginLeft: string;
+ };
+ '.list-nav a,\n\t.list-nav button,\n\t.list-option': {
+ display: string;
+ alignItems: string;
+ whiteSpace: string;
+ paddingLeft: string;
+ paddingRight: string;
+ paddingTop: string;
+ paddingBottom: string;
+ outline: string;
+ outlineOffset: string;
+ cursor: string;
+ borderRadius: string;
+ };
+ '.list-nav a > :not([hidden]) ~ :not([hidden]),\n\t.list-nav button > :not([hidden]) ~ :not([hidden]),\n\t.list-option > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': string;
+ marginRight: string;
+ marginLeft: string;
+ };
+ '.list-nav a:hover,\n\t.list-nav button:hover,\n\t.list-option:hover': {
+ backgroundColor: string;
+ };
+ '.list-nav a:focus,\n\t.list-nav button:focus,\n\t.list-option:focus': {
+ '--tw-bg-opacity': string;
+ backgroundColor: string;
+ color: string;
+ };
+ '.logo-cloud': {
+ display: string;
+ overflow: string;
+ borderRadius: string;
+ };
+ '.logo-item': {
+ '@apply: flex-auto text-center space-x-4 shadow': boolean;
+ display: string;
+ alignItems: string;
+ justifyContent: string;
+ backgroundColor: string;
+ fontSize: string;
+ lineHeight: string;
+ fontWeight: number;
+ '--tw-text-opacity': string;
+ color: string;
+ paddingTop: string;
+ paddingBottom: string;
+ };
+ '.logo-item > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-x-reverse': string;
+ marginRight: string;
+ marginLeft: string;
+ };
+ '.dark .logo-item': {
+ backgroundColor: string;
+ };
+ '.placeholder': {
+ height: string;
+ backgroundColor: string;
+ borderRadius: string;
+ };
+ '.dark .placeholder': {
+ backgroundColor: string;
+ };
+ '.placeholder-circle': {
+ aspectRatio: string;
+ borderRadius: string;
+ backgroundColor: string;
+ };
+ '.dark .placeholder-circle': {
+ backgroundColor: string;
+ };
+ '.table-container': {
+ width: string;
+ overflowX: string;
+ borderRadius: string;
+ };
+ '.dark .table': {
+ backgroundColor: string;
+ };
+ '.table-hover tbody tr:hover': {
+ backgroundColor: string;
+ };
+ '.table-hover tbody tr:hover:nth-child(even)': {
+ backgroundColor: string;
+ };
+ '.table-interactive tbody tr': {
+ cursor: string;
+ };
+ '.table-interactive tbody tr:hover:hover': {
+ backgroundColor: string;
+ };
+ '.table-interactive tbody tr:hover:nth-child(even):hover': {
+ backgroundColor: string;
+ };
+ '.table-sort-asc::after': {
+ opacity: number;
+ '--tw-content': string;
+ content: string;
+ };
+ '.table-sort-dsc::after': {
+ opacity: number;
+ '--tw-content': string;
+ content: string;
+ };
+ '.table thead': {
+ borderBottomWidth: string;
+ borderColor: string;
+ backgroundColor: string;
+ };
+ '.dark .table thead': {
+ backgroundColor: string;
+ };
+ '.table thead tr': {
+ textAlign: string;
+ textTransform: string;
+ };
+ '.table thead th': {
+ padding: string;
+ fontWeight: number;
+ };
+ '.table tbody tr': {
+ borderBottomWidth: string;
+ borderColor: string;
+ };
+ '.table tbody tr:nth-child(even)': {
+ backgroundColor: string;
+ };
+ '.table tbody td': {
+ whiteSpace: string;
+ paddingLeft: string;
+ paddingRight: string;
+ paddingTop: string;
+ paddingBottom: string;
+ verticalAlign: string;
+ fontSize: string;
+ lineHeight: string;
+ };
+ '.table-compact tbody td': {
+ paddingTop: string;
+ paddingBottom: string;
+ };
+ '.table-comfortable tbody td': {
+ paddingTop: string;
+ paddingBottom: string;
+ };
+ '.table tfoot': {
+ backgroundColor: string;
+ };
+ '.dark .table tfoot': {
+ backgroundColor: string;
+ };
+ '.table tfoot tr': {
+ textAlign: string;
+ textTransform: string;
+ };
+ '.table tfoot th,\n\t.table tfoot td': {
+ padding: string;
+ };
+ '.table-row-checked': {
+ backgroundColor: string;
+ };
+ '.table-cell-fit': {
+ width: string;
+ whiteSpace: string;
+ };
+ '.variant-filled': {
+ backgroundColor: string;
+ color: string;
+ };
+ '.dark .variant-filled': {
+ backgroundColor: string;
+ color: string;
+ };
+ '.variant-filled-primary': {
+ '--tw-bg-opacity': string;
+ backgroundColor: string;
+ color: string;
+ };
+ '.variant-filled-secondary': {
+ '--tw-bg-opacity': string;
+ backgroundColor: string;
+ color: string;
+ };
+ '.variant-filled-tertiary': {
+ '--tw-bg-opacity': string;
+ backgroundColor: string;
+ color: string;
+ };
+ '.variant-filled-success': {
+ '--tw-bg-opacity': string;
+ backgroundColor: string;
+ color: string;
+ };
+ '.variant-filled-warning': {
+ '--tw-bg-opacity': string;
+ backgroundColor: string;
+ color: string;
+ };
+ '.variant-filled-error': {
+ '--tw-bg-opacity': string;
+ backgroundColor: string;
+ color: string;
+ };
+ '.variant-filled-surface': {
+ backgroundColor: string;
+ color: string;
+ };
+ '.dark .variant-filled-surface': {
+ backgroundColor: string;
+ };
+ '.variant-ringed': {
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ '--tw-ring-inset': string;
+ '--tw-ring-opacity': string;
+ '--tw-ring-color': string;
+ backgroundColor: string;
+ };
+ '.variant-ringed-primary': {
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ '--tw-ring-inset': string;
+ '--tw-ring-opacity': string;
+ '--tw-ring-color': string;
+ backgroundColor: string;
+ };
+ '.variant-ringed-secondary': {
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ '--tw-ring-inset': string;
+ '--tw-ring-opacity': string;
+ '--tw-ring-color': string;
+ backgroundColor: string;
+ };
+ '.variant-ringed-tertiary': {
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ '--tw-ring-inset': string;
+ '--tw-ring-opacity': string;
+ '--tw-ring-color': string;
+ backgroundColor: string;
+ };
+ '.variant-ringed-success': {
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ '--tw-ring-inset': string;
+ '--tw-ring-opacity': string;
+ '--tw-ring-color': string;
+ backgroundColor: string;
+ };
+ '.variant-ringed-warning': {
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ '--tw-ring-inset': string;
+ '--tw-ring-opacity': string;
+ '--tw-ring-color': string;
+ backgroundColor: string;
+ };
+ '.variant-ringed-error': {
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ '--tw-ring-inset': string;
+ '--tw-ring-opacity': string;
+ '--tw-ring-color': string;
+ backgroundColor: string;
+ };
+ '.variant-ringed-surface': {
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ '--tw-ring-inset': string;
+ '--tw-ring-opacity': string;
+ '--tw-ring-color': string;
+ backgroundColor: string;
+ };
+ '.variant-ghost-primary': {
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ '--tw-ring-inset': string;
+ '--tw-ring-opacity': string;
+ '--tw-ring-color': string;
+ backgroundColor: string;
+ };
+ '.variant-ghost-secondary': {
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ '--tw-ring-inset': string;
+ '--tw-ring-opacity': string;
+ '--tw-ring-color': string;
+ backgroundColor: string;
+ };
+ '.variant-ghost-tertiary': {
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ '--tw-ring-inset': string;
+ '--tw-ring-opacity': string;
+ '--tw-ring-color': string;
+ backgroundColor: string;
+ };
+ '.variant-ghost-success': {
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ '--tw-ring-inset': string;
+ '--tw-ring-opacity': string;
+ '--tw-ring-color': string;
+ backgroundColor: string;
+ };
+ '.variant-ghost-warning': {
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ '--tw-ring-inset': string;
+ '--tw-ring-opacity': string;
+ '--tw-ring-color': string;
+ backgroundColor: string;
+ };
+ '.variant-ghost-error': {
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ '--tw-ring-inset': string;
+ '--tw-ring-opacity': string;
+ '--tw-ring-color': string;
+ backgroundColor: string;
+ };
+ '.variant-ghost,\n\t.variant-ghost-surface': {
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ '--tw-ring-inset': string;
+ '--tw-ring-opacity': string;
+ '--tw-ring-color': string;
+ backgroundColor: string;
+ };
+ '.variant-soft-primary': {
+ backgroundColor: string;
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ color: string;
+ };
+ '.dark .variant-soft-primary': {
+ color: string;
+ };
+ '.variant-soft-secondary': {
+ backgroundColor: string;
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ color: string;
+ };
+ '.dark .variant-soft-secondary': {
+ color: string;
+ };
+ '.variant-soft-tertiary': {
+ backgroundColor: string;
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ color: string;
+ };
+ '.dark .variant-soft-tertiary': {
+ color: string;
+ };
+ '.variant-soft-success': {
+ backgroundColor: string;
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ color: string;
+ };
+ '.dark .variant-soft-success': {
+ color: string;
+ };
+ '.variant-soft-warning': {
+ backgroundColor: string;
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ color: string;
+ };
+ '.dark .variant-soft-warning': {
+ color: string;
+ };
+ '.variant-soft-error': {
+ backgroundColor: string;
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ color: string;
+ };
+ '.dark .variant-soft-error': {
+ color: string;
+ };
+ '.variant-soft,\n\t.variant-soft-surface': {
+ backgroundColor: string;
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ color: string;
+ };
+ '.dark .variant-soft,.dark \n\t.variant-soft-surface': {
+ color: string;
+ };
+ '.variant-glass-primary': {
+ backgroundColor: string;
+ '--tw-backdrop-blur': string;
+ backdropFilter: string;
+ };
+ '.variant-glass-secondary': {
+ backgroundColor: string;
+ '--tw-backdrop-blur': string;
+ backdropFilter: string;
+ };
+ '.variant-glass-tertiary': {
+ backgroundColor: string;
+ '--tw-backdrop-blur': string;
+ backdropFilter: string;
+ };
+ '.variant-glass-success': {
+ backgroundColor: string;
+ '--tw-backdrop-blur': string;
+ backdropFilter: string;
+ };
+ '.variant-glass-warning': {
+ backgroundColor: string;
+ '--tw-backdrop-blur': string;
+ backdropFilter: string;
+ };
+ '.variant-glass-error': {
+ backgroundColor: string;
+ '--tw-backdrop-blur': string;
+ backdropFilter: string;
+ };
+ '.variant-glass-surface': {
+ backgroundColor: string;
+ '--tw-backdrop-blur': string;
+ backdropFilter: string;
+ };
+ '.variant-glass': {
+ backgroundColor: string;
+ '--tw-backdrop-blur': string;
+ backdropFilter: string;
+ };
+ '.left-\\[10\\%\\]': {
+ left: string;
+ };
+ '.left-\\[15\\%\\]': {
+ left: string;
+ };
+ '.left-\\[25\\%\\]': {
+ left: string;
+ };
+ '.left-\\[35\\%\\]': {
+ left: string;
+ };
+ '.left-\\[40\\%\\]': {
+ left: string;
+ };
+ '.left-\\[45\\%\\]': {
+ left: string;
+ };
+ '.left-\\[5\\%\\]': {
+ left: string;
+ };
+ '.left-\\[50\\%\\]': {
+ left: string;
+ };
+ '.left-\\[65\\%\\]': {
+ left: string;
+ };
+ '.left-\\[70\\%\\]': {
+ left: string;
+ };
+ '.top-\\[-12\\%\\]': {
+ top: string;
+ };
+ '.top-\\[0\\%\\]': {
+ top: string;
+ };
+ '.top-\\[20\\%\\]': {
+ top: string;
+ };
+ '.top-\\[32\\%\\]': {
+ top: string;
+ };
+ '.top-\\[45\\%\\]': {
+ top: string;
+ };
+ '.top-\\[50\\%\\]': {
+ top: string;
+ };
+ '.top-\\[55\\%\\]': {
+ top: string;
+ };
+ '.top-\\[60\\%\\]': {
+ top: string;
+ };
+ '.top-\\[78\\%\\]': {
+ top: string;
+ };
+ '.top-\\[98\\%\\]': {
+ top: string;
+ };
+ '.z-\\[-1\\]': {
+ zIndex: number;
+ };
+ '.z-\\[1\\]': {
+ zIndex: number;
+ };
+ '.z-\\[888\\]': {
+ zIndex: number;
+ };
+ '.z-\\[999\\]': {
+ zIndex: number;
+ };
+ '.\\!my-6': {
+ marginTop: string;
+ marginBottom: string;
+ };
+ '.-mt-\\[15px\\]': {
+ marginTop: string;
+ };
+ '.mt-\\[15px\\]': {
+ marginTop: string;
+ };
+ '.\\!flex': {
+ display: string;
+ };
+ '.aspect-\\[21\\/9\\]': {
+ aspectRatio: string;
+ };
+ '.h-\\[120px\\]': {
+ height: string;
+ };
+ '.h-\\[20px\\]': {
+ height: string;
+ };
+ '.h-\\[280px\\]': {
+ height: string;
+ };
+ '.h-\\[480px\\]': {
+ height: string;
+ };
+ '.h-\\[50\\%\\]': {
+ height: string;
+ };
+ '.h-\\[72px\\]': {
+ height: string;
+ };
+ '.max-h-\\[180px\\]': {
+ maxHeight: string;
+ };
+ '.max-h-\\[200px\\]': {
+ maxHeight: string;
+ };
+ '.max-h-\\[480px\\]': {
+ maxHeight: string;
+ };
+ '.max-h-\\[90\\%\\]': {
+ maxHeight: string;
+ };
+ '.min-h-\\[320px\\]': {
+ minHeight: string;
+ };
+ '.min-h-\\[400px\\]': {
+ minHeight: string;
+ };
+ '.\\!w-full': {
+ width: string;
+ };
+ '.w-\\[100px\\]': {
+ width: string;
+ };
+ '.w-\\[240px\\]': {
+ width: string;
+ };
+ '.w-\\[280px\\]': {
+ width: string;
+ };
+ '.w-\\[286px\\]': {
+ width: string;
+ };
+ '.w-\\[320px\\]': {
+ width: string;
+ };
+ '.w-\\[32px\\]': {
+ width: string;
+ };
+ '.w-\\[360px\\]': {
+ width: string;
+ };
+ '.w-\\[50\\%\\]': {
+ width: string;
+ };
+ '.w-\\[70\\%\\]': {
+ width: string;
+ };
+ '.w-\\[70px\\]': {
+ width: string;
+ };
+ '.w-\\[90\\%\\]': {
+ width: string;
+ };
+ '.min-w-\\[150px\\]': {
+ minWidth: string;
+ };
+ '.max-w-\\[180px\\]': {
+ maxWidth: string;
+ };
+ '.max-w-\\[320px\\]': {
+ maxWidth: string;
+ };
+ '.max-w-\\[400px\\]': {
+ maxWidth: string;
+ };
+ '.max-w-\\[475px\\]': {
+ maxWidth: string;
+ };
+ '.max-w-\\[480px\\]': {
+ maxWidth: string;
+ };
+ '.max-w-\\[600px\\]': {
+ maxWidth: string;
+ };
+ '.max-w-\\[640px\\]': {
+ maxWidth: string;
+ };
+ '.max-w-\\[650px\\]': {
+ maxWidth: string;
+ };
+ '.max-w-\\[800px\\]': {
+ maxWidth: string;
+ };
+ '.max-w-\\[90\\%\\]': {
+ maxWidth: string;
+ };
+ '.origin-\\[50\\%_50\\%\\]': {
+ transformOrigin: string;
+ };
+ '.-translate-x-\\[50\\%\\]': {
+ '--tw-translate-x': string;
+ transform: string;
+ };
+ '.-translate-y-\\[50\\%\\]': {
+ '--tw-translate-y': string;
+ transform: string;
+ };
+ '.translate-x-\\[100\\%\\]': {
+ '--tw-translate-x': string;
+ transform: string;
+ };
+ '.translate-x-\\[50\\%\\]': {
+ '--tw-translate-x': string;
+ transform: string;
+ };
+ '.scale-\\[0\\.8\\]': {
+ '--tw-scale-x': string;
+ '--tw-scale-y': string;
+ transform: string;
+ };
+ '.-scale-x-\\[100\\%\\]': {
+ '--tw-scale-x': string;
+ transform: string;
+ };
+ '.grid-cols-\\[100px_1fr\\]': {
+ gridTemplateColumns: string;
+ };
+ '.grid-cols-\\[1fr_auto\\]': {
+ gridTemplateColumns: string;
+ };
+ '.grid-cols-\\[1fr_auto_auto\\]': {
+ gridTemplateColumns: string;
+ };
+ '.grid-cols-\\[auto_1fr\\]': {
+ gridTemplateColumns: string;
+ };
+ '.grid-cols-\\[auto_1fr_auto\\]': {
+ gridTemplateColumns: string;
+ };
+ '.grid-rows-\\[1fr_40px\\]': {
+ gridTemplateRows: string;
+ };
+ '.grid-rows-\\[auto_1fr_auto\\]': {
+ gridTemplateRows: string;
+ };
+ '.space-y-\\[1px\\] > :not([hidden]) ~ :not([hidden])': {
+ '--tw-space-y-reverse': string;
+ marginTop: string;
+ marginBottom: string;
+ };
+ '.\\!rounded-none': {
+ borderRadius: string;
+ };
+ '.rounded-\\[corner\\]': {
+ borderRadius: string;
+ };
+ '.\\!border-t-2': {
+ borderTopWidth: string;
+ };
+ '.\\!border-t-4': {
+ borderTopWidth: string;
+ };
+ '.\\!border-t-8': {
+ borderTopWidth: string;
+ };
+ '.\\!border-dashed': {
+ borderStyle: string;
+ };
+ '.\\!border-dotted': {
+ borderStyle: string;
+ };
+ '.\\!border-double': {
+ borderStyle: string;
+ };
+ '.border-primary-500': {
+ '--tw-border-opacity': string;
+ borderColor: string;
+ };
+ '.border-primary-500\\/50': {
+ borderColor: string;
+ };
+ '.border-secondary-500': {
+ '--tw-border-opacity': string;
+ borderColor: string;
+ };
+ '.border-surface-500': {
+ '--tw-border-opacity': string;
+ borderColor: string;
+ };
+ '.border-surface-500\\/10': {
+ borderColor: string;
+ };
+ '.border-surface-500\\/30': {
+ borderColor: string;
+ };
+ '.border-surface-500\\/50': {
+ borderColor: string;
+ };
+ '.from-primary-500': {
+ '--tw-gradient-from': string;
+ '--tw-gradient-from-position': string;
+ '--tw-gradient-to': string;
+ '--tw-gradient-to-position': string;
+ '--tw-gradient-stops': string;
+ };
+ '.via-tertiary-500': {
+ '--tw-gradient-via-position': string;
+ '--tw-gradient-to': string;
+ '--tw-gradient-to-position': string;
+ '--tw-gradient-stops': string;
+ };
+ '.to-secondary-500': {
+ '--tw-gradient-to': string;
+ '--tw-gradient-to-position': string;
+ };
+ '.fill-primary-500': {
+ fill: string;
+ };
+ '.fill-surface-50': {
+ fill: string;
+ };
+ '.fill-surface-900': {
+ fill: string;
+ };
+ '.stroke-error-500': {
+ stroke: string;
+ };
+ '.stroke-error-500\\/30': {
+ stroke: string;
+ };
+ '.stroke-primary-500': {
+ stroke: string;
+ };
+ '.stroke-primary-500\\/30': {
+ stroke: string;
+ };
+ '.stroke-secondary-500': {
+ stroke: string;
+ };
+ '.stroke-secondary-500\\/30': {
+ stroke: string;
+ };
+ '.stroke-success-500': {
+ stroke: string;
+ };
+ '.stroke-success-500\\/30': {
+ stroke: string;
+ };
+ '.stroke-surface-300': {
+ stroke: string;
+ };
+ '.stroke-surface-500\\/30': {
+ stroke: string;
+ };
+ '.stroke-surface-900': {
+ stroke: string;
+ };
+ '.stroke-tertiary-500': {
+ stroke: string;
+ };
+ '.stroke-tertiary-500\\/30': {
+ stroke: string;
+ };
+ '.stroke-warning-500': {
+ stroke: string;
+ };
+ '.stroke-warning-500\\/30': {
+ stroke: string;
+ };
+ '.\\!p-0': {
+ padding: string;
+ };
+ '.\\!p-4': {
+ padding: string;
+ };
+ '.\\!px-3': {
+ paddingLeft: string;
+ paddingRight: string;
+ };
+ '.\\!text-center': {
+ textAlign: string;
+ };
+ '.\\!text-5xl': {
+ fontSize: string;
+ lineHeight: string;
+ };
+ '.\\!text-lg': {
+ fontSize: string;
+ lineHeight: string;
+ };
+ '.\\!text-sm': {
+ fontSize: string;
+ lineHeight: string;
+ };
+ '.\\!text-xl': {
+ fontSize: string;
+ lineHeight: string;
+ };
+ '.text-\\[16px\\]': {
+ fontSize: string;
+ };
+ '.\\!text-current': {
+ color: string;
+ };
+ '.\\!text-slate-900': {
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.\\!text-stone-900': {
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.\\!text-white': {
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.\\!text-zinc-900': {
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.text-primary-500': {
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.text-primary-700': {
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.text-secondary-500': {
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.text-surface-50': {
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.text-surface-700': {
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.text-surface-900': {
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.text-warning-500': {
+ '--tw-text-opacity': string;
+ color: string;
+ };
+ '.accent-\\[color\\]': {
+ accentColor: string;
+ };
+ '.accent-surface-900': {
+ accentColor: string;
+ };
+ '.shadow-surface-500\\/10': {
+ '--tw-shadow-color': string;
+ '--tw-shadow': string;
+ };
+ '.-outline-offset-\\[3px\\]': {
+ outlineOffset: string;
+ };
+ '.\\!ring-0': {
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ };
+ '.ring-\\[1px\\]': {
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ };
+ '.ring-surface-500\\/10': {
+ '--tw-ring-color': string;
+ };
+ '.ring-surface-500\\/30': {
+ '--tw-ring-color': string;
+ };
+ '.ring-surface-500\\/50': {
+ '--tw-ring-color': string;
+ };
+ '.blur-\\[50px\\]': {
+ '--tw-blur': string;
+ filter: string;
+ };
+ '.transition-\\[stroke-dashoffset\\]': {
+ transitionProperty: string;
+ transitionTimingFunction: string;
+ transitionDuration: string;
+ };
+ '.transition-\\[width\\]': {
+ transitionProperty: string;
+ transitionTimingFunction: string;
+ transitionDuration: string;
+ };
+ '.duration-\\[200ms\\]': {
+ transitionDuration: string;
+ };
+ '.hover\\:card:hover': {
+ backgroundColor: string;
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ '--tw-ring-inset': string;
+ '--tw-ring-color': string;
+ borderRadius: string;
+ };
+ '.dark .hover\\:card:hover': {
+ backgroundColor: string;
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ '--tw-ring-inset': string;
+ '--tw-ring-color': string;
+ };
+ '.hover\\:card:hovera': {
+ transitionProperty: string;
+ transitionTimingFunction: string;
+ transitionDuration: string;
+ };
+ '.hover\\:card:hovera:hover': {
+ '--tw-brightness': string;
+ filter: string;
+ };
+ '.hover\\:variant-filled:hover': {
+ backgroundColor: string;
+ color: string;
+ };
+ '.dark .hover\\:variant-filled:hover': {
+ backgroundColor: string;
+ color: string;
+ };
+ '.hover\\:variant-soft-primary:hover': {
+ backgroundColor: string;
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ color: string;
+ };
+ '.dark .hover\\:variant-soft-primary:hover': {
+ color: string;
+ };
+ '.hover\\:variant-soft:hover': {
+ backgroundColor: string;
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ color: string;
+ };
+ '.dark .hover\\:variant-soft:hover': {
+ color: string;
+ };
+ '.focus\\:\\!variant-filled-primary:focus': {
+ '--tw-bg-opacity': string;
+ backgroundColor: string;
+ color: string;
+ };
+ '.\\[\\&\\>\\.logo-item\\]\\:variant-filled-secondary>.logo-item': {
+ '--tw-bg-opacity': string;
+ backgroundColor: string;
+ color: string;
+ };
+ '.hover\\:scale-105:hover': {
+ '--tw-scale-x': string;
+ '--tw-scale-y': string;
+ transform: string;
+ };
+ '.hover\\:\\!border-primary-500:hover': {
+ '--tw-border-opacity': string;
+ borderColor: string;
+ };
+ '.hover\\:ring-surface-500\\/50:hover': {
+ '--tw-ring-color': string;
+ };
+ '.hover\\:brightness-\\[105\\%\\]:hover': {
+ '--tw-brightness': string;
+ filter: string;
+ };
+ '.focus\\:ring-0:focus': {
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ boxShadow: string;
+ };
+ '.disabled\\:\\!opacity-0:disabled': {
+ opacity: string;
+ };
+ '.\\[\\&\\>\\*\\+\\*\\]\\:border-red-500>*+*': {
+ '--tw-border-opacity': string;
+ borderColor: string;
+ };
+ '.\\[\\&\\>\\.foo-label\\]\\:p-4>.foo-label': {
+ padding: string;
+ };
+ '.w-modal-slim': {
+ width: string;
+ maxWidth: string;
+ };
+ '.w-modal': {
+ width: string;
+ maxWidth: string;
+ };
+ '.w-modal-wide': {
+ width: string;
+ maxWidth: string;
+ };
+};
+export = _exports;
diff --git a/web/app/src/skeleton/tailwind/intellisense.cjs b/web/app/src/skeleton/tailwind/intellisense.cjs
new file mode 100644
index 0000000..03ee2f0
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/intellisense.cjs
@@ -0,0 +1,21 @@
+// The Skeleton Intellisense Tailwind Plugin
+// Tailwind Docs: https://tailwindcss.com/docs/plugins
+// Skeleton Docs: https://www.skeleton.dev/docs/get-started
+
+const plugin = require('tailwindcss/plugin');
+
+module.exports = plugin(({ addComponents }) => {
+ // The following will generate the non-token classes PURELY for Intellisense.
+ // These are excluded from production, which means we still need to lean into
+ // using the `all.css` stylesheet to import non-token styles.
+ if (process.env.NODE_ENV !== 'production') {
+ // try/catch because it will throw when allComponents.cjs isn't generated yet
+ try {
+ const all = require('./generated/intellisense-classes.cjs');
+ addComponents(all, {
+ respectImportant: true,
+ respectPrefix: true
+ });
+ } catch {}
+ }
+});
diff --git a/web/app/src/skeleton/tailwind/intellisense.d.cts b/web/app/src/skeleton/tailwind/intellisense.d.cts
new file mode 100644
index 0000000..8bd9163
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/intellisense.d.cts
@@ -0,0 +1,5 @@
+declare const _exports: {
+ handler: import('tailwindcss/types/config.js').PluginCreator;
+ config?: Partial | undefined;
+};
+export = _exports;
diff --git a/web/app/src/skeleton/tailwind/settings.cjs b/web/app/src/skeleton/tailwind/settings.cjs
new file mode 100644
index 0000000..d307494
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/settings.cjs
@@ -0,0 +1,20 @@
+// Common Shared Settings and Constants
+
+module.exports = {
+ colorNames: ['primary', 'secondary', 'tertiary', 'success', 'warning', 'error', 'surface'],
+ colorShades: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900],
+ colorPairings: [
+ // forward:
+ { light: 50, dark: 900 },
+ { light: 100, dark: 800 },
+ { light: 200, dark: 700 },
+ { light: 300, dark: 600 },
+ { light: 400, dark: 500 },
+ // backwards
+ { light: 900, dark: 50 },
+ { light: 800, dark: 100 },
+ { light: 700, dark: 200 },
+ { light: 600, dark: 300 },
+ { light: 500, dark: 400 }
+ ]
+};
diff --git a/web/app/src/skeleton/tailwind/settings.d.cts b/web/app/src/skeleton/tailwind/settings.d.cts
new file mode 100644
index 0000000..45e1b14
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/settings.d.cts
@@ -0,0 +1,6 @@
+export const colorNames: string[];
+export const colorShades: number[];
+export const colorPairings: {
+ light: number;
+ dark: number;
+}[];
diff --git a/web/app/src/skeleton/tailwind/skeleton.cjs b/web/app/src/skeleton/tailwind/skeleton.cjs
new file mode 100644
index 0000000..0f06e54
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/skeleton.cjs
@@ -0,0 +1,19 @@
+// The Skeleton Tailwind Plugin
+// Tailwind Docs: https://tailwindcss.com/docs/plugins
+// Skeleton Docs: https://www.skeleton.dev/docs/get-started
+
+const intellisensePlugin = require('./intellisense.cjs');
+const corePlugin = require('./core.cjs');
+
+// The default export is a function that returns an array of plugins
+// and accepts an optional config that determines which plugins are included.
+// By default, all plugins are included.
+module.exports = function (config = { intellisense: true }) {
+ const { intellisense } = config;
+ const plugins = [corePlugin];
+
+ // Add the plugin if the option is not explicitly set to false
+ if (intellisense !== false) plugins.push(intellisensePlugin);
+
+ return plugins;
+};
diff --git a/web/app/src/skeleton/tailwind/skeleton.d.cts b/web/app/src/skeleton/tailwind/skeleton.d.cts
new file mode 100644
index 0000000..e8e9f3e
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/skeleton.d.cts
@@ -0,0 +1,5 @@
+declare function _exports(config?: { intellisense: boolean }): {
+ handler: import('tailwindcss/types/config.js').PluginCreator;
+ config?: Partial | undefined;
+}[];
+export = _exports;
diff --git a/web/app/src/skeleton/tailwind/theme/colors.cjs b/web/app/src/skeleton/tailwind/theme/colors.cjs
new file mode 100644
index 0000000..5953e1e
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/theme/colors.cjs
@@ -0,0 +1,18 @@
+// Extends Tailwind with Skeleton theme-specific colors values
+// Doc: https://tailwindcss.com/docs/customizing-colors#using-css-variables
+
+const settings = require('../settings.cjs');
+
+// ex: `50: 'rgb(var(--color-primary-50) / )'`
+function generatePaletteShades(colorName) {
+ const shadeObj = {};
+ settings.colorShades.forEach((s) => (shadeObj[s] = `rgb(var(--color-${colorName}-${s}) / )`));
+ return shadeObj;
+}
+
+// Generate a a color shade palette 50-900 per each color available
+module.exports = () => {
+ const paletteObj = {};
+ settings.colorNames.forEach((n) => (paletteObj[n] = generatePaletteShades(n)));
+ return paletteObj;
+};
diff --git a/web/app/src/skeleton/tailwind/theme/colors.d.cts b/web/app/src/skeleton/tailwind/theme/colors.d.cts
new file mode 100644
index 0000000..d052a92
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/theme/colors.d.cts
@@ -0,0 +1,2 @@
+declare function _exports(): {};
+export = _exports;
diff --git a/web/app/src/skeleton/tailwind/tokens/backgrounds.cjs b/web/app/src/skeleton/tailwind/tokens/backgrounds.cjs
new file mode 100644
index 0000000..2a01916
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/tokens/backgrounds.cjs
@@ -0,0 +1,38 @@
+// Design Tokens: Background
+// Doc: https://www.skeleton.dev/docs/tokens
+
+const settings = require('../settings.cjs');
+
+// Defaults
+const backdropAlpha = 0.7;
+const hoverAlpha = 0.1;
+
+module.exports = () => {
+ const classes = {};
+ settings.colorNames.forEach((n) => {
+ // Backdrops
+ // Example: .bg-primary-backdrop-token
+ classes[`.bg-${n}-backdrop-token`] = { 'background-color': `rgb(var(--color-${n}-400) / ${backdropAlpha})` };
+ classes[`.dark .bg-${n}-backdrop-token`] = { 'background-color': `rgb(var(--color-${n}-900) / ${backdropAlpha})` };
+
+ // Hover
+ // Example: .bg-primary-hover-token
+ classes[`.bg-${n}-hover-token:hover`] = { 'background-color': `rgb(var(--color-${n}-500) / ${hoverAlpha})` };
+
+ // Active
+ // Example: .bg-primary-active-token
+ classes[`.bg-${n}-active-token`] = {
+ 'background-color': `rgb(var(--color-${n}-500)) !important`,
+ color: `rgb(var(--on-${n}))`,
+ fill: `rgb(var(--on-${n}))`
+ };
+
+ // Color Pairings
+ // Example: .bg-primary-50-900-token | .bg-primary-900-50-token
+ settings.colorPairings.forEach((p) => {
+ classes[`.bg-${n}-${p.light}-${p.dark}-token`] = { 'background-color': `rgb(var(--color-${n}-${p.light}))` };
+ classes[`.dark .bg-${n}-${p.light}-${p.dark}-token`] = { 'background-color': `rgb(var(--color-${n}-${p.dark}))` };
+ });
+ });
+ return classes;
+};
diff --git a/web/app/src/skeleton/tailwind/tokens/backgrounds.d.cts b/web/app/src/skeleton/tailwind/tokens/backgrounds.d.cts
new file mode 100644
index 0000000..d052a92
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/tokens/backgrounds.d.cts
@@ -0,0 +1,2 @@
+declare function _exports(): {};
+export = _exports;
diff --git a/web/app/src/skeleton/tailwind/tokens/border-radius.cjs b/web/app/src/skeleton/tailwind/tokens/border-radius.cjs
new file mode 100644
index 0000000..e2d02ce
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/tokens/border-radius.cjs
@@ -0,0 +1,21 @@
+// Design Tokens: Border Radius
+// Doc: https://www.skeleton.dev/docs/tokens
+
+// const settings = require('../settings.cjs');
+
+module.exports = () => {
+ return {
+ // Base
+ '.rounded-token': { 'border-radius': 'var(--theme-rounded-base)' },
+ '.rounded-tl-token': { 'border-top-left-radius': 'var(--theme-rounded-base)' },
+ '.rounded-tr-token': { 'border-top-right-radius': 'var(--theme-rounded-base)' },
+ '.rounded-bl-token': { 'border-bottom-left-radius': 'var(--theme-rounded-base)' },
+ '.rounded-br-token': { 'border-bottom-right-radius': 'var(--theme-rounded-base)' },
+ // Container
+ '.rounded-container-token': { 'border-radius': 'var(--theme-rounded-container)' },
+ '.rounded-tl-container-token': { 'border-top-left-radius': 'var(--theme-rounded-container)' },
+ '.rounded-tr-container-token': { 'border-top-right-radius': 'var(--theme-rounded-container)' },
+ '.rounded-bl-container-token': { 'border-bottom-left-radius': 'var(--theme-rounded-container)' },
+ '.rounded-br-container-token': { 'border-bottom-right-radius': 'var(--theme-rounded-container)' }
+ };
+};
diff --git a/web/app/src/skeleton/tailwind/tokens/border-radius.d.cts b/web/app/src/skeleton/tailwind/tokens/border-radius.d.cts
new file mode 100644
index 0000000..3fafe1b
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/tokens/border-radius.d.cts
@@ -0,0 +1,33 @@
+declare function _exports(): {
+ '.rounded-token': {
+ 'border-radius': string;
+ };
+ '.rounded-tl-token': {
+ 'border-top-left-radius': string;
+ };
+ '.rounded-tr-token': {
+ 'border-top-right-radius': string;
+ };
+ '.rounded-bl-token': {
+ 'border-bottom-left-radius': string;
+ };
+ '.rounded-br-token': {
+ 'border-bottom-right-radius': string;
+ };
+ '.rounded-container-token': {
+ 'border-radius': string;
+ };
+ '.rounded-tl-container-token': {
+ 'border-top-left-radius': string;
+ };
+ '.rounded-tr-container-token': {
+ 'border-top-right-radius': string;
+ };
+ '.rounded-bl-container-token': {
+ 'border-bottom-left-radius': string;
+ };
+ '.rounded-br-container-token': {
+ 'border-bottom-right-radius': string;
+ };
+};
+export = _exports;
diff --git a/web/app/src/skeleton/tailwind/tokens/borders.cjs b/web/app/src/skeleton/tailwind/tokens/borders.cjs
new file mode 100644
index 0000000..3362c1c
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/tokens/borders.cjs
@@ -0,0 +1,20 @@
+// Design Tokens: Borders
+// Doc: https://www.skeleton.dev/docs/tokens
+
+const settings = require('../settings.cjs');
+
+module.exports = () => {
+ const classes = {
+ // Border Width - ex: .border-token
+ '.border-token': { 'border-width': 'var(--theme-border-base)' }
+ };
+ settings.colorNames.forEach((n) => {
+ // Color Pairings
+ // Example: .border-primary-50-900-token | .border-primary-900-50-token
+ settings.colorPairings.forEach((p) => {
+ classes[`.border-${n}-${p.light}-${p.dark}-token`] = { 'border-color': `rgb(var(--color-${n}-${p.light}))` };
+ classes[`.dark .border-${n}-${p.light}-${p.dark}-token`] = { 'border-color': `rgb(var(--color-${n}-${p.dark}))` };
+ });
+ });
+ return classes;
+};
diff --git a/web/app/src/skeleton/tailwind/tokens/borders.d.cts b/web/app/src/skeleton/tailwind/tokens/borders.d.cts
new file mode 100644
index 0000000..d7447ef
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/tokens/borders.d.cts
@@ -0,0 +1,6 @@
+declare function _exports(): {
+ '.border-token': {
+ 'border-width': string;
+ };
+};
+export = _exports;
diff --git a/web/app/src/skeleton/tailwind/tokens/fills.cjs b/web/app/src/skeleton/tailwind/tokens/fills.cjs
new file mode 100644
index 0000000..a2ed3af
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/tokens/fills.cjs
@@ -0,0 +1,20 @@
+// Design Tokens: SVG Fill
+// Doc: https://www.skeleton.dev/docs/tokens
+
+const settings = require('../settings.cjs');
+
+module.exports = () => {
+ const classes = {
+ '.fill-base-token': { fill: 'rgba(var(--theme-font-color-base))' },
+ '.fill-dark-token': { fill: 'rgba(var(--theme-font-color-dark))' },
+ // Fill Token - ex: .fill-token
+ '.fill-token': { fill: 'rgba(var(--theme-font-color-base))' },
+ '.dark .fill-token': { fill: 'rgba(var(--theme-font-color-dark))' }
+ };
+ settings.colorNames.forEach((n) => {
+ // On-X Fill Colors
+ // Example: .fill-on-primary-token
+ classes[`.fill-on-${n}-token`] = { fill: `rgb(var(--on-${n}))` };
+ });
+ return classes;
+};
diff --git a/web/app/src/skeleton/tailwind/tokens/fills.d.cts b/web/app/src/skeleton/tailwind/tokens/fills.d.cts
new file mode 100644
index 0000000..14b3209
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/tokens/fills.d.cts
@@ -0,0 +1,15 @@
+declare function _exports(): {
+ '.fill-base-token': {
+ fill: string;
+ };
+ '.fill-dark-token': {
+ fill: string;
+ };
+ '.fill-token': {
+ fill: string;
+ };
+ '.dark .fill-token': {
+ fill: string;
+ };
+};
+export = _exports;
diff --git a/web/app/src/skeleton/tailwind/tokens/rings.cjs b/web/app/src/skeleton/tailwind/tokens/rings.cjs
new file mode 100644
index 0000000..464a260
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/tokens/rings.cjs
@@ -0,0 +1,50 @@
+// Design Tokens: Rings
+// Doc: https://www.skeleton.dev/docs/tokens
+
+const settings = require('../settings.cjs');
+
+// Local
+const ringTokenTheme = {
+ '--tw-ring-offset-shadow': `var(--tw-ring-inset) 0 0 0 var(--theme-border-base) var(--tw-ring-offset-color)`,
+ '--tw-ring-shadow': `var(--tw-ring-inset) 0 0 0 calc(2px + var(--theme-border-base)) var(--tw-ring-color)`,
+ 'box-shadow': `var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)`
+};
+const ringOutlineShared = {
+ // .ring-[1px]
+ '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
+ '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
+ 'box-shadow': 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
+ // .ring-inset
+ '--tw-ring-inset': 'inset'
+};
+
+module.exports = () => {
+ const classes = {
+ '.ring-token': {
+ ...ringTokenTheme
+ },
+ // Ring Outline (for cards)
+ // Example: .ring-outline-token
+ '.ring-outline-token': {
+ ...ringOutlineShared,
+ '--tw-ring-color': 'rgb(23 23 23 / 0.05);' // neutral-900, 5% opacity
+ },
+ '.dark .ring-outline-token': {
+ ...ringOutlineShared,
+ '--tw-ring-color': 'rgb(250 250 250 / 0.05)' // neutral-50, 5% opacity
+ }
+ };
+ settings.colorNames.forEach((n) => {
+ // Color Pairings
+ // Example: .ring-primary-50-900-token | .ring-primary-900-50-token
+ settings.colorPairings.forEach((p) => {
+ classes[`.ring-${n}-${p.light}-${p.dark}-token`] = {
+ '--tw-ring-color': `rgb(var(--color-${n}-${p.light}) / 1)`
+ };
+ classes[`.dark .ring-${n}-${p.light}-${p.dark}-token`] = {
+ '--tw-ring-color': `rgb(var(--color-${n}-${p.dark}) / 1)`
+ };
+ });
+ });
+ return classes;
+};
diff --git a/web/app/src/skeleton/tailwind/tokens/rings.d.cts b/web/app/src/skeleton/tailwind/tokens/rings.d.cts
new file mode 100644
index 0000000..8ef3b8f
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/tokens/rings.d.cts
@@ -0,0 +1,22 @@
+declare function _exports(): {
+ '.ring-token': {
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ 'box-shadow': string;
+ };
+ '.ring-outline-token': {
+ '--tw-ring-color': string;
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ 'box-shadow': string;
+ '--tw-ring-inset': string;
+ };
+ '.dark .ring-outline-token': {
+ '--tw-ring-color': string;
+ '--tw-ring-offset-shadow': string;
+ '--tw-ring-shadow': string;
+ 'box-shadow': string;
+ '--tw-ring-inset': string;
+ };
+};
+export = _exports;
diff --git a/web/app/src/skeleton/tailwind/tokens/text.cjs b/web/app/src/skeleton/tailwind/tokens/text.cjs
new file mode 100644
index 0000000..b3ec6e2
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/tokens/text.cjs
@@ -0,0 +1,31 @@
+// Design Tokens: Text
+// Doc: https://www.skeleton.dev/docs/tokens
+
+const settings = require('../settings.cjs');
+
+module.exports = () => {
+ const classes = {
+ // Font Family
+ '.font-heading-token': { 'font-family': 'var(--theme-font-family-heading)' },
+ '.font-token': { 'font-family': 'var(--theme-font-family-base)' },
+ // Default Text Colors
+ '.text-base-token': { color: 'rgba(var(--theme-font-color-base))' },
+ '.text-dark-token': { color: 'rgba(var(--theme-font-color-dark))' },
+ // Light/Dark Text Color - ex: .text-token
+ '.text-token': { color: 'rgba(var(--theme-font-color-base))' },
+ '.dark .text-token': { color: 'rgba(var(--theme-font-color-dark))' }
+ };
+ settings.colorNames.forEach((n) => {
+ // On-X Text Colors
+ // Example: .text-on-primary-token
+ classes[`.text-on-${n}-token`] = { color: `rgb(var(--on-${n}))` };
+
+ // Color Pairings
+ // Example: .text-primary-50-900-token | .text-primary-900-50-token
+ settings.colorPairings.forEach((p) => {
+ classes[`.text-${n}-${p.light}-${p.dark}-token`] = { color: `rgb(var(--color-${n}-${p.light}))` };
+ classes[`.dark .text-${n}-${p.light}-${p.dark}-token`] = { color: `rgb(var(--color-${n}-${p.dark}))` };
+ });
+ });
+ return classes;
+};
diff --git a/web/app/src/skeleton/tailwind/tokens/text.d.cts b/web/app/src/skeleton/tailwind/tokens/text.d.cts
new file mode 100644
index 0000000..e0ecc6b
--- /dev/null
+++ b/web/app/src/skeleton/tailwind/tokens/text.d.cts
@@ -0,0 +1,21 @@
+declare function _exports(): {
+ '.font-heading-token': {
+ 'font-family': string;
+ };
+ '.font-token': {
+ 'font-family': string;
+ };
+ '.text-base-token': {
+ color: string;
+ };
+ '.text-dark-token': {
+ color: string;
+ };
+ '.text-token': {
+ color: string;
+ };
+ '.dark .text-token': {
+ color: string;
+ };
+};
+export = _exports;
diff --git a/web/app/src/skeleton/themes/theme-crimson.css b/web/app/src/skeleton/themes/theme-crimson.css
new file mode 100644
index 0000000..a0b0418
--- /dev/null
+++ b/web/app/src/skeleton/themes/theme-crimson.css
@@ -0,0 +1,101 @@
+/* =~= Crimson Theme - made by GitHub user @ak4zh for the Skeleton community theme contest. =~= */
+/* https://github.com/skeletonlabs/skeleton/discussions/401 */
+
+:root {
+ /* =~= Theme Styles =~= */
+ --theme-font-family-base: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
+ 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
+ 'Noto Color Emoji';
+ --theme-font-family-heading: system-ui;
+ --theme-font-color-base: var(--color-surface-900);
+ --theme-font-color-dark: var(--color-surface-50);
+ --theme-rounded-base: 24px;
+ --theme-rounded-container: 24px;
+ --theme-border-base: 1px;
+ /* =~= Theme On-X Colors =~= */
+ --on-primary: 255 255 255;
+ --on-secondary: 255 255 255;
+ --on-tertiary: 0 0 0;
+ --on-success: 0 0 0;
+ --on-warning: 0 0 0;
+ --on-error: 0 0 0;
+ --on-surface: 255 255 255;
+ /* =~= Theme Colors =~= */
+ /* primary | #d4163c */
+ --color-primary-50: 249 220 226; /* ⬅ #f9dce2 */
+ --color-primary-100: 246 208 216; /* ⬅ #f6d0d8 */
+ --color-primary-200: 244 197 206; /* ⬅ #f4c5ce */
+ --color-primary-300: 238 162 177; /* ⬅ #eea2b1 */
+ --color-primary-400: 225 92 119; /* ⬅ #e15c77 */
+ --color-primary-500: 212 22 60; /* ⬅ #d4163c */
+ --color-primary-600: 191 20 54; /* ⬅ #bf1436 */
+ --color-primary-700: 159 17 45; /* ⬅ #9f112d */
+ --color-primary-800: 127 13 36; /* ⬅ #7f0d24 */
+ --color-primary-900: 104 11 29; /* ⬅ #680b1d */
+ /* secondary | #4685af */
+ --color-secondary-50: 227 237 243; /* ⬅ #e3edf3 */
+ --color-secondary-100: 218 231 239; /* ⬅ #dae7ef */
+ --color-secondary-200: 209 225 235; /* ⬅ #d1e1eb */
+ --color-secondary-300: 181 206 223; /* ⬅ #b5cedf */
+ --color-secondary-400: 126 170 199; /* ⬅ #7eaac7 */
+ --color-secondary-500: 70 133 175; /* ⬅ #4685af */
+ --color-secondary-600: 63 120 158; /* ⬅ #3f789e */
+ --color-secondary-700: 53 100 131; /* ⬅ #356483 */
+ --color-secondary-800: 42 80 105; /* ⬅ #2a5069 */
+ --color-secondary-900: 34 65 86; /* ⬅ #224156 */
+ /* tertiary | #c0b6b4 */
+ --color-tertiary-50: 246 244 244; /* ⬅ #f6f4f4 */
+ --color-tertiary-100: 242 240 240; /* ⬅ #f2f0f0 */
+ --color-tertiary-200: 239 237 236; /* ⬅ #efedec */
+ --color-tertiary-300: 230 226 225; /* ⬅ #e6e2e1 */
+ --color-tertiary-400: 211 204 203; /* ⬅ #d3cccb */
+ --color-tertiary-500: 192 182 180; /* ⬅ #c0b6b4 */
+ --color-tertiary-600: 173 164 162; /* ⬅ #ada4a2 */
+ --color-tertiary-700: 144 137 135; /* ⬅ #908987 */
+ --color-tertiary-800: 115 109 108; /* ⬅ #736d6c */
+ --color-tertiary-900: 94 89 88; /* ⬅ #5e5958 */
+ /* success | #c1dd97 */
+ --color-success-50: 246 250 239; /* ⬅ #f6faef */
+ --color-success-100: 243 248 234; /* ⬅ #f3f8ea */
+ --color-success-200: 240 247 229; /* ⬅ #f0f7e5 */
+ --color-success-300: 230 241 213; /* ⬅ #e6f1d5 */
+ --color-success-400: 212 231 182; /* ⬅ #d4e7b6 */
+ --color-success-500: 193 221 151; /* ⬅ #c1dd97 */
+ --color-success-600: 174 199 136; /* ⬅ #aec788 */
+ --color-success-700: 145 166 113; /* ⬅ #91a671 */
+ --color-success-800: 116 133 91; /* ⬅ #74855b */
+ --color-success-900: 95 108 74; /* ⬅ #5f6c4a */
+ /* warning | #e4c25e */
+ --color-warning-50: 251 246 231; /* ⬅ #fbf6e7 */
+ --color-warning-100: 250 243 223; /* ⬅ #faf3df */
+ --color-warning-200: 248 240 215; /* ⬅ #f8f0d7 */
+ --color-warning-300: 244 231 191; /* ⬅ #f4e7bf */
+ --color-warning-400: 236 212 142; /* ⬅ #ecd48e */
+ --color-warning-500: 228 194 94; /* ⬅ #e4c25e */
+ --color-warning-600: 205 175 85; /* ⬅ #cdaf55 */
+ --color-warning-700: 171 146 71; /* ⬅ #ab9247 */
+ --color-warning-800: 137 116 56; /* ⬅ #897438 */
+ --color-warning-900: 112 95 46; /* ⬅ #705f2e */
+ /* error | #d27f81 */
+ --color-error-50: 248 236 236; /* ⬅ #f8ecec */
+ --color-error-100: 246 229 230; /* ⬅ #f6e5e6 */
+ --color-error-200: 244 223 224; /* ⬅ #f4dfe0 */
+ --color-error-300: 237 204 205; /* ⬅ #edcccd */
+ --color-error-400: 224 165 167; /* ⬅ #e0a5a7 */
+ --color-error-500: 210 127 129; /* ⬅ #d27f81 */
+ --color-error-600: 189 114 116; /* ⬅ #bd7274 */
+ --color-error-700: 158 95 97; /* ⬅ #9e5f61 */
+ --color-error-800: 126 76 77; /* ⬅ #7e4c4d */
+ --color-error-900: 103 62 63; /* ⬅ #673e3f */
+ /* surface | #2b2e40 */
+ --color-surface-50: 223 224 226; /* ⬅ #dfe0e2 */
+ --color-surface-100: 213 213 217; /* ⬅ #d5d5d9 */
+ --color-surface-200: 202 203 207; /* ⬅ #cacbcf */
+ --color-surface-300: 170 171 179; /* ⬅ #aaabb3 */
+ --color-surface-400: 107 109 121; /* ⬅ #6b6d79 */
+ --color-surface-500: 43 46 64; /* ⬅ #2b2e40 */
+ --color-surface-600: 39 41 58; /* ⬅ #27293a */
+ --color-surface-700: 32 35 48; /* ⬅ #202330 */
+ --color-surface-800: 26 28 38; /* ⬅ #1a1c26 */
+ --color-surface-900: 21 23 31; /* ⬅ #15171f */
+}
diff --git a/web/app/src/skeleton/themes/theme-gold-nouveau.css b/web/app/src/skeleton/themes/theme-gold-nouveau.css
new file mode 100644
index 0000000..2354811
--- /dev/null
+++ b/web/app/src/skeleton/themes/theme-gold-nouveau.css
@@ -0,0 +1,139 @@
+/* =~= Gold Nouveau - made by GitHub user @Sarenor for the Skeleton community theme contest. =~= */
+/* https://github.com/skeletonlabs/skeleton/discussions/401 */
+
+/* https://fonts.google.com/specimen/Quicksand?query=Quicksand */
+
+/* =~= Gold Nouveau =~= */
+:root {
+ /* =~= Theme Properties =~= */
+ --theme-font-family-base: system-ui, sans-serif;
+ --theme-font-family-heading: 'Quicksand', sans-serif;
+ --theme-font-color-base: var(--color-surface-900);
+ --theme-font-color-dark: var(--color-surface-50);
+ --theme-rounded-base: 4px;
+ --theme-rounded-container: 4px;
+ --theme-border-base: 1px;
+ /* =~= Theme On-X Colors =~= */
+ --on-primary: 255 255 255;
+ --on-secondary: 255 255 255;
+ --on-tertiary: 255 255 255;
+ --on-success: 0 0 0;
+ --on-warning: 0 0 0;
+ --on-error: 255 255 255;
+ --on-surface: 255 255 255;
+ /* =~= Theme Colors =~= */
+ /* primary | #744aa1 */
+ --color-primary-50: 250 248 252; /* ⬅ #faf8fc */
+ --color-primary-100: 242 238 247; /* ⬅ #f2eef7 */
+ --color-primary-200: 229 220 239; /* ⬅ #e5dcef */
+ --color-primary-300: 209 192 226; /* ⬅ #d1c0e2 */
+ --color-primary-400: 162 129 197; /* ⬅ #a281c5 */
+ --color-primary-500: 116 74 161; /* ⬅ #744aa1 */
+ --color-primary-600: 83 53 115; /* ⬅ #533573 */
+ --color-primary-700: 60 39 84; /* ⬅ #3c2754 */
+ --color-primary-800: 35 22 49; /* ⬅ #231631 */
+ --color-primary-900: 18 11 24; /* ⬅ #120b18 */
+ /* secondary | #0672e5 */
+ --color-secondary-50: 218 234 251; /* ⬅ #daeafb */
+ --color-secondary-100: 205 227 250; /* ⬅ #cde3fa */
+ --color-secondary-200: 193 220 249; /* ⬅ #c1dcf9 */
+ --color-secondary-300: 155 199 245; /* ⬅ #9bc7f5 */
+ --color-secondary-400: 81 156 237; /* ⬅ #519ced */
+ --color-secondary-500: 6 114 229; /* ⬅ #0672e5 */
+ --color-secondary-600: 5 103 206; /* ⬅ #0567ce */
+ --color-secondary-700: 5 86 172; /* ⬅ #0556ac */
+ --color-secondary-800: 4 68 137; /* ⬅ #044489 */
+ --color-secondary-900: 3 56 112; /* ⬅ #033870 */
+ /* tertiary | #7f78dd */
+ --color-tertiary-50: 236 235 250; /* ⬅ #ecebfa */
+ --color-tertiary-100: 229 228 248; /* ⬅ #e5e4f8 */
+ --color-tertiary-200: 223 221 247; /* ⬅ #dfddf7 */
+ --color-tertiary-300: 204 201 241; /* ⬅ #ccc9f1 */
+ --color-tertiary-400: 165 161 231; /* ⬅ #a5a1e7 */
+ --color-tertiary-500: 127 120 221; /* ⬅ #7f78dd */
+ --color-tertiary-600: 114 108 199; /* ⬅ #726cc7 */
+ --color-tertiary-700: 95 90 166; /* ⬅ #5f5aa6 */
+ --color-tertiary-800: 76 72 133; /* ⬅ #4c4885 */
+ --color-tertiary-900: 62 59 108; /* ⬅ #3e3b6c */
+ /* success | #72c585 */
+ --color-success-50: 234 246 237; /* ⬅ #eaf6ed */
+ --color-success-100: 227 243 231; /* ⬅ #e3f3e7 */
+ --color-success-200: 220 241 225; /* ⬅ #dcf1e1 */
+ --color-success-300: 199 232 206; /* ⬅ #c7e8ce */
+ --color-success-400: 156 214 170; /* ⬅ #9cd6aa */
+ --color-success-500: 114 197 133; /* ⬅ #72c585 */
+ --color-success-600: 103 177 120; /* ⬅ #67b178 */
+ --color-success-700: 86 148 100; /* ⬅ #569464 */
+ --color-success-800: 68 118 80; /* ⬅ #447650 */
+ --color-success-900: 56 97 65; /* ⬅ #386141 */
+ /* warning | #e77f08 */
+ --color-warning-50: 251 236 218; /* ⬅ #fbecda */
+ --color-warning-100: 250 229 206; /* ⬅ #fae5ce */
+ --color-warning-200: 249 223 193; /* ⬅ #f9dfc1 */
+ --color-warning-300: 245 204 156; /* ⬅ #f5cc9c */
+ --color-warning-400: 238 165 82; /* ⬅ #eea552 */
+ --color-warning-500: 231 127 8; /* ⬅ #e77f08 */
+ --color-warning-600: 208 114 7; /* ⬅ #d07207 */
+ --color-warning-700: 173 95 6; /* ⬅ #ad5f06 */
+ --color-warning-800: 139 76 5; /* ⬅ #8b4c05 */
+ --color-warning-900: 113 62 4; /* ⬅ #713e04 */
+ /* error | #8f0f22 */
+ --color-error-50: 238 219 222; /* ⬅ #eedbde */
+ --color-error-100: 233 207 211; /* ⬅ #e9cfd3 */
+ --color-error-200: 227 195 200; /* ⬅ #e3c3c8 */
+ --color-error-300: 210 159 167; /* ⬅ #d29fa7 */
+ --color-error-400: 177 87 100; /* ⬅ #b15764 */
+ --color-error-500: 143 15 34; /* ⬅ #8f0f22 */
+ --color-error-600: 129 14 31; /* ⬅ #810e1f */
+ --color-error-700: 107 11 26; /* ⬅ #6b0b1a */
+ --color-error-800: 86 9 20; /* ⬅ #560914 */
+ --color-error-900: 70 7 17; /* ⬅ #460711 */
+ /* surface | #744aa1 */
+ --color-surface-50: 250 248 252; /* ⬅ #faf8fc */
+ --color-surface-100: 242 238 247; /* ⬅ #f2eef7 */
+ --color-surface-200: 229 220 239; /* ⬅ #e5dcef */
+ --color-surface-300: 209 192 226; /* ⬅ #d1c0e2 */
+ --color-surface-400: 162 129 197; /* ⬅ #a281c5 */
+ --color-surface-500: 116 74 161; /* ⬅ #744aa1 */
+ --color-surface-600: 83 53 115; /* ⬅ #533573 */
+ --color-surface-700: 60 39 84; /* ⬅ #3c2754 */
+ --color-surface-800: 35 22 49; /* ⬅ #231631 */
+ --color-surface-900: 18 11 24; /* ⬅ #120b18 */
+}
+
+/* =~= Gold Nouveau (dark mode overrides) =~= */
+.dark [data-theme='gold-nouveau'] {
+ /* =~= Theme On-X Colors =~= */
+ --on-primary: 0 0 0;
+ /* =~= Theme Colors =~= */
+ /* primary | #e6c833 */
+ --color-primary-50: 251 247 224; /* ⬅ #fbf7e0 */
+ --color-primary-100: 250 244 214; /* ⬅ #faf4d6 */
+ --color-primary-200: 249 241 204; /* ⬅ #f9f1cc */
+ --color-primary-300: 245 233 173; /* ⬅ #f5e9ad */
+ --color-primary-400: 238 217 112; /* ⬅ #eed970 */
+ --color-primary-500: 230 200 51; /* ⬅ #e6c833 */
+ --color-primary-600: 207 180 46; /* ⬅ #cfb42e */
+ --color-primary-700: 173 150 38; /* ⬅ #ad9626 */
+ --color-primary-800: 138 120 31; /* ⬅ #8a781f */
+ --color-primary-900: 113 98 25; /* ⬅ #716219 */
+}
+
+/* Headings */
+[data-theme='gold-nouveau'] h1,
+[data-theme='gold-nouveau'] h2,
+[data-theme='gold-nouveau'] h3,
+[data-theme='gold-nouveau'] h4,
+[data-theme='gold-nouveau'] h5,
+[data-theme='gold-nouveau'] h6 {
+ font-weight: bold;
+}
+
+/* Applied to body with `` */
+/* Created with: https://csshero.org/mesher/ */
+/* prettier-ignore */
+[data-theme='gold-nouveau'] {
+ background-image:
+ radial-gradient(at 0% 100%, rgba(var(--color-secondary-500) / 0.33) 0px, transparent 50%),
+ radial-gradient(at 98% 100%, rgba(var(--color-error-500) / 0.33) 0px, transparent 50%);
+}
diff --git a/web/app/src/skeleton/themes/theme-hamlindigo.css b/web/app/src/skeleton/themes/theme-hamlindigo.css
new file mode 100644
index 0000000..f4418c1
--- /dev/null
+++ b/web/app/src/skeleton/themes/theme-hamlindigo.css
@@ -0,0 +1,110 @@
+/* =~= Hamlindigo Theme - made by GitHub user @rcgy for the Skeleton community. Go watch Better Call Saul. =~= */
+/* https://github.com/skeletonlabs/skeleton/discussions/401 */
+
+:root {
+ /* =~= Hamlindigo Theme | Custom =~= */
+ --theme-font-family-base: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
+ 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
+ 'Noto Color Emoji';
+ --theme-font-family-heading: serif;
+ --theme-font-color-base: 0 0 0;
+ --theme-font-color-dark: 255 255 255;
+ --theme-rounded-base: 2px;
+ --theme-rounded-container: 2px;
+ --theme-border-base: 2px;
+ /* =~= Theme On-X Colors =~= */
+ --on-primary: 0 0 0;
+ --on-secondary: 255 255 255;
+ --on-tertiary: 255 255 255;
+ --on-success: 255 255 255;
+ --on-warning: 0 0 0;
+ --on-error: 255 255 255;
+ --on-surface: 255 255 255;
+ /* =~= Theme Colors =~= */
+ /* primary | #a8bef1 */
+ --color-primary-50: 242 245 253; /* ⬅ #f2f5fd */
+ --color-primary-100: 238 242 252; /* ⬅ #eef2fc */
+ --color-primary-200: 233 239 252; /* ⬅ #e9effc */
+ --color-primary-300: 220 229 249; /* ⬅ #dce5f9 */
+ --color-primary-400: 194 210 245; /* ⬅ #c2d2f5 */
+ --color-primary-500: 168 190 241; /* ⬅ #a8bef1 */
+ --color-primary-600: 151 171 217; /* ⬅ #97abd9 */
+ --color-primary-700: 126 143 181; /* ⬅ #7e8fb5 */
+ --color-primary-800: 101 114 145; /* ⬅ #657291 */
+ --color-primary-900: 82 93 118; /* ⬅ #525d76 */
+ /* secondary | #a48e5b */
+ --color-secondary-50: 241 238 230; /* ⬅ #f1eee6 */
+ --color-secondary-100: 237 232 222; /* ⬅ #ede8de */
+ --color-secondary-200: 232 227 214; /* ⬅ #e8e3d6 */
+ --color-secondary-300: 219 210 189; /* ⬅ #dbd2bd */
+ --color-secondary-400: 191 176 140; /* ⬅ #bfb08c */
+ --color-secondary-500: 164 142 91; /* ⬅ #a48e5b */
+ --color-secondary-600: 148 128 82; /* ⬅ #948052 */
+ --color-secondary-700: 123 107 68; /* ⬅ #7b6b44 */
+ --color-secondary-800: 98 85 55; /* ⬅ #625537 */
+ --color-secondary-900: 80 70 45; /* ⬅ #50462d */
+ /* tertiary | #6197a3 */
+ --color-tertiary-50: 231 239 241; /* ⬅ #e7eff1 */
+ --color-tertiary-100: 223 234 237; /* ⬅ #dfeaed */
+ --color-tertiary-200: 216 229 232; /* ⬅ #d8e5e8 */
+ --color-tertiary-300: 192 213 218; /* ⬅ #c0d5da */
+ --color-tertiary-400: 144 182 191; /* ⬅ #90b6bf */
+ --color-tertiary-500: 97 151 163; /* ⬅ #6197a3 */
+ --color-tertiary-600: 87 136 147; /* ⬅ #578893 */
+ --color-tertiary-700: 73 113 122; /* ⬅ #49717a */
+ --color-tertiary-800: 58 91 98; /* ⬅ #3a5b62 */
+ --color-tertiary-900: 48 74 80; /* ⬅ #304a50 */
+ /* success | #47947d */
+ --color-success-50: 227 239 236; /* ⬅ #e3efec */
+ --color-success-100: 218 234 229; /* ⬅ #daeae5 */
+ --color-success-200: 209 228 223; /* ⬅ #d1e4df */
+ --color-success-300: 181 212 203; /* ⬅ #b5d4cb */
+ --color-success-400: 126 180 164; /* ⬅ #7eb4a4 */
+ --color-success-500: 71 148 125; /* ⬅ #47947d */
+ --color-success-600: 64 133 113; /* ⬅ #408571 */
+ --color-success-700: 53 111 94; /* ⬅ #356f5e */
+ --color-success-800: 43 89 75; /* ⬅ #2b594b */
+ --color-success-900: 35 73 61; /* ⬅ #23493d */
+ /* warning | #daa93e */
+ --color-warning-50: 249 242 226; /* ⬅ #f9f2e2 */
+ --color-warning-100: 248 238 216; /* ⬅ #f8eed8 */
+ --color-warning-200: 246 234 207; /* ⬅ #f6eacf */
+ --color-warning-300: 240 221 178; /* ⬅ #f0ddb2 */
+ --color-warning-400: 229 195 120; /* ⬅ #e5c378 */
+ --color-warning-500: 218 169 62; /* ⬅ #daa93e */
+ --color-warning-600: 196 152 56; /* ⬅ #c49838 */
+ --color-warning-700: 164 127 47; /* ⬅ #a47f2f */
+ --color-warning-800: 131 101 37; /* ⬅ #836525 */
+ --color-warning-900: 107 83 30; /* ⬅ #6b531e */
+ /* error | #a26175 */
+ --color-error-50: 241 231 234; /* ⬅ #f1e7ea */
+ --color-error-100: 236 223 227; /* ⬅ #ecdfe3 */
+ --color-error-200: 232 216 221; /* ⬅ #e8d8dd */
+ --color-error-300: 218 192 200; /* ⬅ #dac0c8 */
+ --color-error-400: 190 144 158; /* ⬅ #be909e */
+ --color-error-500: 162 97 117; /* ⬅ #a26175 */
+ --color-error-600: 146 87 105; /* ⬅ #925769 */
+ --color-error-700: 122 73 88; /* ⬅ #7a4958 */
+ --color-error-800: 97 58 70; /* ⬅ #613a46 */
+ --color-error-900: 79 48 57; /* ⬅ #4f3039 */
+ /* surface | #6376a3 */
+ --color-surface-50: 232 234 241; /* ⬅ #e8eaf1 */
+ --color-surface-100: 224 228 237; /* ⬅ #e0e4ed */
+ --color-surface-200: 216 221 232; /* ⬅ #d8dde8 */
+ --color-surface-300: 193 200 218; /* ⬅ #c1c8da */
+ --color-surface-400: 146 159 191; /* ⬅ #929fbf */
+ --color-surface-500: 99 118 163; /* ⬅ #6376a3 */
+ --color-surface-600: 89 106 147; /* ⬅ #596a93 */
+ --color-surface-700: 74 89 122; /* ⬅ #4a597a */
+ --color-surface-800: 59 71 98; /* ⬅ #3b4762 */
+ --color-surface-900: 49 58 80; /* ⬅ #313a50 */
+}
+
+/* Applied to body with `` */
+/* Generated via: https://heropatterns.com/ */
+[data-theme='hamlindigo'] {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3E%3Cg fill='%23e0e4ed' fill-opacity='0.5'%3E%3Cpath fill-rule='evenodd' d='M0 0h4v4H0V0zm4 4h4v4H4V4z'/%3E%3C/g%3E%3C/svg%3E");
+}
+.dark [data-theme='hamlindigo'] {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3E%3Cg fill='%233b4762' fill-opacity='0.2'%3E%3Cpath fill-rule='evenodd' d='M0 0h4v4H0V0zm4 4h4v4H4V4z'/%3E%3C/g%3E%3C/svg%3E");
+}
diff --git a/web/app/src/skeleton/themes/theme-modern.css b/web/app/src/skeleton/themes/theme-modern.css
new file mode 100644
index 0000000..05dba20
--- /dev/null
+++ b/web/app/src/skeleton/themes/theme-modern.css
@@ -0,0 +1,126 @@
+/* https://fonts.google.com/specimen/Quicksand?query=Quicksand */
+
+:root {
+ /* =~= Theme Properties =~= */
+ --theme-font-family-base: 'Quicksand', sans-serif;
+ --theme-font-family-heading: 'Quicksand', sans-serif;
+ --theme-font-color-base: var(--color-surface-900);
+ --theme-font-color-dark: var(--color-tertiary-50);
+ --theme-rounded-base: 9999px;
+ --theme-rounded-container: 24px;
+ --theme-border-base: 3px;
+ /* =~= Theme On-X Colors =~= */
+ --on-primary: 255 255 255;
+ --on-secondary: 0 0 0;
+ --on-tertiary: 0 0 0;
+ --on-success: 0 0 0;
+ --on-warning: 0 0 0;
+ --on-error: 255 255 255;
+ --on-surface: 255 255 255;
+ /* =~= Theme Colors =~= */
+ /* primary | #ec4899 */
+ --color-primary-50: 252 228 240; /* ⬅ #fce4f0 */
+ --color-primary-100: 251 218 235; /* ⬅ #fbdaeb */
+ --color-primary-200: 250 209 230; /* ⬅ #fad1e6 */
+ --color-primary-300: 247 182 214; /* ⬅ #f7b6d6 */
+ --color-primary-400: 242 127 184; /* ⬅ #f27fb8 */
+ --color-primary-500: 236 72 153; /* ⬅ #ec4899 */
+ --color-primary-600: 212 65 138; /* ⬅ #d4418a */
+ --color-primary-700: 177 54 115; /* ⬅ #b13673 */
+ --color-primary-800: 142 43 92; /* ⬅ #8e2b5c */
+ --color-primary-900: 116 35 75; /* ⬅ #74234b */
+ /* secondary | #06b6d4 */
+ --color-secondary-50: 218 244 249; /* ⬅ #daf4f9 */
+ --color-secondary-100: 205 240 246; /* ⬅ #cdf0f6 */
+ --color-secondary-200: 193 237 244; /* ⬅ #c1edf4 */
+ --color-secondary-300: 155 226 238; /* ⬅ #9be2ee */
+ --color-secondary-400: 81 204 225; /* ⬅ #51cce1 */
+ --color-secondary-500: 6 182 212; /* ⬅ #06b6d4 */
+ --color-secondary-600: 5 164 191; /* ⬅ #05a4bf */
+ --color-secondary-700: 5 137 159; /* ⬅ #05899f */
+ --color-secondary-800: 4 109 127; /* ⬅ #046d7f */
+ --color-secondary-900: 3 89 104; /* ⬅ #035968 */
+ /* tertiary | #14b8a6 */
+ --color-tertiary-50: 220 244 242; /* ⬅ #dcf4f2 */
+ --color-tertiary-100: 208 241 237; /* ⬅ #d0f1ed */
+ --color-tertiary-200: 196 237 233; /* ⬅ #c4ede9 */
+ --color-tertiary-300: 161 227 219; /* ⬅ #a1e3db */
+ --color-tertiary-400: 91 205 193; /* ⬅ #5bcdc1 */
+ --color-tertiary-500: 20 184 166; /* ⬅ #14b8a6 */
+ --color-tertiary-600: 18 166 149; /* ⬅ #12a695 */
+ --color-tertiary-700: 15 138 125; /* ⬅ #0f8a7d */
+ --color-tertiary-800: 12 110 100; /* ⬅ #0c6e64 */
+ --color-tertiary-900: 10 90 81; /* ⬅ #0a5a51 */
+ /* success | #84cc16 */
+ --color-success-50: 237 247 220; /* ⬅ #edf7dc */
+ --color-success-100: 230 245 208; /* ⬅ #e6f5d0 */
+ --color-success-200: 224 242 197; /* ⬅ #e0f2c5 */
+ --color-success-300: 206 235 162; /* ⬅ #ceeba2 */
+ --color-success-400: 169 219 92; /* ⬅ #a9db5c */
+ --color-success-500: 132 204 22; /* ⬅ #84cc16 */
+ --color-success-600: 119 184 20; /* ⬅ #77b814 */
+ --color-success-700: 99 153 17; /* ⬅ #639911 */
+ --color-success-800: 79 122 13; /* ⬅ #4f7a0d */
+ --color-success-900: 65 100 11; /* ⬅ #41640b */
+ /* warning | #eab308 */
+ --color-warning-50: 252 244 218; /* ⬅ #fcf4da */
+ --color-warning-100: 251 240 206; /* ⬅ #fbf0ce */
+ --color-warning-200: 250 236 193; /* ⬅ #faecc1 */
+ --color-warning-300: 247 225 156; /* ⬅ #f7e19c */
+ --color-warning-400: 240 202 82; /* ⬅ #f0ca52 */
+ --color-warning-500: 234 179 8; /* ⬅ #eab308 */
+ --color-warning-600: 211 161 7; /* ⬅ #d3a107 */
+ --color-warning-700: 176 134 6; /* ⬅ #b08606 */
+ --color-warning-800: 140 107 5; /* ⬅ #8c6b05 */
+ --color-warning-900: 115 88 4; /* ⬅ #735804 */
+ /* error | #ef4444 */
+ --color-error-50: 253 227 227; /* ⬅ #fde3e3 */
+ --color-error-100: 252 218 218; /* ⬅ #fcdada */
+ --color-error-200: 251 208 208; /* ⬅ #fbd0d0 */
+ --color-error-300: 249 180 180; /* ⬅ #f9b4b4 */
+ --color-error-400: 244 124 124; /* ⬅ #f47c7c */
+ --color-error-500: 239 68 68; /* ⬅ #ef4444 */
+ --color-error-600: 215 61 61; /* ⬅ #d73d3d */
+ --color-error-700: 179 51 51; /* ⬅ #b33333 */
+ --color-error-800: 143 41 41; /* ⬅ #8f2929 */
+ --color-error-900: 117 33 33; /* ⬅ #752121 */
+ /* surface | #6366f1 */
+ --color-surface-50: 232 232 253; /* ⬅ #e8e8fd */
+ --color-surface-100: 224 224 252; /* ⬅ #e0e0fc */
+ --color-surface-200: 216 217 252; /* ⬅ #d8d9fc */
+ --color-surface-300: 193 194 249; /* ⬅ #c1c2f9 */
+ --color-surface-400: 146 148 245; /* ⬅ #9294f5 */
+ --color-surface-500: 99 102 241; /* ⬅ #6366f1 */
+ --color-surface-600: 89 92 217; /* ⬅ #595cd9 */
+ --color-surface-700: 74 77 181; /* ⬅ #4a4db5 */
+ --color-surface-800: 59 61 145; /* ⬅ #3b3d91 */
+ --color-surface-900: 49 50 118; /* ⬅ #313276 */
+}
+
+[data-theme='modern'] h1,
+[data-theme='modern'] h2,
+[data-theme='modern'] h3,
+[data-theme='modern'] h4,
+[data-theme='modern'] h5,
+[data-theme='modern'] h6,
+[data-theme='modern'] a,
+[data-theme='modern'] button {
+ font-weight: bold;
+}
+
+/* Applied to body with `` */
+/* Created with: https://csshero.org/mesher/ */
+[data-theme='modern'] {
+ /* prettier-ignore */
+ background-image:
+ radial-gradient(at 76% 0%, hsla(189,100%,56%,0.36) 0px, transparent 50%),
+ radial-gradient(at 1% 0%, hsla(340,100%,76%,0.26) 0px, transparent 50%),
+ radial-gradient(at 20% 100%, hsla(241,100%,70%,0.47) 0px, transparent 50%);
+}
+.dark [data-theme='modern'] {
+ /* prettier-ignore */
+ background-image:
+ radial-gradient(at 76% 0%, hsla(189,100%,56%,0.20) 0px, transparent 50%),
+ radial-gradient(at 1% 0%, hsla(340,100%,76%,0.15) 0px, transparent 50%),
+ radial-gradient(at 20% 100%, hsla(241,100%,70%,0.30) 0px, transparent 50%);
+}
diff --git a/web/app/src/skeleton/themes/theme-rocket.css b/web/app/src/skeleton/themes/theme-rocket.css
new file mode 100644
index 0000000..e2236c6
--- /dev/null
+++ b/web/app/src/skeleton/themes/theme-rocket.css
@@ -0,0 +1,116 @@
+/* https://fonts.google.com/specimen/Space+Grotesk */
+
+:root {
+ /* =~= Theme Properties =~= */
+ --theme-font-family-base: system-ui;
+ --theme-font-family-heading: 'Space Grotesk', sans-serif;
+ --theme-font-color-base: var(--color-primary-900);
+ --theme-font-color-dark: var(--color-primary-100);
+ --theme-rounded-base: 0px;
+ --theme-rounded-container: 0px;
+ --theme-border-base: 0px;
+ /* =~= Theme On-X Colors =~= */
+ --on-primary: 0 0 0;
+ --on-secondary: 255 255 255;
+ --on-tertiary: 255 255 255;
+ --on-success: 0 0 0;
+ --on-warning: 0 0 0;
+ --on-error: 255 255 255;
+ --on-surface: 255 255 255;
+ /* =~= Theme Colors =~= */
+ /* primary | #06b6d4 */
+ --color-primary-50: 218 244 249; /* ⬅ #daf4f9 */
+ --color-primary-100: 205 240 246; /* ⬅ #cdf0f6 */
+ --color-primary-200: 193 237 244; /* ⬅ #c1edf4 */
+ --color-primary-300: 155 226 238; /* ⬅ #9be2ee */
+ --color-primary-400: 81 204 225; /* ⬅ #51cce1 */
+ --color-primary-500: 6 182 212; /* ⬅ #06b6d4 */
+ --color-primary-600: 5 164 191; /* ⬅ #05a4bf */
+ --color-primary-700: 5 137 159; /* ⬅ #05899f */
+ --color-primary-800: 4 109 127; /* ⬅ #046d7f */
+ --color-primary-900: 3 89 104; /* ⬅ #035968 */
+ /* secondary | #3b82f6 */
+ --color-secondary-50: 226 236 254; /* ⬅ #e2ecfe */
+ --color-secondary-100: 216 230 253; /* ⬅ #d8e6fd */
+ --color-secondary-200: 206 224 253; /* ⬅ #cee0fd */
+ --color-secondary-300: 177 205 251; /* ⬅ #b1cdfb */
+ --color-secondary-400: 118 168 249; /* ⬅ #76a8f9 */
+ --color-secondary-500: 59 130 246; /* ⬅ #3b82f6 */
+ --color-secondary-600: 53 117 221; /* ⬅ #3575dd */
+ --color-secondary-700: 44 98 185; /* ⬅ #2c62b9 */
+ --color-secondary-800: 35 78 148; /* ⬅ #234e94 */
+ --color-secondary-900: 29 64 121; /* ⬅ #1d4079 */
+ /* tertiary | #a855f7 */
+ --color-tertiary-50: 242 230 254; /* ⬅ #f2e6fe */
+ --color-tertiary-100: 238 221 253; /* ⬅ #eeddfd */
+ --color-tertiary-200: 233 213 253; /* ⬅ #e9d5fd */
+ --color-tertiary-300: 220 187 252; /* ⬅ #dcbbfc */
+ --color-tertiary-400: 194 136 249; /* ⬅ #c288f9 */
+ --color-tertiary-500: 168 85 247; /* ⬅ #a855f7 */
+ --color-tertiary-600: 151 77 222; /* ⬅ #974dde */
+ --color-tertiary-700: 126 64 185; /* ⬅ #7e40b9 */
+ --color-tertiary-800: 101 51 148; /* ⬅ #653394 */
+ --color-tertiary-900: 82 42 121; /* ⬅ #522a79 */
+ /* success | #4ccb15 */
+ --color-success-50: 228 247 220; /* ⬅ #e4f7dc */
+ --color-success-100: 219 245 208; /* ⬅ #dbf5d0 */
+ --color-success-200: 210 242 197; /* ⬅ #d2f2c5 */
+ --color-success-300: 183 234 161; /* ⬅ #b7eaa1 */
+ --color-success-400: 130 219 91; /* ⬅ #82db5b */
+ --color-success-500: 76 203 21; /* ⬅ #4ccb15 */
+ --color-success-600: 68 183 19; /* ⬅ #44b713 */
+ --color-success-700: 57 152 16; /* ⬅ #399810 */
+ --color-success-800: 46 122 13; /* ⬅ #2e7a0d */
+ --color-success-900: 37 99 10; /* ⬅ #25630a */
+ /* warning | #f4c12a */
+ --color-warning-50: 253 246 223; /* ⬅ #fdf6df */
+ --color-warning-100: 253 243 212; /* ⬅ #fdf3d4 */
+ --color-warning-200: 252 240 202; /* ⬅ #fcf0ca */
+ --color-warning-300: 251 230 170; /* ⬅ #fbe6aa */
+ --color-warning-400: 247 212 106; /* ⬅ #f7d46a */
+ --color-warning-500: 244 193 42; /* ⬅ #f4c12a */
+ --color-warning-600: 220 174 38; /* ⬅ #dcae26 */
+ --color-warning-700: 183 145 32; /* ⬅ #b79120 */
+ --color-warning-800: 146 116 25; /* ⬅ #927419 */
+ --color-warning-900: 120 95 21; /* ⬅ #785f15 */
+ /* error | #b52c55 */
+ --color-error-50: 244 223 230; /* ⬅ #f4dfe6 */
+ --color-error-100: 240 213 221; /* ⬅ #f0d5dd */
+ --color-error-200: 237 202 213; /* ⬅ #edcad5 */
+ --color-error-300: 225 171 187; /* ⬅ #e1abbb */
+ --color-error-400: 203 107 136; /* ⬅ #cb6b88 */
+ --color-error-500: 181 44 85; /* ⬅ #b52c55 */
+ --color-error-600: 163 40 77; /* ⬅ #a3284d */
+ --color-error-700: 136 33 64; /* ⬅ #882140 */
+ --color-error-800: 109 26 51; /* ⬅ #6d1a33 */
+ --color-error-900: 89 22 42; /* ⬅ #59162a */
+ /* surface | #64748b */
+ --color-surface-50: 232 234 238; /* ⬅ #e8eaee */
+ --color-surface-100: 224 227 232; /* ⬅ #e0e3e8 */
+ --color-surface-200: 216 220 226; /* ⬅ #d8dce2 */
+ --color-surface-300: 193 199 209; /* ⬅ #c1c7d1 */
+ --color-surface-400: 147 158 174; /* ⬅ #939eae */
+ --color-surface-500: 100 116 139; /* ⬅ #64748b */
+ --color-surface-600: 90 104 125; /* ⬅ #5a687d */
+ --color-surface-700: 75 87 104; /* ⬅ #4b5768 */
+ --color-surface-800: 60 70 83; /* ⬅ #3c4653 */
+ --color-surface-900: 49 57 68; /* ⬅ #313944 */
+}
+
+[data-theme='rocket'] h1,
+[data-theme='rocket'] h2,
+[data-theme='rocket'] h3,
+[data-theme='rocket'] h4,
+[data-theme='rocket'] h5,
+[data-theme='rocket'] h6 {
+ font-weight: bold;
+}
+
+/* Applied to body with `` */
+/* Created with: https://csshero.org/mesher/ */
+/* prettier-ignore */
+[data-theme='rocket'] {
+ background-image:
+ radial-gradient(at 0% 0%, rgba(var(--color-secondary-500) / 0.33) 0px, transparent 50%),
+ radial-gradient(at 98% 1%, rgba(var(--color-error-500) / 0.33) 0px, transparent 50%);
+}
diff --git a/web/app/src/skeleton/themes/theme-sahara.css b/web/app/src/skeleton/themes/theme-sahara.css
new file mode 100644
index 0000000..645a4cd
--- /dev/null
+++ b/web/app/src/skeleton/themes/theme-sahara.css
@@ -0,0 +1,127 @@
+:root {
+ /* =~= Theme Styles =~= */
+ --theme-font-family-base: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
+ 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
+ 'Noto Color Emoji';
+ --theme-font-family-heading: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
+ 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
+ 'Noto Color Emoji';
+ --theme-font-color-base: var(--color-secondary-900);
+ --theme-font-color-dark: var(--color-primary-100);
+ --theme-rounded-base: 9999px;
+ --theme-rounded-container: 24px;
+ --theme-border-base: 1px;
+ /* =~= Theme On-X Colors =~= */
+ --on-primary: 0 0 0;
+ --on-secondary: 0 0 0;
+ --on-tertiary: 0 0 0;
+ --on-success: 0 0 0;
+ --on-warning: 0 0 0;
+ --on-error: 255 255 255;
+ --on-surface: 255 255 255;
+ /* =~= Theme Colors =~= */
+ /* primary | #ecaa36 */
+ --color-primary-50: 252 242 225; /* ⬅ #fcf2e1 */
+ --color-primary-100: 251 238 215; /* ⬅ #fbeed7 */
+ --color-primary-200: 250 234 205; /* ⬅ #faeacd */
+ --color-primary-300: 247 221 175; /* ⬅ #f7ddaf */
+ --color-primary-400: 242 196 114; /* ⬅ #f2c472 */
+ --color-primary-500: 236 170 54; /* ⬅ #ecaa36 */
+ --color-primary-600: 212 153 49; /* ⬅ #d49931 */
+ --color-primary-700: 177 128 41; /* ⬅ #b18029 */
+ --color-primary-800: 142 102 32; /* ⬅ #8e6620 */
+ --color-primary-900: 116 83 26; /* ⬅ #74531a */
+ /* secondary | #3acbba */
+ --color-secondary-50: 225 247 245; /* ⬅ #e1f7f5 */
+ --color-secondary-100: 216 245 241; /* ⬅ #d8f5f1 */
+ --color-secondary-200: 206 242 238; /* ⬅ #cef2ee */
+ --color-secondary-300: 176 234 227; /* ⬅ #b0eae3 */
+ --color-secondary-400: 117 219 207; /* ⬅ #75dbcf */
+ --color-secondary-500: 58 203 186; /* ⬅ #3acbba */
+ --color-secondary-600: 52 183 167; /* ⬅ #34b7a7 */
+ --color-secondary-700: 44 152 140; /* ⬅ #2c988c */
+ --color-secondary-800: 35 122 112; /* ⬅ #237a70 */
+ --color-secondary-900: 28 99 91; /* ⬅ #1c635b */
+ /* tertiary | #bbdf86 */
+ --color-tertiary-50: 245 250 237; /* ⬅ #f5faed */
+ --color-tertiary-100: 241 249 231; /* ⬅ #f1f9e7 */
+ --color-tertiary-200: 238 247 225; /* ⬅ #eef7e1 */
+ --color-tertiary-300: 228 242 207; /* ⬅ #e4f2cf */
+ --color-tertiary-400: 207 233 170; /* ⬅ #cfe9aa */
+ --color-tertiary-500: 187 223 134; /* ⬅ #bbdf86 */
+ --color-tertiary-600: 168 201 121; /* ⬅ #a8c979 */
+ --color-tertiary-700: 140 167 101; /* ⬅ #8ca765 */
+ --color-tertiary-800: 112 134 80; /* ⬅ #708650 */
+ --color-tertiary-900: 92 109 66; /* ⬅ #5c6d42 */
+ /* success | #84cc16 */
+ --color-success-50: 237 247 220; /* ⬅ #edf7dc */
+ --color-success-100: 230 245 208; /* ⬅ #e6f5d0 */
+ --color-success-200: 224 242 197; /* ⬅ #e0f2c5 */
+ --color-success-300: 206 235 162; /* ⬅ #ceeba2 */
+ --color-success-400: 169 219 92; /* ⬅ #a9db5c */
+ --color-success-500: 132 204 22; /* ⬅ #84cc16 */
+ --color-success-600: 119 184 20; /* ⬅ #77b814 */
+ --color-success-700: 99 153 17; /* ⬅ #639911 */
+ --color-success-800: 79 122 13; /* ⬅ #4f7a0d */
+ --color-success-900: 65 100 11; /* ⬅ #41640b */
+ /* warning | #e5c157 */
+ --color-warning-50: 251 246 230; /* ⬅ #fbf6e6 */
+ --color-warning-100: 250 243 221; /* ⬅ #faf3dd */
+ --color-warning-200: 249 240 213; /* ⬅ #f9f0d5 */
+ --color-warning-300: 245 230 188; /* ⬅ #f5e6bc */
+ --color-warning-400: 237 212 137; /* ⬅ #edd489 */
+ --color-warning-500: 229 193 87; /* ⬅ #e5c157 */
+ --color-warning-600: 206 174 78; /* ⬅ #ceae4e */
+ --color-warning-700: 172 145 65; /* ⬅ #ac9141 */
+ --color-warning-800: 137 116 52; /* ⬅ #897434 */
+ --color-warning-900: 112 95 43; /* ⬅ #705f2b */
+ /* error | #db5c9c */
+ --color-error-50: 250 231 240; /* ⬅ #fae7f0 */
+ --color-error-100: 248 222 235; /* ⬅ #f8deeb */
+ --color-error-200: 246 214 230; /* ⬅ #f6d6e6 */
+ --color-error-300: 241 190 215; /* ⬅ #f1bed7 */
+ --color-error-400: 230 141 186; /* ⬅ #e68dba */
+ --color-error-500: 219 92 156; /* ⬅ #db5c9c */
+ --color-error-600: 197 83 140; /* ⬅ #c5538c */
+ --color-error-700: 164 69 117; /* ⬅ #a44575 */
+ --color-error-800: 131 55 94; /* ⬅ #83375e */
+ --color-error-900: 107 45 76; /* ⬅ #6b2d4c */
+ /* surface | #da4e65 */
+ --color-surface-50: 249 228 232; /* ⬅ #f9e4e8 */
+ --color-surface-100: 248 220 224; /* ⬅ #f8dce0 */
+ --color-surface-200: 246 211 217; /* ⬅ #f6d3d9 */
+ --color-surface-300: 240 184 193; /* ⬅ #f0b8c1 */
+ --color-surface-400: 229 131 147; /* ⬅ #e58393 */
+ --color-surface-500: 218 78 101; /* ⬅ #da4e65 */
+ --color-surface-600: 196 70 91; /* ⬅ #c4465b */
+ --color-surface-700: 164 59 76; /* ⬅ #a43b4c */
+ --color-surface-800: 131 47 61; /* ⬅ #832f3d */
+ --color-surface-900: 107 38 49; /* ⬅ #6b2631 */
+}
+
+[data-theme='sahara'] h1,
+[data-theme='sahara'] h2,
+[data-theme='sahara'] h3,
+[data-theme='sahara'] h4,
+[data-theme='sahara'] h5,
+[data-theme='sahara'] h6 {
+ font-weight: 600;
+}
+[data-theme='sahara'] p {
+ font-weight: 400;
+}
+
+/* Applied to body with `` */
+/* Created with: https://csshero.org/mesher/ */
+[data-theme='sahara'] {
+ /* prettier-ignore */
+ background-image:
+ radial-gradient(at 100% 36%, hsla(37,81%,56%,0.15) 0px, transparent 50%),
+ radial-gradient(at 7% 0%, hsla(37,81%,56%,0.20) 0px, transparent 50%);
+}
+.dark [data-theme='sahara'] {
+ /* prettier-ignore */
+ background-image:
+ radial-gradient(at 100% 36%, hsla(37,81%,56%,0.15) 0px, transparent 50%),
+ radial-gradient(at 7% 0%, hsla(37,81%,56%,0.20) 0px, transparent 50%);
+}
diff --git a/web/app/src/skeleton/themes/theme-seafoam.css b/web/app/src/skeleton/themes/theme-seafoam.css
new file mode 100644
index 0000000..d3e780d
--- /dev/null
+++ b/web/app/src/skeleton/themes/theme-seafoam.css
@@ -0,0 +1,121 @@
+/* https://fonts.google.com/specimen/Playfair+Display?query=playfair */
+
+:root {
+ /* =~= Theme Styles =~= */
+ --theme-font-family-base: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
+ 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
+ 'Noto Color Emoji';
+ --theme-font-family-heading: 'Playfair Display', serif;
+ --theme-font-color-base: var(--color-surface-900);
+ --theme-font-color-dark: var(--color-secondary-100);
+ --theme-rounded-base: 16px;
+ --theme-rounded-container: 16px;
+ --theme-border-base: 3px;
+ /* =~= Theme On-X Colors =~= */
+ --on-primary: 0 0 0;
+ --on-secondary: 255 255 255;
+ --on-tertiary: 255 255 255;
+ --on-success: 0 0 0;
+ --on-warning: 0 0 0;
+ --on-error: 255 255 255;
+ --on-surface: 0 0 0;
+ /* =~= Theme Colors =~= */
+ /* primary | #86d0cb */
+ --color-primary-50: 237 248 247; /* ⬅ #edf8f7 */
+ --color-primary-100: 231 246 245; /* ⬅ #e7f6f5 */
+ --color-primary-200: 225 243 242; /* ⬅ #e1f3f2 */
+ --color-primary-300: 207 236 234; /* ⬅ #cfecea */
+ --color-primary-400: 170 222 219; /* ⬅ #aadedb */
+ --color-primary-500: 134 208 203; /* ⬅ #86d0cb */
+ --color-primary-600: 121 187 183; /* ⬅ #79bbb7 */
+ --color-primary-700: 101 156 152; /* ⬅ #659c98 */
+ --color-primary-800: 80 125 122; /* ⬅ #507d7a */
+ --color-primary-900: 66 102 99; /* ⬅ #426663 */
+ /* secondary | #213355 */
+ --color-secondary-50: 222 224 230; /* ⬅ #dee0e6 */
+ --color-secondary-100: 211 214 221; /* ⬅ #d3d6dd */
+ --color-secondary-200: 200 204 213; /* ⬅ #c8ccd5 */
+ --color-secondary-300: 166 173 187; /* ⬅ #a6adbb */
+ --color-secondary-400: 100 112 136; /* ⬅ #647088 */
+ --color-secondary-500: 33 51 85; /* ⬅ #213355 */
+ --color-secondary-600: 30 46 77; /* ⬅ #1e2e4d */
+ --color-secondary-700: 25 38 64; /* ⬅ #192640 */
+ --color-secondary-800: 20 31 51; /* ⬅ #141f33 */
+ --color-secondary-900: 16 25 42; /* ⬅ #10192a */
+ /* tertiary | #ff3d00 */
+ --color-tertiary-50: 255 226 217; /* ⬅ #ffe2d9 */
+ --color-tertiary-100: 255 216 204; /* ⬅ #ffd8cc */
+ --color-tertiary-200: 255 207 191; /* ⬅ #ffcfbf */
+ --color-tertiary-300: 255 177 153; /* ⬅ #ffb199 */
+ --color-tertiary-400: 255 119 77; /* ⬅ #ff774d */
+ --color-tertiary-500: 255 61 0; /* ⬅ #ff3d00 */
+ --color-tertiary-600: 230 55 0; /* ⬅ #e63700 */
+ --color-tertiary-700: 191 46 0; /* ⬅ #bf2e00 */
+ --color-tertiary-800: 153 37 0; /* ⬅ #992500 */
+ --color-tertiary-900: 125 30 0; /* ⬅ #7d1e00 */
+ /* success | #06e5a2 */
+ --color-success-50: 218 251 241; /* ⬅ #dafbf1 */
+ --color-success-100: 205 250 236; /* ⬅ #cdfaec */
+ --color-success-200: 193 249 232; /* ⬅ #c1f9e8 */
+ --color-success-300: 155 245 218; /* ⬅ #9bf5da */
+ --color-success-400: 81 237 190; /* ⬅ #51edbe */
+ --color-success-500: 6 229 162; /* ⬅ #06e5a2 */
+ --color-success-600: 5 206 146; /* ⬅ #05ce92 */
+ --color-success-700: 5 172 122; /* ⬅ #05ac7a */
+ --color-success-800: 4 137 97; /* ⬅ #048961 */
+ --color-success-900: 3 112 79; /* ⬅ #03704f */
+ /* warning | #eae557 */
+ --color-warning-50: 252 251 230; /* ⬅ #fcfbe6 */
+ --color-warning-100: 251 250 221; /* ⬅ #fbfadd */
+ --color-warning-200: 250 249 213; /* ⬅ #faf9d5 */
+ --color-warning-300: 247 245 188; /* ⬅ #f7f5bc */
+ --color-warning-400: 240 237 137; /* ⬅ #f0ed89 */
+ --color-warning-500: 234 229 87; /* ⬅ #eae557 */
+ --color-warning-600: 211 206 78; /* ⬅ #d3ce4e */
+ --color-warning-700: 176 172 65; /* ⬅ #b0ac41 */
+ --color-warning-800: 140 137 52; /* ⬅ #8c8934 */
+ --color-warning-900: 115 112 43; /* ⬅ #73702b */
+ /* error | #d24646 */
+ --color-error-50: 248 227 227; /* ⬅ #f8e3e3 */
+ --color-error-100: 246 218 218; /* ⬅ #f6dada */
+ --color-error-200: 244 209 209; /* ⬅ #f4d1d1 */
+ --color-error-300: 237 181 181; /* ⬅ #edb5b5 */
+ --color-error-400: 224 126 126; /* ⬅ #e07e7e */
+ --color-error-500: 210 70 70; /* ⬅ #d24646 */
+ --color-error-600: 189 63 63; /* ⬅ #bd3f3f */
+ --color-error-700: 158 53 53; /* ⬅ #9e3535 */
+ --color-error-800: 126 42 42; /* ⬅ #7e2a2a */
+ --color-error-900: 103 34 34; /* ⬅ #672222 */
+ /* surface | #25d1d4 */
+ --color-surface-50: 222 248 249; /* ⬅ #def8f9 */
+ --color-surface-100: 211 246 246; /* ⬅ #d3f6f6 */
+ --color-surface-200: 201 244 244; /* ⬅ #c9f4f4 */
+ --color-surface-300: 168 237 238; /* ⬅ #a8edee */
+ --color-surface-400: 102 223 225; /* ⬅ #66dfe1 */
+ --color-surface-500: 37 209 212; /* ⬅ #25d1d4 */
+ --color-surface-600: 33 188 191; /* ⬅ #21bcbf */
+ --color-surface-700: 28 157 159; /* ⬅ #1c9d9f */
+ --color-surface-800: 22 125 127; /* ⬅ #167d7f */
+ --color-surface-900: 18 102 104; /* ⬅ #126668 */
+}
+
+[data-theme='seafoam'] h1,
+[data-theme='seafoam'] h2,
+[data-theme='seafoam'] h3,
+[data-theme='seafoam'] h4,
+[data-theme='seafoam'] h5,
+[data-theme='seafoam'] h6 {
+ font-weight: bold;
+ font-style: italic;
+ letter-spacing: 1px;
+}
+
+/* #213253 | #08847c */
+/* Applied to body with `` */
+/* Created with: https://csshero.org/mesher/ */
+[data-theme='seafoam'] {
+ background: linear-gradient(0deg, rgba(203, 221, 254, 0.75) 0%, rgba(163, 209, 206, 0.75) 100%);
+}
+.dark [data-theme='seafoam'] {
+ background: linear-gradient(0deg, rgba(33, 50, 83, 1) 0%, rgba(8, 132, 124, 1) 100%);
+}
diff --git a/web/app/src/skeleton/themes/theme-seasonal.css b/web/app/src/skeleton/themes/theme-seasonal.css
new file mode 100644
index 0000000..f02b97a
--- /dev/null
+++ b/web/app/src/skeleton/themes/theme-seasonal.css
@@ -0,0 +1,114 @@
+/* A New Years inspired theme. */
+
+/* https://fonts.google.com/specimen/Quicksand?query=Quicksand */
+
+:root {
+ /* =~= Theme Properties =~= */
+ --theme-font-family-base: system-ui;
+ --theme-font-family-heading: 'Quicksand', sans-serif;
+ --theme-font-color-base: var(--color-surface-500);
+ --theme-font-color-dark: var(--color-surface-200);
+ --theme-rounded-base: 12px;
+ --theme-rounded-container: 12px;
+ --theme-border-base: 0px;
+ /* =~= Theme On-X Colors =~= */
+ --on-primary: 0 0 0;
+ --on-secondary: 255 255 255;
+ --on-tertiary: 255 255 255;
+ --on-success: 0 0 0;
+ --on-warning: 0 0 0;
+ --on-error: 255 255 255;
+ --on-surface: 255 255 255;
+ /* =~= Theme Colors =~= */
+ /* primary | #40b09d */
+ --color-primary-50: 226 243 240; /* ⬅ #e2f3f0 */
+ --color-primary-100: 217 239 235; /* ⬅ #d9efeb */
+ --color-primary-200: 207 235 231; /* ⬅ #cfebe7 */
+ --color-primary-300: 179 223 216; /* ⬅ #b3dfd8 */
+ --color-primary-400: 121 200 186; /* ⬅ #79c8ba */
+ --color-primary-500: 64 176 157; /* ⬅ #40b09d */
+ --color-primary-600: 58 158 141; /* ⬅ #3a9e8d */
+ --color-primary-700: 48 132 118; /* ⬅ #308476 */
+ --color-primary-800: 38 106 94; /* ⬅ #266a5e */
+ --color-primary-900: 31 86 77; /* ⬅ #1f564d */
+ /* secondary | #d7a12d */
+ --color-secondary-50: 249 241 224; /* ⬅ #f9f1e0 */
+ --color-secondary-100: 247 236 213; /* ⬅ #f7ecd5 */
+ --color-secondary-200: 245 232 203; /* ⬅ #f5e8cb */
+ --color-secondary-300: 239 217 171; /* ⬅ #efd9ab */
+ --color-secondary-400: 227 189 108; /* ⬅ #e3bd6c */
+ --color-secondary-500: 215 161 45; /* ⬅ #d7a12d */
+ --color-secondary-600: 194 145 41; /* ⬅ #c29129 */
+ --color-secondary-700: 161 121 34; /* ⬅ #a17922 */
+ --color-secondary-800: 129 97 27; /* ⬅ #81611b */
+ --color-secondary-900: 105 79 22; /* ⬅ #694f16 */
+ /* tertiary | #411d96 */
+ --color-tertiary-50: 227 221 239; /* ⬅ #e3ddef */
+ --color-tertiary-100: 217 210 234; /* ⬅ #d9d2ea */
+ --color-tertiary-200: 208 199 229; /* ⬅ #d0c7e5 */
+ --color-tertiary-300: 179 165 213; /* ⬅ #b3a5d5 */
+ --color-tertiary-400: 122 97 182; /* ⬅ #7a61b6 */
+ --color-tertiary-500: 65 29 150; /* ⬅ #411d96 */
+ --color-tertiary-600: 59 26 135; /* ⬅ #3b1a87 */
+ --color-tertiary-700: 49 22 113; /* ⬅ #311671 */
+ --color-tertiary-800: 39 17 90; /* ⬅ #27115a */
+ --color-tertiary-900: 32 14 74; /* ⬅ #200e4a */
+ /* success | #aad765 */
+ --color-success-50: 242 249 232; /* ⬅ #f2f9e8 */
+ --color-success-100: 238 247 224; /* ⬅ #eef7e0 */
+ --color-success-200: 234 245 217; /* ⬅ #eaf5d9 */
+ --color-success-300: 221 239 193; /* ⬅ #ddefc1 */
+ --color-success-400: 196 227 147; /* ⬅ #c4e393 */
+ --color-success-500: 170 215 101; /* ⬅ #aad765 */
+ --color-success-600: 153 194 91; /* ⬅ #99c25b */
+ --color-success-700: 128 161 76; /* ⬅ #80a14c */
+ --color-success-800: 102 129 61; /* ⬅ #66813d */
+ --color-success-900: 83 105 49; /* ⬅ #536931 */
+ /* warning | #e1ca84 */
+ --color-warning-50: 251 247 237; /* ⬅ #fbf7ed */
+ --color-warning-100: 249 244 230; /* ⬅ #f9f4e6 */
+ --color-warning-200: 248 242 224; /* ⬅ #f8f2e0 */
+ --color-warning-300: 243 234 206; /* ⬅ #f3eace */
+ --color-warning-400: 234 218 169; /* ⬅ #eadaa9 */
+ --color-warning-500: 225 202 132; /* ⬅ #e1ca84 */
+ --color-warning-600: 203 182 119; /* ⬅ #cbb677 */
+ --color-warning-700: 169 152 99; /* ⬅ #a99863 */
+ --color-warning-800: 135 121 79; /* ⬅ #87794f */
+ --color-warning-900: 110 99 65; /* ⬅ #6e6341 */
+ /* error | #e1565d */
+ --color-error-50: 251 230 231; /* ⬅ #fbe6e7 */
+ --color-error-100: 249 221 223; /* ⬅ #f9dddf */
+ --color-error-200: 248 213 215; /* ⬅ #f8d5d7 */
+ --color-error-300: 243 187 190; /* ⬅ #f3bbbe */
+ --color-error-400: 234 137 142; /* ⬅ #ea898e */
+ --color-error-500: 225 86 93; /* ⬅ #e1565d */
+ --color-error-600: 203 77 84; /* ⬅ #cb4d54 */
+ --color-error-700: 169 65 70; /* ⬅ #a94146 */
+ --color-error-800: 135 52 56; /* ⬅ #873438 */
+ --color-error-900: 110 42 46; /* ⬅ #6e2a2e */
+ /* surface | #0f233e */
+ --color-surface-50: 219 222 226; /* ⬅ #dbdee2 */
+ --color-surface-100: 207 211 216; /* ⬅ #cfd3d8 */
+ --color-surface-200: 195 200 207; /* ⬅ #c3c8cf */
+ --color-surface-300: 159 167 178; /* ⬅ #9fa7b2 */
+ --color-surface-400: 87 101 120; /* ⬅ #576578 */
+ --color-surface-500: 15 35 62; /* ⬅ #0f233e */
+ --color-surface-600: 14 32 56; /* ⬅ #0e2038 */
+ --color-surface-700: 11 26 47; /* ⬅ #0b1a2f */
+ --color-surface-800: 9 21 37; /* ⬅ #091525 */
+ --color-surface-900: 7 17 30; /* ⬅ #07111e */
+}
+
+/* Applied to body with `` */
+[data-theme='seasonal'] {
+ /* prettier-ignore */
+ background-image:
+ radial-gradient(at 22% 100%, hsla(39,68%,50%,0.23) 0px, transparent 50%),
+ radial-gradient(at 80% 100%, hsla(189,100%,56%,0.33) 0px, transparent 50%);
+}
+.dark [data-theme='seasonal'] {
+ /* prettier-ignore */
+ background-image:
+ radial-gradient(at 22% 0%, hsla(39,68%,50%,0.15) 0px, transparent 50%),
+ radial-gradient(at 80% 0%, hsla(189,100%,56%,0.15) 0px, transparent 50%);
+}
diff --git a/web/app/src/skeleton/themes/theme-skeleton.css b/web/app/src/skeleton/themes/theme-skeleton.css
new file mode 100644
index 0000000..ce8bc35
--- /dev/null
+++ b/web/app/src/skeleton/themes/theme-skeleton.css
@@ -0,0 +1,115 @@
+:root {
+ /* =~= Theme Properties =~= */
+ --theme-font-family-base: system-ui;
+ --theme-font-family-heading: system-ui;
+ --theme-font-color-base: 0 0 0;
+ --theme-font-color-dark: 255 255 255;
+ --theme-rounded-base: 9999px;
+ --theme-rounded-container: 8px;
+ --theme-border-base: 1px;
+ /* =~= Theme On-X Colors =~= */
+ --on-primary: 0 0 0;
+ --on-secondary: 255 255 255;
+ --on-tertiary: 0 0 0;
+ --on-success: 0 0 0;
+ --on-warning: 0 0 0;
+ --on-error: 255 255 255;
+ --on-surface: 255 255 255;
+ /* =~= Theme Colors =~= */
+ /* primary | #0FBA81 */
+ --color-primary-50: 219 245 236; /* ⬅ #dbf5ec */
+ --color-primary-100: 207 241 230; /* ⬅ #cff1e6 */
+ --color-primary-200: 195 238 224; /* ⬅ #c3eee0 */
+ --color-primary-300: 159 227 205; /* ⬅ #9fe3cd */
+ --color-primary-400: 87 207 167; /* ⬅ #57cfa7 */
+ --color-primary-500: 15 186 129; /* ⬅ #0FBA81 */
+ --color-primary-600: 14 167 116; /* ⬅ #0ea774 */
+ --color-primary-700: 11 140 97; /* ⬅ #0b8c61 */
+ --color-primary-800: 9 112 77; /* ⬅ #09704d */
+ --color-primary-900: 7 91 63; /* ⬅ #075b3f */
+ /* secondary | #4F46E5 */
+ --color-secondary-50: 229 227 251; /* ⬅ #e5e3fb */
+ --color-secondary-100: 220 218 250; /* ⬅ #dcdafa */
+ --color-secondary-200: 211 209 249; /* ⬅ #d3d1f9 */
+ --color-secondary-300: 185 181 245; /* ⬅ #b9b5f5 */
+ --color-secondary-400: 132 126 237; /* ⬅ #847eed */
+ --color-secondary-500: 79 70 229; /* ⬅ #4F46E5 */
+ --color-secondary-600: 71 63 206; /* ⬅ #473fce */
+ --color-secondary-700: 59 53 172; /* ⬅ #3b35ac */
+ --color-secondary-800: 47 42 137; /* ⬅ #2f2a89 */
+ --color-secondary-900: 39 34 112; /* ⬅ #272270 */
+ /* tertiary | #0EA5E9 */
+ --color-tertiary-50: 219 242 252; /* ⬅ #dbf2fc */
+ --color-tertiary-100: 207 237 251; /* ⬅ #cfedfb */
+ --color-tertiary-200: 195 233 250; /* ⬅ #c3e9fa */
+ --color-tertiary-300: 159 219 246; /* ⬅ #9fdbf6 */
+ --color-tertiary-400: 86 192 240; /* ⬅ #56c0f0 */
+ --color-tertiary-500: 14 165 233; /* ⬅ #0EA5E9 */
+ --color-tertiary-600: 13 149 210; /* ⬅ #0d95d2 */
+ --color-tertiary-700: 11 124 175; /* ⬅ #0b7caf */
+ --color-tertiary-800: 8 99 140; /* ⬅ #08638c */
+ --color-tertiary-900: 7 81 114; /* ⬅ #075172 */
+ /* success | #84cc16 */
+ --color-success-50: 237 247 220; /* ⬅ #edf7dc */
+ --color-success-100: 230 245 208; /* ⬅ #e6f5d0 */
+ --color-success-200: 224 242 197; /* ⬅ #e0f2c5 */
+ --color-success-300: 206 235 162; /* ⬅ #ceeba2 */
+ --color-success-400: 169 219 92; /* ⬅ #a9db5c */
+ --color-success-500: 132 204 22; /* ⬅ #84cc16 */
+ --color-success-600: 119 184 20; /* ⬅ #77b814 */
+ --color-success-700: 99 153 17; /* ⬅ #639911 */
+ --color-success-800: 79 122 13; /* ⬅ #4f7a0d */
+ --color-success-900: 65 100 11; /* ⬅ #41640b */
+ /* warning | #EAB308 */
+ --color-warning-50: 252 244 218; /* ⬅ #fcf4da */
+ --color-warning-100: 251 240 206; /* ⬅ #fbf0ce */
+ --color-warning-200: 250 236 193; /* ⬅ #faecc1 */
+ --color-warning-300: 247 225 156; /* ⬅ #f7e19c */
+ --color-warning-400: 240 202 82; /* ⬅ #f0ca52 */
+ --color-warning-500: 234 179 8; /* ⬅ #EAB308 */
+ --color-warning-600: 211 161 7; /* ⬅ #d3a107 */
+ --color-warning-700: 176 134 6; /* ⬅ #b08606 */
+ --color-warning-800: 140 107 5; /* ⬅ #8c6b05 */
+ --color-warning-900: 115 88 4; /* ⬅ #735804 */
+ /* error | #D41976 */
+ --color-error-50: 249 221 234; /* ⬅ #f9ddea */
+ --color-error-100: 246 209 228; /* ⬅ #f6d1e4 */
+ --color-error-200: 244 198 221; /* ⬅ #f4c6dd */
+ --color-error-300: 238 163 200; /* ⬅ #eea3c8 */
+ --color-error-400: 225 94 159; /* ⬅ #e15e9f */
+ --color-error-500: 212 25 118; /* ⬅ #D41976 */
+ --color-error-600: 191 23 106; /* ⬅ #bf176a */
+ --color-error-700: 159 19 89; /* ⬅ #9f1359 */
+ --color-error-800: 127 15 71; /* ⬅ #7f0f47 */
+ --color-error-900: 104 12 58; /* ⬅ #680c3a */
+ /* surface | #495a8f */
+ --color-surface-50: 228 230 238; /* ⬅ #e4e6ee */
+ --color-surface-100: 219 222 233; /* ⬅ #dbdee9 */
+ --color-surface-200: 210 214 227; /* ⬅ #d2d6e3 */
+ --color-surface-300: 182 189 210; /* ⬅ #b6bdd2 */
+ --color-surface-400: 128 140 177; /* ⬅ #808cb1 */
+ --color-surface-500: 73 90 143; /* ⬅ #495a8f */
+ --color-surface-600: 66 81 129; /* ⬅ #425181 */
+ --color-surface-700: 55 68 107; /* ⬅ #37446b */
+ --color-surface-800: 44 54 86; /* ⬅ #2c3656 */
+ --color-surface-900: 36 44 70; /* ⬅ #242c46 */
+}
+
+/* Headings */
+[data-theme='skeleton'] h1,
+[data-theme='skeleton'] h2,
+[data-theme='skeleton'] h3,
+[data-theme='skeleton'] h4,
+[data-theme='skeleton'] h5,
+[data-theme='skeleton'] h6 {
+ font-weight: bold;
+}
+
+/* Applied to body with `` */
+/* Created with: https://csshero.org/mesher/ */
+/* prettier-ignore */
+[data-theme='skeleton'] {
+ background-image:
+ radial-gradient(at 0% 0%, rgba(var(--color-secondary-500) / 0.33) 0px, transparent 50%),
+ radial-gradient(at 98% 1%, rgba(var(--color-error-500) / 0.33) 0px, transparent 50%);
+}
diff --git a/web/app/src/skeleton/themes/theme-test.css b/web/app/src/skeleton/themes/theme-test.css
new file mode 100644
index 0000000..0566566
--- /dev/null
+++ b/web/app/src/skeleton/themes/theme-test.css
@@ -0,0 +1,96 @@
+:root {
+ /* =~= Theme Properties =~= */
+ --theme-font-family-base: system-ui;
+ --theme-font-family-heading: system-ui;
+ --theme-font-color-base: 0 0 0;
+ --theme-font-color-dark: 255 255 255;
+ --theme-rounded-base: 9999px;
+ --theme-rounded-container: 8px;
+ --theme-border-base: 1px;
+ /* =~= Theme On-X Colors =~= */
+ --on-primary: 0 0 0;
+ --on-secondary: 255 255 255;
+ --on-tertiary: 0 0 0;
+ --on-success: 0 0 0;
+ --on-warning: 0 0 0;
+ --on-error: 255 255 255;
+ --on-surface: 255 255 255;
+ /* =~= Theme Colors =~= */
+ /* primary | #0FBA81 */
+ --color-primary-50: 219 245 236; /* ⬅ #dbf5ec */
+ --color-primary-100: 207 241 230; /* ⬅ #cff1e6 */
+ --color-primary-200: 195 238 224; /* ⬅ #c3eee0 */
+ --color-primary-300: 159 227 205; /* ⬅ #9fe3cd */
+ --color-primary-400: 87 207 167; /* ⬅ #57cfa7 */
+ --color-primary-500: 15 186 129; /* ⬅ #0FBA81 */
+ --color-primary-600: 14 167 116; /* ⬅ #0ea774 */
+ --color-primary-700: 11 140 97; /* ⬅ #0b8c61 */
+ --color-primary-800: 9 112 77; /* ⬅ #09704d */
+ --color-primary-900: 7 91 63; /* ⬅ #075b3f */
+ /* secondary | #4F46E5 */
+ --color-secondary-50: 229 227 251; /* ⬅ #e5e3fb */
+ --color-secondary-100: 220 218 250; /* ⬅ #dcdafa */
+ --color-secondary-200: 211 209 249; /* ⬅ #d3d1f9 */
+ --color-secondary-300: 185 181 245; /* ⬅ #b9b5f5 */
+ --color-secondary-400: 132 126 237; /* ⬅ #847eed */
+ --color-secondary-500: 79 70 229; /* ⬅ #4F46E5 */
+ --color-secondary-600: 71 63 206; /* ⬅ #473fce */
+ --color-secondary-700: 59 53 172; /* ⬅ #3b35ac */
+ --color-secondary-800: 47 42 137; /* ⬅ #2f2a89 */
+ --color-secondary-900: 39 34 112; /* ⬅ #272270 */
+ /* tertiary | #0EA5E9 */
+ --color-tertiary-50: 219 242 252; /* ⬅ #dbf2fc */
+ --color-tertiary-100: 207 237 251; /* ⬅ #cfedfb */
+ --color-tertiary-200: 195 233 250; /* ⬅ #c3e9fa */
+ --color-tertiary-300: 159 219 246; /* ⬅ #9fdbf6 */
+ --color-tertiary-400: 86 192 240; /* ⬅ #56c0f0 */
+ --color-tertiary-500: 14 165 233; /* ⬅ #0EA5E9 */
+ --color-tertiary-600: 13 149 210; /* ⬅ #0d95d2 */
+ --color-tertiary-700: 11 124 175; /* ⬅ #0b7caf */
+ --color-tertiary-800: 8 99 140; /* ⬅ #08638c */
+ --color-tertiary-900: 7 81 114; /* ⬅ #075172 */
+ /* success | #84cc16 */
+ --color-success-50: 237 247 220; /* ⬅ #edf7dc */
+ --color-success-100: 230 245 208; /* ⬅ #e6f5d0 */
+ --color-success-200: 224 242 197; /* ⬅ #e0f2c5 */
+ --color-success-300: 206 235 162; /* ⬅ #ceeba2 */
+ --color-success-400: 169 219 92; /* ⬅ #a9db5c */
+ --color-success-500: 132 204 22; /* ⬅ #84cc16 */
+ --color-success-600: 119 184 20; /* ⬅ #77b814 */
+ --color-success-700: 99 153 17; /* ⬅ #639911 */
+ --color-success-800: 79 122 13; /* ⬅ #4f7a0d */
+ --color-success-900: 65 100 11; /* ⬅ #41640b */
+ /* warning | #EAB308 */
+ --color-warning-50: 252 244 218; /* ⬅ #fcf4da */
+ --color-warning-100: 251 240 206; /* ⬅ #fbf0ce */
+ --color-warning-200: 250 236 193; /* ⬅ #faecc1 */
+ --color-warning-300: 247 225 156; /* ⬅ #f7e19c */
+ --color-warning-400: 240 202 82; /* ⬅ #f0ca52 */
+ --color-warning-500: 234 179 8; /* ⬅ #EAB308 */
+ --color-warning-600: 211 161 7; /* ⬅ #d3a107 */
+ --color-warning-700: 176 134 6; /* ⬅ #b08606 */
+ --color-warning-800: 140 107 5; /* ⬅ #8c6b05 */
+ --color-warning-900: 115 88 4; /* ⬅ #735804 */
+ /* error | #D41976 */
+ --color-error-50: 249 221 234; /* ⬅ #f9ddea */
+ --color-error-100: 246 209 228; /* ⬅ #f6d1e4 */
+ --color-error-200: 244 198 221; /* ⬅ #f4c6dd */
+ --color-error-300: 238 163 200; /* ⬅ #eea3c8 */
+ --color-error-400: 225 94 159; /* ⬅ #e15e9f */
+ --color-error-500: 212 25 118; /* ⬅ #D41976 */
+ --color-error-600: 191 23 106; /* ⬅ #bf176a */
+ --color-error-700: 159 19 89; /* ⬅ #9f1359 */
+ --color-error-800: 127 15 71; /* ⬅ #7f0f47 */
+ --color-error-900: 104 12 58; /* ⬅ #680c3a */
+ /* surface | #495a8f */
+ --color-surface-50: 228 230 238; /* ⬅ #e4e6ee */
+ --color-surface-100: 219 222 233; /* ⬅ #dbdee9 */
+ --color-surface-200: 210 214 227; /* ⬅ #d2d6e3 */
+ --color-surface-300: 182 189 210; /* ⬅ #b6bdd2 */
+ --color-surface-400: 128 140 177; /* ⬅ #808cb1 */
+ --color-surface-500: 73 90 143; /* ⬅ #495a8f */
+ --color-surface-600: 66 81 129; /* ⬅ #425181 */
+ --color-surface-700: 55 68 107; /* ⬅ #37446b */
+ --color-surface-800: 44 54 86; /* ⬅ #2c3656 */
+ --color-surface-900: 36 44 70; /* ⬅ #242c46 */
+}
diff --git a/web/app/src/skeleton/themes/theme-vintage.css b/web/app/src/skeleton/themes/theme-vintage.css
new file mode 100644
index 0000000..0245377
--- /dev/null
+++ b/web/app/src/skeleton/themes/theme-vintage.css
@@ -0,0 +1,124 @@
+/* https://fonts.google.com/specimen/Abril+Fatface?query=Abril+Fatface¬o.query=Abril */
+
+:root {
+ /* =~= Theme Styles =~= */
+ --theme-font-family-base: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
+ 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
+ 'Noto Color Emoji';
+ --theme-font-family-heading: 'Abril Fatface', cursive;
+ --theme-font-color-base: var(--color-primary-900);
+ --theme-font-color-dark: var(--color-primary-100);
+ --theme-rounded-base: 2px;
+ --theme-rounded-container: 4px;
+ --theme-border-base: 1px;
+ /* =~= Theme On-X Colors =~= */
+ --on-primary: 0 0 0;
+ --on-secondary: 0 0 0;
+ --on-tertiary: 0 0 0;
+ --on-success: 0 0 0;
+ --on-warning: 0 0 0;
+ --on-error: 0 0 0;
+ --on-surface: 255 255 255;
+ /* =~= Theme Colors =~= */
+ /* primary | #ea861a */
+ --color-primary-50: 252 237 221; /* ⬅ #fceddd */
+ --color-primary-100: 251 231 209; /* ⬅ #fbe7d1 */
+ --color-primary-200: 250 225 198; /* ⬅ #fae1c6 */
+ --color-primary-300: 247 207 163; /* ⬅ #f7cfa3 */
+ --color-primary-400: 240 170 95; /* ⬅ #f0aa5f */
+ --color-primary-500: 234 134 26; /* ⬅ #ea861a */
+ --color-primary-600: 211 121 23; /* ⬅ #d37917 */
+ --color-primary-700: 176 101 20; /* ⬅ #b06514 */
+ --color-primary-800: 140 80 16; /* ⬅ #8c5010 */
+ --color-primary-900: 115 66 13; /* ⬅ #73420d */
+ /* secondary | #97cea5 */
+ --color-secondary-50: 239 248 242; /* ⬅ #eff8f2 */
+ --color-secondary-100: 234 245 237; /* ⬅ #eaf5ed */
+ --color-secondary-200: 229 243 233; /* ⬅ #e5f3e9 */
+ --color-secondary-300: 213 235 219; /* ⬅ #d5ebdb */
+ --color-secondary-400: 182 221 192; /* ⬅ #b6ddc0 */
+ --color-secondary-500: 151 206 165; /* ⬅ #97cea5 */
+ --color-secondary-600: 136 185 149; /* ⬅ #88b995 */
+ --color-secondary-700: 113 155 124; /* ⬅ #719b7c */
+ --color-secondary-800: 91 124 99; /* ⬅ #5b7c63 */
+ --color-secondary-900: 74 101 81; /* ⬅ #4a6551 */
+ /* tertiary | #06b6d4 */
+ --color-tertiary-50: 218 244 249; /* ⬅ #daf4f9 */
+ --color-tertiary-100: 205 240 246; /* ⬅ #cdf0f6 */
+ --color-tertiary-200: 193 237 244; /* ⬅ #c1edf4 */
+ --color-tertiary-300: 155 226 238; /* ⬅ #9be2ee */
+ --color-tertiary-400: 81 204 225; /* ⬅ #51cce1 */
+ --color-tertiary-500: 6 182 212; /* ⬅ #06b6d4 */
+ --color-tertiary-600: 5 164 191; /* ⬅ #05a4bf */
+ --color-tertiary-700: 5 137 159; /* ⬅ #05899f */
+ --color-tertiary-800: 4 109 127; /* ⬅ #046d7f */
+ --color-tertiary-900: 3 89 104; /* ⬅ #035968 */
+ /* success | #84cb5d */
+ --color-success-50: 237 247 231; /* ⬅ #edf7e7 */
+ --color-success-100: 230 245 223; /* ⬅ #e6f5df */
+ --color-success-200: 224 242 215; /* ⬅ #e0f2d7 */
+ --color-success-300: 206 234 190; /* ⬅ #ceeabe */
+ --color-success-400: 169 219 142; /* ⬅ #a9db8e */
+ --color-success-500: 132 203 93; /* ⬅ #84cb5d */
+ --color-success-600: 119 183 84; /* ⬅ #77b754 */
+ --color-success-700: 99 152 70; /* ⬅ #639846 */
+ --color-success-800: 79 122 56; /* ⬅ #4f7a38 */
+ --color-success-900: 65 99 46; /* ⬅ #41632e */
+ /* warning | #f2ac23 */
+ --color-warning-50: 253 243 222; /* ⬅ #fdf3de */
+ --color-warning-100: 252 238 211; /* ⬅ #fceed3 */
+ --color-warning-200: 252 234 200; /* ⬅ #fceac8 */
+ --color-warning-300: 250 222 167; /* ⬅ #fadea7 */
+ --color-warning-400: 246 197 101; /* ⬅ #f6c565 */
+ --color-warning-500: 242 172 35; /* ⬅ #f2ac23 */
+ --color-warning-600: 218 155 32; /* ⬅ #da9b20 */
+ --color-warning-700: 182 129 26; /* ⬅ #b6811a */
+ --color-warning-800: 145 103 21; /* ⬅ #916715 */
+ --color-warning-900: 119 84 17; /* ⬅ #775411 */
+ /* error | #d57e78 */
+ --color-error-50: 249 236 235; /* ⬅ #f9eceb */
+ --color-error-100: 247 229 228; /* ⬅ #f7e5e4 */
+ --color-error-200: 245 223 221; /* ⬅ #f5dfdd */
+ --color-error-300: 238 203 201; /* ⬅ #eecbc9 */
+ --color-error-400: 226 165 161; /* ⬅ #e2a5a1 */
+ --color-error-500: 213 126 120; /* ⬅ #d57e78 */
+ --color-error-600: 192 113 108; /* ⬅ #c0716c */
+ --color-error-700: 160 95 90; /* ⬅ #a05f5a */
+ --color-error-800: 128 76 72; /* ⬅ #804c48 */
+ --color-error-900: 104 62 59; /* ⬅ #683e3b */
+ /* surface | #3f3731 */
+ --color-surface-50: 226 225 224; /* ⬅ #e2e1e0 */
+ --color-surface-100: 217 215 214; /* ⬅ #d9d7d6 */
+ --color-surface-200: 207 205 204; /* ⬅ #cfcdcc */
+ --color-surface-300: 178 175 173; /* ⬅ #b2afad */
+ --color-surface-400: 121 115 111; /* ⬅ #79736f */
+ --color-surface-500: 63 55 49; /* ⬅ #3f3731 */
+ --color-surface-600: 57 50 44; /* ⬅ #39322c */
+ --color-surface-700: 47 41 37; /* ⬅ #2f2925 */
+ --color-surface-800: 38 33 29; /* ⬅ #26211d */
+ --color-surface-900: 31 27 24; /* ⬅ #1f1b18 */
+}
+
+[data-theme='vintage'] h1,
+[data-theme='vintage'] h2,
+[data-theme='vintage'] h3,
+[data-theme='vintage'] h4,
+[data-theme='vintage'] h5,
+[data-theme='vintage'] h6 {
+ letter-spacing: 1px;
+}
+
+/* Applied to body with `` */
+/* Created with: https://csshero.org/mesher/ */
+[data-theme='vintage'] {
+ /* prettier-ignore */
+ background-image:
+ radial-gradient(at 100% 0%, hsla(135,34%,70%,0.20) 0px, transparent 50%),
+ radial-gradient(at 85% 100%, hsla(31,83%,50%,0.20) 0px, transparent 50%);
+}
+.dark [data-theme='vintage'] {
+ /* prettier-ignore */
+ background-image:
+ radial-gradient(at 100% 0%, hsla(135,34%,70%,0.14) 0px, transparent 50%),
+ radial-gradient(at 85% 100%, hsla(31,83%,50%,0.14) 0px, transparent 50%);
+}
diff --git a/web/app/src/skeleton/types/index.d.ts b/web/app/src/skeleton/types/index.d.ts
new file mode 100644
index 0000000..36daa26
--- /dev/null
+++ b/web/app/src/skeleton/types/index.d.ts
@@ -0,0 +1 @@
+export * from './tailwind';
diff --git a/web/app/src/skeleton/types/index.js b/web/app/src/skeleton/types/index.js
new file mode 100644
index 0000000..36daa26
--- /dev/null
+++ b/web/app/src/skeleton/types/index.js
@@ -0,0 +1 @@
+export * from './tailwind';
diff --git a/web/app/src/skeleton/types/tailwind.d.ts b/web/app/src/skeleton/types/tailwind.d.ts
new file mode 100644
index 0000000..92a608d
--- /dev/null
+++ b/web/app/src/skeleton/types/tailwind.d.ts
@@ -0,0 +1,12 @@
+export declare const tailwindNumbers: readonly ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'];
+export type TailwindNumbers = (typeof tailwindNumbers)[number];
+export declare const semanticNames: readonly [
+ 'primary',
+ 'secondary',
+ 'tertiary',
+ 'success',
+ 'warning',
+ 'error',
+ 'surface'
+];
+export type SemanticNames = (typeof semanticNames)[number];
diff --git a/web/app/src/skeleton/types/tailwind.js b/web/app/src/skeleton/types/tailwind.js
new file mode 100644
index 0000000..c76b915
--- /dev/null
+++ b/web/app/src/skeleton/types/tailwind.js
@@ -0,0 +1,2 @@
+export const tailwindNumbers = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'];
+export const semanticNames = ['primary', 'secondary', 'tertiary', 'success', 'warning', 'error', 'surface'];
diff --git a/web/app/src/skeleton/utilities/CodeBlock/CodeBlock.svelte b/web/app/src/skeleton/utilities/CodeBlock/CodeBlock.svelte
new file mode 100644
index 0000000..655b689
--- /dev/null
+++ b/web/app/src/skeleton/utilities/CodeBlock/CodeBlock.svelte
@@ -0,0 +1,57 @@
+
+
+
+{#if language && code}
+
+
+
+
+
{#if formatted}{@html displayCode}{:else}{code.trim()}{/if}
+
+{/if}
diff --git a/web/app/src/skeleton/utilities/CodeBlock/CodeBlock.svelte.d.ts b/web/app/src/skeleton/utilities/CodeBlock/CodeBlock.svelte.d.ts
new file mode 100644
index 0000000..45ac5be
--- /dev/null
+++ b/web/app/src/skeleton/utilities/CodeBlock/CodeBlock.svelte.d.ts
@@ -0,0 +1,40 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Sets a language alias for Highlight.js syntax highlighting.*/
+ language?: string | undefined;
+ /** Provide the code snippet to render. Be mindful to escape as needed!*/
+ code?: string | undefined;
+ /** Provide classes to set the background color.*/
+ background?: string | undefined;
+ /** Provided classes to set the backdrop blur.*/
+ blur?: string | undefined;
+ /** Provide classes to set the text size.*/
+ text?: string | undefined;
+ /** Provide classes to set the text color.*/
+ color?: string | undefined;
+ /** Provide classes to set the border radius.*/
+ rounded?: string | undefined;
+ /** Provide classes to set the box shadow.*/
+ shadow?: string | undefined;
+ /** Provide classes to set the button styles.*/
+ button?: string | undefined;
+ /** Provide the button label text.*/
+ buttonLabel?: string | undefined;
+ /** Provide the button label text when copied.*/
+ buttonCopied?: string | undefined;
+ };
+ events: {
+ /** {{}} copy - Fires when the Copy button is pressed.*/
+ copy: CustomEvent;
+ } & {
+ [evt: string]: CustomEvent;
+ };
+ slots: {};
+};
+export type CodeBlockProps = typeof __propDef.props;
+export type CodeBlockEvents = typeof __propDef.events;
+export type CodeBlockSlots = typeof __propDef.slots;
+export default class CodeBlock extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/utilities/CodeBlock/CodeBlock.test.d.ts b/web/app/src/skeleton/utilities/CodeBlock/CodeBlock.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/utilities/CodeBlock/CodeBlock.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/utilities/CodeBlock/CodeBlock.test.js b/web/app/src/skeleton/utilities/CodeBlock/CodeBlock.test.js
new file mode 100644
index 0000000..6505951
--- /dev/null
+++ b/web/app/src/skeleton/utilities/CodeBlock/CodeBlock.test.js
@@ -0,0 +1,24 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import CodeBlock from './CodeBlock.svelte';
+describe('CodeBlock.svelte', () => {
+ it('Renders with minimal props', async () => {
+ const { getByTestId } = render(CodeBlock, {
+ props: {
+ language: 'html',
+ code: 'Hello World
'
+ }
+ });
+ expect(getByTestId('code-block')).toBeTruthy();
+ });
+ it('Renders with all props', async () => {
+ const { getByTestId } = render(CodeBlock, {
+ props: {
+ language: 'js',
+ code: `Test
`,
+ background: 'bg-slate-800'
+ }
+ });
+ expect(getByTestId('code-block')).toBeTruthy();
+ });
+});
diff --git a/web/app/src/skeleton/utilities/CodeBlock/stores.d.ts b/web/app/src/skeleton/utilities/CodeBlock/stores.d.ts
new file mode 100644
index 0000000..32446b5
--- /dev/null
+++ b/web/app/src/skeleton/utilities/CodeBlock/stores.d.ts
@@ -0,0 +1,2 @@
+import { type Writable } from 'svelte/store';
+export declare const storeHighlightJs: Writable;
diff --git a/web/app/src/skeleton/utilities/CodeBlock/stores.js b/web/app/src/skeleton/utilities/CodeBlock/stores.js
new file mode 100644
index 0000000..b6a0d4c
--- /dev/null
+++ b/web/app/src/skeleton/utilities/CodeBlock/stores.js
@@ -0,0 +1,3 @@
+import { writable } from 'svelte/store';
+export const storeHighlightJs = writable(undefined);
+// TODO: add support for other highlighters here in the future
diff --git a/web/app/src/skeleton/utilities/DataTable/DataTable.d.ts b/web/app/src/skeleton/utilities/DataTable/DataTable.d.ts
new file mode 100644
index 0000000..7577c6a
--- /dev/null
+++ b/web/app/src/skeleton/utilities/DataTable/DataTable.d.ts
@@ -0,0 +1,25 @@
+import type { DataTableModel, DataTableOptions } from './types';
+export * from './types';
+export * from './actions';
+/** Creates the writeable store for the data table */
+export declare function createDataTableStore>(
+ source: T[],
+ options?: DataTableOptions
+): {
+ subscribe: (
+ this: void,
+ run: import('svelte/store').Subscriber>,
+ invalidate?: ((value?: DataTableModel | undefined) => void) | undefined
+ ) => import('svelte/store').Unsubscriber;
+ set: (this: void, value: DataTableModel) => void;
+ /** Sets a new data source while maintaining the state of the original source */
+ updateSource: (data: T[]) => void;
+ /** Triggered by the "select all" checkbox to toggle all row selection. */
+ selectAll: (checked: boolean) => void;
+ /** Allows you to dynamically pre-select rows on-demand. */
+ select: (key: keyof T, valuesArr: unknown[]) => void;
+ /** Listens for clicks to a table heading with `data-sort` attribute. Updates `$dataTableModel.sort`. */
+ sort: (event: Event) => void;
+};
+/** Listens for changes to `$dataTableModel` and triggers: search, selection, sort, and pagination. */
+export declare function dataTableHandler>(model: DataTableModel): void;
diff --git a/web/app/src/skeleton/utilities/DataTable/DataTable.js b/web/app/src/skeleton/utilities/DataTable/DataTable.js
new file mode 100644
index 0000000..aaa8487
--- /dev/null
+++ b/web/app/src/skeleton/utilities/DataTable/DataTable.js
@@ -0,0 +1,132 @@
+// Data Table Utilities
+// A set of utility features for local template-driven data tables.
+import { writable } from 'svelte/store';
+// Exports
+export * from './types';
+export * from './actions';
+/** Creates the writeable store for the data table */
+export function createDataTableStore(source, options = {}) {
+ // Creates a new source that also adds the `dataTableChecked` property to each row object
+ const modifiedList = source.map((rowObj) => ({ ...rowObj, dataTableChecked: false }));
+ // Generate the writable
+ const { subscribe, set, update } = writable({
+ source,
+ base: modifiedList,
+ filtered: modifiedList,
+ sortState: { lastKey: '', asc: true },
+ selection: [],
+ search: options.search ?? '',
+ sort: options.sort ?? '',
+ pagination: options.pagination
+ });
+ return {
+ subscribe,
+ set,
+ /** Sets a new data source while maintaining the state of the original source */
+ updateSource: (data) =>
+ update((model) => {
+ model.source = data;
+ model.base = data.map((row, i) => {
+ return { ...row, dataTableChecked: model.base[i]?.dataTableChecked ?? false };
+ });
+ return { ...model, filtered: model.base };
+ }),
+ /** Triggered by the "select all" checkbox to toggle all row selection. */
+ selectAll: (checked) => {
+ update((model) => {
+ // checks/unchecks all of the rows
+ model.base.forEach((row) => {
+ row.dataTableChecked = checked;
+ return row;
+ });
+ return model;
+ });
+ },
+ /** Allows you to dynamically pre-select rows on-demand. */
+ select: (key, valuesArr) => {
+ update((model) => {
+ model.filtered.map((row) => {
+ if (valuesArr.includes(row[key])) row.dataTableChecked = true;
+ return row;
+ });
+ return model;
+ });
+ },
+ /** Listens for clicks to a table heading with `data-sort` attribute. Updates `$dataTableModel.sort`. */
+ sort: (event) => {
+ update((model) => {
+ if (!(event.target instanceof Element)) return model;
+ const newSortKey = event.target.getAttribute('data-sort');
+ // If same key used repeated, toggle asc/dsc order
+ if (newSortKey !== '' && newSortKey === model.sortState.lastKey) model.sortState.asc = !model.sortState.asc;
+ // Cache the last key used
+ model.sortState.lastKey = newSortKey;
+ // Update sort key
+ model.sort = newSortKey ?? '';
+ return model;
+ });
+ }
+ };
+}
+// Data Table Handler
+/** Listens for changes to `$dataTableModel` and triggers: search, selection, sort, and pagination. */
+export function dataTableHandler(model) {
+ searchHandler(model);
+ selectionHandler(model);
+ sortHandler(model);
+ paginationHandler(model);
+}
+// Search ---
+function searchHandler(store) {
+ store.filtered = store.base.filter((rowObj) => {
+ const formattedSearchTerm = store.search?.toLowerCase() || '';
+ return Object.values(rowObj).join(' ').toLowerCase().includes(formattedSearchTerm);
+ });
+}
+// Selection ---
+function selectionHandler(store) {
+ store.selection = store.base.filter((row) => row.dataTableChecked === true); // ? should this be filtered by source or filter?
+}
+// Sort ---
+function sortHandler(store) {
+ if (!store.sort) return;
+ // Sort order based on current sortState.asc value
+ store.sortState.asc ? sortOrder('asc', store) : sortOrder('dsc', store);
+}
+function sortOrder(order, store) {
+ const key = store.sort;
+ store.filtered = store.base.sort((x, y) => {
+ // If descending, swap x/y
+ if (order === 'dsc') [x, y] = [y, x];
+ // Sort logic
+ if (typeof x[key] === 'string' && typeof y[key] === 'string') {
+ return String(x[key]).localeCompare(String(y[key]));
+ } else {
+ const a = x[key];
+ const b = y[key];
+ return a < b ? -1 : a > b ? 1 : 0;
+ }
+ });
+}
+// Pagination ---
+function paginationHandler(store) {
+ if (store.pagination) {
+ // Slice for Pagination
+ const filtered = store.base.slice(
+ store.pagination.offset * store.pagination.limit, // start
+ store.pagination.offset * store.pagination.limit + store.pagination.limit // end
+ );
+ // filter by search if currently searching
+ if (store.search !== '') {
+ store.filtered = store.filtered.slice(0, store.pagination.limit);
+ // Set Current Size
+ store.pagination.size = store.filtered.length;
+ // Set the current page to the first page
+ store.pagination.offset = 0;
+ } else {
+ store.filtered = filtered;
+ // Set Current Size
+ store.pagination.size = store.base.length;
+ }
+ }
+}
diff --git a/web/app/src/skeleton/utilities/DataTable/actions.d.ts b/web/app/src/skeleton/utilities/DataTable/actions.d.ts
new file mode 100644
index 0000000..a66591a
--- /dev/null
+++ b/web/app/src/skeleton/utilities/DataTable/actions.d.ts
@@ -0,0 +1,8 @@
+/** Svelte Action for applying sort asc/dsc classes. */
+export declare function tableInteraction(node: HTMLElement): {
+ destroy(): void;
+};
+/** Svelte Action for handling table a11y keyboard interactions. */
+export declare function tableA11y(node: HTMLElement): {
+ destroy(): void;
+};
diff --git a/web/app/src/skeleton/utilities/DataTable/actions.js b/web/app/src/skeleton/utilities/DataTable/actions.js
new file mode 100644
index 0000000..10e59b5
--- /dev/null
+++ b/web/app/src/skeleton/utilities/DataTable/actions.js
@@ -0,0 +1,112 @@
+// Shared Data/Table Actions
+// Data Table (only) ---
+/** Svelte Action for applying sort asc/dsc classes. */
+export function tableInteraction(node) {
+ const classAsc = 'table-sort-asc';
+ const classDsc = 'table-sort-dsc';
+ // Click Handler
+ const onClick = (e) => {
+ if (!(e.target instanceof Element)) return;
+ const sortTarget = e.target;
+ // Get target state before modification
+ const targetAscSorted = sortTarget.classList.contains(classAsc);
+ const sortTargetKey = sortTarget.getAttribute('data-sort');
+ // Clear asc class
+ const elemAsc = node.querySelector(`.${classAsc}`);
+ if (elemAsc) elemAsc.classList.remove(classAsc);
+ // Clear dsc class
+ const elemDsc = node.querySelector(`.${classDsc}`);
+ if (elemDsc) elemDsc.classList.remove(classDsc);
+ // Set new sort class
+ if (sortTargetKey) {
+ const classToApply = targetAscSorted ? classDsc : classAsc;
+ e.target.classList.add(classToApply);
+ }
+ };
+ // Events
+ node.addEventListener('click', onClick);
+ // Lifecycle
+ return {
+ destroy() {
+ node.removeEventListener('click', onClick);
+ }
+ };
+}
+// Shared ---
+/** Svelte Action for handling table a11y keyboard interactions. */
+export function tableA11y(node) {
+ const keyWhitelist = ['ArrowRight', 'ArrowUp', 'ArrowLeft', 'ArrowDown', 'Home', 'End'];
+ // on:keydown
+ const onKeyDown = (event) => {
+ // console.log('keydown triggered');
+ if (keyWhitelist.includes(event.code)) {
+ event.preventDefault();
+ // prettier-ignore
+ switch (event.code) {
+ case 'ArrowUp':
+ a11ySetActiveCell(node, 0, -1);
+ break;
+ case 'ArrowDown':
+ a11ySetActiveCell(node, 0, 1);
+ break;
+ case 'ArrowLeft':
+ a11ySetActiveCell(node, -1, 0);
+ break;
+ case 'ArrowRight':
+ a11ySetActiveCell(node, 1, 0);
+ break;
+ case 'Home':
+ a11yJumpToOuterColumn(node, 'first');
+ break;
+ case 'End':
+ a11yJumpToOuterColumn(node, 'last');
+ break;
+ default: break;
+ }
+ }
+ };
+ // Event Listener
+ node.addEventListener('keydown', onKeyDown);
+ // Lifecycle
+ return {
+ destroy() {
+ node.removeEventListener('keydown', onKeyDown);
+ }
+ };
+}
+function a11ySetActiveCell(node, x, y) {
+ // Focused Element
+ const focusedElem = document.activeElement;
+ if (
+ !focusedElem ||
+ !focusedElem.parentElement ||
+ !focusedElem.parentElement.ariaRowIndex ||
+ !focusedElem.ariaColIndex
+ )
+ return;
+ const focusedElemRowIndex = parseInt(focusedElem.parentElement.ariaRowIndex);
+ const focusedElemColIndex = parseInt(focusedElem.ariaColIndex);
+ // Target Element
+ const targetRowElement = node.querySelector(`[aria-rowindex="${focusedElemRowIndex + y}"]`);
+ if (targetRowElement !== null) {
+ const targetColElement = targetRowElement.querySelector(`[aria-colindex="${focusedElemColIndex + x}"]`);
+ if (targetColElement !== null) targetColElement.focus();
+ }
+}
+function a11yGetTargetElem(node) {
+ // Focused Element
+ const focusedElem = document.activeElement;
+ if (!focusedElem || !focusedElem.parentElement || !focusedElem.parentElement.ariaRowIndex) return null;
+ const focusedElemRowIndex = parseInt(focusedElem.parentElement.ariaRowIndex);
+ // Return Target Element
+ return node.querySelector(`[aria-rowindex="${focusedElemRowIndex}"]`);
+}
+function a11yJumpToOuterColumn(node, type = 'first') {
+ const targetRowElement = a11yGetTargetElem(node);
+ if (targetRowElement === null) return;
+ const lastIndex = targetRowElement.children.length;
+ const selected = type === 'first' ? 1 : lastIndex;
+ const targetColElement = targetRowElement.querySelector(`[aria-colindex="${selected}"]`);
+ if (targetColElement === null) return;
+ targetColElement.focus();
+}
diff --git a/web/app/src/skeleton/utilities/DataTable/types.d.ts b/web/app/src/skeleton/utilities/DataTable/types.d.ts
new file mode 100644
index 0000000..0fb700d
--- /dev/null
+++ b/web/app/src/skeleton/utilities/DataTable/types.d.ts
@@ -0,0 +1,33 @@
+import type { PaginationSettings } from '../../components/Paginator/types';
+export interface DataTableModel> {
+ /** The original source data. */
+ source: T[];
+ /** The unfiltered, modified source data */
+ base: Data;
+ /** The filtered source data, shown in UI. */
+ filtered: Data;
+ /** An array of selected row objects. */
+ selection: Data;
+ /** The current search term. */
+ search: string;
+ /** The current sort key. */
+ sort: keyof T | '';
+ /** The current state of the sort key. */
+ sortState: {
+ lastKey: keyof T | '' | null;
+ asc: boolean;
+ };
+ /** The Paginator component settings. */
+ pagination?: PaginationSettings;
+}
+export interface DataTableOptions {
+ /** The current search term. */
+ search?: string;
+ /** The current sort key. */
+ sort?: keyof T | '';
+ /** The Paginator component settings. */
+ pagination?: PaginationSettings;
+}
+export type Data = (T & {
+ dataTableChecked: boolean;
+})[];
diff --git a/web/app/src/skeleton/utilities/DataTable/types.js b/web/app/src/skeleton/utilities/DataTable/types.js
new file mode 100644
index 0000000..657e00f
--- /dev/null
+++ b/web/app/src/skeleton/utilities/DataTable/types.js
@@ -0,0 +1,2 @@
+// Data Table Types
+export {};
diff --git a/web/app/src/skeleton/utilities/Drawer/Drawer.svelte b/web/app/src/skeleton/utilities/Drawer/Drawer.svelte
new file mode 100644
index 0000000..7acf6d7
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Drawer/Drawer.svelte
@@ -0,0 +1,160 @@
+
+
+
+
+{#if $drawerStore.open === true}
+
+
+{/if}
diff --git a/web/app/src/skeleton/utilities/Drawer/Drawer.svelte.d.ts b/web/app/src/skeleton/utilities/Drawer/Drawer.svelte.d.ts
new file mode 100644
index 0000000..01b0ddf
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Drawer/Drawer.svelte.d.ts
@@ -0,0 +1,53 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Set the anchor position.*/
+ position?: 'left' | 'top' | 'right' | 'bottom' | undefined;
+ /** Define the Svelte transition animation duration.*/
+ duration?: number | undefined;
+ /** Backdrop - Provide classes to set the backdrop background color*/
+ bgBackdrop?: string | undefined;
+ /** Backdrop - Provide classes to set the blur style.*/
+ blur?: string | undefined;
+ /** Drawer - Provide classes to set padding.*/
+ padding?: string | undefined;
+ /** Drawer - Provide classes to set the drawer background color.*/
+ bgDrawer?: string | undefined;
+ /** Drawer - Provide classes to set border color.*/
+ border?: string | undefined;
+ /** Drawer - Provide classes to set border radius.*/
+ rounded?: string | undefined;
+ /** Drawer - Provide classes to set the box shadow.*/
+ shadow?: string | undefined;
+ /** Drawer - Provide classes to override the width.*/
+ width?: string | undefined;
+ /** Drawer - Provide classes to override the height.*/
+ height?: string | undefined;
+ /** Provide a class to override the z-index*/
+ zIndex?: string | undefined;
+ /** Provide arbitrary classes to the backdrop region.*/
+ regionBackdrop?: string | undefined;
+ /** Provide arbitrary classes to the drawer region.*/
+ regionDrawer?: string | undefined;
+ /** Provide an ID of the element labeling the drawer.*/
+ labelledby?: string | undefined;
+ /** Provide an ID of the element describing the drawer.*/
+ describedby?: string | undefined;
+ };
+ events: {
+ keypress: KeyboardEvent;
+ /** {{ event }} backdrop - Fires on backdrop interaction.*/
+ backdrop: CustomEvent;
+ } & {
+ [evt: string]: CustomEvent;
+ };
+ slots: {
+ default: {};
+ };
+};
+export type DrawerProps = typeof __propDef.props;
+export type DrawerEvents = typeof __propDef.events;
+export type DrawerSlots = typeof __propDef.slots;
+export default class Drawer extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/utilities/Drawer/Drawer.test.d.ts b/web/app/src/skeleton/utilities/Drawer/Drawer.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Drawer/Drawer.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/utilities/Drawer/Drawer.test.js b/web/app/src/skeleton/utilities/Drawer/Drawer.test.js
new file mode 100644
index 0000000..868dc86
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Drawer/Drawer.test.js
@@ -0,0 +1,13 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import Drawer from './Drawer.svelte';
+import { drawerStore } from './stores';
+describe('Drawer.svelte', () => {
+ it('Drawer hidden on load', async () => {
+ const { getByTestId } = render(Drawer, {});
+ // FIXME: open drawer and verify elements exist
+ // drawerStore.open();
+ // expect(getByTestId('drawer-backdrop')).toBeFalsy();
+ // expect(getByTestId('drawer')).toBeFalsy();
+ });
+});
diff --git a/web/app/src/skeleton/utilities/Drawer/stores.d.ts b/web/app/src/skeleton/utilities/Drawer/stores.d.ts
new file mode 100644
index 0000000..7823f4f
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Drawer/stores.d.ts
@@ -0,0 +1,14 @@
+import type { DrawerSettings } from './types';
+export declare const drawerStore: {
+ subscribe: (
+ this: void,
+ run: import('svelte/store').Subscriber,
+ invalidate?: ((value?: DrawerSettings | undefined) => void) | undefined
+ ) => import('svelte/store').Unsubscriber;
+ set: (this: void, value: DrawerSettings) => void;
+ update: (this: void, updater: import('svelte/store').Updater) => void;
+ /** Open the drawer. */
+ open: (newSettings?: DrawerSettings) => void;
+ /** Close the drawer. */
+ close: () => void;
+};
diff --git a/web/app/src/skeleton/utilities/Drawer/stores.js b/web/app/src/skeleton/utilities/Drawer/stores.js
new file mode 100644
index 0000000..d5a2092
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Drawer/stores.js
@@ -0,0 +1,23 @@
+// Drawer Stores
+import { writable } from 'svelte/store';
+function drawerService() {
+ const { subscribe, set, update } = writable({});
+ return {
+ subscribe,
+ set,
+ update,
+ /** Open the drawer. */
+ open: (newSettings) =>
+ update(() => {
+ return { open: true, ...newSettings };
+ }),
+ /** Close the drawer. */
+ close: () =>
+ update((d) => {
+ d.open = false;
+ return d;
+ })
+ };
+}
+// Exports
+export const drawerStore = drawerService();
diff --git a/web/app/src/skeleton/utilities/Drawer/types.d.ts b/web/app/src/skeleton/utilities/Drawer/types.d.ts
new file mode 100644
index 0000000..5510375
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Drawer/types.d.ts
@@ -0,0 +1,39 @@
+export interface DrawerSettings {
+ open?: boolean;
+ /** A unique identifier, useful for setting contents. */
+ id?: string;
+ /** Pass arbitrary information for your own persona use. */
+ meta?: any;
+ /** Set the anchor position.
+ * @type {'left' | 'top' | 'right' | 'bottom'}
+ */
+ position?: 'left' | 'top' | 'right' | 'bottom';
+ /** Define the Svelte transition animation duration.*/
+ duration?: number;
+ /** Backdrop - Provide classes to set the backdrop background color*/
+ bgBackdrop?: string;
+ /** Backdrop - Provide classes to set the blur style.*/
+ blur?: string;
+ /** Drawer - Provide classes to set padding.*/
+ padding?: string;
+ /** Drawer - Provide classes to set the drawer background color.*/
+ bgDrawer?: string;
+ /** Drawer - Provide classes to set border color.*/
+ border?: string;
+ /** Drawer - Provide classes to set border radius.*/
+ rounded?: string;
+ /** Drawer - Provide classes to set box shadow.*/
+ shadow?: string;
+ /** Drawer - Provide classes to override the width.*/
+ width?: string;
+ /** Drawer - Provide classes to override the height.*/
+ height?: string;
+ /** Provide arbitrary classes to the backdrop region. */
+ regionBackdrop?: string;
+ /** Provide arbitrary classes to the drawer region. */
+ regionDrawer?: string;
+ /** Provide an ID of the element labeling the drawer.*/
+ labelledby?: string;
+ /** Provide an ID of the element describing the drawer.*/
+ describedby?: string;
+}
diff --git a/web/app/src/skeleton/utilities/Drawer/types.js b/web/app/src/skeleton/utilities/Drawer/types.js
new file mode 100644
index 0000000..2b3fc61
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Drawer/types.js
@@ -0,0 +1,2 @@
+// Drawer Types
+export {};
diff --git a/web/app/src/skeleton/utilities/LightSwitch/LightSwitch.svelte b/web/app/src/skeleton/utilities/LightSwitch/LightSwitch.svelte
new file mode 100644
index 0000000..078fb3d
--- /dev/null
+++ b/web/app/src/skeleton/utilities/LightSwitch/LightSwitch.svelte
@@ -0,0 +1,64 @@
+
+
+
+ {@html ``}
+
+
+
diff --git a/web/app/src/skeleton/utilities/LightSwitch/LightSwitch.svelte.d.ts b/web/app/src/skeleton/utilities/LightSwitch/LightSwitch.svelte.d.ts
new file mode 100644
index 0000000..b6e9a71
--- /dev/null
+++ b/web/app/src/skeleton/utilities/LightSwitch/LightSwitch.svelte.d.ts
@@ -0,0 +1,36 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Provide classes to set the light background color.*/
+ bgLight?: string | undefined;
+ /** Provide classes to set the dark background color.*/
+ bgDark?: string | undefined;
+ /** Provide classes to set the light SVG fill color.*/
+ fillLight?: string | undefined;
+ /** Provide classes to set the dark SVG fill color.*/
+ fillDark?: string | undefined;
+ /** Provide classes to set width styles.*/
+ width?: string | undefined;
+ /** Provide classes to set height styles. Should be half of width.*/
+ height?: string | undefined;
+ /** Provide classes to set ring styles.*/
+ ring?: string | undefined;
+ /** Provide classes to set border radius styles.*/
+ rounded?: string | undefined;
+ };
+ events: {
+ click: MouseEvent;
+ keydown: KeyboardEvent;
+ keyup: KeyboardEvent;
+ keypress: KeyboardEvent;
+ } & {
+ [evt: string]: CustomEvent;
+ };
+ slots: {};
+};
+export type LightSwitchProps = typeof __propDef.props;
+export type LightSwitchEvents = typeof __propDef.events;
+export type LightSwitchSlots = typeof __propDef.slots;
+export default class LightSwitch extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/utilities/LightSwitch/LightSwitch.test.d.ts b/web/app/src/skeleton/utilities/LightSwitch/LightSwitch.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/utilities/LightSwitch/LightSwitch.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/utilities/LightSwitch/LightSwitch.test.js b/web/app/src/skeleton/utilities/LightSwitch/LightSwitch.test.js
new file mode 100644
index 0000000..c1c2b1b
--- /dev/null
+++ b/web/app/src/skeleton/utilities/LightSwitch/LightSwitch.test.js
@@ -0,0 +1,16 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import LightSwitch from './LightSwitch.svelte';
+describe.skip('LightSwitch.svelte', () => {
+ it('Renders', async () => {
+ const { getByTestId } = render(LightSwitch);
+ expect(getByTestId('menu-wrapper')).toBeTruthy();
+ });
+ /*
+ TODO: additional test cases:
+ - default render state
+ - click to toggle
+ - off = light, on = dark (L<-->D)
+ - etc.
+ */
+});
diff --git a/web/app/src/skeleton/utilities/LightSwitch/lightswitch.d.ts b/web/app/src/skeleton/utilities/LightSwitch/lightswitch.d.ts
new file mode 100644
index 0000000..ef8e975
--- /dev/null
+++ b/web/app/src/skeleton/utilities/LightSwitch/lightswitch.d.ts
@@ -0,0 +1,20 @@
+/** Store: OS Preference Mode */
+export declare const modeOsPrefers: import('svelte/store').Writable;
+/** Store: User Preference Mode */
+export declare const modeUserPrefers: import('svelte/store').Writable;
+/** Store: Current Mode State */
+export declare const modeCurrent: import('svelte/store').Writable;
+/** Get the OS Preference for light/dark mode */
+export declare function getModeOsPrefers(): boolean;
+/** Get the User for light/dark mode */
+export declare function getModeUserPrefers(): boolean | undefined;
+/** Get the Automatic Preference light/dark mode */
+export declare function getModeAutoPrefers(): boolean;
+/** Set the User Preference for light/dark mode */
+export declare function setModeUserPrefers(value: boolean): void;
+/** Set the the current light/dark mode */
+export declare function setModeCurrent(value: boolean): void;
+/** Set the visible light/dark mode on page load. */
+export declare function setInitialClassState(): void;
+/** Automatically set the visible light/dark, updates on change. */
+export declare function autoModeWatcher(): void;
diff --git a/web/app/src/skeleton/utilities/LightSwitch/lightswitch.js b/web/app/src/skeleton/utilities/LightSwitch/lightswitch.js
new file mode 100644
index 0000000..dbd815a
--- /dev/null
+++ b/web/app/src/skeleton/utilities/LightSwitch/lightswitch.js
@@ -0,0 +1,70 @@
+// Lightswitch Service
+import { get } from 'svelte/store';
+import { localStorageStore } from '../LocalStorageStore/LocalStorageStore';
+// Stores ---
+// TRUE: light, FALSE: dark
+/** Store: OS Preference Mode */
+export const modeOsPrefers = localStorageStore('modeOsPrefers', false);
+/** Store: User Preference Mode */
+export const modeUserPrefers = localStorageStore('modeUserPrefers', undefined);
+/** Store: Current Mode State */
+export const modeCurrent = localStorageStore('modeCurrent', false);
+// Get ---
+/** Get the OS Preference for light/dark mode */
+export function getModeOsPrefers() {
+ const prefersLightMode = window.matchMedia('(prefers-color-scheme: light)').matches;
+ modeOsPrefers.set(prefersLightMode);
+ return prefersLightMode;
+}
+/** Get the User for light/dark mode */
+export function getModeUserPrefers() {
+ return get(modeUserPrefers);
+}
+/** Get the Automatic Preference light/dark mode */
+export function getModeAutoPrefers() {
+ const os = getModeOsPrefers();
+ const user = getModeUserPrefers();
+ const modeValue = user !== undefined ? user : os;
+ return modeValue;
+}
+// Set ---
+/** Set the User Preference for light/dark mode */
+export function setModeUserPrefers(value) {
+ modeUserPrefers.set(value);
+}
+/** Set the the current light/dark mode */
+export function setModeCurrent(value) {
+ const elemHtmlClasses = document.documentElement.classList;
+ const classDark = `dark`;
+ value === true ? elemHtmlClasses.remove(classDark) : elemHtmlClasses.add(classDark);
+ modeCurrent.set(value);
+}
+// Lightswitch Utility
+/** Set the visible light/dark mode on page load. */
+export function setInitialClassState() {
+ const elemHtmlClasses = document.documentElement.classList;
+ // Conditions
+ const condLocalStorageUserPrefs = localStorage.getItem('modeUserPrefers') === 'false';
+ const condLocalStorageUserPrefsExists = !('modeUserPrefers' in localStorage);
+ const condMatchMedia = window.matchMedia('(prefers-color-scheme: dark)').matches;
+ // Add/remove `.dark` class to HTML element
+ if (condLocalStorageUserPrefs || (condLocalStorageUserPrefsExists && condMatchMedia)) {
+ elemHtmlClasses.add('dark');
+ } else {
+ elemHtmlClasses.remove('dark');
+ }
+}
+// Auto-Switch Utility
+/** Automatically set the visible light/dark, updates on change. */
+export function autoModeWatcher() {
+ const mql = window.matchMedia('(prefers-color-scheme: light)');
+ function setMode(value) {
+ const elemHtmlClasses = document.documentElement.classList;
+ const classDark = `dark`;
+ value === true ? elemHtmlClasses.remove(classDark) : elemHtmlClasses.add(classDark);
+ }
+ setMode(mql.matches);
+ mql.onchange = () => {
+ setMode(mql.matches);
+ };
+}
diff --git a/web/app/src/skeleton/utilities/LocalStorageStore/LocalStorageStore.d.ts b/web/app/src/skeleton/utilities/LocalStorageStore/LocalStorageStore.d.ts
new file mode 100644
index 0000000..4309e58
--- /dev/null
+++ b/web/app/src/skeleton/utilities/LocalStorageStore/LocalStorageStore.d.ts
@@ -0,0 +1,12 @@
+import { type Writable } from 'svelte/store';
+interface Serializer {
+ parse(text: string): T;
+ stringify(object: T): string;
+}
+type StorageType = 'local' | 'session';
+interface Options {
+ serializer?: Serializer;
+ storage?: StorageType;
+}
+export declare function localStorageStore(key: string, initialValue: T, options?: Options): Writable;
+export {};
diff --git a/web/app/src/skeleton/utilities/LocalStorageStore/LocalStorageStore.js b/web/app/src/skeleton/utilities/LocalStorageStore/LocalStorageStore.js
new file mode 100644
index 0000000..d7e23dd
--- /dev/null
+++ b/web/app/src/skeleton/utilities/LocalStorageStore/LocalStorageStore.js
@@ -0,0 +1,47 @@
+// Source: https://github.com/joshnuss/svelte-local-storage-store
+// https://github.com/joshnuss/svelte-local-storage-store/blob/master/index.ts
+// Represents version v0.4.0 (2023-01-18)
+import { writable as internal, get } from 'svelte/store';
+/* eslint-disable @typescript-eslint/no-explicit-any */
+const stores = {};
+function getStorage(type) {
+ return type === 'local' ? localStorage : sessionStorage;
+}
+export function localStorageStore(key, initialValue, options) {
+ const serializer = options?.serializer ?? JSON;
+ const storageType = options?.storage ?? 'local';
+ const browser = typeof window !== 'undefined' && typeof document !== 'undefined';
+ function updateStorage(key, value) {
+ if (!browser) return;
+ getStorage(storageType).setItem(key, serializer.stringify(value));
+ }
+ if (!stores[key]) {
+ const store = internal(initialValue, (set) => {
+ const json = browser ? getStorage(storageType).getItem(key) : null;
+ if (json) {
+ set(serializer.parse(json));
+ }
+ if (browser) {
+ const handleStorage = (event) => {
+ if (event.key === key) set(event.newValue ? serializer.parse(event.newValue) : null);
+ };
+ window.addEventListener('storage', handleStorage);
+ return () => window.removeEventListener('storage', handleStorage);
+ }
+ });
+ const { subscribe, set } = store;
+ stores[key] = {
+ set(value) {
+ updateStorage(key, value);
+ set(value);
+ },
+ update(updater) {
+ const value = updater(get(store));
+ updateStorage(key, value);
+ set(value);
+ },
+ subscribe
+ };
+ }
+ return stores[key];
+}
diff --git a/web/app/src/skeleton/utilities/LocalStorageStore/LocalStorageStore.test.d.ts b/web/app/src/skeleton/utilities/LocalStorageStore/LocalStorageStore.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/utilities/LocalStorageStore/LocalStorageStore.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/utilities/LocalStorageStore/LocalStorageStore.test.js b/web/app/src/skeleton/utilities/LocalStorageStore/LocalStorageStore.test.js
new file mode 100644
index 0000000..a6b0cad
--- /dev/null
+++ b/web/app/src/skeleton/utilities/LocalStorageStore/LocalStorageStore.test.js
@@ -0,0 +1,167 @@
+// Source: https://github.com/joshnuss/svelte-local-storage-store
+// https://github.com/joshnuss/svelte-local-storage-store/blob/master/test/localStorageStore.test.ts
+// Represents version v0.4.0 (2023-01-18)
+import { describe, it, expect, beforeEach, vitest } from 'vitest';
+import { get } from 'svelte/store';
+import { localStorageStore } from './LocalStorageStore';
+beforeEach(() => localStorage.clear());
+// describe('localStorageStore()', () => {
+// it('it works, but raises deprecation warning', () => {
+// console.warn = vitest.fn();
+// localStorage.setItem('myKey2', '"existing"');
+// const store = localStorageStore('myKey2', 'initial');
+// const value = get(store);
+// expect(value).toEqual('existing');
+// expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(/deprecated/));
+// });
+// });
+describe('localStorageStore()', () => {
+ it('uses initial value if nothing in local storage', () => {
+ const store = localStorageStore('myKey', 123);
+ const value = get(store);
+ expect(value).toEqual(123);
+ });
+ it('uses existing value if data already in local storage', () => {
+ localStorage.setItem('myKey2', '"existing"');
+ const store = localStorageStore('myKey2', 'initial');
+ const value = get(store);
+ expect(value).toEqual('existing');
+ });
+ describe('set()', () => {
+ it('replaces old value', () => {
+ localStorage.setItem('myKey3', '"existing"');
+ const store = localStorageStore('myKey3', '');
+ store.set('new-value');
+ const value = get(store);
+ expect(localStorage.myKey3).toEqual('"new-value"');
+ expect(value).toEqual('new-value');
+ });
+ it('adds new value', () => {
+ const store = localStorageStore('myKey4', '');
+ store.set('new-value');
+ const value = get(store);
+ expect(localStorage.myKey4).toEqual('"new-value"');
+ expect(value).toEqual('new-value');
+ });
+ });
+ describe('update()', () => {
+ it('replaces old value', () => {
+ localStorage.setItem('myKey5', '123');
+ const store = localStorageStore('myKey5', 0);
+ store.update((n) => n + 1);
+ const value = get(store);
+ expect(localStorage.myKey5).toEqual('124');
+ expect(value).toEqual(124);
+ });
+ it('adds new value', () => {
+ const store = localStorageStore('myKey6', 123);
+ store.update((n) => n + 1);
+ const value = get(store);
+ expect(localStorage.myKey6).toEqual('124');
+ expect(value).toEqual(124);
+ });
+ });
+ describe('subscribe()', () => {
+ it('publishes updates', () => {
+ const store = localStorageStore('myKey7', 123);
+ const values = [];
+ const unsub = store.subscribe((value) => {
+ if (value !== undefined) values.push(value);
+ });
+ store.set(456);
+ store.set(999);
+ expect(values).toEqual([123, 456, 999]);
+ unsub();
+ });
+ });
+ it('handles duplicate stores with the same key', () => {
+ const store1 = localStorageStore('same-key', 1);
+ const values1 = [];
+ const unsub1 = store1.subscribe((value) => {
+ values1.push(value);
+ });
+ store1.set(2);
+ const store2 = localStorageStore('same-key', 99);
+ const values2 = [];
+ const unsub2 = store2.subscribe((value) => {
+ values2.push(value);
+ });
+ store1.set(3);
+ store2.set(4);
+ expect(values1).toEqual([1, 2, 3, 4]);
+ expect(values2).toEqual([2, 3, 4]);
+ expect(get(store1)).toEqual(get(store2));
+ expect(store1).toEqual(store2);
+ unsub1();
+ unsub2();
+ });
+ describe('handles window.storage event', () => {
+ it('sets storage when key matches', () => {
+ const store = localStorageStore('myKey8', { a: 1 });
+ const values = [];
+ const unsub = store.subscribe((value) => {
+ values.push(value);
+ });
+ const event = new StorageEvent('storage', { key: 'myKey8', newValue: '{"a": 1, "b": 2}' });
+ window.dispatchEvent(event);
+ expect(values).toEqual([{ a: 1 }, { a: 1, b: 2 }]);
+ unsub();
+ });
+ it('sets store to null when value is null', () => {
+ const store = localStorageStore('myKey9', { a: 1 });
+ const values = [];
+ const unsub = store.subscribe((value) => {
+ values.push(value);
+ });
+ const event = new StorageEvent('storage', { key: 'myKey9', newValue: null });
+ window.dispatchEvent(event);
+ expect(values).toEqual([{ a: 1 }, null]);
+ unsub();
+ });
+ it("doesn't update store when key doesn't match", () => {
+ const store = localStorageStore('myKey10', 1);
+ const values = [];
+ const unsub = store.subscribe((value) => {
+ values.push(value);
+ });
+ const event = new StorageEvent('storage', { key: 'unknownKey', newValue: '2' });
+ window.dispatchEvent(event);
+ expect(values).toEqual([1]);
+ unsub();
+ });
+ it("doesn't update store when there are no subscribers", () => {
+ const store = localStorageStore('myKey', 1);
+ const values = [];
+ const event = new StorageEvent('storage', { key: 'myKey', newValue: '2' });
+ window.dispatchEvent(event);
+ localStorage.setItem('myKey', '2');
+ const unsub = store.subscribe((value) => {
+ values.push(value);
+ });
+ expect(values).toEqual([2]);
+ unsub();
+ });
+ });
+ it('allows custom serialize/deserialize functions', () => {
+ const serializer = {
+ stringify: (set) => JSON.stringify(Array.from(set)),
+ parse: (json) => new Set(JSON.parse(json))
+ };
+ const testSet = new Set([1, 2, 3]);
+ const store = localStorageStore('myKey11', testSet, { serializer });
+ const value = get(store);
+ store.update((d) => d.add(4));
+ expect(value).toEqual(testSet);
+ expect(localStorage.myKey11).toEqual(serializer.stringify(testSet));
+ });
+ it('lets you switch storage type', () => {
+ vitest.spyOn(Object.getPrototypeOf(window.sessionStorage), 'setItem');
+ Object.setPrototypeOf(window.sessionStorage.setItem, vitest.fn());
+ const value = 'foo';
+ const store = localStorageStore('myKey12', value, {
+ storage: 'session'
+ });
+ store.set('bar');
+ expect(window.sessionStorage.setItem).toHaveBeenCalled();
+ });
+});
diff --git a/web/app/src/skeleton/utilities/Modal/Modal.svelte b/web/app/src/skeleton/utilities/Modal/Modal.svelte
new file mode 100644
index 0000000..dc89b17
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Modal/Modal.svelte
@@ -0,0 +1,203 @@
+
+
+
+
+{#if $modalStore.length > 0}
+ {#key $modalStore}
+
+
+
+
+ {#if $modalStore[0].type !== 'component'}
+
+
+
+ {#if $modalStore[0]?.title}
+
+ {/if}
+
+ {#if $modalStore[0]?.body}
+
{@html $modalStore[0].body}
+ {/if}
+
+ {#if $modalStore[0]?.image && typeof $modalStore[0]?.image === 'string'}
+
+ {/if}
+
+ {#if $modalStore[0].type === 'alert'}
+
+
+ {:else if $modalStore[0].type === 'confirm'}
+
+
+ {:else if $modalStore[0].type === 'prompt'}
+
+
+ {/if}
+
+ {:else}
+
+
+
+
+ {#if currentComponent?.slot}
+ {@html currentComponent?.slot}
+ {/if}
+
+
+ {/if}
+
+
+ {/key}
+{/if}
diff --git a/web/app/src/skeleton/utilities/Modal/Modal.svelte.d.ts b/web/app/src/skeleton/utilities/Modal/Modal.svelte.d.ts
new file mode 100644
index 0000000..113f6c1
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Modal/Modal.svelte.d.ts
@@ -0,0 +1,65 @@
+import { SvelteComponentTyped } from 'svelte';
+import type { ModalComponent } from './types';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Set the modal position within the backdrop container*/
+ position?: string | undefined;
+ /** Register a list of reusable component modals.*/
+ components?: Record | undefined;
+ /** The open/close animation duration. Set '0' (zero) to disable.*/
+ duration?: number | undefined;
+ /** Set the fly transition opacity.*/
+ flyOpacity?: number | undefined;
+ /** Set the fly transition X axis value.*/
+ flyX?: number | undefined;
+ /** Set the fly transition Y axis value.*/
+ flyY?: number | undefined;
+ /** Provide classes to style the modal background.*/
+ background?: string | undefined;
+ /** Provide classes to style the modal width.*/
+ width?: string | undefined;
+ /** Provide classes to style the modal height.*/
+ height?: string | undefined;
+ /** Provide classes to style the modal padding.*/
+ padding?: string | undefined;
+ /** Provide classes to style the modal spacing.*/
+ spacing?: string | undefined;
+ /** Provide classes to style the modal border radius.*/
+ rounded?: string | undefined;
+ /** Provide classes to style modal box shadow.*/
+ shadow?: string | undefined;
+ /** Provide a class to override the z-index*/
+ zIndex?: string | undefined;
+ /** Provide classes for neutral buttons, such as Cancel.*/
+ buttonNeutral?: string | undefined;
+ /** Provide classes for positive actions, such as Confirm or Submit.*/
+ buttonPositive?: string | undefined;
+ /** Override the text for the Cancel button.*/
+ buttonTextCancel?: string | undefined;
+ /** Override the text for the Confirm button.*/
+ buttonTextConfirm?: string | undefined;
+ /** Override the text for the Submit button.*/
+ buttonTextSubmit?: string | undefined;
+ /** Provide classes to style the backdrop.*/
+ regionBackdrop?: string | undefined;
+ /** Provide arbitrary classes to modal header region.*/
+ regionHeader?: string | undefined;
+ /** Provide arbitrary classes to modal body region.*/
+ regionBody?: string | undefined;
+ /** Provide arbitrary classes to modal footer region.*/
+ regionFooter?: string | undefined;
+ };
+ events: {
+ /** {{ event }} backdrop - Fires on backdrop interaction.*/
+ backdrop: CustomEvent;
+ } & {
+ [evt: string]: CustomEvent;
+ };
+ slots: {};
+};
+export type ModalProps = typeof __propDef.props;
+export type ModalEvents = typeof __propDef.events;
+export type ModalSlots = typeof __propDef.slots;
+export default class Modal extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/utilities/Modal/Modal.test.d.ts b/web/app/src/skeleton/utilities/Modal/Modal.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Modal/Modal.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/utilities/Modal/Modal.test.js b/web/app/src/skeleton/utilities/Modal/Modal.test.js
new file mode 100644
index 0000000..d90f2a7
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Modal/Modal.test.js
@@ -0,0 +1,43 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import { modalStore } from './stores';
+import Modal from './Modal.svelte';
+// Modal Payloads
+const modalAlert = {
+ type: 'alert',
+ title: 'Welcome to Skeleton.',
+ body: 'This is a standard alert modal.'
+};
+const modalConfirm = {
+ type: 'confirm',
+ title: 'Please Confirm',
+ body: 'Are you sure you wish to proceed?',
+ response: (r) => console.log(r)
+};
+const modalPrompt = {
+ type: 'prompt',
+ title: 'Enter Name',
+ body: 'Provide your first name in the field below.',
+ value: 'foobar',
+ response: (r) => console.log(r)
+};
+describe('Modal.svelte', () => {
+ it('Renders modal alert', async () => {
+ modalStore.trigger(modalAlert);
+ const { getByTestId } = render(Modal);
+ expect(getByTestId('modal-backdrop')).toBeTruthy();
+ expect(getByTestId('modal')).toBeTruthy();
+ });
+ it('Renders modal confirm', async () => {
+ modalStore.trigger(modalConfirm);
+ const { getByTestId } = render(Modal);
+ expect(getByTestId('modal-backdrop')).toBeTruthy();
+ expect(getByTestId('modal')).toBeTruthy();
+ });
+ it('Renders modal prompt', async () => {
+ modalStore.trigger(modalPrompt);
+ const { getByTestId } = render(Modal);
+ expect(getByTestId('modal-backdrop')).toBeTruthy();
+ expect(getByTestId('modal')).toBeTruthy();
+ });
+});
diff --git a/web/app/src/skeleton/utilities/Modal/stores.d.ts b/web/app/src/skeleton/utilities/Modal/stores.d.ts
new file mode 100644
index 0000000..ae7c894
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Modal/stores.d.ts
@@ -0,0 +1,16 @@
+import type { ModalSettings } from './types';
+export declare const modalStore: {
+ subscribe: (
+ this: void,
+ run: import('svelte/store').Subscriber,
+ invalidate?: ((value?: ModalSettings[] | undefined) => void) | undefined
+ ) => import('svelte/store').Unsubscriber;
+ set: (this: void, value: ModalSettings[]) => void;
+ update: (this: void, updater: import('svelte/store').Updater) => void;
+ /** Append to end of queue. */
+ trigger: (modal: ModalSettings) => void;
+ /** Remove first item in queue. */
+ close: () => void;
+ /** Remove all items from queue. */
+ clear: () => void;
+};
diff --git a/web/app/src/skeleton/utilities/Modal/stores.js b/web/app/src/skeleton/utilities/Modal/stores.js
new file mode 100644
index 0000000..d50d703
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Modal/stores.js
@@ -0,0 +1,25 @@
+// Modal Store Queue
+import { writable } from 'svelte/store';
+function modalService() {
+ const { subscribe, set, update } = writable([]);
+ return {
+ subscribe,
+ set,
+ update,
+ /** Append to end of queue. */
+ trigger: (modal) =>
+ update((mStore) => {
+ mStore.push(modal);
+ return mStore;
+ }),
+ /** Remove first item in queue. */
+ close: () =>
+ update((mStore) => {
+ if (mStore.length > 0) mStore.shift();
+ return mStore;
+ }),
+ /** Remove all items from queue. */
+ clear: () => set([])
+ };
+}
+export const modalStore = modalService();
diff --git a/web/app/src/skeleton/utilities/Modal/types.d.ts b/web/app/src/skeleton/utilities/Modal/types.d.ts
new file mode 100644
index 0000000..d6adb6c
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Modal/types.d.ts
@@ -0,0 +1,40 @@
+export interface ModalComponent {
+ /** Import and provide your component reference. */
+ ref: any;
+ /** Provide component props as key/value pairs. */
+ props?: Record;
+ /** Provide an HTML template literal for the default slot. */
+ slot?: string;
+}
+export interface ModalSettings {
+ /** Designate what type of component will display. */
+ type: 'alert' | 'confirm' | 'prompt' | 'component';
+ /** Set the modal position within the backdrop container. */
+ position?: string;
+ /** Provide the modal header content. Accepts HTML. */
+ title?: string;
+ /** Provide the modal body content. Accepts HTML. */
+ body?: string;
+ /** Provide a URL to display an image within the modal. */
+ image?: string;
+ /** By default, used to provide a prompt value. */
+ value?: any;
+ /** Provide input attributes as key/value pairs. */
+ valueAttr?: object;
+ /** Provide your component reference key or object. */
+ component?: ModalComponent | string;
+ /** Provide a function. Returns the response value. */
+ response?: (r: any) => void;
+ /** Provide arbitrary classes to the backdrop. */
+ backdropClasses?: string;
+ /** Provide arbitrary classes to the modal window. */
+ modalClasses?: string;
+ /** Override the Cancel button label. */
+ buttonTextCancel?: string;
+ /** Override the Confirm button label. */
+ buttonTextConfirm?: string;
+ /** Override the Submit button label. */
+ buttonTextSubmit?: string;
+ /** Pass arbitrary data per modal instance. */
+ meta?: any;
+}
diff --git a/web/app/src/skeleton/utilities/Modal/types.js b/web/app/src/skeleton/utilities/Modal/types.js
new file mode 100644
index 0000000..7390374
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Modal/types.js
@@ -0,0 +1,2 @@
+// Modal Types
+export {};
diff --git a/web/app/src/skeleton/utilities/Popup/popup.d.ts b/web/app/src/skeleton/utilities/Popup/popup.d.ts
new file mode 100644
index 0000000..d8e7667
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Popup/popup.d.ts
@@ -0,0 +1,12 @@
+import { type Writable } from 'svelte/store';
+import type { PopupSettings } from './types';
+export declare const storePopup: Writable;
+export declare function popup(
+ node: HTMLElement,
+ args: PopupSettings
+):
+ | {
+ update(newArgs: PopupSettings): void;
+ destroy(): void;
+ }
+ | undefined;
diff --git a/web/app/src/skeleton/utilities/Popup/popup.js b/web/app/src/skeleton/utilities/Popup/popup.js
new file mode 100644
index 0000000..fcea7a4
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Popup/popup.js
@@ -0,0 +1,258 @@
+import { get, writable } from 'svelte/store';
+// Store
+export const storePopup = writable(undefined);
+function isNode(node) {
+ return node instanceof Node;
+}
+// Action
+export function popup(node, args) {
+ if (!args.event || !args.target) return;
+ // Local
+ const { computePosition, autoUpdate, flip, shift, offset, arrow } = get(storePopup);
+ const elemPopup = document.querySelector(`[data-popup="${args.target}"]`);
+ const elemArrow = elemPopup?.querySelector(`.arrow`) ?? null;
+ let isVisible = false;
+ let autoUpdateCleanup;
+ // Local A11y Variables
+ const elemWhitelist = ':is(a[href], button, input, textarea, select, details, [tabindex]):not([tabindex="-1"])';
+ let activeFocusIdx;
+ let focusableElems;
+ // On Init (floating ui)
+ function render() {
+ if (!elemPopup || !computePosition) return;
+ // Construct Middleware
+ // Note the order: https://floating-ui.com/docs/middleware#ordering
+ const genMiddleware = [];
+ // https://floating-ui.com/docs/offset
+ if (offset) genMiddleware.push(offset(args.middleware?.offset ?? 8));
+ // https://floating-ui.com/docs/shift
+ if (shift) genMiddleware.push(shift(args.middleware?.shift ?? { padding: 8 }));
+ // https://floating-ui.com/docs/flip
+ if (flip) genMiddleware.push(flip(args.middleware?.flip));
+ // https://floating-ui.com/docs/arrow
+ if (arrow && elemArrow) genMiddleware.push(arrow(args.middleware?.arrow ?? { element: elemArrow }));
+ // https://floating-ui.com/docs/computePosition
+ computePosition(node, elemPopup, {
+ placement: args.placement ?? 'bottom',
+ middleware: genMiddleware
+ }).then(({ x, y, placement, middlewareData }) => {
+ Object.assign(elemPopup.style, {
+ left: `${x}px`,
+ top: `${y}px`
+ });
+ // Handle Arrow Placement:
+ // https://floating-ui.com/docs/arrow
+ if (elemArrow) {
+ const { x: arrowX, y: arrowY } = middlewareData.arrow;
+ // @ts-ignore
+ const staticSide = {
+ top: 'bottom',
+ right: 'left',
+ bottom: 'top',
+ left: 'right'
+ }[placement.split('-')[0]];
+ Object.assign(elemArrow.style, {
+ left: arrowX != null ? `${arrowX}px` : '',
+ top: arrowY != null ? `${arrowY}px` : '',
+ right: '',
+ bottom: '',
+ [staticSide]: '-4px'
+ });
+ }
+ // Set Focusable State
+ setFocusableState();
+ });
+ }
+ // Set Focusable State
+ function setFocusableState() {
+ if (!elemPopup) return;
+ // Create array of all focusable elements, so that we can iterate through them
+ focusableElems = Array.from(elemPopup?.querySelectorAll(elemWhitelist));
+ // reset the focus index
+ activeFocusIdx = -1;
+ // Automatically focus the element if openWithFocus is true (for example if
+ // the menu was opened with Enter instead of with a click
+ activeFocusIdx = 0;
+ // if the popup was triggered via focus, we don't want to move that focus
+ if (args.event !== 'focus' && args.event !== 'focus-click') {
+ focusableElems[0]?.focus();
+ }
+ }
+ // Close popup if element matching closeQuery is clicked
+ function closeOnQueryClick(clickedEl) {
+ if (!clickedEl) return;
+ const elemsCloseQuery = !args.closeQuery || args.closeQuery === '' ? 'a[href], button' : args.closeQuery;
+ const interactiveMenuElems = elemPopup?.querySelectorAll(elemsCloseQuery);
+ if (!interactiveMenuElems?.length) return;
+ interactiveMenuElems.forEach((elem) => {
+ if (elem.contains(clickedEl)) close();
+ });
+ }
+ // Window Click Handler
+ const onWindowClick = (event) => {
+ if (!node || !elemPopup) return;
+ // If click is within the trigger node
+ const clickTriggerNode = node.contains(event.target);
+ if (clickTriggerNode) {
+ isVisible == false ? show() : close();
+ } else {
+ // If click is outside the popup
+ const clickedOutsidePopup = elemPopup && !elemPopup.contains(event.target);
+ if (clickedOutsidePopup) {
+ close();
+ } else {
+ closeOnQueryClick(event.target);
+ }
+ }
+ };
+ // Hover Handlers
+ const onMouseOver = () => {
+ show();
+ isVisible = true;
+ stateEventHandler(true);
+ };
+ const onMouseOut = () => {
+ close();
+ isVisible = false;
+ stateEventHandler(false);
+ };
+ // Focus Handlers
+ function onFocusIn() {
+ if (!isVisible) node.focus();
+ }
+ function onFocusOut(e) {
+ // if the focus is within the popup, or on the trigger node, do nothing
+ if (
+ e.relatedTarget instanceof Element &&
+ (elemPopup?.contains(e.relatedTarget) || node.isSameNode(e.relatedTarget))
+ )
+ return;
+ close();
+ }
+ function onMouseDown(e) {
+ e.preventDefault();
+ if (isNode(document.activeElement)) {
+ if (!node.isSameNode(document.activeElement)) {
+ node.focus();
+ return;
+ }
+ if (isVisible) close();
+ else show();
+ }
+ }
+ // Visibility
+ function show() {
+ if (!elemPopup) return;
+ render(); // update
+ elemPopup.style.display = 'block';
+ elemPopup.style.opacity = '1';
+ isVisible = true;
+ stateEventHandler(true);
+ // Utilize autoUpdate ONLY when the popup is.
+ // https://floating-ui.com/docs/autoUpdate
+ autoUpdateCleanup = autoUpdate(node, elemPopup, render);
+ }
+ function close() {
+ if (!elemPopup) return;
+ elemPopup.style.opacity = '0';
+ const cssTransitionDuration =
+ parseFloat(window.getComputedStyle(elemPopup).transitionDuration.replace('s', '')) * 1000;
+ setTimeout(() => {
+ elemPopup.style.display = 'none';
+ isVisible = false;
+ stateEventHandler(false);
+ }, cssTransitionDuration);
+ // Cleanup autoUpdate on close (REQUIRED)
+ if (autoUpdateCleanup) autoUpdateCleanup();
+ }
+ // State Handler
+ const stateEventHandler = (value) => {
+ if (args.state) args.state({ state: value });
+ };
+ // A11y Keydown Handler
+ const onWindowKeyDown = (event) => {
+ if (!isVisible) return;
+ // Handle keys
+ const key = event.key;
+ // Handle keyboard interaction
+ if (key === 'Escape' || (document.activeElement === node && key === 'Tab')) {
+ event.preventDefault();
+ close();
+ node.focus();
+ return;
+ } else if (key === 'ArrowDown') {
+ event.preventDefault();
+ if (activeFocusIdx < focusableElems.length - 1) {
+ // Move down the menu
+ activeFocusIdx += 1;
+ focusableElems[activeFocusIdx]?.focus();
+ }
+ } else if (key === 'ArrowUp') {
+ event.preventDefault();
+ if (activeFocusIdx > 0) {
+ // Move up the menu
+ activeFocusIdx -= 1;
+ focusableElems[activeFocusIdx]?.focus();
+ } else if (focusableElems.length && activeFocusIdx === -1) {
+ // Start at the bottom of the menu if first key is arrow up key
+ event.preventDefault();
+ activeFocusIdx = focusableElems.length - 1;
+ focusableElems[activeFocusIdx]?.focus();
+ }
+ }
+ };
+ // On Init
+ render();
+ // Event Listeners
+ if (args.event === 'click') {
+ window.addEventListener('click', onWindowClick, true);
+ }
+ if (args.event === 'hover') {
+ node.addEventListener('mouseover', show, true);
+ node.addEventListener('mouseout', close, true);
+ }
+ if (args.event === 'hover-click') {
+ node.addEventListener('mouseover', show, true);
+ window.addEventListener('click', onWindowClick, true);
+ }
+ if (args.event === 'focus' || args.event === 'focus-click') {
+ if (!elemPopup) return;
+ node.addEventListener('focusin', show, true);
+ node.addEventListener('focusout', onFocusOut, true);
+ // if we tab into a closed popup, we will tab into the last element of any focusable element in elemPopup
+ // so we add an event listener to move the focus back to the node the `use:popup` directive is on
+ elemPopup.addEventListener('focusin', onFocusIn, true);
+ // when we focus off the end of the list, close the popup
+ elemPopup.addEventListener('focusout', onFocusOut, true);
+ // when an element inside the popup is clicked, close the popup if the element matches the closeQuery
+ elemPopup.addEventListener('click', onWindowClick, true);
+ }
+ if (args.event === 'focus-click') {
+ // we must use mousedown instead of click because click fires after focusin, meaning isVisible would always be true
+ // if the active element (one with current focus) is the same as the node (the element with the use:popup directive),
+ // then the user clicked on the node, so we should toggle the state of the popup
+ node.addEventListener('mousedown', onMouseDown, true);
+ }
+ // A11y Event Listeners
+ window.addEventListener('keydown', onWindowKeyDown, true);
+ // Lifecycle
+ return {
+ update(newArgs) {
+ args = newArgs;
+ render();
+ },
+ destroy() {
+ // ---
+ window.removeEventListener('click', onWindowClick, true);
+ node.removeEventListener('mouseover', onMouseOver, true);
+ node.removeEventListener('mouseout', onMouseOut, true);
+ node.removeEventListener('focusin', show, true);
+ node.removeEventListener('focusout', onFocusOut, true);
+ node.removeEventListener('mousedown', onMouseDown, true);
+ elemPopup?.removeEventListener('focusin', onFocusIn, true);
+ elemPopup?.removeEventListener('focusout', onFocusOut, true);
+ // ---
+ window.removeEventListener('keydown', onWindowKeyDown, true);
+ }
+ };
+}
diff --git a/web/app/src/skeleton/utilities/Popup/types.d.ts b/web/app/src/skeleton/utilities/Popup/types.d.ts
new file mode 100644
index 0000000..4b26fe7
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Popup/types.d.ts
@@ -0,0 +1,30 @@
+type Direction = 'top' | 'bottom' | 'left' | 'right';
+/** Placement https://floating-ui.com/docs/computePosition#placement */
+type Placement = Direction | `${Direction}-start` | `${Direction}-end`;
+interface Middleware {
+ /** Offset options: https://floating-ui.com/docs/offset */
+ offset?: number | Record;
+ /** Shift options: https://floating-ui.com/docs/shift */
+ shift?: Record;
+ /** Flip options: https://floating-ui.com/docs/flip */
+ flip?: Record;
+ /** Arrow options: https://floating-ui.com/docs/arrow */
+ arrow?: {
+ element: string;
+ } & Record;
+}
+export interface PopupSettings {
+ /** Provide the event type. */
+ event: 'click' | 'hover' | 'hover-click' | 'focus' | 'focus-click';
+ /** Match the popup data value `[data-popup]="targetNameHere"` */
+ target: string;
+ /** Set the placement position. Defaults 'bottom'. */
+ placement?: Placement;
+ /** Query list of elements that will close the popup. Default: `'a[href], button'`. */
+ closeQuery?: string;
+ /** Provide additional options and middleware settings. */
+ middleware?: Middleware;
+ /** Provide an optional callback function to monitor open/close state. */
+ state?: (event: { state: boolean }) => void;
+}
+export {};
diff --git a/web/app/src/skeleton/utilities/Popup/types.js b/web/app/src/skeleton/utilities/Popup/types.js
new file mode 100644
index 0000000..44f5f8f
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Popup/types.js
@@ -0,0 +1,3 @@
+// Popup Types
+// Note: these are a simple iteration based on the official docs.
+export {};
diff --git a/web/app/src/skeleton/utilities/Toast/Toast.svelte b/web/app/src/skeleton/utilities/Toast/Toast.svelte
new file mode 100644
index 0000000..f5ea612
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Toast/Toast.svelte
@@ -0,0 +1,119 @@
+
+
+{#if $toastStore.length}
+
+
+
+
+ {#each filteredToasts as t, i (t)}
+
+
+
+
{@html t.message}
+
+ {#if t.action} onAction(i)}>{@html t.action.label} {/if}
+ toastStore.close(t.id)}>{buttonDismissLabel}
+
+
+
+ {/each}
+
+
+{/if}
diff --git a/web/app/src/skeleton/utilities/Toast/Toast.svelte.d.ts b/web/app/src/skeleton/utilities/Toast/Toast.svelte.d.ts
new file mode 100644
index 0000000..54be0ea
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Toast/Toast.svelte.d.ts
@@ -0,0 +1,43 @@
+import { SvelteComponentTyped } from 'svelte';
+declare const __propDef: {
+ props: {
+ [x: string]: any;
+ /** Set the toast position.*/
+ position?: string | undefined;
+ /** Maximum toasts that can show at once.*/
+ max?: number | undefined;
+ /** The duration of the fly in/out animation.*/
+ duration?: number | undefined;
+ /** Provide classes to set the background color.*/
+ background?: string | undefined;
+ /** Provide classes to set width styles.*/
+ width?: string | undefined;
+ /** Provide classes to set the text color.*/
+ color?: string | undefined;
+ /** Provide classes to set the padding.*/
+ padding?: string | undefined;
+ /** Provide classes to set toast horizontal spacing.*/
+ spacing?: string | undefined;
+ /** Provide classes to set the border radius styles.*/
+ rounded?: string | undefined;
+ /** Provide classes to set the border box shadow.*/
+ shadow?: string | undefined;
+ /** Provide a class to override the z-index*/
+ zIndex?: string | undefined;
+ /** Provide styles for the action button.*/
+ buttonAction?: string | undefined;
+ /** Provide styles for the dismiss button.*/
+ buttonDismiss?: string | undefined;
+ /** The button label text.*/
+ buttonDismissLabel?: string | undefined;
+ };
+ events: {
+ [evt: string]: CustomEvent;
+ };
+ slots: {};
+};
+export type ToastProps = typeof __propDef.props;
+export type ToastEvents = typeof __propDef.events;
+export type ToastSlots = typeof __propDef.slots;
+export default class Toast extends SvelteComponentTyped {}
+export {};
diff --git a/web/app/src/skeleton/utilities/Toast/Toats.test.d.ts b/web/app/src/skeleton/utilities/Toast/Toats.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Toast/Toats.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/app/src/skeleton/utilities/Toast/Toats.test.js b/web/app/src/skeleton/utilities/Toast/Toats.test.js
new file mode 100644
index 0000000..bd209bc
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Toast/Toats.test.js
@@ -0,0 +1,27 @@
+import { render } from '@testing-library/svelte';
+import { describe, it, expect } from 'vitest';
+import { toastStore } from './stores';
+import Toast from './Toast.svelte';
+// Toast Payload
+const toastMessage = {
+ message: 'Your Message Here',
+ autohide: true,
+ timeout: 5000,
+ action: {
+ label: 'Greeting',
+ response: () => alert('Hello, Skeleton')
+ }
+};
+describe('Toast.svelte', () => {
+ it('Renders modal alert', async () => {
+ toastStore.trigger(toastMessage);
+ const { getByTestId } = render(Toast);
+ expect(getByTestId('toast')).toBeTruthy();
+ });
+ it('Renders only the configured max toasts at a time', async () => {
+ toastStore.trigger({ message: '1' });
+ toastStore.trigger({ message: '2' });
+ const { getAllByTestId } = render(Toast, { max: 1 });
+ expect(getAllByTestId('toast').length).toBe(1);
+ });
+});
diff --git a/web/app/src/skeleton/utilities/Toast/stores.d.ts b/web/app/src/skeleton/utilities/Toast/stores.d.ts
new file mode 100644
index 0000000..4d75c04
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Toast/stores.d.ts
@@ -0,0 +1,14 @@
+import type { ToastSettings, Toast } from './types';
+export declare const toastStore: {
+ subscribe: (
+ this: void,
+ run: import('svelte/store').Subscriber,
+ invalidate?: ((value?: Toast[] | undefined) => void) | undefined
+ ) => import('svelte/store').Unsubscriber;
+ /** Add a new toast to the queue. */
+ trigger: (toast: ToastSettings) => void;
+ /** Remove first toast in queue */
+ close: (id: string) => void;
+ /** Remove all toasts from queue */
+ clear: () => void;
+};
diff --git a/web/app/src/skeleton/utilities/Toast/stores.js b/web/app/src/skeleton/utilities/Toast/stores.js
new file mode 100644
index 0000000..e6d2ffd
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Toast/stores.js
@@ -0,0 +1,57 @@
+// Toast Store Queue
+import { writable } from 'svelte/store';
+const toastDefaults = { message: 'Missing Toast Message', autohide: true, timeout: 5000 };
+// Note for security; differentiates the queued toasts
+function randomUUID() {
+ const random = Math.random();
+ return Number(random).toString(32);
+}
+// If toast should auto-hide, wait X time, then close by ID
+function handleAutoHide(toast) {
+ if (toast.autohide === true) {
+ return setTimeout(() => {
+ toastStore.close(toast.id);
+ }, toast.timeout);
+ }
+}
+function toastService() {
+ const { subscribe, set, update } = writable([]);
+ return {
+ subscribe,
+ /** Add a new toast to the queue. */
+ trigger: (toast) =>
+ update((tStore) => {
+ const id = randomUUID();
+ // Trigger Callback
+ if (toast && toast.callback) toast.callback({ id, status: 'queued' });
+ // Merge with defaults
+ const tMerged = { ...toastDefaults, ...toast, id };
+ // Handle auto-hide, if needed
+ tMerged.timeoutId = handleAutoHide(tMerged);
+ // Push into store
+ tStore.push(tMerged);
+ // Return
+ return tStore;
+ }),
+ /** Remove first toast in queue */
+ close: (id) =>
+ update((tStore) => {
+ if (tStore.length > 0) {
+ const index = tStore.findIndex((t) => t.id === id);
+ const selectedToast = tStore[index];
+ if (selectedToast) {
+ // Trigger Callback
+ if (selectedToast.callback) selectedToast.callback({ id, status: 'closed' });
+ // Clear timeout
+ if (selectedToast.timeoutId) clearTimeout(selectedToast.timeoutId);
+ // Remove
+ tStore.splice(index, 1);
+ }
+ }
+ return tStore;
+ }),
+ /** Remove all toasts from queue */
+ clear: () => set([])
+ };
+}
+export const toastStore = toastService();
diff --git a/web/app/src/skeleton/utilities/Toast/types.d.ts b/web/app/src/skeleton/utilities/Toast/types.d.ts
new file mode 100644
index 0000000..a55209a
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Toast/types.d.ts
@@ -0,0 +1,27 @@
+export interface ToastSettings {
+ /** Provide the toast message. Supports HTML. */
+ message: string;
+ /** Provide CSS classes to set the background color. */
+ background?: string;
+ /** Enables auto-hide after the timeout duration. */
+ autohide?: boolean;
+ /** Set the auto-hide timeout duration. */
+ timeout?: number;
+ /** Generate a custom action button UI. */
+ action?: {
+ /** The button label. Supports HTML. */
+ label: string;
+ /** The function triggered when the button is pressed. */
+ response: () => void;
+ };
+ /** Provide arbitrary CSS classes to style the toast. */
+ classes?: string;
+ /** Callback function that fires on trigger and close. */
+ callback?: (response: { id: string; status: 'queued' | 'closed' }) => void;
+}
+export interface Toast extends ToastSettings {
+ /** A UUID will be auto-assigned on `.trigger()`. */
+ id: string;
+ /** The id of the `setTimeout` if `autohide` is enabled */
+ timeoutId?: ReturnType;
+}
diff --git a/web/app/src/skeleton/utilities/Toast/types.js b/web/app/src/skeleton/utilities/Toast/types.js
new file mode 100644
index 0000000..bd31e8b
--- /dev/null
+++ b/web/app/src/skeleton/utilities/Toast/types.js
@@ -0,0 +1,2 @@
+// Toast interface types
+export {};
diff --git a/web/app/src/theme.postcss b/web/app/src/theme.postcss
new file mode 100644
index 0000000..bf29742
--- /dev/null
+++ b/web/app/src/theme.postcss
@@ -0,0 +1,97 @@
+:root {
+ /* =~= Theme Properties =~= */
+ --theme-font-family-base: system-ui;
+ --theme-font-family-heading: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
+ monospace;
+ --theme-font-color-base: 0 0 0;
+ --theme-font-color-dark: 255 255 255;
+ --theme-rounded-base: 9999px;
+ --theme-rounded-container: 8px;
+ --theme-border-base: 1px;
+ /* =~= Theme On-X Colors =~= */
+ --on-primary: 0 0 0;
+ --on-secondary: 0 0 0;
+ --on-tertiary: 0 0 0;
+ --on-success: 0 0 0;
+ --on-warning: 0 0 0;
+ --on-error: 255 255 255;
+ --on-surface: 255 255 255;
+ /* =~= Theme Colors =~= */
+ /* primary | #80d0ff */
+ --color-primary-50: 236 248 255; /* ⬅ #ecf8ff */
+ --color-primary-100: 230 246 255; /* ⬅ #e6f6ff */
+ --color-primary-200: 223 243 255; /* ⬅ #dff3ff */
+ --color-primary-300: 204 236 255; /* ⬅ #ccecff */
+ --color-primary-400: 166 222 255; /* ⬅ #a6deff */
+ --color-primary-500: 128 208 255; /* ⬅ #80d0ff */
+ --color-primary-600: 115 187 230; /* ⬅ #73bbe6 */
+ --color-primary-700: 96 156 191; /* ⬅ #609cbf */
+ --color-primary-800: 77 125 153; /* ⬅ #4d7d99 */
+ --color-primary-900: 63 102 125; /* ⬅ #3f667d */
+ /* secondary | #b6c9d8 */
+ --color-secondary-50: 244 247 249; /* ⬅ #f4f7f9 */
+ --color-secondary-100: 240 244 247; /* ⬅ #f0f4f7 */
+ --color-secondary-200: 237 242 245; /* ⬅ #edf2f5 */
+ --color-secondary-300: 226 233 239; /* ⬅ #e2e9ef */
+ --color-secondary-400: 204 217 228; /* ⬅ #ccd9e4 */
+ --color-secondary-500: 182 201 216; /* ⬅ #b6c9d8 */
+ --color-secondary-600: 164 181 194; /* ⬅ #a4b5c2 */
+ --color-secondary-700: 137 151 162; /* ⬅ #8997a2 */
+ --color-secondary-800: 109 121 130; /* ⬅ #6d7982 */
+ --color-secondary-900: 89 98 106; /* ⬅ #59626a */
+ /* tertiary | #cbc1e9 */
+ --color-tertiary-50: 247 246 252; /* ⬅ #f7f6fc */
+ --color-tertiary-100: 245 243 251; /* ⬅ #f5f3fb */
+ --color-tertiary-200: 242 240 250; /* ⬅ #f2f0fa */
+ --color-tertiary-300: 234 230 246; /* ⬅ #eae6f6 */
+ --color-tertiary-400: 219 212 240; /* ⬅ #dbd4f0 */
+ --color-tertiary-500: 203 193 233; /* ⬅ #cbc1e9 */
+ --color-tertiary-600: 183 174 210; /* ⬅ #b7aed2 */
+ --color-tertiary-700: 152 145 175; /* ⬅ #9891af */
+ --color-tertiary-800: 122 116 140; /* ⬅ #7a748c */
+ --color-tertiary-900: 99 95 114; /* ⬅ #635f72 */
+ /* success | #84cc16 */
+ --color-success-50: 237 247 220; /* ⬅ #edf7dc */
+ --color-success-100: 230 245 208; /* ⬅ #e6f5d0 */
+ --color-success-200: 224 242 197; /* ⬅ #e0f2c5 */
+ --color-success-300: 206 235 162; /* ⬅ #ceeba2 */
+ --color-success-400: 169 219 92; /* ⬅ #a9db5c */
+ --color-success-500: 132 204 22; /* ⬅ #84cc16 */
+ --color-success-600: 119 184 20; /* ⬅ #77b814 */
+ --color-success-700: 99 153 17; /* ⬅ #639911 */
+ --color-success-800: 79 122 13; /* ⬅ #4f7a0d */
+ --color-success-900: 65 100 11; /* ⬅ #41640b */
+ /* warning | #EAB308 */
+ --color-warning-50: 252 244 218; /* ⬅ #fcf4da */
+ --color-warning-100: 251 240 206; /* ⬅ #fbf0ce */
+ --color-warning-200: 250 236 193; /* ⬅ #faecc1 */
+ --color-warning-300: 247 225 156; /* ⬅ #f7e19c */
+ --color-warning-400: 240 202 82; /* ⬅ #f0ca52 */
+ --color-warning-500: 234 179 8; /* ⬅ #EAB308 */
+ --color-warning-600: 211 161 7; /* ⬅ #d3a107 */
+ --color-warning-700: 176 134 6; /* ⬅ #b08606 */
+ --color-warning-800: 140 107 5; /* ⬅ #8c6b05 */
+ --color-warning-900: 115 88 4; /* ⬅ #735804 */
+ /* error | #93000a */
+ --color-error-50: 239 217 218; /* ⬅ #efd9da */
+ --color-error-100: 233 204 206; /* ⬅ #e9ccce */
+ --color-error-200: 228 191 194; /* ⬅ #e4bfc2 */
+ --color-error-300: 212 153 157; /* ⬅ #d4999d */
+ --color-error-400: 179 77 84; /* ⬅ #b34d54 */
+ --color-error-500: 147 0 10; /* ⬅ #93000a */
+ --color-error-600: 132 0 9; /* ⬅ #840009 */
+ --color-error-700: 110 0 8; /* ⬅ #6e0008 */
+ --color-error-800: 88 0 6; /* ⬅ #580006 */
+ --color-error-900: 72 0 5; /* ⬅ #480005 */
+ /* surface | #41484d */
+ --color-surface-50: 227 228 228; /* ⬅ #e3e4e4 */
+ --color-surface-100: 217 218 219; /* ⬅ #d9dadb */
+ --color-surface-200: 208 209 211; /* ⬅ #d0d1d3 */
+ --color-surface-300: 179 182 184; /* ⬅ #b3b6b8 */
+ --color-surface-400: 122 127 130; /* ⬅ #7a7f82 */
+ --color-surface-500: 65 72 77; /* ⬅ #41484d */
+ --color-surface-600: 59 65 69; /* ⬅ #3b4145 */
+ --color-surface-700: 49 54 58; /* ⬅ #31363a */
+ --color-surface-800: 39 43 46; /* ⬅ #272b2e */
+ --color-surface-900: 32 35 38; /* ⬅ #202326 */
+}
diff --git a/web/app/static/error.png b/web/app/static/error.png
new file mode 100644
index 0000000..6d9f8f8
Binary files /dev/null and b/web/app/static/error.png differ
diff --git a/web/app/static/fail.png b/web/app/static/fail.png
new file mode 100644
index 0000000..bc6883f
Binary files /dev/null and b/web/app/static/fail.png differ
diff --git a/web/app/static/favicon.svg b/web/app/static/favicon.svg
new file mode 100644
index 0000000..2f4547e
--- /dev/null
+++ b/web/app/static/favicon.svg
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/web/app/static/info.png b/web/app/static/info.png
new file mode 100644
index 0000000..8ff107c
Binary files /dev/null and b/web/app/static/info.png differ
diff --git a/web/app/static/pass.png b/web/app/static/pass.png
new file mode 100644
index 0000000..a2f34b0
Binary files /dev/null and b/web/app/static/pass.png differ
diff --git a/web/app/static/sounds/fail.mp3 b/web/app/static/sounds/fail.mp3
new file mode 100644
index 0000000..2750de4
Binary files /dev/null and b/web/app/static/sounds/fail.mp3 differ
diff --git a/web/app/static/sounds/fail.webm b/web/app/static/sounds/fail.webm
new file mode 100644
index 0000000..cec43f4
Binary files /dev/null and b/web/app/static/sounds/fail.webm differ
diff --git a/web/app/static/sounds/pass.mp3 b/web/app/static/sounds/pass.mp3
new file mode 100644
index 0000000..bf60fd9
Binary files /dev/null and b/web/app/static/sounds/pass.mp3 differ
diff --git a/web/app/static/sounds/pass.webm b/web/app/static/sounds/pass.webm
new file mode 100644
index 0000000..389ffa4
Binary files /dev/null and b/web/app/static/sounds/pass.webm differ
diff --git a/web/app/static/sounds/warning.mp3 b/web/app/static/sounds/warning.mp3
new file mode 100644
index 0000000..02b3d31
Binary files /dev/null and b/web/app/static/sounds/warning.mp3 differ
diff --git a/web/app/static/sounds/warning.webm b/web/app/static/sounds/warning.webm
new file mode 100644
index 0000000..2abfe9d
Binary files /dev/null and b/web/app/static/sounds/warning.webm differ
diff --git a/web/app/static/warning.png b/web/app/static/warning.png
new file mode 100644
index 0000000..fcdc7b8
Binary files /dev/null and b/web/app/static/warning.png differ
diff --git a/web/app/svelte.config.js b/web/app/svelte.config.js
new file mode 100644
index 0000000..8c0aac6
--- /dev/null
+++ b/web/app/svelte.config.js
@@ -0,0 +1,27 @@
+import adapter from '@sveltejs/adapter-static';
+import { vitePreprocess } from '@sveltejs/kit/vite';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ // Consult https://kit.svelte.dev/docs/integrations#preprocessors
+ // for more information about preprocessors
+ preprocess: vitePreprocess(),
+
+ kit: {
+ // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
+ // If your environment is not supported or you settled on a specific environment, switch out the adapter.
+ // See https://kit.svelte.dev/docs/adapters for more information about adapters.
+ adapter: adapter({
+ pages: 'build',
+ assets: 'build',
+ fallback: 'index.html',
+ precompress: false,
+ strict: true,
+ csp: {
+ mode: 'hash'
+ }
+ })
+ }
+};
+
+export default config;
diff --git a/web/app/tailwind.config.cjs b/web/app/tailwind.config.cjs
new file mode 100644
index 0000000..1004042
--- /dev/null
+++ b/web/app/tailwind.config.cjs
@@ -0,0 +1,9 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ darkMode: 'class',
+ content: ['./src/**/*.{html,js,svelte,ts}'],
+ theme: {
+ extend: {}
+ },
+ plugins: [require('@tailwindcss/forms'), ...require('./src/skeleton/tailwind/skeleton.cjs')()]
+};
diff --git a/web/app/tsconfig.json b/web/app/tsconfig.json
new file mode 100644
index 0000000..6ae0c8c
--- /dev/null
+++ b/web/app/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true
+ }
+ // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
+ //
+ // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
+ // from the referenced tsconfig.json - TypeScript does not merge them in
+}
diff --git a/web/app/vite.config.ts b/web/app/vite.config.ts
new file mode 100644
index 0000000..674f963
--- /dev/null
+++ b/web/app/vite.config.ts
@@ -0,0 +1,22 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [sveltekit()],
+ test: {
+ include: ['src/**/*.{test,spec}.{js,ts}'],
+ exclude: ['src/skeleton/**/*'],
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['src/setupTest.ts']
+ },
+ server: {
+ proxy: {
+ '/api': 'http://127.0.0.1:9393',
+ '/ws': {
+ target: 'http://127.0.0.1:9393',
+ ws: true
+ }
+ }
+ }
+});
diff --git a/web/embed.go b/web/embed.go
new file mode 100644
index 0000000..212b1d4
--- /dev/null
+++ b/web/embed.go
@@ -0,0 +1,6 @@
+package web
+
+import "embed"
+
+//go:embed all:app/build/*
+var StaticAssetFS embed.FS