'
+
+ # Login and the admin API go through the proxy...
+ token="$(curl -fsS http://127.0.0.1:8009/_matrix/client/v3/login -d '{
+ "type": "m.login.password",
+ "identifier": {"type": "m.id.user", "user": "ci-admin"},
+ "password": "hunter2hunter2"
+ }' | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')"
+ test -n "$token"
+ curl -fsS http://127.0.0.1:8009/_warp/admin/v1/stats \
+ -H "Authorization: Bearer $token" | grep -q '"admins":1'
+
+ # ...and the rest of the homeserver does not.
+ status="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8009/_matrix/client/v3/sync \
+ -H "Authorization: Bearer $token")"
+ test "$status" = "404"
diff --git a/Makefile b/Makefile
index 15bb822..c47fac9 100644
--- a/Makefile
+++ b/Makefile
@@ -91,6 +91,43 @@ create-account: build ## Create an account: make create-account ACCOUNT=alice AR
@test -n "$(ACCOUNT)" || { echo "set ACCOUNT, e.g. make create-account ACCOUNT=alice ARGS=--admin"; exit 1; }
./$(BIN_DIR)/$(BINARY)$(EXE) create-account --username "$(ACCOUNT)" $(ARGS)
+# --- Admin panel (apps/admin) -------------------------------------------
+# The panel is optional and runs as its own process, warp-admin, which
+# serves the built frontend and proxies to the homeserver.
+
+PNPM ?= pnpm
+ADMIN_DIR := apps/admin
+ADMIN_IMAGE ?= warp-admin
+
+.PHONY: admin-deps
+admin-deps: ## Install the frontend dependencies (pnpm)
+ $(PNPM) install --frozen-lockfile
+
+.PHONY: admin-web
+admin-web: ## Build the admin panel frontend into apps/admin/internal/panel/build/dist
+ $(PNPM) --filter @warp/admin build
+
+.PHONY: admin
+admin: admin-web ## Build bin/warp-admin with the panel embedded
+ cd $(ADMIN_DIR) && $(GO) build -ldflags "-X main.version=$(VERSION)" -o ../../$(BIN_DIR)/warp-admin$(EXE) ./cmd/warp-admin
+
+.PHONY: admin-run
+admin-run: admin ## Run warp-admin against http://localhost:8008
+ ./$(BIN_DIR)/warp-admin$(EXE) -homeserver http://localhost:8008
+
+.PHONY: admin-dev
+admin-dev: ## Run the panel with hot reload on :5174, proxied to localhost:8008
+ $(PNPM) --filter @warp/admin dev
+
+.PHONY: admin-test
+admin-test: ## Typecheck the frontend and test warp-admin
+ $(PNPM) -r typecheck
+ cd $(ADMIN_DIR) && $(GO) vet ./... && $(GO) test ./...
+
+.PHONY: admin-docker
+admin-docker: ## Build the warp-admin container image
+ docker build -t $(ADMIN_IMAGE):$(VERSION) -f $(ADMIN_DIR)/Dockerfile .
+
.PHONY: complement-image
complement-image: ## Build the Complement homeserver image
docker build -t $(COMPLEMENT_IMAGE) -f $(SERVER_DIR)/test/complement/Dockerfile .
diff --git a/README.md b/README.md
index 3c3eca0..74c89de 100644
--- a/README.md
+++ b/README.md
@@ -200,6 +200,11 @@ application services and their delivery, the room list and detail,
purging a room, removing a user from one, the audit log and the dashboard
counters. See [docs/admin-api.md](docs/admin-api.md).
+The admin panel is a web UI for all of it. It is optional and runs as its
+own process, `warp-admin`, next to the homeserver:
+`docker compose --profile admin up -d`, or see
+[apps/admin](apps/admin/README.md).
+
Also in place: argon2id passwords, hashed access and refresh tokens,
per-endpoint rate limiting, YAML + environment configuration, structured
logging, graceful shutdown, the storage interfaces with SQLite, PostgreSQL
@@ -364,7 +369,7 @@ server/ the homeserver (Go module)
filestore/ media content on the local filesystem
sqlite/ postgres/ pocketbase/
test/complement/ the Complement homeserver image and runner
-apps/ clients: ui, admin, web, desktop, mobile (Phase 1+)
+apps/ clients: ui (design system), admin (warp-admin panel); web, desktop, mobile to come
deploy/ compose/ (with Prometheus, Grafana, Jaeger), helm/warp, grafana/ dashboard
docs/
```
diff --git a/apps/README.md b/apps/README.md
index 4bc3354..692d8fd 100644
--- a/apps/README.md
+++ b/apps/README.md
@@ -1,14 +1,17 @@
# Clients
-Nothing here yet. The client sub-projects start once the server can do
-something worth talking to:
-
-| Directory | What | Starts after |
+| Directory | What | State |
|---|---|---|
-| `ui/` | shared Vue 3 + Tailwind component library and Matrix store | server Phase 1 |
-| `admin/` | admin web panel, embedded into the `warp` binary | server Phase 1 |
-| `web/` | web client (Vue SPA) + Go host | server M1 (Phase 4) |
+| [`ui/`](ui/README.md) | shared Vue 3 + Tailwind design system: tokens, fonts, icons, components | in use by admin; the Matrix store comes with web |
+| [`admin/`](admin/README.md) | admin web panel, served by its own optional `warp-admin` binary | done for the current admin API |
+| `web/` | web client (Vue SPA) + Go host | next |
| `desktop/` | Wails v3 shell reusing the web frontend | once web has a timeline |
-| `mobile/` | Flutter app, Android first | server Phase 8 (push) |
+| `mobile/` | Flutter app, Android first | after web |
-See PLAN.md section 8 for the stack decisions behind each one.
+The frontend packages are a pnpm workspace (`pnpm-workspace.yaml` at the
+repository root); the Go hosts join `go.work`.
+
+```bash
+pnpm install
+pnpm admin:dev
+```
diff --git a/apps/admin/Dockerfile b/apps/admin/Dockerfile
new file mode 100644
index 0000000..4d19f2a
--- /dev/null
+++ b/apps/admin/Dockerfile
@@ -0,0 +1,52 @@
+# warp-admin — the optional admin panel, as its own container.
+#
+# Build from the repository root (the panel uses the shared apps/ui package):
+#
+# docker build -t warp-admin -f apps/admin/Dockerfile .
+# docker run -p 8009:8009 -e WARP_ADMIN_HOMESERVER=http://warp:8008 warp-admin
+
+FROM node:22-alpine AS web
+
+WORKDIR /src
+RUN corepack enable
+
+# Lockfile and manifests first, so the package store survives source changes.
+COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
+COPY apps/ui/package.json ./apps/ui/
+COPY apps/admin/package.json ./apps/admin/
+RUN pnpm install --frozen-lockfile --filter @warp/admin...
+
+COPY apps/ui ./apps/ui
+COPY apps/admin ./apps/admin
+RUN pnpm --filter @warp/admin build
+
+
+FROM golang:1.26-alpine AS build
+
+WORKDIR /src/apps/admin
+COPY apps/admin/go.mod ./
+COPY apps/admin/cmd ./cmd
+COPY apps/admin/internal ./internal
+COPY --from=web /src/apps/admin/internal/panel/build/dist ./internal/panel/build/dist
+
+ARG VERSION=0.0.0-dev
+ENV CGO_ENABLED=0 GOWORK=off
+RUN go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/warp-admin ./cmd/warp-admin
+
+
+FROM alpine:3.21
+
+RUN apk add --no-cache ca-certificates curl && adduser -D -u 10002 warp-admin
+COPY --from=build /out/warp-admin /usr/local/bin/warp-admin
+USER warp-admin
+
+ENV WARP_ADMIN_LISTEN=":8009" \
+ WARP_ADMIN_HOMESERVER="http://warp:8008" \
+ WARP_ADMIN_LOG_FORMAT="json"
+
+EXPOSE 8009
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
+ CMD curl -fsS http://127.0.0.1:8009/healthz || exit 1
+
+ENTRYPOINT ["warp-admin"]
diff --git a/apps/admin/README.md b/apps/admin/README.md
new file mode 100644
index 0000000..e48300b
--- /dev/null
+++ b/apps/admin/README.md
@@ -0,0 +1,119 @@
+# Warp admin panel
+
+The operator's web UI for a Warp homeserver: dashboard, users, rooms,
+federation, registration and tokens, server notices, application services
+and the audit log. It is the frontend of the admin API
+([docs/admin-api.md](../../docs/admin-api.md)).
+
+The panel is **optional and separate from the homeserver**. It ships as its
+own small binary, `warp-admin`, with the built Vue app embedded. A Warp
+server runs the same whether or not you install it.
+
+```
+browser ──► warp-admin :8009 ──► warp :8008
+ serves the panel /_matrix/client/v3/login, logout, account/whoami
+ proxies 5 paths /_matrix/client/versions
+ /_warp/admin/v1/*
+```
+
+warp-admin proxies only what the panel uses and answers `404` for anything
+else, so it is not a second way into the homeserver. Because the browser
+only talks to warp-admin, the panel is same-origin (no CORS), the homeserver
+URL is runtime configuration rather than baked into the build, and
+`/_warp/admin` can stay off your public listener: only warp-admin has to
+reach it.
+
+## Signing in
+
+With a normal account that has the admin flag. Make the first one on the
+server:
+
+```bash
+warp create-account --username root --admin
+```
+
+The panel logs in like any Matrix client and uses the access token for the
+admin API. The token is kept in `sessionStorage` (gone with the tab) unless
+you tick "Stay signed in on this browser". Signing out logs the session out
+on the homeserver.
+
+## Run it
+
+### With Docker Compose
+
+```bash
+cd deploy/compose
+docker compose --profile admin up -d
+```
+
+The panel is on
. Leave out `--profile admin` and it
+isn't installed.
+
+### As a container
+
+```bash
+docker build -t warp-admin -f apps/admin/Dockerfile .
+docker run -p 8009:8009 -e WARP_ADMIN_HOMESERVER=http://warp:8008 warp-admin
+```
+
+### As a binary
+
+```bash
+make admin
+./bin/warp-admin -homeserver http://localhost:8008
+```
+
+| Flag | Environment | Default |
+|---|---|---|
+| `-listen` | `WARP_ADMIN_LISTEN` | `:8009` |
+| `-homeserver` | `WARP_ADMIN_HOMESERVER` | `http://localhost:8008` |
+| `-log-format` | `WARP_ADMIN_LOG_FORMAT` | `text` (or `json`) |
+
+`/healthz` answers `ok` for container health checks.
+
+## Deploying
+
+- Put warp-admin behind your TLS proxy, ideally on a hostname or network
+ only admins can reach. It sends a strict Content-Security-Policy,
+ `X-Frame-Options: DENY` and `Referrer-Policy: no-referrer`.
+- warp-admin adds `X-Forwarded-For`. The homeserver uses it for rate limits
+ and a session's last-seen IP only with `client_api.trust_x_forwarded_for`
+ on; without it, admin logins count against warp-admin's own address.
+ Turn it on only when every client reaches the homeserver through proxies
+ you control.
+
+## Develop
+
+```bash
+pnpm install
+pnpm admin:dev # http://localhost:5174, proxied to localhost:8008
+```
+
+`WARP_ADMIN_HOMESERVER` points the dev server at another homeserver. Vite
+proxies the same paths warp-admin does, so the app code is the same in
+both.
+
+| Command | What |
+|---|---|
+| `make admin-web` | build the frontend into `internal/panel/build/dist` |
+| `make admin` | build the frontend, then `bin/warp-admin` with it embedded |
+| `make admin-test` | typecheck the frontend, vet and test the Go host |
+| `make admin-docker` | build the container image |
+
+A `warp-admin` compiled before the frontend is built still runs. It serves a
+page saying the panel is missing.
+
+## Layout
+
+| Path | What |
+|---|---|
+| `src/api/` | typed client for the admin API and the login endpoints |
+| `src/stores/` | the admin's session, toasts (Pinia) |
+| `src/views/` | one view per page |
+| `src/components/` | panel-specific pieces: page header, pager, confirm dialog |
+| `cmd/warp-admin/` | the binary |
+| `internal/host/` | static serving and the allow-listed proxy |
+| `internal/panel/` | the embedded build |
+
+The design system (tokens, fonts, icons, base components) comes from
+[`@warp/ui`](../ui/README.md).
diff --git a/apps/admin/cmd/warp-admin/main.go b/apps/admin/cmd/warp-admin/main.go
new file mode 100644
index 0000000..c5d0946
--- /dev/null
+++ b/apps/admin/cmd/warp-admin/main.go
@@ -0,0 +1,115 @@
+// Command warp-admin serves the Warp admin panel and proxies its requests
+// to a homeserver. It is optional: a Warp server runs without it, and an
+// operator who wants the panel runs this next to it.
+//
+// warp-admin -homeserver http://localhost:8008 -listen :8009
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/dariuszjaniak/warp/apps/admin/internal/host"
+ "github.com/dariuszjaniak/warp/apps/admin/internal/panel"
+)
+
+// Set with -ldflags "-X main.version=...".
+var version = "0.0.0-dev"
+
+func main() {
+ if err := run(); err != nil {
+ fmt.Fprintln(os.Stderr, "warp-admin:", err)
+ os.Exit(1)
+ }
+}
+
+func env(key, def string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return def
+}
+
+func run() error {
+ listen := flag.String("listen", env("WARP_ADMIN_LISTEN", ":8009"), "address to listen on (WARP_ADMIN_LISTEN)")
+ homeserver := flag.String("homeserver", env("WARP_ADMIN_HOMESERVER", "http://localhost:8008"),
+ "base URL of the Warp homeserver (WARP_ADMIN_HOMESERVER)")
+ logFormat := flag.String("log-format", env("WARP_ADMIN_LOG_FORMAT", "text"), "text or json (WARP_ADMIN_LOG_FORMAT)")
+ showVersion := flag.Bool("version", false, "print the version and exit")
+ flag.Parse()
+
+ if *showVersion {
+ fmt.Println("warp-admin", version)
+ return nil
+ }
+
+ var logger *slog.Logger
+ switch *logFormat {
+ case "json":
+ logger = slog.New(slog.NewJSONHandler(os.Stderr, nil))
+ case "text":
+ logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
+ default:
+ return fmt.Errorf("unknown log format %q", *logFormat)
+ }
+ slog.SetDefault(logger)
+
+ target, err := url.Parse(strings.TrimRight(*homeserver, "/"))
+ if err != nil {
+ return fmt.Errorf("homeserver: %w", err)
+ }
+
+ files, built := panel.FS()
+ if !built {
+ logger.Warn("the panel was not built into this binary; serving a placeholder page")
+ files = nil
+ }
+
+ h, err := host.New(host.Options{Homeserver: target, Panel: files, Logger: logger})
+ if err != nil {
+ return err
+ }
+
+ srv := &http.Server{
+ Addr: *listen,
+ Handler: h,
+ ReadHeaderTimeout: 10 * time.Second,
+ ReadTimeout: time.Minute,
+ WriteTimeout: 3 * time.Minute,
+ IdleTimeout: 2 * time.Minute,
+ MaxHeaderBytes: 64 << 10,
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ errc := make(chan error, 1)
+ go func() {
+ logger.Info("warp-admin listening", "addr", *listen, "homeserver", target.String(), "version", version)
+ errc <- srv.ListenAndServe()
+ }()
+
+ select {
+ case err := <-errc:
+ if !errors.Is(err, http.ErrServerClosed) {
+ return err
+ }
+ return nil
+ case <-ctx.Done():
+ }
+
+ logger.Info("shutting down")
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ return srv.Shutdown(shutdownCtx)
+}
diff --git a/apps/admin/go.mod b/apps/admin/go.mod
new file mode 100644
index 0000000..487bd25
--- /dev/null
+++ b/apps/admin/go.mod
@@ -0,0 +1,3 @@
+module github.com/dariuszjaniak/warp/apps/admin
+
+go 1.26.4
diff --git a/apps/admin/index.html b/apps/admin/index.html
new file mode 100644
index 0000000..a726e4a
--- /dev/null
+++ b/apps/admin/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+ Warp admin
+
+
+
+
+
+
diff --git a/apps/admin/internal/host/host.go b/apps/admin/internal/host/host.go
new file mode 100644
index 0000000..de01e36
--- /dev/null
+++ b/apps/admin/internal/host/host.go
@@ -0,0 +1,207 @@
+// Package host is the warp-admin HTTP server: it serves the built panel and
+// proxies the few homeserver paths the panel uses.
+//
+// The panel runs apart from the homeserver so an operator chooses whether
+// to have one at all, and where. Proxying, rather than having the browser
+// call the homeserver, keeps the panel same-origin (no CORS, no homeserver
+// URL baked into the build) and lets a deployment keep /_warp/admin off the
+// public listener: only warp-admin needs to reach it.
+package host
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "io/fs"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "path"
+ "strings"
+ "time"
+)
+
+// Options configures the handler.
+type Options struct {
+ // Homeserver is where the proxied requests go, e.g. http://warp:8008.
+ Homeserver *url.URL
+ // Panel is the built panel rooted at its index.html; nil serves a page
+ // saying it has not been built.
+ Panel fs.FS
+ // Logger receives proxy errors. Defaults to slog.Default().
+ Logger *slog.Logger
+ // Transport overrides the proxy's round tripper, for tests.
+ Transport http.RoundTripper
+}
+
+// proxiedExact are the Client-Server endpoints the panel calls for its own
+// session. Nothing else of the C-S API is reachable through warp-admin, so
+// it can't be used as a general client front door.
+var proxiedExact = map[string]bool{
+ "/_matrix/client/versions": true,
+ "/_matrix/client/v3/login": true,
+ "/_matrix/client/v3/logout": true,
+ "/_matrix/client/v3/account/whoami": true,
+}
+
+const adminPrefix = "/_warp/admin/v1/"
+
+// contentSecurityPolicy allows the panel's own scripts, styles and fonts and
+// nothing else. Inline styles are allowed because Vue binds style attributes.
+const contentSecurityPolicy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
+ "img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; " +
+ "base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
+
+// New returns the warp-admin handler.
+func New(opts Options) (http.Handler, error) {
+ if opts.Homeserver == nil || opts.Homeserver.Scheme == "" || opts.Homeserver.Host == "" {
+ return nil, errors.New("host: homeserver must be an absolute URL")
+ }
+ if opts.Logger == nil {
+ opts.Logger = slog.Default()
+ }
+
+ h := &handler{opts: opts}
+
+ transport := opts.Transport
+ if transport == nil {
+ transport = &http.Transport{
+ Proxy: http.ProxyFromEnvironment,
+ DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
+ ForceAttemptHTTP2: true,
+ MaxIdleConns: 32,
+ IdleConnTimeout: 90 * time.Second,
+ TLSHandshakeTimeout: 10 * time.Second,
+ ResponseHeaderTimeout: 2 * time.Minute,
+ }
+ }
+ upstream := opts.Homeserver
+ h.proxy = &httputil.ReverseProxy{
+ Rewrite: func(pr *httputil.ProxyRequest) {
+ pr.SetURL(upstream)
+ pr.SetXForwarded()
+ // The panel has no cookies of its own to send; don't pass on any
+ // a shared domain might have set.
+ pr.Out.Header.Del("Cookie")
+ },
+ Transport: transport,
+ ModifyResponse: func(res *http.Response) error {
+ res.Header.Del("Set-Cookie")
+ if res.Header.Get("Cache-Control") == "" {
+ res.Header.Set("Cache-Control", "no-store")
+ }
+ return nil
+ },
+ ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
+ opts.Logger.Warn("homeserver unreachable", "path", r.URL.Path, "err", err)
+ writeError(w, http.StatusBadGateway, "M_UNKNOWN", "The admin service can’t reach the homeserver.")
+ },
+ }
+
+ if opts.Panel != nil {
+ index, err := fs.ReadFile(opts.Panel, "index.html")
+ if err != nil {
+ return nil, err
+ }
+ h.index = index
+ }
+ return h, nil
+}
+
+type handler struct {
+ opts Options
+ proxy *httputil.ReverseProxy
+ index []byte
+}
+
+func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ hdr := w.Header()
+ hdr.Set("X-Content-Type-Options", "nosniff")
+ hdr.Set("Referrer-Policy", "no-referrer")
+ hdr.Set("X-Frame-Options", "DENY")
+
+ p := r.URL.Path
+ switch {
+ case p == "/healthz":
+ hdr.Set("Content-Type", "text/plain; charset=utf-8")
+ hdr.Set("Cache-Control", "no-store")
+ _, _ = w.Write([]byte("ok\n"))
+ return
+
+ case strings.HasPrefix(p, "/_matrix/") || strings.HasPrefix(p, "/_warp/"):
+ // A path that cleans to something else (dot segments, doubled
+ // slashes) is refused rather than normalised: what is allowed
+ // here must be exactly what the homeserver routes.
+ if path.Clean(p) != p || !(proxiedExact[p] || strings.HasPrefix(p, adminPrefix)) {
+ writeError(w, http.StatusNotFound, "M_UNRECOGNIZED", "Not served by the admin service.")
+ return
+ }
+ h.proxy.ServeHTTP(w, r)
+ return
+ }
+
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ hdr.Set("Allow", "GET, HEAD")
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ hdr.Set("Content-Security-Policy", contentSecurityPolicy)
+ h.serveStatic(w, r)
+}
+
+func (h *handler) serveStatic(w http.ResponseWriter, r *http.Request) {
+ if h.opts.Panel == nil {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(http.StatusServiceUnavailable)
+ _, _ = w.Write([]byte(notBuiltPage))
+ return
+ }
+
+ name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
+ if name != "" && name != "index.html" {
+ if st, err := fs.Stat(h.opts.Panel, name); err == nil && st.Mode().IsRegular() {
+ if strings.HasPrefix(name, "assets/") {
+ // Vite puts a content hash in every file name under assets/.
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ } else {
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ }
+ http.ServeFileFS(w, r, h.opts.Panel, name)
+ return
+ }
+ if strings.HasPrefix(name, "assets/") || (!strings.Contains(name, "/") && path.Ext(name) != "") {
+ // A missing asset is a 404, not the app: answering a stale
+ // script URL with HTML only produces a confusing MIME error.
+ // Deeper paths are routes even with a dot in them, like
+ // /users/@alice:warp.example.
+ http.NotFound(w, r)
+ return
+ }
+ }
+
+ // Everything else is a route of the single-page app.
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-cache")
+ http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(h.index))
+}
+
+func writeError(w http.ResponseWriter, status int, errcode, msg string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(map[string]string{"errcode": errcode, "error": msg})
+}
+
+const notBuiltPage = `
+
+Warp admin
+
+The admin panel isn’t built into this binary
+This warp-admin was compiled before the frontend. Build it, then the binary:
+pnpm --filter @warp/admin buildmake admin
+`
diff --git a/apps/admin/internal/host/host_test.go b/apps/admin/internal/host/host_test.go
new file mode 100644
index 0000000..159416e
--- /dev/null
+++ b/apps/admin/internal/host/host_test.go
@@ -0,0 +1,236 @@
+package host
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "testing/fstest"
+)
+
+// upstream records what reached the homeserver.
+type upstream struct {
+ srv *httptest.Server
+ hits []*http.Request
+}
+
+func newUpstream(t *testing.T) *upstream {
+ t.Helper()
+ u := &upstream{}
+ u.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ u.hits = append(u.hits, r.Clone(r.Context()))
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Set-Cookie", "tracker=1")
+ _, _ = w.Write([]byte(`{"path":"` + r.URL.EscapedPath() + `"}`))
+ }))
+ t.Cleanup(u.srv.Close)
+ return u
+}
+
+var panelFS = fstest.MapFS{
+ "index.html": {Data: []byte("panel ")},
+ "favicon.svg": {Data: []byte(" ")},
+ "assets/index-a1.js": {Data: []byte("console.log(1)")},
+}
+
+func newHandler(t *testing.T, up *upstream, panel bool) http.Handler {
+ t.Helper()
+ target, _ := url.Parse(up.srv.URL)
+ opts := Options{Homeserver: target}
+ if panel {
+ opts.Panel = panelFS
+ }
+ h, err := New(opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return h
+}
+
+func do(h http.Handler, method, target string, header http.Header) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(method, target, nil)
+ for k, v := range header {
+ req.Header[k] = v
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+}
+
+func TestProxiesAdminAndSessionPaths(t *testing.T) {
+ up := newUpstream(t)
+ h := newHandler(t, up, true)
+
+ for _, p := range []string{
+ "/_matrix/client/versions",
+ "/_matrix/client/v3/login",
+ "/_matrix/client/v3/logout",
+ "/_matrix/client/v3/account/whoami",
+ "/_warp/admin/v1/stats",
+ "/_warp/admin/v1/users/%40alice%3Awarp.example/devices",
+ "/_warp/admin/v1/audit_log?limit=5&action=user.create",
+ } {
+ rec := do(h, http.MethodGet, p, nil)
+ if rec.Code != http.StatusOK {
+ t.Errorf("%s: status %d, want 200", p, rec.Code)
+ }
+ }
+ if len(up.hits) != 7 {
+ t.Fatalf("upstream saw %d requests, want 7", len(up.hits))
+ }
+ // Escaped IDs and the query reach the homeserver unchanged.
+ if got := up.hits[5].URL.EscapedPath(); got != "/_warp/admin/v1/users/%40alice%3Awarp.example/devices" {
+ t.Errorf("escaped path = %q", got)
+ }
+ if got := up.hits[6].URL.RawQuery; got != "limit=5&action=user.create" {
+ t.Errorf("query = %q", got)
+ }
+}
+
+func TestRefusesOtherHomeserverPaths(t *testing.T) {
+ up := newUpstream(t)
+ h := newHandler(t, up, true)
+
+ for _, p := range []string{
+ "/_matrix/client/v3/sync",
+ "/_matrix/client/v3/rooms/!a:b/send/m.room.message/1",
+ "/_matrix/federation/v1/version",
+ "/_matrix/media/v3/upload",
+ "/_warp/admin/v2/stats",
+ "/_warp/admin/v1/../../_matrix/client/v3/sync",
+ "/_warp/admin/v1/%2e%2e/%2e%2e/%2e%2e/_matrix/client/v3/sync",
+ "/_warp/admin/v1//stats",
+ } {
+ rec := do(h, http.MethodGet, p, nil)
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("%s: status %d, want 404", p, rec.Code)
+ }
+ var body map[string]string
+ _ = json.Unmarshal(rec.Body.Bytes(), &body)
+ if body["errcode"] != "M_UNRECOGNIZED" {
+ t.Errorf("%s: errcode %q", p, body["errcode"])
+ }
+ }
+ if len(up.hits) != 0 {
+ t.Fatalf("upstream saw %d requests, want none", len(up.hits))
+ }
+}
+
+func TestProxyStripsCookiesAndForwardsToken(t *testing.T) {
+ up := newUpstream(t)
+ h := newHandler(t, up, true)
+
+ rec := do(h, http.MethodGet, "/_warp/admin/v1/stats", http.Header{
+ "Authorization": {"Bearer tok"},
+ "Cookie": {"session=secret"},
+ })
+ if rec.Header().Get("Set-Cookie") != "" {
+ t.Error("Set-Cookie from the homeserver was passed to the browser")
+ }
+ if rec.Header().Get("Cache-Control") != "no-store" {
+ t.Errorf("Cache-Control = %q, want no-store", rec.Header().Get("Cache-Control"))
+ }
+ got := up.hits[0]
+ if got.Header.Get("Cookie") != "" {
+ t.Error("Cookie was passed to the homeserver")
+ }
+ if got.Header.Get("Authorization") != "Bearer tok" {
+ t.Errorf("Authorization = %q", got.Header.Get("Authorization"))
+ }
+ if got.Header.Get("X-Forwarded-For") == "" {
+ t.Error("X-Forwarded-For not set")
+ }
+}
+
+func TestUnreachableHomeserverIs502(t *testing.T) {
+ target, _ := url.Parse("http://127.0.0.1:1")
+ h, err := New(Options{Homeserver: target, Panel: panelFS})
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := do(h, http.MethodGet, "/_warp/admin/v1/stats", nil)
+ if rec.Code != http.StatusBadGateway {
+ t.Fatalf("status %d, want 502", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "M_UNKNOWN") {
+ t.Errorf("body %q", rec.Body.String())
+ }
+}
+
+func TestServesPanel(t *testing.T) {
+ up := newUpstream(t)
+ h := newHandler(t, up, true)
+
+ cases := []struct {
+ path, wantBody, wantCache string
+ wantCode int
+ }{
+ {"/", "panel", "no-cache", 200},
+ {"/users/%40alice%3Awarp.example", "panel", "no-cache", 200},
+ {"/rooms/!abc:warp.example", "panel", "no-cache", 200},
+ {"/index.html", "panel", "no-cache", 200},
+ {"/assets/index-a1.js", "console.log", "public, max-age=31536000, immutable", 200},
+ {"/favicon.svg", "Warp
\ No newline at end of file
diff --git a/apps/admin/src/App.vue b/apps/admin/src/App.vue
new file mode 100644
index 0000000..21cd9c0
--- /dev/null
+++ b/apps/admin/src/App.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
diff --git a/apps/admin/src/api/admin.ts b/apps/admin/src/api/admin.ts
new file mode 100644
index 0000000..af029ff
--- /dev/null
+++ b/apps/admin/src/api/admin.ts
@@ -0,0 +1,138 @@
+import { enc, request } from './client'
+import type {
+ AppService,
+ AuditLog,
+ Destinations,
+ Device,
+ Federation,
+ MediaUsage,
+ RegistrationToken,
+ RegistrationTokenList,
+ RoomDetail,
+ RoomList,
+ Stats,
+ Switch,
+ UserDetail,
+ UserList,
+} from './types'
+
+const base = '/_warp/admin/v1'
+
+function query(params: Record): string {
+ const q = new URLSearchParams()
+ for (const [k, v] of Object.entries(params)) {
+ if (v !== undefined && v !== '') q.set(k, String(v))
+ }
+ const s = q.toString()
+ return s ? `?${s}` : ''
+}
+
+// --- Matrix login, for the admin's own session ---
+
+export interface LoginResponse {
+ user_id: string
+ access_token: string
+ device_id: string
+}
+
+export function login(username: string, password: string) {
+ return request(
+ 'POST',
+ '/_matrix/client/v3/login',
+ {
+ type: 'm.login.password',
+ identifier: { type: 'm.id.user', user: username },
+ password,
+ initial_device_display_name: 'Warp admin panel',
+ },
+ false,
+ )
+}
+
+export function logout() {
+ return request('POST', '/_matrix/client/v3/logout', {})
+}
+
+export function whoami() {
+ return request<{ user_id: string; device_id?: string }>('GET', '/_matrix/client/v3/account/whoami')
+}
+
+// --- Dashboard ---
+
+export const getStats = () => request('GET', `${base}/stats`)
+
+// --- Federation ---
+
+export const getFederation = () => request('GET', `${base}/federation`)
+export const setFederation = (enabled: boolean) => request('PUT', `${base}/federation`, { enabled })
+export const setFederationLists = (allowlist: string[], denylist: string[]) =>
+ request('PUT', `${base}/federation/lists`, { allowlist, denylist })
+export const resetFederationLists = () => request('DELETE', `${base}/federation/lists`)
+export const getDestinations = () => request('GET', `${base}/federation/destinations`)
+export const resetDestination = (serverName: string) =>
+ request('POST', `${base}/federation/destinations/${enc(serverName)}/reset`)
+
+// --- Registration ---
+
+export const getRegistration = () => request('GET', `${base}/registration`)
+export const setRegistration = (enabled: boolean) => request('PUT', `${base}/registration`, { enabled })
+
+export const listTokens = () => request('GET', `${base}/registration_tokens`)
+export const createToken = (body: {
+ token?: string
+ length?: number
+ uses_allowed?: number | null
+ expiry_time_ms?: number | null
+}) => request('POST', `${base}/registration_tokens`, body)
+export const updateToken = (token: string, body: { uses_allowed?: number | null; expiry_time_ms?: number | null }) =>
+ request('PUT', `${base}/registration_tokens/${enc(token)}`, body)
+export const deleteToken = (token: string) => request('DELETE', `${base}/registration_tokens/${enc(token)}`)
+
+// --- Users ---
+
+export const listUsers = (search: string, limit: number, offset: number) =>
+ request('GET', `${base}/users${query({ search, limit, offset })}`)
+export const createUser = (body: { username: string; password: string; display_name?: string; admin: boolean }) =>
+ request('POST', `${base}/users`, body)
+
+const user = (id: string) => `${base}/users/${enc(id)}`
+
+export const getUser = (id: string) => request('GET', user(id))
+export const setAdmin = (id: string, admin: boolean) => request('PUT', `${user(id)}/admin`, { admin })
+export const deactivateUser = (id: string, erase: boolean) =>
+ request('POST', `${user(id)}/deactivate`, { erase })
+export const reactivateUser = (id: string) => request('POST', `${user(id)}/reactivate`)
+export const resetPassword = (id: string, password: string, logoutDevices: boolean) =>
+ request('POST', `${user(id)}/password`, { password, logout_devices: logoutDevices })
+
+export const listDevices = (id: string) => request<{ devices: Device[] }>('GET', `${user(id)}/devices`)
+export const deleteDevice = (id: string, deviceId: string) =>
+ request('DELETE', `${user(id)}/devices/${enc(deviceId)}`)
+export const deleteAllDevices = (id: string) => request('DELETE', `${user(id)}/devices`)
+
+export const getMedia = (id: string) => request('GET', `${user(id)}/media`)
+export const setQuota = (id: string, maxBytes: number) =>
+ request('PUT', `${user(id)}/media/quota`, { max_bytes: maxBytes })
+export const resetQuota = (id: string) => request('DELETE', `${user(id)}/media/quota`)
+
+// --- Rooms ---
+
+export const listRooms = (search: string, limit: number, offset: number) =>
+ request('GET', `${base}/rooms${query({ search, limit, offset })}`)
+export const getRoom = (id: string) => request('GET', `${base}/rooms/${enc(id)}`)
+export const purgeRoom = (id: string) => request('DELETE', `${base}/rooms/${enc(id)}`)
+export const forceLeave = (id: string, userId: string) =>
+ request('POST', `${base}/rooms/${enc(id)}/force_leave`, { user_id: userId })
+
+// --- Server notices, app services, audit log ---
+
+export const sendNotice = (userId: string, body: string) =>
+ request<{ room_id: string; event_id: string }>('POST', `${base}/server_notices`, {
+ user_id: userId,
+ content: { msgtype: 'm.text', body },
+ })
+
+export const listAppServices = () => request<{ appservices: AppService[] }>('GET', `${base}/appservices`)
+
+export const getAuditLog = (params: { limit?: number; before?: number; actor?: string; action?: string }) =>
+ request('GET', `${base}/audit_log${query(params)}`)
diff --git a/apps/admin/src/api/client.ts b/apps/admin/src/api/client.ts
new file mode 100644
index 0000000..f56c69b
--- /dev/null
+++ b/apps/admin/src/api/client.ts
@@ -0,0 +1,90 @@
+// One fetch wrapper for both APIs the panel speaks: the C-S login and the
+// admin API share the Matrix error envelope, so there is one error path.
+//
+// Requests go to the panel's own origin. warp-admin (or Vite, in
+// development) proxies /_matrix/client and /_warp/admin to the homeserver,
+// so the panel never needs CORS or to know where the homeserver lives.
+
+export class ApiError extends Error {
+ constructor(
+ readonly status: number,
+ readonly errcode: string,
+ message: string,
+ ) {
+ super(message)
+ this.name = 'ApiError'
+ }
+
+ get isUnauthorized() {
+ return this.status === 401
+ }
+}
+
+type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'
+
+let accessToken: string | null = null
+let onUnauthorized: (() => void) | null = null
+
+export function setAccessToken(token: string | null) {
+ accessToken = token
+}
+
+/** Called when a request comes back 401: the token was revoked or expired. */
+export function setUnauthorizedHandler(fn: () => void) {
+ onUnauthorized = fn
+}
+
+export async function request(method: Method, path: string, body?: unknown, auth = true): Promise {
+ const headers: Record = { Accept: 'application/json' }
+ if (body !== undefined) headers['Content-Type'] = 'application/json'
+ // The admin API only takes the token in the header: in a URL it would land
+ // in every access log on the way.
+ if (auth && accessToken) headers.Authorization = `Bearer ${accessToken}`
+
+ let res: Response
+ try {
+ res = await fetch(path, {
+ method,
+ headers,
+ body: body === undefined ? undefined : JSON.stringify(body),
+ credentials: 'omit',
+ cache: 'no-store',
+ })
+ } catch {
+ throw new ApiError(0, 'NETWORK', 'Can’t reach the admin service. Check your connection and try again.')
+ }
+
+ const text = await res.text()
+ let data: unknown = undefined
+ if (text) {
+ try {
+ data = JSON.parse(text)
+ } catch {
+ data = undefined
+ }
+ }
+
+ if (!res.ok) {
+ const env = (data ?? {}) as { errcode?: string; error?: string }
+ const err = new ApiError(res.status, env.errcode ?? `HTTP_${res.status}`, env.error ?? describeStatus(res.status))
+ if (err.isUnauthorized && auth && onUnauthorized) onUnauthorized()
+ throw err
+ }
+ return (data ?? {}) as T
+}
+
+function describeStatus(status: number): string {
+ switch (status) {
+ case 404:
+ return 'Not found.'
+ case 502:
+ case 504:
+ return 'The admin service can’t reach the homeserver.'
+ case 503:
+ return 'The homeserver is not ready for this.'
+ default:
+ return `The server answered ${status}.`
+ }
+}
+
+export const enc = encodeURIComponent
diff --git a/apps/admin/src/api/types.ts b/apps/admin/src/api/types.ts
new file mode 100644
index 0000000..9b36096
--- /dev/null
+++ b/apps/admin/src/api/types.ts
@@ -0,0 +1,162 @@
+// Shapes of /_warp/admin/v1, as docs/admin-api.md describes them.
+
+export interface Stats {
+ server_name: string
+ version: string
+ database: string
+ users: number
+ admins: number
+ deactivated: number
+ devices: number
+ rooms: number
+ published_rooms: number
+ events: number
+ federation_enabled: boolean
+ registration_enabled: boolean
+}
+
+export interface Switch {
+ enabled: boolean
+ toggleable: boolean
+}
+
+export interface Federation extends Switch {
+ allowlist: string[]
+ denylist: string[]
+ lists_edited: boolean
+}
+
+export interface Destination {
+ server_name: string
+ failures: number
+ retry_at_ms: number
+ last_success_ms: number
+ last_failure_ms: number
+ queued_pdus: number
+ queued_edus: number
+}
+
+export interface Destinations {
+ running: boolean
+ destinations: Destination[]
+}
+
+export interface User {
+ user_id: string
+ display_name?: string
+ admin: boolean
+ deactivated: boolean
+ created_at_ms: number
+}
+
+export interface Page {
+ total: number
+ limit: number
+ offset: number
+}
+
+export interface UserList extends Page {
+ users: User[]
+}
+
+export interface MediaUsage {
+ uploads: number
+ bytes: number
+ quota_bytes: number
+ custom_quota: boolean
+}
+
+export interface UserDetail extends User {
+ devices: number
+ joined_rooms?: number
+ media: MediaUsage
+}
+
+export interface Device {
+ device_id: string
+ display_name?: string
+ created_at_ms: number
+ last_seen_ms?: number
+ last_seen_ip?: string
+ user_agent?: string
+}
+
+export interface RegistrationToken {
+ token: string
+ uses_allowed: number | null
+ completed: number
+ expiry_time_ms: number | null
+ created_at_ms: number
+ valid: boolean
+ pending?: number
+}
+
+export interface RegistrationTokenList {
+ registration_tokens: RegistrationToken[]
+ required: boolean
+}
+
+export interface Room {
+ room_id: string
+ room_version: string
+ creator: string
+ name?: string
+ topic?: string
+ canonical_alias?: string
+ join_rule?: string
+ history_visibility?: string
+ guest_access?: string
+ published: boolean
+ joined_members: number
+ created_at_ms: number
+}
+
+export interface RoomList extends Page {
+ rooms: Room[]
+}
+
+export interface RoomDetail extends Room {
+ members: number
+ invited_members: number
+ aliases: string[]
+ forward_extremities: string[]
+}
+
+export interface Namespace {
+ regex: string
+ exclusive: boolean
+}
+
+export interface AppService {
+ id: string
+ url?: string
+ sender: string
+ rate_limited: boolean
+ protocols: string[]
+ namespaces: { users: Namespace[]; aliases: Namespace[]; rooms: Namespace[] }
+ delivery?: {
+ stream_pos: number
+ behind: number
+ txn_id: number
+ events_delivered: number
+ failures: number
+ last_success_ms: number
+ last_failure_ms: number
+ retry_at_ms: number
+ last_error?: string
+ }
+}
+
+export interface AuditEntry {
+ id: number
+ ts_ms: number
+ actor: string
+ action: string
+ target?: string
+ details?: Record
+}
+
+export interface AuditLog {
+ entries: AuditEntry[]
+ next_before?: number
+}
diff --git a/apps/admin/src/components/ConfirmDialog.vue b/apps/admin/src/components/ConfirmDialog.vue
new file mode 100644
index 0000000..5498e36
--- /dev/null
+++ b/apps/admin/src/components/ConfirmDialog.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+ {{ error }}
+
+ Cancel
+
+ {{ confirmLabel }}
+
+
+
+
diff --git a/apps/admin/src/components/LoadState.vue b/apps/admin/src/components/LoadState.vue
new file mode 100644
index 0000000..63d9769
--- /dev/null
+++ b/apps/admin/src/components/LoadState.vue
@@ -0,0 +1,22 @@
+
+
+
+
+ {{ error }}
+
+ Try again
+
+
+
+
+ Loading…
+
+
diff --git a/apps/admin/src/components/Mxid.vue b/apps/admin/src/components/Mxid.vue
new file mode 100644
index 0000000..cc3ba23
--- /dev/null
+++ b/apps/admin/src/components/Mxid.vue
@@ -0,0 +1,36 @@
+
+
+
+
+ {{ value }}
+
+
+
+
+
diff --git a/apps/admin/src/components/PageHeader.vue b/apps/admin/src/components/PageHeader.vue
new file mode 100644
index 0000000..6a21ed4
--- /dev/null
+++ b/apps/admin/src/components/PageHeader.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+ {{ back.label }}
+
+
+ {{ title }}
+
+
+ {{ description }}
+
+
+
+
+
+
+
diff --git a/apps/admin/src/components/Pager.vue b/apps/admin/src/components/Pager.vue
new file mode 100644
index 0000000..4c53b67
--- /dev/null
+++ b/apps/admin/src/components/Pager.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+ No {{ noun }}
+ {{ formatNumber(from) }}–{{ formatNumber(to) }} of {{ formatNumber(total) }} {{ noun }}
+
+
+
+ Previous
+
+ Next
+
+
+
diff --git a/apps/admin/src/components/StatTile.vue b/apps/admin/src/components/StatTile.vue
new file mode 100644
index 0000000..30bd1d6
--- /dev/null
+++ b/apps/admin/src/components/StatTile.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+ {{ label }}
+
+ {{ value }}
+ {{ detail }}
+
+
diff --git a/apps/admin/src/components/ToastList.vue b/apps/admin/src/components/ToastList.vue
new file mode 100644
index 0000000..ec4a9f1
--- /dev/null
+++ b/apps/admin/src/components/ToastList.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
diff --git a/apps/admin/src/env.d.ts b/apps/admin/src/env.d.ts
new file mode 100644
index 0000000..86dd518
--- /dev/null
+++ b/apps/admin/src/env.d.ts
@@ -0,0 +1,10 @@
+///
+
+import 'vue-router'
+
+declare module 'vue-router' {
+ interface RouteMeta {
+ title?: string
+ public?: boolean
+ }
+}
diff --git a/apps/admin/src/layouts/AppShell.vue b/apps/admin/src/layouts/AppShell.vue
new file mode 100644
index 0000000..f76b7b2
--- /dev/null
+++ b/apps/admin/src/layouts/AppShell.vue
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+ Admin
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
+ {{ t.label }}
+
+
+
+
+
+
{{ localpart(session.userId) }}
+
{{ session.userId }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/admin/src/lib/format.ts b/apps/admin/src/lib/format.ts
new file mode 100644
index 0000000..6dbd37c
--- /dev/null
+++ b/apps/admin/src/lib/format.ts
@@ -0,0 +1,60 @@
+import { ApiError } from '@/api/client'
+
+const dateTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' })
+const dateOnly = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' })
+const number = new Intl.NumberFormat()
+const relative = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
+
+export function formatDateTime(ms: number | null | undefined): string {
+ return ms ? dateTime.format(ms) : '—'
+}
+
+export function formatDate(ms: number | null | undefined): string {
+ return ms ? dateOnly.format(ms) : '—'
+}
+
+export function formatNumber(n: number | null | undefined): string {
+ return n === null || n === undefined ? '—' : number.format(n)
+}
+
+export function formatRelative(ms: number | null | undefined, now = Date.now()): string {
+ if (!ms) return '—'
+ const s = Math.round((ms - now) / 1000)
+ const abs = Math.abs(s)
+ if (abs < 60) return relative.format(s, 'second')
+ if (abs < 3600) return relative.format(Math.round(s / 60), 'minute')
+ if (abs < 86400) return relative.format(Math.round(s / 3600), 'hour')
+ return relative.format(Math.round(s / 86400), 'day')
+}
+
+const units = ['B', 'KB', 'MB', 'GB', 'TB']
+
+export function formatBytes(n: number): string {
+ if (n < 1024) return `${n} B`
+ let v = n
+ let i = 0
+ while (v >= 1024 && i < units.length - 1) {
+ v /= 1024
+ i++
+ }
+ return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`
+}
+
+/** Parses "512 MB", "1.5GB", "200k" into bytes; null when it can't. */
+export function parseBytes(input: string): number | null {
+ const m = input.trim().match(/^(\d+(?:\.\d+)?)\s*([kmgt]?)(?:i?b)?$/i)
+ if (!m) return null
+ const mult: Record = { '': 1, k: 1024, m: 1024 ** 2, g: 1024 ** 3, t: 1024 ** 4 }
+ return Math.round(Number(m[1]) * (mult[(m[2] ?? '').toLowerCase()] ?? 1))
+}
+
+export function errorMessage(e: unknown): string {
+ if (e instanceof ApiError) return e.message
+ if (e instanceof Error) return e.message
+ return 'Something went wrong.'
+}
+
+/** The localpart of a Matrix ID, for avatars and short labels. */
+export function localpart(mxid: string): string {
+ return mxid.replace(/^@/, '').split(':')[0] ?? mxid
+}
diff --git a/apps/admin/src/lib/useLoad.ts b/apps/admin/src/lib/useLoad.ts
new file mode 100644
index 0000000..367efba
--- /dev/null
+++ b/apps/admin/src/lib/useLoad.ts
@@ -0,0 +1,58 @@
+import { ref, shallowRef, type Ref } from 'vue'
+import { ApiError } from '@/api/client'
+import { errorMessage } from './format'
+
+// A view's data: what was loaded, whether it is loading, and why it failed.
+// A reload keeps the old data on screen until the new data is in.
+
+export function useLoad(fn: () => Promise) {
+ const data = shallowRef() as Ref
+ const loading = ref(false)
+ const error = ref('')
+ const status = ref(0)
+ let seq = 0
+
+ async function load() {
+ const mine = ++seq
+ loading.value = true
+ error.value = ''
+ try {
+ const value = await fn()
+ if (mine === seq) {
+ data.value = value
+ status.value = 200
+ }
+ } catch (e) {
+ if (mine === seq) {
+ error.value = errorMessage(e)
+ status.value = e instanceof ApiError ? e.status : 0
+ }
+ } finally {
+ if (mine === seq) loading.value = false
+ }
+ }
+
+ return { data, loading, error, status, load }
+}
+
+/** Runs an action and tracks whether it is running; returns whether it worked. */
+export function useAction() {
+ const busy = ref(false)
+ const error = ref('')
+
+ async function run(fn: () => Promise): Promise {
+ busy.value = true
+ error.value = ''
+ try {
+ await fn()
+ return true
+ } catch (e) {
+ error.value = errorMessage(e)
+ return false
+ } finally {
+ busy.value = false
+ }
+ }
+
+ return { busy, error, run }
+}
diff --git a/apps/admin/src/main.ts b/apps/admin/src/main.ts
new file mode 100644
index 0000000..ddab9c5
--- /dev/null
+++ b/apps/admin/src/main.ts
@@ -0,0 +1,7 @@
+import { createPinia } from 'pinia'
+import { createApp } from 'vue'
+import App from './App.vue'
+import { router } from './router'
+import './style.css'
+
+createApp(App).use(createPinia()).use(router).mount('#app')
diff --git a/apps/admin/src/router.ts b/apps/admin/src/router.ts
new file mode 100644
index 0000000..892d531
--- /dev/null
+++ b/apps/admin/src/router.ts
@@ -0,0 +1,71 @@
+import { createRouter, createWebHistory } from 'vue-router'
+import { useSession } from './stores/session'
+
+export const router = createRouter({
+ history: createWebHistory(),
+ routes: [
+ { path: '/login', name: 'login', component: () => import('./views/LoginView.vue'), meta: { public: true } },
+ {
+ path: '/',
+ component: () => import('./layouts/AppShell.vue'),
+ children: [
+ { path: '', name: 'dashboard', component: () => import('./views/DashboardView.vue'), meta: { title: 'Dashboard' } },
+ { path: 'users', name: 'users', component: () => import('./views/UsersView.vue'), meta: { title: 'Users' } },
+ {
+ path: 'users/:id',
+ name: 'user',
+ component: () => import('./views/UserView.vue'),
+ props: true,
+ meta: { title: 'User' },
+ },
+ { path: 'rooms', name: 'rooms', component: () => import('./views/RoomsView.vue'), meta: { title: 'Rooms' } },
+ {
+ path: 'rooms/:id',
+ name: 'room',
+ component: () => import('./views/RoomView.vue'),
+ props: true,
+ meta: { title: 'Room' },
+ },
+ {
+ path: 'federation',
+ name: 'federation',
+ component: () => import('./views/FederationView.vue'),
+ meta: { title: 'Federation' },
+ },
+ {
+ path: 'registration',
+ name: 'registration',
+ component: () => import('./views/RegistrationView.vue'),
+ meta: { title: 'Registration' },
+ },
+ {
+ path: 'notices',
+ name: 'notices',
+ component: () => import('./views/NoticesView.vue'),
+ meta: { title: 'Server notices' },
+ },
+ {
+ path: 'appservices',
+ name: 'appservices',
+ component: () => import('./views/AppServicesView.vue'),
+ meta: { title: 'Application services' },
+ },
+ { path: 'audit', name: 'audit', component: () => import('./views/AuditView.vue'), meta: { title: 'Audit log' } },
+ ],
+ },
+ { path: '/:pathMatch(.*)*', redirect: '/' },
+ ],
+})
+
+router.beforeEach((to) => {
+ const session = useSession()
+ if (!to.meta.public && !session.signedIn) {
+ return { name: 'login', query: to.fullPath !== '/' ? { next: to.fullPath } : undefined }
+ }
+ if (to.name === 'login' && session.signedIn) return { name: 'dashboard' }
+})
+
+router.afterEach((to) => {
+ const title = to.meta.title as string | undefined
+ document.title = title ? `${title} · Warp admin` : 'Warp admin'
+})
diff --git a/apps/admin/src/stores/session.ts b/apps/admin/src/stores/session.ts
new file mode 100644
index 0000000..889904e
--- /dev/null
+++ b/apps/admin/src/stores/session.ts
@@ -0,0 +1,110 @@
+import { defineStore } from 'pinia'
+import { computed, ref } from 'vue'
+import * as api from '@/api/admin'
+import { ApiError, setAccessToken, setUnauthorizedHandler } from '@/api/client'
+
+// The admin's own Matrix session. The token lives in sessionStorage, so it
+// goes with the tab, unless the admin asked to stay signed in; then it is in
+// localStorage until they sign out.
+
+interface Saved {
+ userId: string
+ deviceId: string
+ accessToken: string
+ serverName: string
+}
+
+const key = 'warp.admin.session'
+
+function load(): Saved | null {
+ for (const store of [sessionStorage, localStorage]) {
+ try {
+ const raw = store.getItem(key)
+ if (raw) return JSON.parse(raw) as Saved
+ } catch {
+ // Unreadable or blocked: treat as signed out.
+ }
+ }
+ return null
+}
+
+function save(s: Saved | null, remember: boolean) {
+ for (const store of [sessionStorage, localStorage]) {
+ try {
+ store.removeItem(key)
+ } catch {
+ // Ignore.
+ }
+ }
+ if (!s) return
+ try {
+ ;(remember ? localStorage : sessionStorage).setItem(key, JSON.stringify(s))
+ } catch {
+ // Not kept across reloads; the session still works in this page.
+ }
+}
+
+export const useSession = defineStore('session', () => {
+ const saved = load()
+ const userId = ref(saved?.userId ?? '')
+ const deviceId = ref(saved?.deviceId ?? '')
+ const token = ref(saved?.accessToken ?? '')
+ const serverName = ref(saved?.serverName ?? '')
+ /** Set when the session ended on its own, shown on the sign-in page. */
+ const endedReason = ref('')
+
+ setAccessToken(token.value || null)
+
+ const signedIn = computed(() => token.value !== '')
+
+ function clear(reason = '') {
+ userId.value = ''
+ deviceId.value = ''
+ token.value = ''
+ serverName.value = ''
+ endedReason.value = reason
+ setAccessToken(null)
+ save(null, false)
+ }
+
+ setUnauthorizedHandler(() => {
+ if (token.value) clear('Your session ended. Sign in again to continue.')
+ })
+
+ async function signIn(username: string, password: string, remember: boolean) {
+ endedReason.value = ''
+ const res = await api.login(username.trim(), password)
+ setAccessToken(res.access_token)
+ try {
+ // The dashboard call doubles as the admin check: a valid token
+ // without the admin flag gets 403 here.
+ const stats = await api.getStats()
+ userId.value = res.user_id
+ deviceId.value = res.device_id
+ token.value = res.access_token
+ serverName.value = stats.server_name
+ save(
+ { userId: res.user_id, deviceId: res.device_id, accessToken: res.access_token, serverName: stats.server_name },
+ remember,
+ )
+ } catch (e) {
+ await api.logout().catch(() => undefined)
+ setAccessToken(null)
+ if (e instanceof ApiError && e.status === 403) {
+ throw new ApiError(403, e.errcode, `${res.user_id} isn’t an administrator of this homeserver.`)
+ }
+ throw e
+ }
+ }
+
+ async function signOut() {
+ try {
+ await api.logout()
+ } catch {
+ // The token may already be gone; forget it either way.
+ }
+ clear()
+ }
+
+ return { userId, deviceId, serverName, signedIn, endedReason, signIn, signOut, clear }
+})
diff --git a/apps/admin/src/stores/toasts.ts b/apps/admin/src/stores/toasts.ts
new file mode 100644
index 0000000..5ffd43f
--- /dev/null
+++ b/apps/admin/src/stores/toasts.ts
@@ -0,0 +1,34 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+
+// Short confirmations after an action ("Password reset for @alice:..."),
+// and errors that have no form to sit next to.
+
+export interface Toast {
+ id: number
+ tone: 'success' | 'danger' | 'info'
+ text: string
+}
+
+export const useToasts = defineStore('toasts', () => {
+ const items = ref([])
+ let next = 1
+
+ function push(tone: Toast['tone'], text: string, ms = 5000) {
+ const id = next++
+ items.value.push({ id, tone, text })
+ window.setTimeout(() => dismiss(id), ms)
+ }
+
+ function dismiss(id: number) {
+ items.value = items.value.filter((t) => t.id !== id)
+ }
+
+ return {
+ items,
+ success: (text: string) => push('success', text),
+ error: (text: string) => push('danger', text, 8000),
+ info: (text: string) => push('info', text),
+ dismiss,
+ }
+})
diff --git a/apps/admin/src/style.css b/apps/admin/src/style.css
new file mode 100644
index 0000000..f3c256e
--- /dev/null
+++ b/apps/admin/src/style.css
@@ -0,0 +1,2 @@
+@import "tailwindcss";
+@import "@warp/ui/styles.css";
diff --git a/apps/admin/src/views/AppServicesView.vue b/apps/admin/src/views/AppServicesView.vue
new file mode 100644
index 0000000..b473b94
--- /dev/null
+++ b/apps/admin/src/views/AppServicesView.vue
@@ -0,0 +1,117 @@
+
+
+
+
+
+ Refresh
+
+
+
+
+
+
+
+ No application services. Add registration files under app_service_api.config_files and
+ restart.
+
+
+
+
+
+
+
{{ s.id }}
+
+
+
+ Rate limited
+
+ Delivering
+ {{ s.delivery.failures }} failed in a row
+
+ No URL, sent nothing
+
+
+
+
+ {{ s.delivery.last_error || 'Delivery is failing.' }}
+ Next try {{ formatRelative(s.delivery.retry_at_ms, now) }}.
+
+
+
+
+
URL
+
+
+
+
+
Events delivered
+ {{ formatNumber(s.delivery.events_delivered) }}
+
+
+
Behind
+ {{ formatNumber(s.delivery.behind) }} positions
+
+
+
Last success
+ {{ formatRelative(s.delivery.last_success_ms, now) }}
+
+
+
Transaction
+ {{ formatNumber(s.delivery.txn_id) }}
+
+
+
+
+
+
+
{{ k.label }} namespace
+
+
+ {{ n.regex }}
+ Exclusive
+
+
+
None
+
+
+
+
+
+
diff --git a/apps/admin/src/views/AuditView.vue b/apps/admin/src/views/AuditView.vue
new file mode 100644
index 0000000..3d31297
--- /dev/null
+++ b/apps/admin/src/views/AuditView.vue
@@ -0,0 +1,134 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ When
+ What
+ Admin
+
+
+
+
+ {{ formatDateTime(e.ts_ms) }}
+
+
+ {{ labels[e.action] ?? e.action }}
+ {{ e.action }}
+
+
+
+ {{ details(e.details) }}
+
+
+
+
+
+
+ No entries match.
+
+
+
+
+
+ Show older
+
+
+
diff --git a/apps/admin/src/views/DashboardView.vue b/apps/admin/src/views/DashboardView.vue
new file mode 100644
index 0000000..5e1e8de
--- /dev/null
+++ b/apps/admin/src/views/DashboardView.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+ Refresh
+
+
+
+
+
+
+
+
+
+
Version
+
{{ s.version }}
+
+
+
Database
+
{{ s.database }}
+
+
+
+
+ Federation {{ s.federation_enabled ? 'on' : 'off' }}
+
+
+
+
+ Registration {{ s.registration_enabled ? 'open' : 'closed' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/admin/src/views/FederationView.vue b/apps/admin/src/views/FederationView.vue
new file mode 100644
index 0000000..cb33d7b
--- /dev/null
+++ b/apps/admin/src/views/FederationView.vue
@@ -0,0 +1,274 @@
+
+
+
+
+
+
+ Refresh
+
+
+
+
+
+
+
+
+
+
+ Federation
+
+ {{ f.enabled ? 'On' : 'Off' }}
+
+
+
+
+ Off, this server neither sends to nor accepts from other homeservers. The change applies to the next
+ request, with no restart.
+
+
+ Pinned by federation.allow_runtime_toggle in the config file. Change it there and
+ restart to switch it here.
+
+
+
+
+
+
+
+
+
+ Server lists
+ Edited here
+ From config file
+
+
+ One server name per line. With an allowlist, only those servers are reached; the denylist always wins.
+ Saved lists replace the config file’s until you reset them.
+
+
+
+
+
+
+ {{ lists.error.value }}
+
+ Save lists
+
+ Use config file lists
+
+
+
+
+
+
+
+
Delivery to other servers
+
+ Queue {{ dest.data.value.running ? 'running' : 'stopped' }}
+
+
+
+
+ This homeserver started without a signing key, so it has no federation at all.
+
+
+
+
+
+
+
+ Server
+ Health
+ Queued
+ Last success
+ Actions
+
+
+
+
+
+
+ Healthy
+
+ {{ d.failures }} failed in a row
+
+ Next try {{ formatRelative(d.retry_at_ms, now) }}
+
+
+
+
+ {{ formatNumber(d.queued_pdus) }}
+ {{ formatNumber(d.queued_edus) }} EDUs
+
+ {{ formatRelative(d.last_success_ms, now) }}
+
+
+ Retry now
+
+
+
+
+
+ Nothing queued for other servers, and no failures.
+
+
+
+
+
+
+
+
+ The lists saved here are forgotten, and the allowlist and denylist from the config file apply again.
+
+
diff --git a/apps/admin/src/views/LoginView.vue b/apps/admin/src/views/LoginView.vue
new file mode 100644
index 0000000..bbfb498
--- /dev/null
+++ b/apps/admin/src/views/LoginView.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+ No admin yet? On the server, run warp create-account --username root --admin
+
+
+
+
diff --git a/apps/admin/src/views/NoticesView.vue b/apps/admin/src/views/NoticesView.vue
new file mode 100644
index 0000000..0812a38
--- /dev/null
+++ b/apps/admin/src/views/NoticesView.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+ {{ send.error.value }}
+
+
+ will see it in their server notices room .
+
+
+
+
diff --git a/apps/admin/src/views/RegistrationView.vue b/apps/admin/src/views/RegistrationView.vue
new file mode 100644
index 0000000..8e92789
--- /dev/null
+++ b/apps/admin/src/views/RegistrationView.vue
@@ -0,0 +1,269 @@
+
+
+
+
+
+
+
+
+
+
+ Open registration
+
+ {{ reg.data.value.enabled ? 'Open' : 'Closed' }}
+
+
+
+
+ Open, anyone who can reach this server can sign up, or anyone with a token when tokens are required. Closed,
+ only you can create accounts, from Users.
+
+ Pinned in the config file. Change it there and restart to switch it here.
+
+
+
+
+
+
+
+
Registration tokens
+ Create token
+
+
+ Sign-up doesn’t ask for a token on this server, so these aren’t needed yet. Turn on
+ registration.require_token to require one.
+
+
+
+
+
+
+ Token
+ Uses
+ Expires
+ Created
+ Actions
+
+
+
+
+
+
+
+ Valid
+ Used up or expired
+
+
+ {{ uses(t) }}
+ {{ t.expiry_time_ms ? formatDateTime(t.expiry_time_ms) : 'Never' }}
+ {{ formatDate(t.created_at_ms) }}
+
+
+
+
+
+
+
+ No tokens. Tokens in the config file work too, but aren’t listed here.
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+ {{ editing ? 'Save token' : 'Create token' }}
+
+
+
+
+
+
+ stops working for new sign-ups. Accounts already made with it aren’t affected.
+
+
+
diff --git a/apps/admin/src/views/RoomView.vue b/apps/admin/src/views/RoomView.vue
new file mode 100644
index 0000000..787079d
--- /dev/null
+++ b/apps/admin/src/views/RoomView.vue
@@ -0,0 +1,201 @@
+
+
+
+
+
+
+
+
+ Purge room
+
+
+
+
+
+
+
+ Its event graph ends in {{ r.forward_extremities.length }} places instead of one. The next event sent to the room
+ will usually join them back together.
+
+
+
+
+ About
+ {{ r.topic }}
+
+ Creator
+
+ Created
+ {{ formatDateTime(r.created_at_ms) }}
+ Room version
+ {{ r.room_version }}
+ Join rule
+ {{ r.join_rule || '—' }}
+ History
+ {{ r.history_visibility || '—' }}
+ Guests
+ {{ r.guest_access || 'forbidden' }}
+ Directory
+
+ Published
+ Not published
+
+
+
+
+
+
+
Members
+
+
+
+ {{ formatNumber(r.joined_members) }}
+
+
Joined
+
+
+
+ {{ formatNumber(r.invited_members) }}
+
+
Invited
+
+
+
+ {{ formatNumber(r.members) }}
+
+
Any membership
+
+
+
+
+
+
Aliases
+
+
No aliases.
+
+
+
+
+
+ Force a user to leave
+
+ For a user stuck in a room whose power levels are broken or whose last moderator has gone. It skips the
+ room’s rules, but it’s an ordinary leave event, so the other members see it.
+
+
+
+
+
+ Forward extremities
+
+
+
+
+
+ leaves {{ title }} . They can rejoin if the room lets them.
+
+
+
+
+ Every event, the state and the aliases of {{ title }} are deleted from this
+ server. There is no undo.
+
+ The members aren’t told: there is no room left to tell them in.
+
+
diff --git a/apps/admin/src/views/RoomsView.vue b/apps/admin/src/views/RoomsView.vue
new file mode 100644
index 0000000..5ad0d11
--- /dev/null
+++ b/apps/admin/src/views/RoomsView.vue
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+ Search rooms
+
+
+
+
+
+
+ This homeserver started without a signing key, so it has no room server. Generate a key with
+ warp generate-keys and restart it.
+
+
+
+
+
+
+
+
+ Room
+ Members
+ Access
+ Created
+
+
+
+
+
+
+
+ {{ r.name || r.canonical_alias || 'Unnamed room' }}
+
+
+ {{ r.topic }}
+
+ {{ formatNumber(r.joined_members) }}
+
+
+ Directory
+ {{ r.join_rule || 'invite' }}
+
+
+ {{ formatDate(r.created_at_ms) }}
+
+
+
+ {{ search ? `No rooms match “${search}”.` : 'No rooms yet.' }}
+
+
+
+
+
+
+
+
diff --git a/apps/admin/src/views/UserView.vue b/apps/admin/src/views/UserView.vue
new file mode 100644
index 0000000..59f58b9
--- /dev/null
+++ b/apps/admin/src/views/UserView.vue
@@ -0,0 +1,477 @@
+
+
+
+
+
+
+
+ {{ name }}
+
+
+
+
+
+
+ Send notice
+ Reset password
+
+ Reactivate
+
+
+ Deactivate
+
+
+
+
+
+
+
+
+ It can’t sign in, and it has left every room. Reactivating lets it sign in again; the rooms and sessions don’t
+ come back.
+
+
+
+
+ Account
+
+ Status
+
+ Deactivated
+ Active
+
+ Created
+ {{ formatDateTime(u.created_at_ms) }}
+ Sessions
+ {{ formatNumber(u.devices) }}
+
+ Rooms
+ {{ formatNumber(u.joined_rooms) }} joined
+
+
+
+
+
Admin
+
+ {{
+ isSelf
+ ? 'You can’t remove your own admin flag. Another admin can.'
+ : 'Admins can use this panel and the admin API.'
+ }}
+
+
+
+
+
+
+
+
+
+ Change quota
+
+
+ {{ formatBytes(u.media.bytes) }}
+ in {{ formatNumber(u.media.uploads) }} uploads
+ of a {{ formatBytes(u.media.quota_bytes) }} quota , no quota
+ Set for this user
+
+
+
+ Over quota: further uploads are refused until this user is back under it.
+
+
+
+
+
+
+
Sessions
+
+ Sign out all
+
+
+
+
+
+
+
+ Session
+ Last seen
+ Created
+ Actions
+
+
+
+
+
+ {{ d.display_name || 'Unnamed session' }}
+
+ {{ d.user_agent }}
+
+
+ {{ formatRelative(d.last_seen_ms) }}
+ {{ d.last_seen_ip }}
+
+ {{ formatDateTime(d.created_at_ms) }}
+
+ Sign out
+
+
+
+ No sessions. This user isn’t signed in anywhere.
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+ Reset password
+
+
+
+
+
+
+ Every session of is signed out at once, and the account leaves every room, rejects its
+ invites and withdraws its knocks.
+
+ The username stays taken. You can reactivate the account later, but the rooms and sessions don’t come back.
+
+
+
+
+
+ {{ deviceToDelete.display_name || 'This session' }}
+ ( ) is signed out, and its encryption keys and pushers go with it.
+
+
+
+
+
+ Every session of is signed out, with its encryption keys and pushers. Messages in
+ end-to-end encrypted rooms may become unreadable unless the user has key backup.
+
+
+
+
+
+
+
+ Use configured quota
+
+ Cancel
+
+ Set quota
+
+
+
+
+
+
+
+ Cancel
+
+ Send notice
+
+
+
+
diff --git a/apps/admin/src/views/UsersView.vue b/apps/admin/src/views/UsersView.vue
new file mode 100644
index 0000000..b8c9f29
--- /dev/null
+++ b/apps/admin/src/views/UsersView.vue
@@ -0,0 +1,198 @@
+
+
+
+
+
+ Create user
+
+
+
+
+
+ Search users
+
+
+
+
+
+
+
+
+
+
+
+
+ User
+ Status
+ Created
+
+
+
+
+
+
+
+
+
+ {{ u.display_name || localpart(u.user_id) }}
+ (you)
+
+
+
+
+
+
+
+ Admin
+ Deactivated
+ Active
+
+
+ {{ formatDate(u.created_at_ms) }}
+
+
+
+ {{ search ? `No users match “${search}”.` : 'No users yet.' }}
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+ Create user
+
+
+
+
diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json
new file mode 100644
index 0000000..3cc5123
--- /dev/null
+++ b/apps/admin/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "jsx": "preserve",
+ "types": ["vite/client", "node"],
+ "paths": { "@/*": ["./src/*"] }
+ },
+ "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
+}
diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts
new file mode 100644
index 0000000..d4c4ce8
--- /dev/null
+++ b/apps/admin/vite.config.ts
@@ -0,0 +1,29 @@
+import { fileURLToPath } from 'node:url'
+import tailwindcss from '@tailwindcss/vite'
+import vue from '@vitejs/plugin-vue'
+import { defineConfig } from 'vite'
+
+// In development the panel talks to a homeserver through Vite's proxy, the
+// same paths warp-admin proxies in production, so the code never needs to
+// know where the homeserver is. WARP_ADMIN_HOMESERVER points it elsewhere.
+const homeserver = process.env.WARP_ADMIN_HOMESERVER ?? 'http://localhost:8008'
+
+export default defineConfig({
+ plugins: [vue(), tailwindcss()],
+ resolve: {
+ alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
+ },
+ build: {
+ // Embedded into the warp-admin binary by internal/panel.
+ outDir: 'internal/panel/build/dist',
+ emptyOutDir: true,
+ assetsInlineLimit: 0,
+ },
+ server: {
+ port: 5174,
+ proxy: {
+ '/_matrix': { target: homeserver, changeOrigin: true },
+ '/_warp/admin': { target: homeserver, changeOrigin: true },
+ },
+ },
+})
diff --git a/apps/ui/README.md b/apps/ui/README.md
new file mode 100644
index 0000000..0dddd4c
--- /dev/null
+++ b/apps/ui/README.md
@@ -0,0 +1,62 @@
+# @warp/ui
+
+Warp's design system for Vue 3 and Tailwind CSS v4: the brand tokens, fonts,
+icons and logo, and the components built on them. Every Warp app in the
+browser uses it, so a colour or a radius is changed here once.
+
+The package ships source, not a build: apps import the `.vue` and `.ts`
+files and their own Vite compiles them.
+
+## Use
+
+```css
+/* the app's main stylesheet */
+@import "tailwindcss";
+@import "@warp/ui/styles.css";
+```
+
+```ts
+import { WButton, WTextField, WBanner, useTheme } from '@warp/ui'
+```
+
+The stylesheet brings in:
+
+- `tokens.css`: the colour, type, space, radius and shadow tokens as CSS
+ variables, the three font faces and the type classes (`title`, `heading`,
+ `body`, `small`, `caption`, `mxid`, `code`, ...). Dark follows the OS, or
+ `data-theme="dark"` on `` (`useTheme()` sets it).
+- A Tailwind theme on those tokens. The default palette is cleared, so the
+ colour utilities are the token names: `bg-surface-sunken`, `text-ink-muted`,
+ `border-line`, `text-teal-text`. They switch with the theme on their own;
+ no `dark:` variants. `shadow-raised` and `shadow-float` are the tokens'
+ `shadow-sm` and `shadow-lg`.
+- The component styles (`wp-*` classes) from the brand package, plus the
+ ones drawn here for the apps: card, switch, checkbox, select, textarea,
+ dialog, table, nav item, segmented control, spinner.
+
+## Components
+
+| Component | What |
+|---|---|
+| `WLogo` | horizontal lockup or symbol, never redrawn |
+| `WIcon` | the stroke icons, in `currentColor` |
+| `WButton`, `WIconButton` | actions; one `primary` per view |
+| `WTextField`, `WTextArea`, `WSelect`, `WCheckbox`, `WSwitch` | forms |
+| `WAvatar`, `WBadge` | identity and status |
+| `WBanner` | feedback; always icon and words, never colour alone |
+| `WDialog` | modal on the native `` |
+| `WSpinner` | progress |
+
+## Where things come from
+
+`src/styles/tokens.css`, `tokens.json`, `fonts/`, the 20 brand icons in
+`src/icons/` and the logo paths in `src/logos/` are copies from the brand
+package (`warp-brand/design-system`). Change them there and copy them over.
+The other icons (`home`, `globe`, `key`, `trash`, ...) were drawn for the
+apps on the same grid: 24px, 1.75 stroke, round caps and joins.
+
+The fonts are Outfit, Figtree and JetBrains Mono under the SIL Open Font
+License.
+
+The Matrix store (Pinia over matrix-js-sdk) joins this package with the web
+client.
diff --git a/apps/ui/package.json b/apps/ui/package.json
new file mode 100644
index 0000000..e3c82ed
--- /dev/null
+++ b/apps/ui/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@warp/ui",
+ "version": "0.0.0",
+ "private": true,
+ "description": "Warp's design system for Vue 3 + Tailwind: tokens, fonts, icons and components",
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts",
+ "./styles.css": "./src/styles/index.css",
+ "./tokens.json": "./src/styles/tokens.json"
+ },
+ "scripts": {
+ "typecheck": "vue-tsc --noEmit"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0",
+ "tailwindcss": "^4.1.0"
+ },
+ "devDependencies": {
+ "tailwindcss": "^4.1.14",
+ "typescript": "~5.9.3",
+ "vite": "^7.1.9",
+ "vue": "^3.5.22",
+ "vue-tsc": "^3.1.1"
+ }
+}
diff --git a/apps/ui/src/components/WAvatar.vue b/apps/ui/src/components/WAvatar.vue
new file mode 100644
index 0000000..7cd9a51
--- /dev/null
+++ b/apps/ui/src/components/WAvatar.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
+ {{ initial }}
+
+
+
diff --git a/apps/ui/src/components/WBadge.vue b/apps/ui/src/components/WBadge.vue
new file mode 100644
index 0000000..3d8eb01
--- /dev/null
+++ b/apps/ui/src/components/WBadge.vue
@@ -0,0 +1,24 @@
+
+
+
+
+ {{ count > 99 ? '99+' : count }}
+
+
+
+
+
+
diff --git a/apps/ui/src/components/WBanner.vue b/apps/ui/src/components/WBanner.vue
new file mode 100644
index 0000000..8d92de7
--- /dev/null
+++ b/apps/ui/src/components/WBanner.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
diff --git a/apps/ui/src/components/WButton.vue b/apps/ui/src/components/WButton.vue
new file mode 100644
index 0000000..1bcbf36
--- /dev/null
+++ b/apps/ui/src/components/WButton.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/ui/src/components/WCheckbox.vue b/apps/ui/src/components/WCheckbox.vue
new file mode 100644
index 0000000..078c841
--- /dev/null
+++ b/apps/ui/src/components/WCheckbox.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
+ {{ label }}
+
+
diff --git a/apps/ui/src/components/WDialog.vue b/apps/ui/src/components/WDialog.vue
new file mode 100644
index 0000000..980375c
--- /dev/null
+++ b/apps/ui/src/components/WDialog.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
{{ title }}
+
+
+
+
+
+
+
+
+
diff --git a/apps/ui/src/components/WIcon.vue b/apps/ui/src/components/WIcon.vue
new file mode 100644
index 0000000..a3772b3
--- /dev/null
+++ b/apps/ui/src/components/WIcon.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
diff --git a/apps/ui/src/components/WIconButton.vue b/apps/ui/src/components/WIconButton.vue
new file mode 100644
index 0000000..7447489
--- /dev/null
+++ b/apps/ui/src/components/WIconButton.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
diff --git a/apps/ui/src/components/WLogo.vue b/apps/ui/src/components/WLogo.vue
new file mode 100644
index 0000000..fae9ae6
--- /dev/null
+++ b/apps/ui/src/components/WLogo.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
diff --git a/apps/ui/src/components/WSelect.vue b/apps/ui/src/components/WSelect.vue
new file mode 100644
index 0000000..ed123b8
--- /dev/null
+++ b/apps/ui/src/components/WSelect.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
{{ label }}
+
+ {{ o.label }}
+
+
{{ hint }}
+
+
diff --git a/apps/ui/src/components/WSpinner.vue b/apps/ui/src/components/WSpinner.vue
new file mode 100644
index 0000000..a210316
--- /dev/null
+++ b/apps/ui/src/components/WSpinner.vue
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/apps/ui/src/components/WSwitch.vue b/apps/ui/src/components/WSwitch.vue
new file mode 100644
index 0000000..934559b
--- /dev/null
+++ b/apps/ui/src/components/WSwitch.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
diff --git a/apps/ui/src/components/WTextArea.vue b/apps/ui/src/components/WTextArea.vue
new file mode 100644
index 0000000..c5f9b6a
--- /dev/null
+++ b/apps/ui/src/components/WTextArea.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
{{ label }}
+
+
+
+ {{ error || hint }}
+
+
+
diff --git a/apps/ui/src/components/WTextField.vue b/apps/ui/src/components/WTextField.vue
new file mode 100644
index 0000000..8824aa7
--- /dev/null
+++ b/apps/ui/src/components/WTextField.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
{{ label }}
+
+
+
+ {{ error || hint }}
+
+
+
diff --git a/apps/ui/src/env.d.ts b/apps/ui/src/env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/apps/ui/src/env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/ui/src/icons/attach.svg b/apps/ui/src/icons/attach.svg
new file mode 100644
index 0000000..5a7b0c9
--- /dev/null
+++ b/apps/ui/src/icons/attach.svg
@@ -0,0 +1 @@
+attach
\ No newline at end of file
diff --git a/apps/ui/src/icons/bell.svg b/apps/ui/src/icons/bell.svg
new file mode 100644
index 0000000..420ef36
--- /dev/null
+++ b/apps/ui/src/icons/bell.svg
@@ -0,0 +1 @@
+bell
\ No newline at end of file
diff --git a/apps/ui/src/icons/check.svg b/apps/ui/src/icons/check.svg
new file mode 100644
index 0000000..20b0e51
--- /dev/null
+++ b/apps/ui/src/icons/check.svg
@@ -0,0 +1 @@
+check
\ No newline at end of file
diff --git a/apps/ui/src/icons/chevron-left.svg b/apps/ui/src/icons/chevron-left.svg
new file mode 100644
index 0000000..6b1481d
--- /dev/null
+++ b/apps/ui/src/icons/chevron-left.svg
@@ -0,0 +1 @@
+chevron-left
diff --git a/apps/ui/src/icons/chevron-right.svg b/apps/ui/src/icons/chevron-right.svg
new file mode 100644
index 0000000..7d747a5
--- /dev/null
+++ b/apps/ui/src/icons/chevron-right.svg
@@ -0,0 +1 @@
+chevron-right
diff --git a/apps/ui/src/icons/close.svg b/apps/ui/src/icons/close.svg
new file mode 100644
index 0000000..09e9df3
--- /dev/null
+++ b/apps/ui/src/icons/close.svg
@@ -0,0 +1 @@
+close
\ No newline at end of file
diff --git a/apps/ui/src/icons/copy.svg b/apps/ui/src/icons/copy.svg
new file mode 100644
index 0000000..f2f7aa9
--- /dev/null
+++ b/apps/ui/src/icons/copy.svg
@@ -0,0 +1 @@
+copy
diff --git a/apps/ui/src/icons/edit.svg b/apps/ui/src/icons/edit.svg
new file mode 100644
index 0000000..a584c89
--- /dev/null
+++ b/apps/ui/src/icons/edit.svg
@@ -0,0 +1 @@
+edit
diff --git a/apps/ui/src/icons/globe.svg b/apps/ui/src/icons/globe.svg
new file mode 100644
index 0000000..f3f20c1
--- /dev/null
+++ b/apps/ui/src/icons/globe.svg
@@ -0,0 +1 @@
+globe
diff --git a/apps/ui/src/icons/history.svg b/apps/ui/src/icons/history.svg
new file mode 100644
index 0000000..ed085b5
--- /dev/null
+++ b/apps/ui/src/icons/history.svg
@@ -0,0 +1 @@
+history
diff --git a/apps/ui/src/icons/home.svg b/apps/ui/src/icons/home.svg
new file mode 100644
index 0000000..8e49abb
--- /dev/null
+++ b/apps/ui/src/icons/home.svg
@@ -0,0 +1 @@
+home
diff --git a/apps/ui/src/icons/image.svg b/apps/ui/src/icons/image.svg
new file mode 100644
index 0000000..aa78134
--- /dev/null
+++ b/apps/ui/src/icons/image.svg
@@ -0,0 +1 @@
+image
diff --git a/apps/ui/src/icons/index.ts b/apps/ui/src/icons/index.ts
new file mode 100644
index 0000000..e0534b1
--- /dev/null
+++ b/apps/ui/src/icons/index.ts
@@ -0,0 +1,60 @@
+// The stroke icons: the brand package's 20 plus the ones drawn for the apps
+// on the same grid (24px, 1.75 stroke, round caps, currentColor).
+
+export type IconName =
+ | 'attach'
+ | 'bell'
+ | 'check'
+ | 'chevron-left'
+ | 'chevron-right'
+ | 'close'
+ | 'copy'
+ | 'edit'
+ | 'globe'
+ | 'history'
+ | 'home'
+ | 'image'
+ | 'key'
+ | 'link'
+ | 'lock'
+ | 'logout'
+ | 'megaphone'
+ | 'menu'
+ | 'moon'
+ | 'more'
+ | 'plug'
+ | 'plus'
+ | 'react'
+ | 'read'
+ | 'refresh'
+ | 'reply'
+ | 'room'
+ | 'search'
+ | 'send'
+ | 'server'
+ | 'settings'
+ | 'shield-alert'
+ | 'shield-check'
+ | 'sun'
+ | 'trash'
+ | 'user'
+ | 'video'
+
+const files = import.meta.glob('./*.svg', { query: '?raw', import: 'default', eager: true })
+
+// Only the drawing is kept: the outer is rendered by the Icon
+// component, and the would give a decorative icon a name.
+const bodies: Record = {}
+for (const [path, svg] of Object.entries(files)) {
+ const name = path.slice(2, -4)
+ bodies[name] = svg
+ .replace(/^[\s\S]*?]*>/, '')
+ .replace(/<\/svg>\s*$/, '')
+ .replace(/[\s\S]*?<\/title>/, '')
+}
+
+export function iconBody(name: IconName): string {
+ return bodies[name] ?? ''
+}
+
+export const iconNames = Object.keys(bodies).sort() as IconName[]
diff --git a/apps/ui/src/icons/key.svg b/apps/ui/src/icons/key.svg
new file mode 100644
index 0000000..5c80c33
--- /dev/null
+++ b/apps/ui/src/icons/key.svg
@@ -0,0 +1 @@
+key
diff --git a/apps/ui/src/icons/link.svg b/apps/ui/src/icons/link.svg
new file mode 100644
index 0000000..1c8a27b
--- /dev/null
+++ b/apps/ui/src/icons/link.svg
@@ -0,0 +1 @@
+link
\ No newline at end of file
diff --git a/apps/ui/src/icons/lock.svg b/apps/ui/src/icons/lock.svg
new file mode 100644
index 0000000..769b037
--- /dev/null
+++ b/apps/ui/src/icons/lock.svg
@@ -0,0 +1 @@
+lock
\ No newline at end of file
diff --git a/apps/ui/src/icons/logout.svg b/apps/ui/src/icons/logout.svg
new file mode 100644
index 0000000..90934fc
--- /dev/null
+++ b/apps/ui/src/icons/logout.svg
@@ -0,0 +1 @@
+logout
diff --git a/apps/ui/src/icons/megaphone.svg b/apps/ui/src/icons/megaphone.svg
new file mode 100644
index 0000000..a70abf7
--- /dev/null
+++ b/apps/ui/src/icons/megaphone.svg
@@ -0,0 +1 @@
+megaphone
diff --git a/apps/ui/src/icons/menu.svg b/apps/ui/src/icons/menu.svg
new file mode 100644
index 0000000..2ae8e8a
--- /dev/null
+++ b/apps/ui/src/icons/menu.svg
@@ -0,0 +1 @@
+menu
diff --git a/apps/ui/src/icons/moon.svg b/apps/ui/src/icons/moon.svg
new file mode 100644
index 0000000..83090a2
--- /dev/null
+++ b/apps/ui/src/icons/moon.svg
@@ -0,0 +1 @@
+moon
diff --git a/apps/ui/src/icons/more.svg b/apps/ui/src/icons/more.svg
new file mode 100644
index 0000000..ed88f18
--- /dev/null
+++ b/apps/ui/src/icons/more.svg
@@ -0,0 +1 @@
+more
\ No newline at end of file
diff --git a/apps/ui/src/icons/plug.svg b/apps/ui/src/icons/plug.svg
new file mode 100644
index 0000000..541b8b0
--- /dev/null
+++ b/apps/ui/src/icons/plug.svg
@@ -0,0 +1 @@
+plug
diff --git a/apps/ui/src/icons/plus.svg b/apps/ui/src/icons/plus.svg
new file mode 100644
index 0000000..50b3970
--- /dev/null
+++ b/apps/ui/src/icons/plus.svg
@@ -0,0 +1 @@
+plus
\ No newline at end of file
diff --git a/apps/ui/src/icons/react.svg b/apps/ui/src/icons/react.svg
new file mode 100644
index 0000000..740c639
--- /dev/null
+++ b/apps/ui/src/icons/react.svg
@@ -0,0 +1 @@
+react
\ No newline at end of file
diff --git a/apps/ui/src/icons/read.svg b/apps/ui/src/icons/read.svg
new file mode 100644
index 0000000..9fd0cfc
--- /dev/null
+++ b/apps/ui/src/icons/read.svg
@@ -0,0 +1 @@
+read
\ No newline at end of file
diff --git a/apps/ui/src/icons/refresh.svg b/apps/ui/src/icons/refresh.svg
new file mode 100644
index 0000000..5bfba6a
--- /dev/null
+++ b/apps/ui/src/icons/refresh.svg
@@ -0,0 +1 @@
+refresh
diff --git a/apps/ui/src/icons/reply.svg b/apps/ui/src/icons/reply.svg
new file mode 100644
index 0000000..2ee526c
--- /dev/null
+++ b/apps/ui/src/icons/reply.svg
@@ -0,0 +1 @@
+reply
\ No newline at end of file
diff --git a/apps/ui/src/icons/room.svg b/apps/ui/src/icons/room.svg
new file mode 100644
index 0000000..ea9f839
--- /dev/null
+++ b/apps/ui/src/icons/room.svg
@@ -0,0 +1 @@
+room
\ No newline at end of file
diff --git a/apps/ui/src/icons/search.svg b/apps/ui/src/icons/search.svg
new file mode 100644
index 0000000..a4feff8
--- /dev/null
+++ b/apps/ui/src/icons/search.svg
@@ -0,0 +1 @@
+search
\ No newline at end of file
diff --git a/apps/ui/src/icons/send.svg b/apps/ui/src/icons/send.svg
new file mode 100644
index 0000000..edab2a8
--- /dev/null
+++ b/apps/ui/src/icons/send.svg
@@ -0,0 +1 @@
+send
\ No newline at end of file
diff --git a/apps/ui/src/icons/server.svg b/apps/ui/src/icons/server.svg
new file mode 100644
index 0000000..6bd5ff5
--- /dev/null
+++ b/apps/ui/src/icons/server.svg
@@ -0,0 +1 @@
+server
\ No newline at end of file
diff --git a/apps/ui/src/icons/settings.svg b/apps/ui/src/icons/settings.svg
new file mode 100644
index 0000000..9626d80
--- /dev/null
+++ b/apps/ui/src/icons/settings.svg
@@ -0,0 +1 @@
+settings
\ No newline at end of file
diff --git a/apps/ui/src/icons/shield-alert.svg b/apps/ui/src/icons/shield-alert.svg
new file mode 100644
index 0000000..dc16490
--- /dev/null
+++ b/apps/ui/src/icons/shield-alert.svg
@@ -0,0 +1 @@
+shield-alert
\ No newline at end of file
diff --git a/apps/ui/src/icons/shield-check.svg b/apps/ui/src/icons/shield-check.svg
new file mode 100644
index 0000000..68c8a99
--- /dev/null
+++ b/apps/ui/src/icons/shield-check.svg
@@ -0,0 +1 @@
+shield-check
\ No newline at end of file
diff --git a/apps/ui/src/icons/sun.svg b/apps/ui/src/icons/sun.svg
new file mode 100644
index 0000000..0d38d81
--- /dev/null
+++ b/apps/ui/src/icons/sun.svg
@@ -0,0 +1 @@
+sun
diff --git a/apps/ui/src/icons/trash.svg b/apps/ui/src/icons/trash.svg
new file mode 100644
index 0000000..9eb18f4
--- /dev/null
+++ b/apps/ui/src/icons/trash.svg
@@ -0,0 +1 @@
+trash
diff --git a/apps/ui/src/icons/user.svg b/apps/ui/src/icons/user.svg
new file mode 100644
index 0000000..c4f8267
--- /dev/null
+++ b/apps/ui/src/icons/user.svg
@@ -0,0 +1 @@
+user
\ No newline at end of file
diff --git a/apps/ui/src/icons/video.svg b/apps/ui/src/icons/video.svg
new file mode 100644
index 0000000..203a854
--- /dev/null
+++ b/apps/ui/src/icons/video.svg
@@ -0,0 +1 @@
+video
\ No newline at end of file
diff --git a/apps/ui/src/index.ts b/apps/ui/src/index.ts
new file mode 100644
index 0000000..5d6b486
--- /dev/null
+++ b/apps/ui/src/index.ts
@@ -0,0 +1,17 @@
+export { default as WAvatar } from './components/WAvatar.vue'
+export { default as WBadge } from './components/WBadge.vue'
+export { default as WBanner } from './components/WBanner.vue'
+export { default as WButton } from './components/WButton.vue'
+export { default as WCheckbox } from './components/WCheckbox.vue'
+export { default as WDialog } from './components/WDialog.vue'
+export { default as WIcon } from './components/WIcon.vue'
+export { default as WIconButton } from './components/WIconButton.vue'
+export { default as WLogo } from './components/WLogo.vue'
+export { default as WSelect } from './components/WSelect.vue'
+export { default as WSpinner } from './components/WSpinner.vue'
+export { default as WSwitch } from './components/WSwitch.vue'
+export { default as WTextArea } from './components/WTextArea.vue'
+export { default as WTextField } from './components/WTextField.vue'
+
+export { iconNames, type IconName } from './icons'
+export { useTheme, type ThemeChoice } from './theme'
diff --git a/apps/ui/src/logos/favicon.svg b/apps/ui/src/logos/favicon.svg
new file mode 100644
index 0000000..7f69159
--- /dev/null
+++ b/apps/ui/src/logos/favicon.svg
@@ -0,0 +1 @@
+Warp
\ No newline at end of file
diff --git a/apps/ui/src/logos/index.ts b/apps/ui/src/logos/index.ts
new file mode 100644
index 0000000..4d388b6
--- /dev/null
+++ b/apps/ui/src/logos/index.ts
@@ -0,0 +1,12 @@
+// Path data of the Warp logo, from the brand package (assets/Logos). The
+// logo is never redrawn or retyped: change these only by copying the files.
+
+/** The Link symbol, 256x256 viewBox. */
+export const symbolPath = "M71.6 23.1 L64.4 24.2 L58.8 25.8 L53.5 28.2 L47.4 32.2 L42.1 37.1 L37.5 42.7 L34 49.1 L32 54.5 L30.7 60.2 L30.1 66 L30.1 107.8 L30.3 112.2 L31.2 117.9 L32.9 123.5 L35.3 128.8 L39.2 134.9 L42.1 138.2 L45.2 141.2 L51 145.5 L54.8 147.7 L58.8 149.4 L65.8 151.4 L70.1 152 L74.5 152.2 L114.9 152.2 L120.7 151.9 L126.4 150.7 L133.2 148.3 L139.6 144.8 L144.2 141.2 L149.2 136 L152.6 131.3 L155.9 124.8 L158.1 117.9 L125.2 117.9 L128 115.8 L131.4 114.4 L135.3 113.9 L175.9 113.9 L179.1 114.4 L182.1 115.5 L185.1 117.7 L187.5 120.5 L189 123.9 L189.6 127.5 L189.5 169.8 L189 172.5 L187.7 175.5 L186.1 177.7 L184.1 179.6 L182.1 180.9 L180.4 181.6 L178.7 182.2 L176.4 182.5 L135.3 182.5 L132.8 182.3 L130.1 181.6 L127.6 180.4 L125.4 178.7 L123.3 176.2 L122 173.8 L121.2 171.2 L120.9 168.9 L120.9 164 L115.1 164.4 L90.6 164.4 L90.7 169.9 L91 174.2 L91.8 178.5 L93.5 184 L95.2 188 L98.1 193.1 L100.7 196.6 L103.7 199.8 L106.9 202.7 L111.6 206.1 L116.7 208.9 L120.8 210.5 L127.8 212.2 L133.6 212.8 L175.5 212.8 L181.1 212.5 L225.9 231 L217.5 182.9 L219 177.1 L219.9 169.9 L219.9 128 L219.5 122.2 L218.4 116.5 L216.5 111 L213.9 105.8 L210.7 101 L207.9 97.6 L204.7 94.6 L198.9 90.3 L192.5 87 L188.4 85.5 L184.1 84.4 L179.8 83.8 L175.5 83.6 L135.1 83.6 L129.3 84 L122.2 85.5 L116.7 87.5 L111.6 90.3 L105.8 94.6 L100.7 99.8 L97.3 104.5 L94 111 L91.8 117.9 L124.8 117.9 L121.9 120.1 L118.5 121.5 L114.6 122 L74 121.9 L70.8 121.5 L67.4 120.1 L64.8 118.1 L62.7 115.7 L61.3 112.8 L60.5 109.7 L60.3 67.7 L60.8 63.8 L62.2 60.4 L64.5 57.4 L66.3 55.9 L67.8 55 L70.8 53.8 L74 53.3 L114.6 53.3 L117.2 53.5 L119.9 54.2 L122.3 55.4 L124.5 57.1 L126.6 59.6 L127.9 62 L128.7 64.7 L129 67 L129 71.8 L134.9 71.5 L159.3 71.5 L159.2 64.5 L158.4 58.8 L156.9 53.1 L154.1 46.5 L151 41.5 L146.3 36 L140.8 31.3 L135.8 28.2 L131.9 26.4 L127.8 24.9 L122.1 23.6 L117.8 23.1Z"
+
+/** The cut of the symbol for sizes under 32px, 256x256 viewBox. */
+export const symbolSmallPath = "M73.5 23.1 L67.4 23.9 L60.1 26 L54.6 28.5 L48.2 32.6 L42.6 37.7 L37.9 43.6 L34.2 50.3 L32.1 56 L30.7 61.9 L30.1 67.9 L30.1 105.8 L30.3 110.3 L31.3 116.3 L33 122.2 L35.5 127.7 L38.8 132.9 L43.7 138.6 L47 141.7 L50.7 144.4 L54.6 146.8 L58.7 148.7 L66 151 L70.4 151.8 L75 152.2 L112.9 152.2 L117.4 152 L124.9 150.7 L130.6 148.7 L136.1 146 L142.3 141.7 L146.8 137.5 L151.5 131.6 L155.2 125 L157.7 117.9 L174.2 117.9 L176.6 118.3 L179.1 119.3 L181.7 121.2 L183.7 123.6 L185 126.5 L185.5 129.6 L185.5 167.6 L184.9 170.3 L183.9 172.4 L182.5 174.4 L180.8 176 L177.7 177.7 L174.2 178.5 L135.9 178.4 L133.6 178 L131.7 177.2 L129.7 176 L128 174.4 L126.6 172.4 L125.8 170.6 L125.1 168.4 L125 166.1 L125 167.2 L117.2 168.2 L90.7 168.4 L91 172.4 L91.9 176.9 L94.2 184.2 L96.1 188.3 L99.3 193.4 L102.2 197 L105.3 200.3 L108.8 203.2 L113.9 206.6 L117.9 208.7 L122.2 210.4 L128 211.9 L135.6 212.8 L173.4 212.8 L180.6 212.3 L225.9 231 L217.3 181.8 L219 175.4 L219.9 167.9 L219.9 130 L219.5 124 L218.3 118 L216.9 113.7 L214.4 108.1 L211.2 103 L206.3 97.2 L202.9 94.1 L199.2 91.4 L195.3 89.1 L191.2 87.1 L184 84.8 L179.5 84 L175 83.6 L137.1 83.6 L132.5 83.8 L125.1 85.2 L119.3 87.1 L112.6 90.6 L107.6 94.1 L102.2 99.4 L98.5 104.2 L94.8 110.9 L92.3 117.9 L75.7 117.9 L73 117.4 L70.4 116.3 L68.2 114.6 L66.4 112.5 L65.2 110.1 L64.5 107.4 L64.4 69.7 L64.8 66.3 L66 63.4 L67.9 60.9 L70.8 58.8 L73.4 57.7 L76.1 57.3 L112.6 57.3 L115.2 57.6 L117.1 58.1 L118.9 59 L120.8 60.3 L122.2 61.8 L123.3 63.4 L124.3 65.6 L124.8 67.5 L125 69.7 L125 68.6 L132.7 67.6 L159.3 67.4 L158.7 61.9 L157.3 56 L155.2 50.3 L152.3 44.9 L148.8 40 L144.6 35.5 L139.9 31.7 L134.7 28.5 L130.6 26.5 L126.3 25 L121.9 23.9 L117.4 23.2 L112.9 23Z"
+
+/** The Outfit Bold wordmark of the horizontal lockup, 768x256 viewBox. */
+export const wordmarkPath = "M173.0 0.0 5.0 -486.0H155.0L238.0 -204.0L330.0 -486.0H454.0L546.0 -204.0L630.0 -486.0H780.0L612.0 0.0H487.0L392.0 -280.0L297.0 0.0Z M1007.0 10.0Q940.0 10.0 887.5 -23.0Q835.0 -56.0 804.5 -113.0Q774.0 -170.0 774.0 -243.0Q774.0 -316.0 804.5 -373.0Q835.0 -430.0 887.5 -463.0Q940.0 -496.0 1007.0 -496.0Q1056.0 -496.0 1096.0 -477.0Q1121.0 -465.0 1141.0 -446.0V-486.0H1291.0V0.0H1141.0V-39.0Q1121.0 -21.0 1096.0 -9.0Q1056.0 10.0 1007.0 10.0ZM1038.0 -128.0Q1087.0 -128.0 1117.0 -160.5Q1147.0 -193.0 1147.0 -243.0Q1147.0 -277.0 1133.5 -303.0Q1120.0 -329.0 1095.5 -343.5Q1071.0 -358.0 1039.0 -358.0Q1007.0 -358.0 982.5 -343.5Q958.0 -329.0 943.5 -303.0Q929.0 -277.0 929.0 -243.5Q929.0 -210.0 943.0 -184.0Q957.0 -158.0 982.0 -143.0Q1007.0 -128.0 1038.0 -128.0Z M1364.0 0.0V-486.0H1517.0V-449.0Q1562.0 -496.0 1642.0 -496.0Q1677.0 -496.0 1703.5 -485.5Q1730.0 -475.0 1750.0 -453.0L1659.0 -338.0Q1649.0 -349.0 1634.0 -355.0Q1619.0 -361.0 1600.0 -361.0Q1562.0 -361.0 1539.5 -337.5Q1517.0 -314.0 1517.0 -267.0V0.0Z M2053.0 10.0Q2004.0 10.0 1963.0 -9.0Q1941.0 -19.0 1924.0 -33.0V205.0H1773.0V-486.0H1926.0V-452.0Q1943.0 -466.0 1963.0 -476.0Q2004.0 -496.0 2053.0 -496.0Q2122.0 -496.0 2175.5 -463.0Q2229.0 -430.0 2259.5 -373.0Q2290.0 -316.0 2290.0 -243.0Q2290.0 -170.0 2259.5 -113.0Q2229.0 -56.0 2175.5 -23.0Q2122.0 10.0 2053.0 10.0ZM2025.0 -128.0Q2058.0 -128.0 2082.5 -143.0Q2107.0 -158.0 2121.0 -184.0Q2135.0 -210.0 2135.0 -243.0Q2135.0 -277.0 2121.0 -303.0Q2107.0 -329.0 2082.5 -343.5Q2058.0 -358.0 2026.0 -358.0Q1994.0 -358.0 1969.5 -343.5Q1945.0 -329.0 1931.0 -303.0Q1917.0 -277.0 1917.0 -243.0Q1917.0 -210.0 1930.5 -184.0Q1944.0 -158.0 1969.0 -143.0Q1994.0 -128.0 2025.0 -128.0Z"
+export const wordmarkTransform = "translate(287.33 174.87) scale(0.19678)"
diff --git a/apps/ui/src/logos/warp-horizontal-black.svg b/apps/ui/src/logos/warp-horizontal-black.svg
new file mode 100644
index 0000000..ca7f376
--- /dev/null
+++ b/apps/ui/src/logos/warp-horizontal-black.svg
@@ -0,0 +1 @@
+Warp
\ No newline at end of file
diff --git a/apps/ui/src/logos/warp-horizontal-reversed.svg b/apps/ui/src/logos/warp-horizontal-reversed.svg
new file mode 100644
index 0000000..2e7138c
--- /dev/null
+++ b/apps/ui/src/logos/warp-horizontal-reversed.svg
@@ -0,0 +1 @@
+Warp
\ No newline at end of file
diff --git a/apps/ui/src/logos/warp-horizontal-white.svg b/apps/ui/src/logos/warp-horizontal-white.svg
new file mode 100644
index 0000000..201268c
--- /dev/null
+++ b/apps/ui/src/logos/warp-horizontal-white.svg
@@ -0,0 +1 @@
+Warp
\ No newline at end of file
diff --git a/apps/ui/src/logos/warp-horizontal.svg b/apps/ui/src/logos/warp-horizontal.svg
new file mode 100644
index 0000000..771c2e9
--- /dev/null
+++ b/apps/ui/src/logos/warp-horizontal.svg
@@ -0,0 +1 @@
+Warp
\ No newline at end of file
diff --git a/apps/ui/src/logos/warp-stacked-reversed.svg b/apps/ui/src/logos/warp-stacked-reversed.svg
new file mode 100644
index 0000000..a73374d
--- /dev/null
+++ b/apps/ui/src/logos/warp-stacked-reversed.svg
@@ -0,0 +1 @@
+Warp
\ No newline at end of file
diff --git a/apps/ui/src/logos/warp-stacked.svg b/apps/ui/src/logos/warp-stacked.svg
new file mode 100644
index 0000000..70bae23
--- /dev/null
+++ b/apps/ui/src/logos/warp-stacked.svg
@@ -0,0 +1 @@
+Warp
\ No newline at end of file
diff --git a/apps/ui/src/logos/warp-symbol-app-icon.svg b/apps/ui/src/logos/warp-symbol-app-icon.svg
new file mode 100644
index 0000000..9eddb4b
--- /dev/null
+++ b/apps/ui/src/logos/warp-symbol-app-icon.svg
@@ -0,0 +1 @@
+Warp
\ No newline at end of file
diff --git a/apps/ui/src/logos/warp-symbol-black.svg b/apps/ui/src/logos/warp-symbol-black.svg
new file mode 100644
index 0000000..a34f805
--- /dev/null
+++ b/apps/ui/src/logos/warp-symbol-black.svg
@@ -0,0 +1 @@
+Warp
\ No newline at end of file
diff --git a/apps/ui/src/logos/warp-symbol-small.svg b/apps/ui/src/logos/warp-symbol-small.svg
new file mode 100644
index 0000000..1b749a4
--- /dev/null
+++ b/apps/ui/src/logos/warp-symbol-small.svg
@@ -0,0 +1 @@
+Warp
\ No newline at end of file
diff --git a/apps/ui/src/logos/warp-symbol-white.svg b/apps/ui/src/logos/warp-symbol-white.svg
new file mode 100644
index 0000000..58c14b9
--- /dev/null
+++ b/apps/ui/src/logos/warp-symbol-white.svg
@@ -0,0 +1 @@
+Warp
\ No newline at end of file
diff --git a/apps/ui/src/logos/warp-symbol.svg b/apps/ui/src/logos/warp-symbol.svg
new file mode 100644
index 0000000..ee1a55a
--- /dev/null
+++ b/apps/ui/src/logos/warp-symbol.svg
@@ -0,0 +1 @@
+Warp
\ No newline at end of file
diff --git a/apps/ui/src/logos/warp-wordmark.svg b/apps/ui/src/logos/warp-wordmark.svg
new file mode 100644
index 0000000..673a5d6
--- /dev/null
+++ b/apps/ui/src/logos/warp-wordmark.svg
@@ -0,0 +1 @@
+Warp
\ No newline at end of file
diff --git a/apps/ui/src/styles/components.css b/apps/ui/src/styles/components.css
new file mode 100644
index 0000000..bb5f04a
--- /dev/null
+++ b/apps/ui/src/styles/components.css
@@ -0,0 +1,105 @@
+/* Component styles from the brand package (design-system/components/bundle.css),
+ shared by the Vue components in ../components. */
+
+@layer components {
+ .wp-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
+ .wp-icon { flex: none; display: inline-block; vertical-align: middle; }
+ .wp-logo { display: block; color: var(--ink); }
+
+ /* Button */
+ .wp-btn { font: inherit; font-weight: 600; display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); border-radius: var(--radius-sm); border: 1px solid transparent; cursor: pointer; white-space: nowrap; transition: background-color .12s ease, border-color .12s ease; }
+ .wp-btn-md { height: 40px; padding: 0 var(--space-4); font-size: 14px; }
+ .wp-btn-sm { height: 32px; padding: 0 var(--space-3); font-size: 13px; }
+ .wp-btn-primary { background: var(--teal-strong); color: var(--on-teal); }
+ .wp-btn-primary:hover { filter: brightness(1.06); }
+ .wp-btn-secondary { background: var(--surface-raised); color: var(--ink); border-color: var(--line-strong); }
+ .wp-btn-secondary:hover { background: var(--surface-sunken); }
+ .wp-btn-ghost { background: transparent; color: var(--ink); }
+ .wp-btn-ghost:hover { background: var(--surface-sunken); }
+ .wp-btn-danger { background: var(--danger-soft); color: var(--danger); border-color: var(--danger); }
+ .wp-btn:disabled { opacity: .45; cursor: not-allowed; filter: none; }
+ .wp-btn:focus-visible, .wp-iconbtn:focus-visible, .wp-room:focus-visible, .wp-composer-send:focus-visible { outline: 2px solid transparent; box-shadow: var(--focus-ring); }
+
+ /* IconButton */
+ .wp-iconbtn { width: 40px; height: 40px; flex: none; display: inline-flex; align-items: center; justify-content: center; border-radius: var(--radius-sm); border: 1px solid transparent; background: transparent; color: var(--ink-muted); cursor: pointer; }
+ .wp-iconbtn:hover { background: var(--surface-sunken); color: var(--ink); }
+ .wp-iconbtn-solid { background: var(--surface-raised); border-color: var(--line-strong); color: var(--ink); }
+
+ /* TextField */
+ .wp-field { display: flex; flex-direction: column; gap: var(--space-1); max-width: 360px; }
+ .wp-field-label { font-size: 13px; line-height: 18px; font-weight: 600; color: var(--ink); }
+ .wp-input { font: inherit; font-size: 14px; height: 40px; padding: 0 var(--space-3); border-radius: var(--radius-sm); border: 1px solid var(--line-strong); background: var(--surface-raised); color: var(--ink); }
+ .wp-input::placeholder { color: var(--ink-muted); }
+ .wp-input:focus-visible { outline: 2px solid transparent; border-color: var(--teal-strong); box-shadow: var(--focus-ring); }
+ .wp-input-mono { font-family: var(--font-mono); font-size: 13px; }
+ .wp-field-hint { margin: 0; display: flex; align-items: center; gap: var(--space-1); font-size: 12px; line-height: 16px; color: var(--ink-muted); }
+ .wp-field-error .wp-input { border-color: var(--danger); }
+ .wp-field-error .wp-field-hint { color: var(--danger); }
+
+ /* Avatar */
+ .wp-avatar { position: relative; flex: none; display: inline-flex; align-items: center; justify-content: center; border-radius: var(--radius-full); background: var(--teal-soft); color: var(--teal-text); font-family: var(--font-display); font-weight: 600; }
+ .wp-avatar img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
+ .wp-presence { position: absolute; right: -1px; bottom: -1px; border-radius: var(--radius-full); box-shadow: 0 0 0 2px var(--surface); box-sizing: border-box; }
+ .wp-presence-online { background: var(--presence-online); }
+ .wp-presence-away { background: var(--presence-away); }
+ .wp-presence-busy { background: var(--presence-busy); }
+ .wp-presence-offline { background: var(--surface); border: 2px solid var(--presence-offline); }
+
+ /* Badge */
+ .wp-badge { display: inline-flex; align-items: center; gap: var(--space-1); height: 20px; padding: 0 var(--space-2); border-radius: var(--radius-xs); font-size: 12px; line-height: 16px; font-weight: 600; letter-spacing: .02em; white-space: nowrap; font-variant-numeric: tabular-nums; }
+ .wp-badge-count { min-width: 20px; justify-content: center; padding: 0 6px; border-radius: var(--radius-full); box-sizing: border-box; }
+ .wp-badge-neutral { background: var(--line); color: var(--ink); }
+ .wp-badge-brand { background: var(--teal-strong); color: var(--on-teal); }
+ .wp-badge-success { background: var(--teal-soft); color: var(--teal-text); }
+ .wp-badge-warning { background: var(--warning-soft); color: var(--warning); }
+ .wp-badge-danger { background: var(--danger-soft); color: var(--danger); }
+ .wp-badge-info { background: var(--info-soft); color: var(--info); }
+
+ /* Banner */
+ .wp-banner { display: flex; align-items: flex-start; gap: var(--space-3); padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); }
+ .wp-banner > .wp-icon { margin-top: 1px; }
+ .wp-banner-body { flex: 1; min-width: 0; }
+ .wp-banner-title { margin: 0; font-weight: 600; }
+ .wp-banner-text { margin: 2px 0 0; color: var(--ink); }
+ .wp-banner-action { flex: none; align-self: center; }
+ .wp-banner-info { background: var(--info-soft); color: var(--info); }
+ .wp-banner-success { background: var(--teal-soft); color: var(--teal-text); }
+ .wp-banner-warning { background: var(--warning-soft); color: var(--warning); }
+ .wp-banner-danger { background: var(--danger-soft); color: var(--danger); }
+
+ /* RoomItem */
+ .wp-room { font: inherit; color: inherit; text-align: left; width: 100%; display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-3); border: 0; border-radius: var(--radius-md); background: transparent; cursor: pointer; }
+ .wp-room:hover { background: var(--line); }
+ .wp-room-active, .wp-room-active:hover { background: var(--teal-soft); }
+ .wp-room-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
+ .wp-room-top, .wp-room-bottom { display: flex; align-items: center; gap: var(--space-1); min-width: 0; }
+ .wp-room-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .wp-room-lock { color: var(--ink-muted); }
+ .wp-room-time { margin-left: auto; padding-left: var(--space-2); font-size: 12px; line-height: 16px; color: var(--ink-muted); font-variant-numeric: tabular-nums; }
+ .wp-room-preview { flex: 1; min-width: 0; font-size: 13px; line-height: 18px; color: var(--ink-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .wp-room-unread .wp-room-preview { color: var(--ink); }
+ .wp-room-unread .wp-room-time { color: var(--teal-text); font-weight: 600; }
+
+ /* MessageBubble */
+ .wp-msg { display: flex; flex-direction: column; gap: var(--space-1); max-width: 520px; }
+ .wp-msg-own { align-items: flex-end; margin-left: auto; }
+ .wp-msg-other { align-items: flex-start; }
+ .wp-msg-sender { margin: 0 0 0 var(--space-3); font-size: 13px; line-height: 18px; font-weight: 600; color: var(--teal-text); }
+ .wp-msg-bubble { font-size: 15px; line-height: 22px; padding: var(--space-2) var(--space-3); border-radius: var(--radius-lg); color: var(--ink); overflow-wrap: anywhere; }
+ .wp-msg-bubble code { font-family: var(--font-mono); font-size: 13px; padding: 1px 4px; border-radius: var(--radius-xs); background: var(--surface); }
+ .wp-msg-own .wp-msg-bubble { background: var(--teal-soft); border-bottom-right-radius: var(--radius-xs); }
+ .wp-msg-other .wp-msg-bubble { background: var(--bubble-other); border-bottom-left-radius: var(--radius-xs); }
+ .wp-msg-failed .wp-msg-bubble { background: var(--danger-soft); }
+ .wp-msg-meta { margin: 0 var(--space-1); display: flex; align-items: center; gap: var(--space-2); font-size: 12px; line-height: 16px; color: var(--ink-muted); font-variant-numeric: tabular-nums; }
+ .wp-msg-status { display: inline-flex; align-items: center; gap: 2px; }
+ .wp-msg-failed .wp-msg-status { color: var(--danger); font-weight: 600; }
+
+ /* Composer */
+ .wp-composer { display: flex; align-items: center; gap: var(--space-1); padding: var(--space-1); border-radius: var(--radius-lg); border: 1px solid var(--line-strong); background: var(--surface-raised); box-shadow: var(--shadow-sm); }
+ .wp-composer:focus-within { border-color: var(--teal-strong); }
+ .wp-composer-input { flex: 1; min-width: 0; font: inherit; font-size: 15px; line-height: 22px; border: 0; outline: 0; background: transparent; color: var(--ink); padding: var(--space-2) var(--space-1); }
+ .wp-composer-input::placeholder { color: var(--ink-muted); }
+ .wp-composer-lock { color: var(--teal-text); margin: 0 var(--space-1); }
+ .wp-composer-send { width: 40px; height: 40px; flex: none; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: var(--radius-md); background: var(--teal-strong); color: var(--on-teal); cursor: pointer; }
+ .wp-composer-send:disabled { background: var(--line); color: var(--ink-muted); cursor: default; }
+}
diff --git a/apps/ui/src/styles/extras.css b/apps/ui/src/styles/extras.css
new file mode 100644
index 0000000..90d10f9
--- /dev/null
+++ b/apps/ui/src/styles/extras.css
@@ -0,0 +1,69 @@
+/* Components the brand package does not ship yet, drawn with the same
+ * tokens and rules: controls bordered in line-strong, radius-sm for
+ * controls, radius-md for cards, radius-lg for dialogs, shadow-lg for
+ * anything floating, focus-ring on keyboard focus.
+ */
+
+@layer components {
+ /* Card */
+ .wp-card { background: var(--surface-raised); border: 1px solid var(--line); border-radius: var(--radius-md); }
+
+ /* Textarea and select share the input's look */
+ .wp-textarea { font: inherit; font-size: 14px; line-height: 20px; min-height: 96px; padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm); border: 1px solid var(--line-strong); background: var(--surface-raised); color: var(--ink); resize: vertical; }
+ .wp-textarea::placeholder { color: var(--ink-muted); }
+ .wp-textarea:focus-visible, .wp-select:focus-visible { outline: 2px solid transparent; border-color: var(--teal-strong); box-shadow: var(--focus-ring); }
+ .wp-select { font: inherit; font-size: 14px; height: 40px; padding: 0 var(--space-8) 0 var(--space-3); border-radius: var(--radius-sm); border: 1px solid var(--line-strong); background-color: var(--surface-raised); color: var(--ink); appearance: none; background-image: linear-gradient(45deg, transparent 50%, var(--ink-muted) 50%), linear-gradient(135deg, var(--ink-muted) 50%, transparent 50%); background-position: calc(100% - 17px) 50%, calc(100% - 12px) 50%; background-size: 5px 5px; background-repeat: no-repeat; }
+ .wp-field-wide { max-width: none; }
+
+ /* Switch */
+ .wp-switch { position: relative; flex: none; width: 40px; height: 24px; padding: 0; border: 1px solid var(--line-strong); border-radius: var(--radius-full); background: var(--line); cursor: pointer; transition: background-color .15s ease, border-color .15s ease; }
+ .wp-switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: var(--radius-full); background: var(--surface-raised); box-shadow: var(--shadow-sm); transition: transform .15s ease; }
+ .wp-switch[aria-checked="true"] { background: var(--teal-strong); border-color: var(--teal-strong); }
+ .wp-switch[aria-checked="true"]::after { transform: translateX(16px); background: var(--on-teal); }
+ .wp-switch:disabled { opacity: .45; cursor: not-allowed; }
+ .wp-switch:focus-visible { outline: 2px solid transparent; box-shadow: var(--focus-ring); }
+
+ /* Checkbox */
+ .wp-check { display: inline-flex; align-items: center; gap: var(--space-2); cursor: pointer; }
+ .wp-check input { width: 18px; height: 18px; margin: 0; accent-color: var(--teal-strong); cursor: pointer; }
+
+ /* Dialog */
+ .wp-dialog { margin: auto; width: calc(100% - 2 * var(--space-4)); max-width: 480px; max-height: calc(100vh - 2 * var(--space-4)); box-sizing: border-box; overflow: auto; padding: var(--space-6); border-radius: var(--radius-lg); background: var(--surface-raised); color: var(--ink); box-shadow: var(--shadow-lg); border: 1px solid var(--line); }
+ .wp-dialog[open] { display: flex; flex-direction: column; gap: var(--space-4); }
+ .wp-dialog::backdrop { background: #0f121399; }
+ .wp-dialog-head { display: flex; align-items: flex-start; gap: var(--space-3); }
+ .wp-dialog-title { flex: 1; margin: 0; font-family: var(--font-display); font-size: 20px; line-height: 26px; font-weight: 600; }
+ .wp-dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); }
+
+ /* Table */
+ .wp-table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface-raised); }
+ .wp-table { width: 100%; border-collapse: collapse; font-size: 14px; line-height: 20px; }
+ .wp-table th { text-align: left; font-size: 12px; line-height: 16px; font-weight: 600; letter-spacing: .02em; color: var(--ink-muted); padding: var(--space-2) var(--space-4); background: var(--surface-sunken); border-bottom: 1px solid var(--line); white-space: nowrap; }
+ .wp-table td { padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--line); vertical-align: middle; }
+ .wp-table tr:last-child td { border-bottom: 0; }
+ .wp-table tbody tr.wp-row-link { cursor: pointer; }
+ .wp-table tbody tr.wp-row-link:hover { background: var(--surface-sunken); }
+ .wp-table .wp-num { text-align: right; font-variant-numeric: tabular-nums; }
+
+ /* Spinner */
+ .wp-spinner { display: inline-block; flex: none; border-radius: var(--radius-full); border: 2px solid var(--line); border-top-color: var(--teal); animation: wp-spin .8s linear infinite; }
+ @keyframes wp-spin { to { transform: rotate(360deg); } }
+
+ /* Navigation item, for rails and sidebars */
+ .wp-nav { font: inherit; color: var(--ink); text-decoration: none; display: flex; align-items: center; gap: var(--space-3); height: 40px; padding: 0 var(--space-3); border-radius: var(--radius-md); font-weight: 600; }
+ .wp-nav > .wp-icon { color: var(--ink-muted); }
+ .wp-nav:hover { background: var(--line); }
+ .wp-nav-active, .wp-nav-active:hover { background: var(--teal-soft); color: var(--teal-text); }
+ .wp-nav-active > .wp-icon { color: var(--teal-text); }
+
+ /* Segmented control */
+ .wp-segmented { display: inline-flex; padding: 2px; gap: 2px; border-radius: var(--radius-sm); border: 1px solid var(--line-strong); background: var(--surface-raised); }
+ .wp-segmented button { font: inherit; font-size: 13px; font-weight: 600; height: 30px; padding: 0 var(--space-3); display: inline-flex; align-items: center; gap: var(--space-1); border: 0; border-radius: 6px; background: transparent; color: var(--ink-muted); cursor: pointer; }
+ .wp-segmented button[aria-pressed="true"] { background: var(--teal-soft); color: var(--teal-text); }
+ .wp-segmented button:focus-visible { outline: 2px solid transparent; box-shadow: var(--focus-ring); }
+
+ /* Danger text button */
+ .wp-btn-danger-solid { background: var(--danger); color: var(--surface); border-color: var(--danger); }
+ .wp-btn-danger-solid:hover { filter: brightness(1.06); }
+ .wp-btn-danger:hover { filter: brightness(0.98); }
+}
diff --git a/apps/ui/src/styles/fonts/Figtree-Variable.woff2 b/apps/ui/src/styles/fonts/Figtree-Variable.woff2
new file mode 100644
index 0000000..61c0499
Binary files /dev/null and b/apps/ui/src/styles/fonts/Figtree-Variable.woff2 differ
diff --git a/apps/ui/src/styles/fonts/JetBrainsMono-Variable.woff2 b/apps/ui/src/styles/fonts/JetBrainsMono-Variable.woff2
new file mode 100644
index 0000000..cbb9462
Binary files /dev/null and b/apps/ui/src/styles/fonts/JetBrainsMono-Variable.woff2 differ
diff --git a/apps/ui/src/styles/fonts/Outfit-Variable.woff2 b/apps/ui/src/styles/fonts/Outfit-Variable.woff2
new file mode 100644
index 0000000..1afb679
Binary files /dev/null and b/apps/ui/src/styles/fonts/Outfit-Variable.woff2 differ
diff --git a/apps/ui/src/styles/index.css b/apps/ui/src/styles/index.css
new file mode 100644
index 0000000..b4989eb
--- /dev/null
+++ b/apps/ui/src/styles/index.css
@@ -0,0 +1,49 @@
+/* Warp design system for Tailwind v4 apps. An app's main stylesheet is
+ *
+ * @import "tailwindcss";
+ * @import "@warp/ui/styles.css";
+ *
+ * Tokens and the type classes (.title, .heading, .body, .mxid, ...) sit in
+ * the base layer, so a utility on the same element still wins.
+ */
+
+@import "./tokens.css" layer(base);
+@import "./theme.css";
+@import "./components.css";
+@import "./extras.css";
+
+@source "../components";
+
+@layer base {
+ html {
+ -webkit-text-size-adjust: 100%;
+ }
+
+ body {
+ margin: 0;
+ font-family: var(--font-sans);
+ font-size: 14px;
+ line-height: 20px;
+ background: var(--surface);
+ color: var(--ink);
+ -webkit-font-smoothing: antialiased;
+ }
+
+ a {
+ color: var(--link);
+ }
+
+ :focus-visible {
+ outline: 2px solid transparent;
+ box-shadow: var(--focus-ring);
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ transition-duration: 0s !important;
+ animation-duration: 0s !important;
+ }
+ }
+}
diff --git a/apps/ui/src/styles/theme.css b/apps/ui/src/styles/theme.css
new file mode 100644
index 0000000..98bf2a5
--- /dev/null
+++ b/apps/ui/src/styles/theme.css
@@ -0,0 +1,68 @@
+/* Tailwind v4 theme built on the Warp tokens.
+ *
+ * The default palette is cleared on purpose: every colour in an app comes
+ * from the design system, so `bg-surface-sunken` and `text-ink-muted` exist
+ * and `bg-gray-100` does not. Colours point at the token variables, which
+ * switch with the theme, so a utility never needs a `dark:` variant.
+ *
+ * Radius and font names match the tokens' own variables, so they are given
+ * the same literal values here rather than var() references (which would
+ * point at themselves). Shadows change between themes and get their own
+ * names: `shadow-raised` is the token `shadow-sm`, `shadow-float` is
+ * `shadow-lg`.
+ */
+
+@theme inline {
+ --color-*: initial;
+ --color-transparent: transparent;
+ --color-current: currentColor;
+
+ --color-surface: var(--surface);
+ --color-surface-sunken: var(--surface-sunken);
+ --color-surface-raised: var(--surface-raised);
+ --color-line: var(--line);
+ --color-line-strong: var(--line-strong);
+ --color-ink: var(--ink);
+ --color-ink-muted: var(--ink-muted);
+ --color-teal: var(--teal);
+ --color-teal-strong: var(--teal-strong);
+ --color-on-teal: var(--on-teal);
+ --color-teal-text: var(--teal-text);
+ --color-teal-soft: var(--teal-soft);
+ --color-bubble-other: var(--bubble-other);
+ --color-danger: var(--danger);
+ --color-danger-soft: var(--danger-soft);
+ --color-warning: var(--warning);
+ --color-warning-soft: var(--warning-soft);
+ --color-info: var(--info);
+ --color-info-soft: var(--info-soft);
+ --color-success: var(--success);
+ --color-link: var(--link);
+ --color-presence-online: var(--presence-online);
+ --color-presence-away: var(--presence-away);
+ --color-presence-busy: var(--presence-busy);
+ --color-presence-offline: var(--presence-offline);
+
+ --shadow-*: initial;
+ --shadow-raised: var(--shadow-sm);
+ --shadow-float: var(--shadow-lg);
+ --shadow-focus: var(--focus-ring);
+}
+
+@theme {
+ --radius-*: initial;
+ --radius-xs: 4px;
+ --radius-sm: 8px;
+ --radius-md: 12px;
+ --radius-lg: 18px;
+ --radius-xl: 28px;
+ --radius-full: 9999px;
+
+ --font-*: initial;
+ --font-display: "Outfit", "Figtree", system-ui, sans-serif;
+ --font-sans: "Figtree", system-ui, -apple-system, "Segoe UI", sans-serif;
+ --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
+
+ --ease-out: cubic-bezier(0.2, 0, 0, 1);
+ --default-transition-duration: 150ms;
+}
diff --git a/apps/ui/src/styles/tokens.css b/apps/ui/src/styles/tokens.css
new file mode 100644
index 0000000..871b4b8
--- /dev/null
+++ b/apps/ui/src/styles/tokens.css
@@ -0,0 +1,141 @@
+/* Warp design tokens, copied from the brand package (design-system/tokens.css,
+ generated from tokens.json). Edit tokens.json upstream and copy both. */
+
+@font-face { font-family: "Outfit"; src: url("fonts/Outfit-Variable.woff2") format("woff2"); font-weight: 100 900; font-display: swap; }
+
+@font-face { font-family: "Figtree"; src: url("fonts/Figtree-Variable.woff2") format("woff2"); font-weight: 300 900; font-display: swap; }
+
+@font-face { font-family: "JetBrains Mono"; src: url("fonts/JetBrainsMono-Variable.woff2") format("woff2"); font-weight: 100 800; font-display: swap; }
+
+:root, [data-theme="light"] {
+ --surface: #ffffff;
+ --surface-sunken: #f3f5f4;
+ --surface-raised: #ffffff;
+ --line: #e1e6e4;
+ --line-strong: #7b8682;
+ --ink: #111114;
+ --ink-muted: #5b6461;
+ --teal: #00a389;
+ --teal-strong: #00806a;
+ --on-teal: #ffffff;
+ --teal-text: #00735f;
+ --teal-soft: #ddf4ee;
+ --bubble-other: #eff2f1;
+ --danger: #b42318;
+ --danger-soft: #fde8e6;
+ --warning: #8a5300;
+ --warning-soft: #fff2db;
+ --info: #1d5fd1;
+ --info-soft: #e3edff;
+ --success: var(--teal-text);
+ --link: var(--teal-text);
+ --presence-online: var(--teal);
+ --presence-away: #d99a00;
+ --presence-busy: #d64545;
+ --presence-offline: #8a9490;
+ --shadow-sm: 0 1px 2px #1114140f, 0 1px 3px #1114141a;
+ --shadow-lg: 0 4px 8px #11141414, 0 16px 32px -4px #11141429;
+ --focus-ring: 0 0 0 2px #ffffff, 0 0 0 4px #00806a;
+}
+
+[data-theme="dark"] {
+ --surface: #0f1213;
+ --surface-sunken: #161a1c;
+ --surface-raised: #1b2022;
+ --line: #2a3134;
+ --line-strong: #6e7976;
+ --ink: #eceeee;
+ --ink-muted: #9aa5a1;
+ --teal: #22c3a6;
+ --teal-strong: #22c3a6;
+ --on-teal: #062a23;
+ --teal-text: #3ed6b8;
+ --teal-soft: #123a33;
+ --bubble-other: #1e2427;
+ --danger: #f97066;
+ --danger-soft: #3a1512;
+ --warning: #f5b547;
+ --warning-soft: #33260b;
+ --info: #7eb1ff;
+ --info-soft: #14233f;
+ --presence-away: #f5b547;
+ --presence-busy: #f97066;
+ --presence-offline: #6e7976;
+ --shadow-sm: 0 1px 3px #000000b3;
+ --shadow-lg: 0 16px 32px -4px #000000cc;
+ --focus-ring: 0 0 0 2px #0f1213, 0 0 0 4px #3ed6b8;
+ color-scheme: dark;
+}
+
+@media (prefers-color-scheme: dark) {
+:root:not([data-theme="light"]) {
+ --surface: #0f1213;
+ --surface-sunken: #161a1c;
+ --surface-raised: #1b2022;
+ --line: #2a3134;
+ --line-strong: #6e7976;
+ --ink: #eceeee;
+ --ink-muted: #9aa5a1;
+ --teal: #22c3a6;
+ --teal-strong: #22c3a6;
+ --on-teal: #062a23;
+ --teal-text: #3ed6b8;
+ --teal-soft: #123a33;
+ --bubble-other: #1e2427;
+ --danger: #f97066;
+ --danger-soft: #3a1512;
+ --warning: #f5b547;
+ --warning-soft: #33260b;
+ --info: #7eb1ff;
+ --info-soft: #14233f;
+ --presence-away: #f5b547;
+ --presence-busy: #f97066;
+ --presence-offline: #6e7976;
+ --shadow-sm: 0 1px 3px #000000b3;
+ --shadow-lg: 0 16px 32px -4px #000000cc;
+ --focus-ring: 0 0 0 2px #0f1213, 0 0 0 4px #3ed6b8;
+ color-scheme: dark;
+}
+}
+
+:root {
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 12px;
+ --space-4: 16px;
+ --space-5: 20px;
+ --space-6: 24px;
+ --space-8: 32px;
+ --space-12: 48px;
+ --radius-xs: 4px;
+ --radius-sm: 8px;
+ --radius-md: 12px;
+ --radius-lg: 18px;
+ --radius-xl: 28px;
+ --radius-full: 9999px;
+ --font-display: "Outfit", "Figtree", system-ui, sans-serif;
+ --font-sans: "Figtree", system-ui, -apple-system, "Segoe UI", sans-serif;
+ --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
+}
+
+.display { font-family: var(--font-display); font-size: 40px; line-height: 44px; font-weight: 700; letter-spacing: -0.02em; }
+
+.title { font-family: var(--font-display); font-size: 28px; line-height: 34px; font-weight: 600; letter-spacing: -0.01em; }
+
+.heading { font-family: var(--font-display); font-size: 20px; line-height: 26px; font-weight: 600; }
+
+.subheading { font-family: var(--font-display); font-size: 16px; line-height: 22px; font-weight: 600; }
+
+.message { font-family: var(--font-sans); font-size: 15px; line-height: 22px; font-weight: 400; }
+
+.body { font-family: var(--font-sans); font-size: 14px; line-height: 20px; font-weight: 400; }
+
+.body-strong { font-family: var(--font-sans); font-size: 14px; line-height: 20px; font-weight: 600; }
+
+.small { font-family: var(--font-sans); font-size: 13px; line-height: 18px; font-weight: 400; }
+
+.caption { font-family: var(--font-sans); font-size: 12px; line-height: 16px; font-weight: 500; letter-spacing: 0.02em; }
+
+.code { font-family: var(--font-mono); font-size: 13px; line-height: 20px; font-weight: 400; }
+
+.mxid { font-family: var(--font-mono); font-size: 12px; line-height: 16px; font-weight: 500; }
diff --git a/apps/ui/src/styles/tokens.json b/apps/ui/src/styles/tokens.json
new file mode 100644
index 0000000..8f26d14
--- /dev/null
+++ b/apps/ui/src/styles/tokens.json
@@ -0,0 +1,115 @@
+{
+ "name": "Warp",
+ "version": 1,
+ "color": {
+ "themes": [
+ { "id": "light", "name": "Light" },
+ { "id": "dark", "name": "Dark" }
+ ],
+ "tokens": [
+ { "name": "surface", "value": { "light": "#ffffff", "dark": "#0f1213" }, "usage": "Conversation pane and page background." },
+ { "name": "surface-sunken", "value": { "light": "#f3f5f4", "dark": "#161a1c" }, "usage": "App chrome: room list sidebar, settings rail, the strip behind the composer." },
+ { "name": "surface-raised", "value": { "light": "#ffffff", "dark": "#1b2022" }, "usage": "Menus, dialogs, cards, the composer field. Pair with shadow-sm or shadow-lg." },
+ { "name": "line", "value": { "light": "#e1e6e4", "dark": "#2a3134" }, "usage": "Hairline dividers between rooms, panes and list rows. Decorative only; never the only edge of a control." },
+ { "name": "line-strong", "value": { "light": "#7b8682", "dark": "#6e7976" }, "usage": "Input and control borders (3.8:1 on surface light, 4.2:1 dark)." },
+ { "name": "ink", "value": { "light": "#111114", "dark": "#eceeee" }, "usage": "Primary text and icons on surface, surface-sunken, surface-raised, teal-soft and the bubbles." },
+ { "name": "ink-muted", "value": { "light": "#5b6461", "dark": "#9aa5a1" }, "usage": "Timestamps, previews, hints and metadata, on surface, surface-sunken and surface-raised (≥5.5:1 both themes)." },
+ { "name": "teal", "value": { "light": "#00a389", "dark": "#22c3a6" }, "usage": "Brand Deep Teal: the symbol, unread dots, online presence, selection marks. Graphic use only on light surfaces (3.2:1) — never body text on white." },
+ { "name": "teal-strong", "value": { "light": "#00806a", "dark": "#22c3a6" }, "usage": "Primary button and send button fill. Label it with on-teal (4.9:1 light, 6.9:1 dark)." },
+ { "name": "on-teal", "value": { "light": "#ffffff", "dark": "#062a23" }, "usage": "Text and icons on a teal-strong fill." },
+ { "name": "teal-text", "value": { "light": "#00735f", "dark": "#3ed6b8" }, "usage": "Teal as text: links, verified labels, mentions — on surface, surface-raised and teal-soft (≥5:1 both themes)." },
+ { "name": "teal-soft", "value": { "light": "#ddf4ee", "dark": "#123a33" }, "usage": "Your own message bubbles, the selected room row, mention highlights. Text on it is ink or teal-text." },
+ { "name": "bubble-other", "value": { "light": "#eff2f1", "dark": "#1e2427" }, "usage": "Message bubbles from other people. Text on it is ink." },
+ { "name": "danger", "value": { "light": "#b42318", "dark": "#f97066" }, "usage": "Errors, failed sends, destructive actions; as text on surface or danger-soft. Always with an icon or word." },
+ { "name": "danger-soft", "value": { "light": "#fde8e6", "dark": "#3a1512" }, "usage": "Background behind danger text: error banners, failed message rows." },
+ { "name": "warning", "value": { "light": "#8a5300", "dark": "#f5b547" }, "usage": "Unverified sessions, degraded federation, expiring keys; as text on surface or warning-soft." },
+ { "name": "warning-soft", "value": { "light": "#fff2db", "dark": "#33260b" }, "usage": "Background behind warning text." },
+ { "name": "info", "value": { "light": "#1d5fd1", "dark": "#7eb1ff" }, "usage": "Neutral system notices (room upgrades, server messages); as text on surface or info-soft." },
+ { "name": "info-soft", "value": { "light": "#e3edff", "dark": "#14233f" }, "usage": "Background behind info text." },
+ { "name": "success", "value": "{teal-text}", "usage": "Verified, delivered, connected. An alias of teal-text; always paired with the shield-check or check icon." },
+ { "name": "link", "value": "{teal-text}", "usage": "Inline links in messages and UI copy." },
+ { "name": "presence-online", "value": "{teal}", "usage": "Presence dot: online." },
+ { "name": "presence-away", "value": { "light": "#d99a00", "dark": "#f5b547" }, "usage": "Presence dot: idle / away." },
+ { "name": "presence-busy", "value": { "light": "#d64545", "dark": "#f97066" }, "usage": "Presence dot: do not disturb." },
+ { "name": "presence-offline", "value": { "light": "#8a9490", "dark": "#6e7976" }, "usage": "Presence dot: offline or unknown." }
+ ]
+ },
+ "type": {
+ "fonts": [
+ { "family": "Outfit", "file": "fonts/Outfit-Variable.woff2", "weight": "100 900" },
+ { "family": "Figtree", "file": "fonts/Figtree-Variable.woff2", "weight": "300 900" },
+ { "family": "JetBrains Mono", "file": "fonts/JetBrainsMono-Variable.woff2", "weight": "100 800" }
+ ],
+ "families": {
+ "display": "\"Outfit\", \"Figtree\", system-ui, sans-serif",
+ "sans": "\"Figtree\", system-ui, -apple-system, \"Segoe UI\", sans-serif",
+ "mono": "\"JetBrains Mono\", ui-monospace, \"SF Mono\", Menlo, monospace"
+ },
+ "groups": [
+ {
+ "name": "Display",
+ "family": "display",
+ "note": "Outfit, the wordmark's face. Headings and empty states only — never message text.",
+ "styles": [
+ { "name": "display", "fontSize": "40px", "lineHeight": "44px", "fontWeight": 700, "letterSpacing": "-0.02em", "sample": "Welcome to Warp", "usage": "Onboarding and marketing headlines. One per screen." },
+ { "name": "title", "fontSize": "28px", "lineHeight": "34px", "fontWeight": 600, "letterSpacing": "-0.01em", "sample": "Create a space", "usage": "Dialog and settings page titles." },
+ { "name": "heading", "fontSize": "20px", "lineHeight": "26px", "fontWeight": 600, "sample": "#homelab-ops", "usage": "Room header name, section headings." },
+ { "name": "subheading", "fontSize": "16px", "lineHeight": "22px", "fontWeight": 600, "sample": "Sessions", "usage": "Group labels inside settings and the room list." }
+ ]
+ },
+ {
+ "name": "Text",
+ "family": "sans",
+ "note": "Figtree for everything people read and write.",
+ "styles": [
+ { "name": "message", "fontSize": "15px", "lineHeight": "22px", "fontWeight": 400, "sample": "Federation is syncing again — matrix.org caught up in 4 s.", "usage": "Message bodies. Never smaller on any platform." },
+ { "name": "body", "fontSize": "14px", "lineHeight": "20px", "fontWeight": 400, "sample": "Messages in this room are end-to-end encrypted.", "usage": "UI copy, descriptions, settings rows." },
+ { "name": "body-strong", "fontSize": "14px", "lineHeight": "20px", "fontWeight": 600, "sample": "Dariusz Janiak", "usage": "Sender names, room names in the list, button labels." },
+ { "name": "small", "fontSize": "13px", "lineHeight": "18px", "fontWeight": 400, "sample": "Last message preview…", "usage": "Room list previews, secondary rows." },
+ { "name": "caption", "fontSize": "12px", "lineHeight": "16px", "fontWeight": 500, "letterSpacing": "0.02em", "sample": "14:32 · edited", "usage": "Timestamps, read receipts, badges." }
+ ]
+ },
+ {
+ "name": "Code",
+ "family": "mono",
+ "note": "JetBrains Mono for anything machine-readable.",
+ "styles": [
+ { "name": "code", "fontSize": "13px", "lineHeight": "20px", "fontWeight": 400, "sample": "docker compose up -d warp", "usage": "Inline code and code blocks in messages." },
+ { "name": "mxid", "fontSize": "12px", "lineHeight": "16px", "fontWeight": 500, "sample": "@dariusz:warp.chat", "usage": "Matrix IDs, room aliases, server names, session IDs and key fingerprints." }
+ ]
+ }
+ ]
+ },
+ "spacing": {
+ "note": "4px base. Rows and controls snap to it.",
+ "tokens": [
+ { "name": "space-1", "value": "4px", "usage": "Icon-to-label gap, bubble-to-timestamp gap." },
+ { "name": "space-2", "value": "8px", "usage": "Inside badges and chips; gap between consecutive bubbles from one sender." },
+ { "name": "space-3", "value": "12px", "usage": "Bubble padding (horizontal), room row padding (vertical)." },
+ { "name": "space-4", "value": "16px", "usage": "Pane gutters, card padding, gap between message groups." },
+ { "name": "space-5", "value": "20px", "usage": "Dialog padding on phones." },
+ { "name": "space-6", "value": "24px", "usage": "Dialog padding on desktop; between settings sections." },
+ { "name": "space-8", "value": "32px", "usage": "Empty-state spacing, onboarding blocks." },
+ { "name": "space-12", "value": "48px", "usage": "Page-level separation on marketing and docs pages." }
+ ]
+ },
+ "radius": {
+ "note": "Rounded frames, taken from the Link symbol. Bubbles keep one tight corner where the tail would be.",
+ "tokens": [
+ { "name": "radius-xs", "value": "4px", "usage": "Inline code, small badges, a bubble's tail corner." },
+ { "name": "radius-sm", "value": "8px", "usage": "Buttons, inputs, menu items." },
+ { "name": "radius-md", "value": "12px", "usage": "Cards, menus, room rows when selected, banners." },
+ { "name": "radius-lg", "value": "18px", "usage": "Message bubbles, the composer, dialogs." },
+ { "name": "radius-xl", "value": "28px", "usage": "Bottom sheets and app-icon tiles." },
+ { "name": "radius-full", "value": "9999px", "usage": "Avatars, presence dots, unread counts." }
+ ]
+ },
+ "shadow": {
+ "note": "Two elevations plus the focus ring. Prefer surfaces and lines over shadow.",
+ "tokens": [
+ { "name": "shadow-sm", "value": { "light": "0 1px 2px #1114140f, 0 1px 3px #1114141a", "dark": "0 1px 3px #000000b3" }, "usage": "The composer and cards at rest." },
+ { "name": "shadow-lg", "value": { "light": "0 4px 8px #11141414, 0 16px 32px -4px #11141429", "dark": "0 16px 32px -4px #000000cc" }, "usage": "Menus, popovers, dialogs, toasts." },
+ { "name": "focus-ring", "value": { "light": "0 0 0 2px #ffffff, 0 0 0 4px #00806a", "dark": "0 0 0 2px #0f1213, 0 0 0 4px #3ed6b8" }, "usage": "Keyboard focus: a 2px surface gap, then 2px of solid teal (4.9:1 light, 10:1 dark). Applied as box-shadow so it follows the radius." }
+ ]
+ }
+}
diff --git a/apps/ui/src/theme.ts b/apps/ui/src/theme.ts
new file mode 100644
index 0000000..6e79355
--- /dev/null
+++ b/apps/ui/src/theme.ts
@@ -0,0 +1,45 @@
+import { ref, watch } from 'vue'
+
+// The tokens follow the OS until `data-theme` on says otherwise.
+// The choice is a per-browser convenience, kept in localStorage.
+
+export type ThemeChoice = 'system' | 'light' | 'dark'
+
+const storageKey = 'warp.theme'
+
+function read(): ThemeChoice {
+ try {
+ const v = localStorage.getItem(storageKey)
+ if (v === 'light' || v === 'dark') return v
+ } catch {
+ // Storage blocked: fall back to the OS.
+ }
+ return 'system'
+}
+
+function apply(choice: ThemeChoice) {
+ const root = document.documentElement
+ if (choice === 'system') root.removeAttribute('data-theme')
+ else root.setAttribute('data-theme', choice)
+}
+
+const theme = ref('system')
+let started = false
+
+export function useTheme() {
+ if (!started) {
+ started = true
+ theme.value = read()
+ apply(theme.value)
+ watch(theme, (choice) => {
+ apply(choice)
+ try {
+ if (choice === 'system') localStorage.removeItem(storageKey)
+ else localStorage.setItem(storageKey, choice)
+ } catch {
+ // Not remembered; it still applies to this page.
+ }
+ })
+ }
+ return theme
+}
diff --git a/apps/ui/tsconfig.json b/apps/ui/tsconfig.json
new file mode 100644
index 0000000..4e7e7a8
--- /dev/null
+++ b/apps/ui/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "jsx": "preserve",
+ "types": ["vite/client"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.vue"]
+}
diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml
index 928e64d..2bf8d3d 100644
--- a/deploy/compose/docker-compose.yml
+++ b/deploy/compose/docker-compose.yml
@@ -8,7 +8,8 @@
# reverse proxy in front of it before pointing a domain at it; see
# docs/deployment.md. With the observability profile, Grafana is on
# http://localhost:3000 (admin / admin, change it), Prometheus on :9090 and
-# the Jaeger UI on :16686.
+# the Jaeger UI on :16686. With the admin profile, the admin panel is on
+# http://localhost:8009.
#
# Create the first administrator once the server is up:
#
@@ -63,6 +64,24 @@ services:
ports:
- "8008:8008"
+ # The admin panel is optional. Start it with the admin profile:
+ # docker compose --profile admin up -d
+ # It is on http://localhost:8009 and reaches the homeserver over the
+ # compose network, so /_warp/admin never has to be public.
+ admin:
+ build:
+ context: ../..
+ dockerfile: apps/admin/Dockerfile
+ image: warp-admin:latest
+ profiles: [admin]
+ restart: unless-stopped
+ depends_on:
+ - warp
+ environment:
+ WARP_ADMIN_HOMESERVER: http://warp:8008
+ ports:
+ - "${WARP_ADMIN_PORT:-8009}:8009"
+
prometheus:
image: prom/prometheus:v3.5.0
profiles: [observability]
diff --git a/docs/admin-api.md b/docs/admin-api.md
index 50bed53..54c9848 100644
--- a/docs/admin-api.md
+++ b/docs/admin-api.md
@@ -2,7 +2,8 @@
Everything under `/_warp/admin/v1` is Warp's own, not Matrix. Clients use
the Client-Server API; this is the operator's surface and the backend for
-the admin panel (`apps/admin`, PLAN.md section 8.1).
+the admin panel ([`apps/admin`](../apps/admin/README.md), a separate,
+optional `warp-admin` service).
## Authentication
diff --git a/docs/deployment.md b/docs/deployment.md
index 189336f..b8e9781 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -86,7 +86,10 @@ Service. Its image is built separately, also from the repository root:
```sh
docker build -t registry.example.org/warp-admin:0.10.0 -f apps/admin/Dockerfile .
-helm upgrade warp deploy/helm/warp --reuse-values --set admin.enabled=true --set admin.image.repository=registry.example.org/warp-admin --set admin.image.tag=0.10.0
+helm upgrade warp deploy/helm/warp --reuse-values \
+ --set admin.enabled=true \
+ --set admin.image.repository=registry.example.org/warp-admin \
+ --set admin.image.tag=0.10.0
```
Without an ingress, reach it with `kubectl port-forward svc/warp-admin
@@ -96,11 +99,31 @@ reaches `/_warp/admin` inside the cluster, so you can then drop `/_warp`
from `ingress.hosts[].paths`. `admin.resources` and `admin.replicas` are in
`values.yaml`.
+## Admin panel
+
+The admin panel is optional and is not part of the `warp` binary. It is a
+separate service, `warp-admin`, that serves the panel and proxies the
+login endpoints and `/_warp/admin/v1` to the homeserver. Install it only
+if you want it:
+
+```sh
+cd deploy/compose
+docker compose --profile admin up -d # http://localhost:8009
+```
+
+Or build the image (`make admin-docker`) or the binary (`make admin`) and
+point it at the homeserver with `WARP_ADMIN_HOMESERVER`. Since only
+warp-admin needs `/_warp/admin`, your public proxy can leave that path out
+and keep the panel on an internal hostname. See
+[apps/admin](../apps/admin/README.md).
+
## Reverse proxy
Behind a proxy that terminates TLS:
-- Route `/_matrix`, `/.well-known/matrix` and `/_warp` to Warp.
+- Route `/_matrix` and `/.well-known/matrix` to Warp. Route `/_warp` too
+ only if something outside reaches the admin API directly; warp-admin
+ talks to Warp over its own connection.
- Keep the proxy's read timeout above `sync.long_poll_timeout` (30 s), or
every idle `/sync` is cut off.
- Allow request bodies of `media.max_upload_bytes` (50 MiB by default).
diff --git a/go.work b/go.work
index 6609e38..22ea436 100644
--- a/go.work
+++ b/go.work
@@ -1,7 +1,10 @@
go 1.26.4
-use ./server
+use (
+ ./apps/admin
+ ./server
+)
-// The client sub-projects join the workspace as they are created:
+// The other client sub-projects join the workspace as they are created:
// use ./apps/web (Go host for the web client, Phase 8.2)
// use ./apps/desktop (Wails v3 shell, Phase 8.3)
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..62ce294
--- /dev/null
+++ b/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "warp-apps",
+ "private": true,
+ "packageManager": "pnpm@10.18.3",
+ "engines": {
+ "node": ">=22"
+ },
+ "scripts": {
+ "admin:dev": "pnpm --filter @warp/admin dev",
+ "admin:build": "pnpm --filter @warp/admin build",
+ "typecheck": "pnpm -r typecheck"
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
new file mode 100644
index 0000000..67ee8b8
--- /dev/null
+++ b/pnpm-lock.yaml
@@ -0,0 +1,1412 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .: {}
+
+ apps/admin:
+ dependencies:
+ '@warp/ui':
+ specifier: workspace:*
+ version: link:../ui
+ pinia:
+ specifier: ^3.0.3
+ version: 3.0.4(typescript@5.9.3)(vue@3.5.44(typescript@5.9.3))
+ vue:
+ specifier: ^3.5.22
+ version: 3.5.44(typescript@5.9.3)
+ vue-router:
+ specifier: ^4.5.1
+ version: 4.6.4(vue@3.5.44(typescript@5.9.3))
+ devDependencies:
+ '@tailwindcss/vite':
+ specifier: ^4.1.14
+ version: 4.3.3(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0))
+ '@types/node':
+ specifier: ^22.18.10
+ version: 22.20.5
+ '@vitejs/plugin-vue':
+ specifier: ^6.0.1
+ version: 6.0.10(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.44(typescript@5.9.3))
+ tailwindcss:
+ specifier: ^4.1.14
+ version: 4.3.3
+ typescript:
+ specifier: ~5.9.3
+ version: 5.9.3
+ vite:
+ specifier: ^7.1.9
+ version: 7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0)
+ vue-tsc:
+ specifier: ^3.1.1
+ version: 3.3.13(typescript@5.9.3)
+
+ apps/ui:
+ devDependencies:
+ tailwindcss:
+ specifier: ^4.1.14
+ version: 4.3.3
+ typescript:
+ specifier: ~5.9.3
+ version: 5.9.3
+ vite:
+ specifier: ^7.1.9
+ version: 7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0)
+ vue:
+ specifier: ^3.5.22
+ version: 3.5.44(typescript@5.9.3)
+ vue-tsc:
+ specifier: ^3.1.1
+ version: 3.3.13(typescript@5.9.3)
+
+packages:
+
+ '@babel/helper-string-parser@7.29.7':
+ resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.29.7':
+ resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.29.9':
+ resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/types@7.29.8':
+ resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@esbuild/aix-ppc64@0.28.2':
+ resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.28.2':
+ resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.28.2':
+ resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.28.2':
+ resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.28.2':
+ resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.28.2':
+ resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.28.2':
+ resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.28.2':
+ resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.28.2':
+ resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.28.2':
+ resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.28.2':
+ resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.28.2':
+ resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.28.2':
+ resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.28.2':
+ resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.28.2':
+ resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.28.2':
+ resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.28.2':
+ resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.28.2':
+ resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.28.2':
+ resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.28.2':
+ resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.28.2':
+ resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.28.2':
+ resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.28.2':
+ resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.28.2':
+ resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.28.2':
+ resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.28.2':
+ resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.6.0':
+ resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@napi-rs/lzma-linux-x64-gnu@1.5.1':
+ resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
+ engines: {node: ^22.20 || ^24.12 || >=25}
+ cpu: [x64]
+ os: [linux]
+
+ '@rolldown/pluginutils@1.0.1':
+ resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
+
+ '@rollup/rollup-android-arm-eabi@4.64.9':
+ resolution: {integrity: sha512-qJmhckvOleXp8R+BaXiVW/S34/3M7pvN17/QcG90A74BDaSvKv8ffY+GYG+a/xU0vEoITzdMSiM7o9HZSQWbfA==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.64.9':
+ resolution: {integrity: sha512-dqQgds39BNWGB2ughJ6n2LTQFXk8RAOwnXj6m387OHpygfD93QRS1ODIfpiWyAa9gsFB9l34pSqeLmA0Xk/VBw==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.64.9':
+ resolution: {integrity: sha512-scrMx4SGikOSbWknLmPD0v6OeJ1Wih3kyRLdNZnlVbBeKVWp1nRTVcpM0IxDJdc3u8UjesPHTmnX649u8cQpBw==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.64.9':
+ resolution: {integrity: sha512-fkwi2OQR+QWoViRFt08Bsca6G0e9pk/4z+JgmqHq6jSRTT0LtI27KRVK8bu++6L/CBtQ3OZePbqC3bcrwGzTqQ==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.64.9':
+ resolution: {integrity: sha512-8Apr0Khn0r2MUsDtcOMbp85fDdXsX/E+iLhO17WUjG24m3O8ChArqI0isI7CAdj9Ouq177PHtVtEUpMNE1C4Gw==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.64.9':
+ resolution: {integrity: sha512-H+upy4DhBcBZlUsCAMyr8wR0Dg9kj8sGloVZdZ7va2heJM/5jnROxI47dtXseJjlD/VoQe6MrXhY3b5ikOq/Vw==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.64.9':
+ resolution: {integrity: sha512-b50NS9TbAEK/b16vBoKrkeNwy+HvGBXmtBbzX4wcodWOh9dQZvRTN9rUyElIXa36QnwXhU9PAVVkZpqRzSi2Tg==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.64.9':
+ resolution: {integrity: sha512-SdqcudB/FzjLESsRMgXhV+FXGtf6usxXwlMoRW7D/XS3f9CZ4T4wycRBu2h9bVl2AOaPpunoA8ftAM/MF4altQ==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-gnu@4.64.9':
+ resolution: {integrity: sha512-dg19FFRoovN+KZ+gMHBAq4Do8PgQmBcxpuhux57OuiR0QLavfn8uPBY8CP+UO50bO+xf0tK0GlqI/jld++5E6g==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-musl@4.64.9':
+ resolution: {integrity: sha512-+jyv5ofGmpAvtrzLjSr/e4syskeikEp5L82bUzB76auwOgK1VNVwZV/F2g1Pes9bhTznbejOefAqPglHSKYmTA==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-loong64-gnu@4.64.9':
+ resolution: {integrity: sha512-MKFXxqmkzCbtNvx/vjiJdss44eRtVabhCtDWcCFD9NHGwP+YkqnM/slnEsv/3fsoq9ukTtudraAjGhptrUPCVg==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-loong64-musl@4.64.9':
+ resolution: {integrity: sha512-lhARcOT6n7Ni9hpQRq8LZjbuoW+G/GXaW4Jfhx2MbmjnCj9XxLMRTceJIMpiMLQVWm+qxe7oq1sYvlIGOAW34Q==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.64.9':
+ resolution: {integrity: sha512-oORQzGxZ1oHghJwLdwc8Ni0SY9otILAeXsWdiImTF4F5HkWJUwh5s/269PF5tYILNC1z/3RwFsrgcHPguBcb0g==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rollup/rollup-linux-ppc64-musl@4.64.9':
+ resolution: {integrity: sha512-J2JQHQt7oR1uutd2OSwbV2Qpb2LI5gggPolbkUwDe7vn/0dnduZ0w6EIbGnZUAw7uIKaMgfVylcH6V+Aov8kRA==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.64.9':
+ resolution: {integrity: sha512-bWaKLrfzeQ9mOzJJxXbPgw2RH6VAaTJrb/60Tqf7YSMxubG8780Hzm7Us+D6qVW8QRGJYl4VUvVXxnRB0o9zvQ==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-musl@4.64.9':
+ resolution: {integrity: sha512-TFJyaCB9NsvSyvVGQgTSRD9XgxsAq99ivDDkt9KAySlrNW5Obsd1kQpvoCvBCOLq+JlJTjmtroLH0wp8M8mEsA==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-s390x-gnu@4.64.9':
+ resolution: {integrity: sha512-DvmOM5xz/wDnUJFVjqP32ahalxn7BDuvO25bpuFOrb50AIFCCcFPN4lWWUDD1c8Ddv9sMO9SXyqtOet8G8u9cQ==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-gnu@4.64.9':
+ resolution: {integrity: sha512-aFzokHK3yE00mzAvKm/PY76OsOOTjvoTGE82Vp89iBDyD2wUBlV4ZsR2tWkRECTEFk6TC4Yc3zaEWjPqq1vstQ==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-musl@4.64.9':
+ resolution: {integrity: sha512-t/yMdeaaQUuNNpmT7taBmH2VvHzgT4YA/IaKVE3qVr4PYQemn/kmJkL4uG4UvTLcWXQd0MSoOa/3URYSWGmUEg==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-openbsd-x64@4.64.9':
+ resolution: {integrity: sha512-4dnxKQ8GHARyjqAU87q5UWhywZTpIbR2VWhtACdC37GD1SGszmvb3TaBFVA4n+fnDHSKMut4ZJ63hVFwgnxVtg==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.64.9':
+ resolution: {integrity: sha512-1qdQj6IrNoR0xFvrCQHmhBXe9HMbBGNTFi+3n4jKMt2rTx4k0aeAdraZU4WDOMfL9tAwS8/TRSuquvNuPQ+Gpw==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.64.9':
+ resolution: {integrity: sha512-yc1GSEHIYhKGN5WSaJc+eMy8bZvzcvAmflEgFh8L3xcy8dYxGmfrudyBnBFde9f6JzOa1kFqocF8BHaWybjQoA==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.64.9':
+ resolution: {integrity: sha512-VzHGvosAkLIzrwqvSgJBPDmGFTF/yE8OtMT4H5z8pM1NA0NiPpvTYnUh+9xPd+d5oy/wqF317FmJ1I8Kk8pp0Q==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.64.9':
+ resolution: {integrity: sha512-FB77txy1PiAcJgG72nOLA6R0S3PRZahs8Spx0z2yseDUkoMmJcrQghiebJ2JHZ74cCOVlX/MYd4dUbJtJJ8fow==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.64.9':
+ resolution: {integrity: sha512-IoIgQLpK1DbOcQf1vX+i5oe5LXypsCVb1j/mU6BLAbOGmXH4E+j4kMfgZRwPYUohVBurNyVMgIKVM5dR6xA0kA==}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/node@4.3.3':
+ resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
+
+ '@tailwindcss/oxide-android-arm64@4.3.3':
+ resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [android]
+
+ '@tailwindcss/oxide-darwin-arm64@4.3.3':
+ resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-darwin-x64@4.3.3':
+ resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-freebsd-x64@4.3.3':
+ resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
+ resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
+ resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
+ resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
+ resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-x64-musl@4.3.3':
+ resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
+
+ '@tailwindcss/oxide-wasm32-wasi@4.3.3':
+ resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+ bundledDependencies:
+ - '@napi-rs/wasm-runtime'
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+ - '@tybys/wasm-util'
+ - '@emnapi/wasi-threads'
+ - tslib
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
+ resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
+ resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/oxide@4.3.3':
+ resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
+ engines: {node: '>= 20'}
+
+ '@tailwindcss/vite@4.3.3':
+ resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==}
+ peerDependencies:
+ vite: ^5.2.0 || ^6 || ^7 || ^8
+
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
+ '@types/node@22.20.5':
+ resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==}
+
+ '@vitejs/plugin-vue@6.0.10':
+ resolution: {integrity: sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
+ vue: ^3.2.25 || ^3.6.0-rc.10
+
+ '@volar/language-core@2.4.28':
+ resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==}
+
+ '@volar/source-map@2.4.28':
+ resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==}
+
+ '@volar/typescript@2.4.28':
+ resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
+
+ '@vue/compiler-core@3.5.44':
+ resolution: {integrity: sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ==}
+
+ '@vue/compiler-dom@3.5.44':
+ resolution: {integrity: sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg==}
+
+ '@vue/compiler-sfc@3.5.44':
+ resolution: {integrity: sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw==}
+
+ '@vue/compiler-ssr@3.5.44':
+ resolution: {integrity: sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA==}
+
+ '@vue/devtools-api@6.6.4':
+ resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
+
+ '@vue/devtools-api@7.7.10':
+ resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==}
+
+ '@vue/devtools-kit@7.7.10':
+ resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==}
+
+ '@vue/devtools-shared@7.7.10':
+ resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==}
+
+ '@vue/language-core@3.3.13':
+ resolution: {integrity: sha512-axA0Xemn5LuUKuKWob0/kVq7xQ2mJJM6VqA9Exvt35nM9yRC31goWVve/G/Zb2ZCLsXC8FHYjjfXlnshknSCew==}
+
+ '@vue/reactivity@3.5.44':
+ resolution: {integrity: sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw==}
+
+ '@vue/runtime-core@3.5.44':
+ resolution: {integrity: sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ==}
+
+ '@vue/runtime-dom@3.5.44':
+ resolution: {integrity: sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg==}
+
+ '@vue/server-renderer@3.5.44':
+ resolution: {integrity: sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ==}
+
+ '@vue/shared@3.5.44':
+ resolution: {integrity: sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg==}
+
+ alien-signals@3.2.1:
+ resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==}
+
+ birpc@2.9.0:
+ resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
+
+ copy-anything@4.1.5:
+ resolution: {integrity: sha512-vePst2VLTSqPglW7Q98VwFpGiGpOHTyXIo81MO5VyLecA5zMbZw/P/V1vmwD1jksa2iJpn/YkDrJ3XhTfzUyhQ==}
+ engines: {node: '>=18'}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ enhanced-resolve@5.26.0:
+ resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==}
+ engines: {node: '>=10.13.0'}
+
+ entities@7.0.1:
+ resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
+ engines: {node: '>=0.12'}
+
+ esbuild@0.28.2:
+ resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ estree-walker@2.0.2:
+ resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+ hookable@5.5.3:
+ resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
+
+ jiti@2.7.0:
+ resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
+ hasBin: true
+
+ lightningcss-android-arm64@1.32.0:
+ resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.32.0:
+ resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.32.0:
+ resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.32.0:
+ resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-linux-x64-musl@1.32.0:
+ resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.32.0:
+ resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
+ engines: {node: '>= 12.0.0'}
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ mitt@3.0.1:
+ resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
+
+ muggle-string@0.4.1:
+ resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
+
+ nanoid@3.3.20:
+ resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ path-browserify@1.0.1:
+ resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
+
+ perfect-debounce@1.0.0:
+ resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.7:
+ resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
+ engines: {node: '>=12'}
+
+ pinia@3.0.4:
+ resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
+ peerDependencies:
+ typescript: '>=4.5.0'
+ vue: ^3.5.11
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ postcss@8.5.30:
+ resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ rfdc@1.4.1:
+ resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
+
+ rollup@4.64.9:
+ resolution: {integrity: sha512-mnPSQINChN7PpT+a5CI0/+87hm/+6zxSKBK1XonrzQ0zFnglaTjhkTP2Br4yNCTcLHUEvrAfZffH57jGf+8xBQ==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ source-map-js@1.2.2:
+ resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
+ engines: {node: '>=0.10.0'}
+
+ speakingurl@14.0.1:
+ resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
+ engines: {node: '>=0.10.0'}
+
+ superjson@2.2.6:
+ resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
+ engines: {node: '>=16'}
+
+ tailwindcss@4.3.3:
+ resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
+
+ tapable@2.3.3:
+ resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
+ engines: {node: '>=6'}
+
+ tinyglobby@0.2.17:
+ resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+ engines: {node: '>=12.0.0'}
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ undici-types@6.21.0:
+ resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
+ vite@7.3.7:
+ resolution: {integrity: sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ lightningcss: ^1.21.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ vscode-uri@3.2.0:
+ resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==}
+
+ vue-router@4.6.4:
+ resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
+ peerDependencies:
+ vue: ^3.5.0
+
+ vue-tsc@3.3.13:
+ resolution: {integrity: sha512-jE4/bXdOMSHVxEGgQCqNllJy+9N3Gx6tfSAsg0yhFK/MAN6eXqISihupnDEKSCt12VVnjN2gI3AqLYcqoh5x/A==}
+ hasBin: true
+ peerDependencies:
+ typescript: '>=5.0.0'
+
+ vue@3.5.44:
+ resolution: {integrity: sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ==}
+ peerDependencies:
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+snapshots:
+
+ '@babel/helper-string-parser@7.29.7': {}
+
+ '@babel/helper-validator-identifier@7.29.7': {}
+
+ '@babel/parser@7.29.9':
+ dependencies:
+ '@babel/types': 7.29.8
+
+ '@babel/types@7.29.8':
+ dependencies:
+ '@babel/helper-string-parser': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+
+ '@esbuild/aix-ppc64@0.28.2':
+ optional: true
+
+ '@esbuild/android-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/android-arm@0.28.2':
+ optional: true
+
+ '@esbuild/android-x64@0.28.2':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/darwin-x64@0.28.2':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-arm@0.28.2':
+ optional: true
+
+ '@esbuild/linux-ia32@0.28.2':
+ optional: true
+
+ '@esbuild/linux-loong64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.28.2':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-s390x@0.28.2':
+ optional: true
+
+ '@esbuild/linux-x64@0.28.2':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/sunos-x64@0.28.2':
+ optional: true
+
+ '@esbuild/win32-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/win32-ia32@0.28.2':
+ optional: true
+
+ '@esbuild/win32-x64@0.28.2':
+ optional: true
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.6.0
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.6.0': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.6.0
+
+ '@napi-rs/lzma-linux-x64-gnu@1.5.1':
+ optional: true
+
+ '@rolldown/pluginutils@1.0.1': {}
+
+ '@rollup/rollup-android-arm-eabi@4.64.9':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.64.9':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.64.9':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.64.9':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.64.9':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.64.9':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.64.9':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.64.9':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.64.9':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.64.9':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.64.9':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.64.9':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.64.9':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.64.9':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.64.9':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.64.9':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.64.9':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.64.9':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.64.9':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.64.9':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.64.9':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.64.9':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.64.9':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.64.9':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.64.9':
+ optional: true
+
+ '@tailwindcss/node@4.3.3':
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ enhanced-resolve: 5.26.0
+ jiti: 2.7.0
+ lightningcss: 1.32.0
+ magic-string: 0.30.21
+ source-map-js: 1.2.2
+ tailwindcss: 4.3.3
+
+ '@tailwindcss/oxide-android-arm64@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-arm64@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-x64@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-freebsd-x64@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-musl@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-wasm32-wasi@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide@4.3.3':
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.3.3
+ '@tailwindcss/oxide-darwin-arm64': 4.3.3
+ '@tailwindcss/oxide-darwin-x64': 4.3.3
+ '@tailwindcss/oxide-freebsd-x64': 4.3.3
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
+ '@tailwindcss/oxide-linux-arm64-musl': 4.3.3
+ '@tailwindcss/oxide-linux-x64-gnu': 4.3.3
+ '@tailwindcss/oxide-linux-x64-musl': 4.3.3
+ '@tailwindcss/oxide-wasm32-wasi': 4.3.3
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
+ '@tailwindcss/oxide-win32-x64-msvc': 4.3.3
+
+ '@tailwindcss/vite@4.3.3(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0))':
+ dependencies:
+ '@tailwindcss/node': 4.3.3
+ '@tailwindcss/oxide': 4.3.3
+ tailwindcss: 4.3.3
+ vite: 7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0)
+
+ '@types/estree@1.0.9': {}
+
+ '@types/node@22.20.5':
+ dependencies:
+ undici-types: 6.21.0
+
+ '@vitejs/plugin-vue@6.0.10(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.44(typescript@5.9.3))':
+ dependencies:
+ '@rolldown/pluginutils': 1.0.1
+ vite: 7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0)
+ vue: 3.5.44(typescript@5.9.3)
+
+ '@volar/language-core@2.4.28':
+ dependencies:
+ '@volar/source-map': 2.4.28
+
+ '@volar/source-map@2.4.28': {}
+
+ '@volar/typescript@2.4.28':
+ dependencies:
+ '@volar/language-core': 2.4.28
+ path-browserify: 1.0.1
+ vscode-uri: 3.2.0
+
+ '@vue/compiler-core@3.5.44':
+ dependencies:
+ '@babel/parser': 7.29.9
+ '@vue/shared': 3.5.44
+ entities: 7.0.1
+ estree-walker: 2.0.2
+ source-map-js: 1.2.2
+
+ '@vue/compiler-dom@3.5.44':
+ dependencies:
+ '@vue/compiler-core': 3.5.44
+ '@vue/shared': 3.5.44
+
+ '@vue/compiler-sfc@3.5.44':
+ dependencies:
+ '@babel/parser': 7.29.9
+ '@vue/compiler-core': 3.5.44
+ '@vue/compiler-dom': 3.5.44
+ '@vue/compiler-ssr': 3.5.44
+ '@vue/shared': 3.5.44
+ estree-walker: 2.0.2
+ magic-string: 0.30.21
+ postcss: 8.5.30
+ source-map-js: 1.2.2
+
+ '@vue/compiler-ssr@3.5.44':
+ dependencies:
+ '@vue/compiler-dom': 3.5.44
+ '@vue/shared': 3.5.44
+
+ '@vue/devtools-api@6.6.4': {}
+
+ '@vue/devtools-api@7.7.10':
+ dependencies:
+ '@vue/devtools-kit': 7.7.10
+
+ '@vue/devtools-kit@7.7.10':
+ dependencies:
+ '@vue/devtools-shared': 7.7.10
+ birpc: 2.9.0
+ hookable: 5.5.3
+ mitt: 3.0.1
+ perfect-debounce: 1.0.0
+ speakingurl: 14.0.1
+ superjson: 2.2.6
+
+ '@vue/devtools-shared@7.7.10':
+ dependencies:
+ rfdc: 1.4.1
+
+ '@vue/language-core@3.3.13':
+ dependencies:
+ '@volar/language-core': 2.4.28
+ '@vue/compiler-dom': 3.5.44
+ '@vue/shared': 3.5.44
+ alien-signals: 3.2.1
+ muggle-string: 0.4.1
+ path-browserify: 1.0.1
+ picomatch: 4.0.7
+
+ '@vue/reactivity@3.5.44':
+ dependencies:
+ '@vue/shared': 3.5.44
+
+ '@vue/runtime-core@3.5.44':
+ dependencies:
+ '@vue/reactivity': 3.5.44
+ '@vue/shared': 3.5.44
+
+ '@vue/runtime-dom@3.5.44':
+ dependencies:
+ '@vue/reactivity': 3.5.44
+ '@vue/runtime-core': 3.5.44
+ '@vue/shared': 3.5.44
+ csstype: 3.2.3
+
+ '@vue/server-renderer@3.5.44':
+ dependencies:
+ '@vue/compiler-ssr': 3.5.44
+ '@vue/runtime-dom': 3.5.44
+ '@vue/shared': 3.5.44
+
+ '@vue/shared@3.5.44': {}
+
+ alien-signals@3.2.1: {}
+
+ birpc@2.9.0: {}
+
+ copy-anything@4.1.5: {}
+
+ csstype@3.2.3: {}
+
+ detect-libc@2.1.2: {}
+
+ enhanced-resolve@5.26.0:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.3.3
+
+ entities@7.0.1: {}
+
+ esbuild@0.28.2:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.28.2
+ '@esbuild/android-arm': 0.28.2
+ '@esbuild/android-arm64': 0.28.2
+ '@esbuild/android-x64': 0.28.2
+ '@esbuild/darwin-arm64': 0.28.2
+ '@esbuild/darwin-x64': 0.28.2
+ '@esbuild/freebsd-arm64': 0.28.2
+ '@esbuild/freebsd-x64': 0.28.2
+ '@esbuild/linux-arm': 0.28.2
+ '@esbuild/linux-arm64': 0.28.2
+ '@esbuild/linux-ia32': 0.28.2
+ '@esbuild/linux-loong64': 0.28.2
+ '@esbuild/linux-mips64el': 0.28.2
+ '@esbuild/linux-ppc64': 0.28.2
+ '@esbuild/linux-riscv64': 0.28.2
+ '@esbuild/linux-s390x': 0.28.2
+ '@esbuild/linux-x64': 0.28.2
+ '@esbuild/netbsd-arm64': 0.28.2
+ '@esbuild/netbsd-x64': 0.28.2
+ '@esbuild/openbsd-arm64': 0.28.2
+ '@esbuild/openbsd-x64': 0.28.2
+ '@esbuild/openharmony-arm64': 0.28.2
+ '@esbuild/sunos-x64': 0.28.2
+ '@esbuild/win32-arm64': 0.28.2
+ '@esbuild/win32-ia32': 0.28.2
+ '@esbuild/win32-x64': 0.28.2
+
+ estree-walker@2.0.2: {}
+
+ fdir@6.5.0(picomatch@4.0.7):
+ optionalDependencies:
+ picomatch: 4.0.7
+
+ fsevents@2.3.3:
+ optional: true
+
+ graceful-fs@4.2.11: {}
+
+ hookable@5.5.3: {}
+
+ jiti@2.7.0: {}
+
+ lightningcss-android-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-x64@1.32.0:
+ optional: true
+
+ lightningcss-freebsd-x64@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.32.0:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ optional: true
+
+ lightningcss@1.32.0:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.32.0
+ lightningcss-darwin-arm64: 1.32.0
+ lightningcss-darwin-x64: 1.32.0
+ lightningcss-freebsd-x64: 1.32.0
+ lightningcss-linux-arm-gnueabihf: 1.32.0
+ lightningcss-linux-arm64-gnu: 1.32.0
+ lightningcss-linux-arm64-musl: 1.32.0
+ lightningcss-linux-x64-gnu: 1.32.0
+ lightningcss-linux-x64-musl: 1.32.0
+ lightningcss-win32-arm64-msvc: 1.32.0
+ lightningcss-win32-x64-msvc: 1.32.0
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.6.0
+
+ mitt@3.0.1: {}
+
+ muggle-string@0.4.1: {}
+
+ nanoid@3.3.20: {}
+
+ path-browserify@1.0.1: {}
+
+ perfect-debounce@1.0.0: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.7: {}
+
+ pinia@3.0.4(typescript@5.9.3)(vue@3.5.44(typescript@5.9.3)):
+ dependencies:
+ '@vue/devtools-api': 7.7.10
+ vue: 3.5.44(typescript@5.9.3)
+ optionalDependencies:
+ typescript: 5.9.3
+
+ postcss@8.5.30:
+ dependencies:
+ nanoid: 3.3.20
+ picocolors: 1.1.1
+ source-map-js: 1.2.2
+
+ rfdc@1.4.1: {}
+
+ rollup@4.64.9:
+ dependencies:
+ '@types/estree': 1.0.9
+ optionalDependencies:
+ '@napi-rs/lzma-linux-x64-gnu': 1.5.1
+ '@rollup/rollup-android-arm-eabi': 4.64.9
+ '@rollup/rollup-android-arm64': 4.64.9
+ '@rollup/rollup-darwin-arm64': 4.64.9
+ '@rollup/rollup-darwin-x64': 4.64.9
+ '@rollup/rollup-freebsd-arm64': 4.64.9
+ '@rollup/rollup-freebsd-x64': 4.64.9
+ '@rollup/rollup-linux-arm-gnueabihf': 4.64.9
+ '@rollup/rollup-linux-arm-musleabihf': 4.64.9
+ '@rollup/rollup-linux-arm64-gnu': 4.64.9
+ '@rollup/rollup-linux-arm64-musl': 4.64.9
+ '@rollup/rollup-linux-loong64-gnu': 4.64.9
+ '@rollup/rollup-linux-loong64-musl': 4.64.9
+ '@rollup/rollup-linux-ppc64-gnu': 4.64.9
+ '@rollup/rollup-linux-ppc64-musl': 4.64.9
+ '@rollup/rollup-linux-riscv64-gnu': 4.64.9
+ '@rollup/rollup-linux-riscv64-musl': 4.64.9
+ '@rollup/rollup-linux-s390x-gnu': 4.64.9
+ '@rollup/rollup-linux-x64-gnu': 4.64.9
+ '@rollup/rollup-linux-x64-musl': 4.64.9
+ '@rollup/rollup-openbsd-x64': 4.64.9
+ '@rollup/rollup-openharmony-arm64': 4.64.9
+ '@rollup/rollup-win32-arm64-msvc': 4.64.9
+ '@rollup/rollup-win32-ia32-msvc': 4.64.9
+ '@rollup/rollup-win32-x64-gnu': 4.64.9
+ '@rollup/rollup-win32-x64-msvc': 4.64.9
+ fsevents: 2.3.3
+
+ source-map-js@1.2.2: {}
+
+ speakingurl@14.0.1: {}
+
+ superjson@2.2.6:
+ dependencies:
+ copy-anything: 4.1.5
+
+ tailwindcss@4.3.3: {}
+
+ tapable@2.3.3: {}
+
+ tinyglobby@0.2.17:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.7)
+ picomatch: 4.0.7
+
+ typescript@5.9.3: {}
+
+ undici-types@6.21.0: {}
+
+ vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0):
+ dependencies:
+ esbuild: 0.28.2
+ fdir: 6.5.0(picomatch@4.0.7)
+ picomatch: 4.0.7
+ postcss: 8.5.30
+ rollup: 4.64.9
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ '@types/node': 22.20.5
+ fsevents: 2.3.3
+ jiti: 2.7.0
+ lightningcss: 1.32.0
+
+ vscode-uri@3.2.0: {}
+
+ vue-router@4.6.4(vue@3.5.44(typescript@5.9.3)):
+ dependencies:
+ '@vue/devtools-api': 6.6.4
+ vue: 3.5.44(typescript@5.9.3)
+
+ vue-tsc@3.3.13(typescript@5.9.3):
+ dependencies:
+ '@volar/typescript': 2.4.28
+ '@vue/language-core': 3.3.13
+ typescript: 5.9.3
+
+ vue@3.5.44(typescript@5.9.3):
+ dependencies:
+ '@vue/compiler-dom': 3.5.44
+ '@vue/compiler-sfc': 3.5.44
+ '@vue/runtime-dom': 3.5.44
+ '@vue/server-renderer': 3.5.44
+ '@vue/shared': 3.5.44
+ optionalDependencies:
+ typescript: 5.9.3
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 0000000..e240ff5
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,6 @@
+packages:
+ - apps/ui
+ - apps/admin
+
+onlyBuiltDependencies:
+ - esbuild