From a89157803ba318ec4bebfabb870fd04b52f843fe Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:44:54 -0600 Subject: [PATCH] Add web UI with Smokey's crow as the stand-in mascot - ui/: Vite + plain TypeScript, builds into ./web for `crows serve` - CrowView mirrors CrowModel in RIVE_CONTRACT.md (pose, trust, reactions) so the .riv can replace the SVG later - Event handling follows the contract's mapping table; server text is only ever inserted as text - Prank: toast plus a send-button cooldown countdown - art/crow.svg: source art for Rive import Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UQnSw4RRWp8uqpj3nsnnXF --- .gitignore | 1 + README.md | 2 + art/crow.svg | 10 + ui/index.html | 34 ++ ui/package-lock.json | 1135 ++++++++++++++++++++++++++++++++++++++++++ ui/package.json | 13 + ui/src/api.ts | 45 ++ ui/src/crow.ts | 66 +++ ui/src/main.ts | 280 +++++++++++ ui/src/style.css | 190 +++++++ ui/tsconfig.json | 13 + ui/vite.config.ts | 7 + 12 files changed, 1796 insertions(+) create mode 100644 art/crow.svg create mode 100644 ui/index.html create mode 100644 ui/package-lock.json create mode 100644 ui/package.json create mode 100644 ui/src/api.ts create mode 100644 ui/src/crow.ts create mode 100644 ui/src/main.ts create mode 100644 ui/src/style.css create mode 100644 ui/tsconfig.json create mode 100644 ui/vite.config.ts diff --git a/.gitignore b/.gitignore index 36921d6..ae05b96 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules/ dist/ /somanycrows +/web/ diff --git a/README.md b/README.md index 8fecdf1..67157d1 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Optional: `CROWS_MODEL` overrides the model. Results land in `todo.crows.md`; st ## Web server ``` +cd ui && npm install && npm run build && cd .. # builds the UI into ./web ./crows serve # localhost:8080; API + Server-Sent Events (SSE); Ctrl-C to stop ``` @@ -34,6 +35,7 @@ curl localhost:8080/api/trust - Item IDs are `-`, numbered from 1 in list order. - Approvals left unanswered for 10 minutes count as rejected (`verdict: timeout`). `-approval-timeout` changes this for testing. - A flooding requester gets 429 with `message` and `cooldown_s`; any list of theirs that is still flying also gets `requester.pranked`. +- UI development: run `crows serve`, then `cd ui && npm run dev` (Vite proxies `/api`). The crow art is `art/crow.svg`; the UI's `CrowView` mirrors `RIVE_CONTRACT.md`, so a Rive-backed view replaces the SVG stand-in later. - POST bodies are capped at 64 KB. Static files are served from `./web` when that folder exists. ## How an item flies diff --git a/art/crow.svg b/art/crow.svg new file mode 100644 index 0000000..3f2ff1c --- /dev/null +++ b/art/crow.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..11b5013 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,34 @@ + + + + + + So Many Crows + + + + + +
+

So Many Crows

+ +
+ +
+
+ + +
+ + + +
+ +
    + +
    + + + + + diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..83b81bc --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,1135 @@ +{ + "name": "so-many-crows-ui", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "so-many-crows-ui", + "devDependencies": { + "typescript": "^5.6.0", + "vite": "^6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.65.1.tgz", + "integrity": "sha512-5coxNSjqOI2J3NkVhlp3u+zGcyjnowQFsU9sg7MMz4UPXY4Dv63kFGLt7ConXz9CNq2i0yuOJZAF/2RMrgI2OA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.65.1.tgz", + "integrity": "sha512-K0HFf6XMB3IYs+jDZ1/RRhZaVOwxNXrl0n4MDEyGDjuK7+B6K/nDJp8DCBitO3S1sK/fzNxAf9QZAny4AIUPtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.65.1.tgz", + "integrity": "sha512-zkZrXYwNlIcSUmcwY2DpsaDHr0XjVUeQHCuSunG3eQD7/5Wn/DvI7OMUE8NRRweK87yG8ebJ1xGOzmwpMsbZQA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.65.1.tgz", + "integrity": "sha512-Zaed+R3cpkkA3aiNd7NO0GV5KFh/LVf8j6bGcoRdpoXkSm/mWH8M0oX2/3u/nyGcx/bHEovD9CzX+w9/gcpI4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.65.1.tgz", + "integrity": "sha512-h12p3WJWhyJ9kir0zqAPr0rKNmBzOWIjoaOMT9OULs9KZQZCeOMKd+mGSvERjd9VxJYShd3zIxtrVxkYz6BEVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.65.1.tgz", + "integrity": "sha512-yRBwh/5imKY4DydDulVtrjy8Jf7h7NQJUqZbgmZ3zFsCD9QqlnWvZ7RtEF+ecO1b+IukKjXlu5Bb9fehV3tIbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.65.1.tgz", + "integrity": "sha512-1XuPiWYYaLE1UDOqhbKVOfsqxvfmFS/lYPSk92U1hEX1qRfSYH0hNeAlbxFIgjp4d6+YQmL95EPRgdvjbZEddA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.65.1.tgz", + "integrity": "sha512-Y3C5e55Yrf6p2r5rBcc4P9hFWVNi6oqWy/53I/AyCJJGlp3KRSpTP8eoUmVntchTbEnhgBgAgLO9flj9AvOdUw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.65.1.tgz", + "integrity": "sha512-Itj6coSswkYPIcVLczObXmwdplHNQQxyfnK1ppBg4czDaX8RiEzLKqU7EMc64BiBuiDZEUS+M93ktM18rv5llw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.65.1.tgz", + "integrity": "sha512-KeO8jTEqdrnhvcahufbimyMoI44vedRUp1bVz2QUnRX2vDm/hxzCPt5F8fERVD5fsHyTW/vXmCElT054VOSVOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.65.1.tgz", + "integrity": "sha512-dDOz5VZZ+ZGnglpmET6VJefamWXFsgaVtuhSClf2/URZBp048jprD6z1hddsIMN6n1w+oUzGZEBp9u6Mlszsfg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.65.1.tgz", + "integrity": "sha512-JbfRKz8nJCuo8gn1Dqx8lQWEHw4qhZ3uRaHphw5vrSjnSptJTKTRC/bLKHh8QQMsEnhL4uq+bdg3tkiXbJ2q/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.65.1.tgz", + "integrity": "sha512-3oQpe1q0emSeCAssVHMQdOz5fz3PBH5my/Fr0ZekV2UMNL4ZiRa1ciikYlGQXyDkeqseYYv1InwOY8YPqY7RIQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.65.1.tgz", + "integrity": "sha512-nNV3HHi23o1LtpGGwH+Bav4hqnc4+klQtC96tSvCfvql8bLlNFTnTFiGl/jai2nve3eEr/vT2ocuyf+h+eMnYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.65.1.tgz", + "integrity": "sha512-b5zab0FryBxuocuDeqOsYGAg2EzjKw4mKA6LjQXCyewXjGv/BXqACBsY2cO45xM4hV2weYtdjGThP2VB2ByAYg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.65.1.tgz", + "integrity": "sha512-Rh08xZakiUxKNgI3hbgH9mivlx478lSqckS9s+Oc3/TX1VrAH3W8z/5wpJXR/v5VjqcJ/jJNVBg64PU1+iQsTQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.65.1.tgz", + "integrity": "sha512-qDgtS6juN3/k97y4kcD6XLFpXEo4f6CnG8lTOGYwlhrUYTrMyhN0WHdZCPAW5rqS0MqQG7qI7dxnjugt6QB39Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.65.1.tgz", + "integrity": "sha512-44IKzBPqXXVm0kt1O+F5eW4juR5FyIxGNlFkpP8qwIDzucBsjM1HCbdYUQrQw8rU/g6HRl/tfBm0+Qq4H4Ro3w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.65.1.tgz", + "integrity": "sha512-tvYWZ/h2PCHs558Xu7B6LptuRCV8lXV2xI75ONMcldII1m2zlFwuI4BJZi9M6rSMkYqcPLLtPUPrjyqfbzLOdg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.65.1.tgz", + "integrity": "sha512-gdyeucgZCnei3pXwadKVTEAOgRx0B+c847DUPR/PdOMmp3olhfkYo6r0J4PzRFZB5ZICZQTxntyvgmWH5xT7rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.65.1.tgz", + "integrity": "sha512-Wf/NEKdn2SAL4Nvt8HyDtdkseWTwiLbZEOoNYnEaHkbso0B9eHO42vKnZh+OYsn+IMhAMcWcuIwPlBOgOXsUzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.65.1.tgz", + "integrity": "sha512-x9VSsDmsMsEWy4cr/mFeaEsXnVJ6Kc4NCAU/lCnwml3s7LQbZoa9+/qwc9etWHbuuDT5mYxdyyKUwo76njtjZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.65.1.tgz", + "integrity": "sha512-RIDbiVvvLkN45mOFpbo6eYMgnuiBEB62BGR/0s4QJgGg2AvVxucDDNn67kpAcUoYyfmY8iX8/RifMcmxmmnejA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.65.1.tgz", + "integrity": "sha512-+KN8HIr88vN59JNxlrM2fMimzY43Y0lQB7+0xdVsFbvFCAA74CLKCdfzIHzzvndJryBc94Pm+MvwtukKjaS2ug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.65.1.tgz", + "integrity": "sha512-+Y1Sd49cSDq60UOl8/U2HI0ebUvnSl/hDhMHQn+ouw17yb8fYwMWIazzJ4JFpSkjPE7sj5NTNSX9IoyEqcFfyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.65.1.tgz", + "integrity": "sha512-HzD4IdphaPMvuAacnL/vtoq5B8FwqewKvxTtqYibWvP6VDyZ+UsTtlw5F8HIOIIpLQAkN2NOP/0sM3puo9xN/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.65.1", + "@rollup/rollup-android-arm64": "4.65.1", + "@rollup/rollup-darwin-arm64": "4.65.1", + "@rollup/rollup-darwin-x64": "4.65.1", + "@rollup/rollup-freebsd-arm64": "4.65.1", + "@rollup/rollup-freebsd-x64": "4.65.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.65.1", + "@rollup/rollup-linux-arm-musleabihf": "4.65.1", + "@rollup/rollup-linux-arm64-gnu": "4.65.1", + "@rollup/rollup-linux-arm64-musl": "4.65.1", + "@rollup/rollup-linux-loong64-gnu": "4.65.1", + "@rollup/rollup-linux-loong64-musl": "4.65.1", + "@rollup/rollup-linux-ppc64-gnu": "4.65.1", + "@rollup/rollup-linux-ppc64-musl": "4.65.1", + "@rollup/rollup-linux-riscv64-gnu": "4.65.1", + "@rollup/rollup-linux-riscv64-musl": "4.65.1", + "@rollup/rollup-linux-s390x-gnu": "4.65.1", + "@rollup/rollup-linux-x64-gnu": "4.65.1", + "@rollup/rollup-linux-x64-musl": "4.65.1", + "@rollup/rollup-openbsd-x64": "4.65.1", + "@rollup/rollup-openharmony-arm64": "4.65.1", + "@rollup/rollup-win32-arm64-msvc": "4.65.1", + "@rollup/rollup-win32-ia32-msvc": "4.65.1", + "@rollup/rollup-win32-x64-gnu": "4.65.1", + "@rollup/rollup-win32-x64-msvc": "4.65.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz", + "integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "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 + } + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..d0ac50b --- /dev/null +++ b/ui/package.json @@ -0,0 +1,13 @@ +{ + "name": "so-many-crows-ui", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build" + }, + "devDependencies": { + "typescript": "^5.6.0", + "vite": "^6.0.0" + } +} diff --git a/ui/src/api.ts b/ui/src/api.ts new file mode 100644 index 0000000..79b6cc1 --- /dev/null +++ b/ui/src/api.ts @@ -0,0 +1,45 @@ +// Thin client for the HTTP API in EVENTS.md. + +export type PostListResult = + | { ok: true; listId: string } + | { ok: false; pranked: true; message: string; cooldownS: number } + | { ok: false; pranked: false; message: string }; + +export async function postList(text: string, requester: string): Promise { + const res = await fetch("/api/lists", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text, requester }), + }); + const body = await res.json().catch(() => ({})); + if (res.ok) return { ok: true, listId: String(body.list_id) }; + if (res.status === 429) return { ok: false, pranked: true, message: String(body.message ?? "Caw."), cooldownS: Number(body.cooldown_s ?? 30) }; + return { ok: false, pranked: false, message: String(body.message ?? `The flock said no (${res.status}).`) }; +} + +export async function postVerdict(itemId: string, target: "triage" | "output", approve: boolean): Promise { + const res = await fetch("/api/verdicts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ item_id: itemId, target, approve }), + }); + if (!res.ok) throw new Error(res.status === 404 ? "That question already has an answer." : `Verdict failed (${res.status}).`); +} + +export type TrustLevels = Map; // "Name|task_type" -> level + +export async function getTrust(): Promise { + const out: TrustLevels = new Map(); + try { + const res = await fetch("/api/trust"); + const body = await res.json(); + for (const [key, rec] of Object.entries<{ level?: number }>(body.crows ?? {})) out.set(key, rec.level ?? 0); + } catch { + // Badges start at zero if the ledger can't be read. + } + return out; +} + +export function listEvents(listId: string): EventSource { + return new EventSource(`/api/events?list_id=${encodeURIComponent(listId)}`); +} diff --git a/ui/src/crow.ts b/ui/src/crow.ts new file mode 100644 index 0000000..690b368 --- /dev/null +++ b/ui/src/crow.ts @@ -0,0 +1,66 @@ +// A crow on screen. This interface mirrors CrowModel in RIVE_CONTRACT.md, +// so a Rive-backed view can replace the SVG stand-in without touching main.ts. + +export type Role = "crow" | "sentry" | "elder"; +export type Pose = "perched" | "flying" | "waiting" | "grounded"; +export type Reaction = "cheer" | "sulk" | "levelUp" | "levelDown" | "prank"; + +export interface CrowView { + readonly el: HTMLElement; + setPose(pose: Pose): void; + setTrust(level: number): void; + fire(reaction: Reaction): void; +} + +const NS = "http://www.w3.org/2000/svg"; + +// Smokey's crow head: disc, upper beak, head (with eye cutout), jaw. +const ART = ` + + + + + +`; + +const REACTION_MS: Record = { cheer: 800, sulk: 800, levelUp: 2000, levelDown: 1000, prank: 2500 }; + +export function svgCrow(name: string, role: Role): CrowView { + const el = document.createElement("figure"); + el.className = `crow role-${role} pose-perched`; + el.dataset.trust = "0"; + + const svg = document.createElementNS(NS, "svg"); + svg.setAttribute("viewBox", "0 0 352.23 324"); + svg.setAttribute("aria-hidden", "true"); + svg.innerHTML = ART; // static art only; no server data goes through innerHTML + const body = document.createElement("div"); + body.className = "crow-body"; + body.append(svg); + + const badge = document.createElement("span"); + badge.className = "badge"; + const cap = document.createElement("figcaption"); + cap.textContent = name; + el.append(body, badge, cap); + + let timer = 0; + return { + el, + setPose(pose) { + el.classList.remove("pose-perched", "pose-flying", "pose-waiting", "pose-grounded"); + el.classList.add(`pose-${pose}`); + el.setAttribute("aria-label", `${name}, ${pose}`); + }, + setTrust(level) { + el.dataset.trust = String(Math.max(0, Math.min(2, level))); + }, + fire(reaction) { + clearTimeout(timer); + el.classList.remove(...Object.keys(REACTION_MS).map((r) => `r-${r}`)); + void el.offsetWidth; // restart the animation if the same reaction fires twice + el.classList.add(`r-${reaction}`); + timer = window.setTimeout(() => el.classList.remove(`r-${reaction}`), REACTION_MS[reaction]); + }, + }; +} diff --git a/ui/src/main.ts b/ui/src/main.ts new file mode 100644 index 0000000..b09d080 --- /dev/null +++ b/ui/src/main.ts @@ -0,0 +1,280 @@ +import "./style.css"; +import { getTrust, listEvents, postList, postVerdict, type TrustLevels } from "./api"; +import { svgCrow, type CrowView, type Role } from "./crow"; + +// ---------- page ---------- +const $ = (id: string) => document.getElementById(id) as T; +const perch = $("perch"); +const cards = $("cards"); +const form = $("composer"); +const listInput = $("list"); +const sendBtn = $("send"); +const requesterInput = $("requester"); +const banner = $("banner"); +const toast = $("toast"); +const meter = $("meter"); + +const store = { + get(k: string) { try { return localStorage.getItem(k); } catch { return null; } }, + set(k: string, v: string) { try { localStorage.setItem(k, v); } catch { /* storage off */ } }, +}; +requesterInput.value = store.get("requester") ?? ""; +requesterInput.addEventListener("change", () => store.set("requester", requesterInput.value.trim())); + +// ---------- the flock on screen ---------- +const crows = new Map(); +let trust: TrustLevels = new Map(); + +function roleOf(name: string): Role { + return name === "Sentry" ? "sentry" : name === "Elder" ? "elder" : "crow"; +} + +function crow(name: string): CrowView { + let c = crows.get(name); + if (!c) { + c = svgCrow(name, roleOf(name)); + crows.set(name, c); + perch.append(c.el); + } + return c; +} + +// Badges show trust for the task type the crow is working on now. +function showTrust(name: string, taskType: string) { + const key = name === "Sentry" ? `Sentry|triage/${taskType}` : `${name}|${taskType}`; + crow(name).setTrust(trust.get(key) ?? 0); +} + +// ---------- item cards ---------- +type Card = { el: HTMLLIElement; crows: string[]; taskType: string; actions: HTMLDivElement }; +const items = new Map(); + +function el(tag: K, cls?: string, text?: string) { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (text !== undefined) e.textContent = text; // server text always goes in as text, never HTML + return e; +} + +function addCard(itemId: string, text: string) { + const li = el("li", "card"); + li.dataset.state = "waiting"; + const head = el("div", "card-head"); + head.append(el("span", "dot"), el("p", "text", text)); + const actions = el("div", "actions"); + li.append(head, el("div", "chips"), el("p", "reason"), el("ul", "subtasks"), el("blockquote", "answer"), actions); + li.querySelector(".reason")!.hidden = true; + li.querySelector(".answer")!.hidden = true; + cards.append(li); + items.set(itemId, { el: li, crows: [], taskType: "other", actions }); +} + +function ask(itemId: string, target: "triage" | "output") { + const card = items.get(itemId); + if (!card) return; + card.el.dataset.state = "asking"; + card.actions.replaceChildren(); + const q = el("p", "question", target === "triage" ? "Approve the Sentry's plan?" : "Accept this answer?"); + const yes = el("button", "btn btn-go", target === "triage" ? "Fly it" : "Looks good"); + const no = el("button", "btn btn-no", target === "triage" ? "Not like that" : "Not right"); + const err = el("p", "err"); + for (const [b, approve] of [[yes, true], [no, false]] as const) { + b.type = "button"; + b.addEventListener("click", async () => { + yes.disabled = no.disabled = true; + err.textContent = ""; + try { + await postVerdict(itemId, target, approve); + } catch (e) { + err.textContent = (e as Error).message; + yes.disabled = no.disabled = false; + } + }); + } + const row = el("div", "buttons"); + row.append(no, yes); + card.actions.append(q, row, err); + yes.focus({ preventScroll: true }); +} + +// ---------- events (mapping from RIVE_CONTRACT.md section 3) ---------- +type Payload = Record; +let source: EventSource | null = null; + +const handlers: Record void> = { + "list.started": (d) => { + crow("Sentry").setPose("perched"); + for (const it of d.items ?? []) addCard(it.item_id, it.text); + }, + + "item.triaged": (d) => { + const card = items.get(d.item_id); + if (!card) return; + card.taskType = d.task_type; + const chips = card.el.querySelector(".chips")!; + chips.replaceChildren(el("span", `chip mode-${d.mode}`, d.mode), el("span", "chip", d.task_type)); + const reason = card.el.querySelector(".reason")!; + reason.textContent = d.reason; + reason.hidden = !d.reason; + card.el.querySelector(".subtasks")!.replaceChildren(...(d.subtasks ?? []).map((s: string) => el("li", "", s))); + showTrust("Sentry", d.task_type); + if (d.needs_approval) { + crow("Sentry").setPose("waiting"); + ask(d.item_id, "triage"); + } + }, + + "crow.flying": (d) => { + const card = items.get(d.item_id); + if (card) { + card.el.dataset.state = "flying"; + if (d.crow !== "Sentry") showTrust(d.crow, card.taskType); + } + crow(d.crow).setPose("flying"); + }, + + "crow.landed": (d) => crow(d.crow).setPose(d.ok ? "perched" : "grounded"), + + "item.answered": (d) => { + const card = items.get(d.item_id); + if (!card) return; + card.crows = d.crows ?? []; + const answer = card.el.querySelector(".answer")!; + answer.textContent = d.answer; + answer.hidden = false; + const chips = card.el.querySelector(".chips")!; + chips.append(el("span", "chip crows", card.crows.join(", "))); + if (d.needs_approval) { + for (const n of card.crows) crow(n).setPose("waiting"); + ask(d.item_id, "output"); + } + }, + + verdict: (d) => { + const card = items.get(d.item_id); + if (!card) return; + card.actions.replaceChildren(); + const good = d.verdict === "approved" || d.verdict === "auto"; + const who = d.target === "triage" ? ["Sentry"] : card.crows; + if (d.target === "triage" && d.verdict === "auto") return; // a trusted Sentry just keeps going + for (const n of who) { + crow(n).fire(good ? "cheer" : "sulk"); + crow(n).setPose("perched"); + } + if (d.target === "output") { + card.el.dataset.state = good ? "done" : "rejected"; + if (d.verdict === "auto") card.actions.append(el("p", "note", "Trusted crows: accepted without asking.")); + } else if (!good) { + card.el.dataset.state = "rejected"; + } else { + card.el.dataset.state = "flying"; + } + }, + + "trust.changed": (d) => { + trust.set(d.holder === "Sentry" ? `Sentry|${d.task_type}` : `${d.holder}|${d.task_type}`, d.to); + const names = ["stranger", "regular", "trusted"]; + if (d.holder === "flock") { + showBanner(d.to > d.from + ? `The flock earned "${names[d.to]}" on ${d.task_type}.` + : `The flock dropped to "${names[d.to]}" on ${d.task_type}.`); + return; + } + const c = crow(d.holder); + c.setTrust(d.to); + c.fire(d.to > d.from ? "levelUp" : "levelDown"); + if (d.to > d.from) showBanner(`${d.holder} leveled up: ${names[d.to]} on ${String(d.task_type).replace("triage/", "triage of ")}.`); + }, + + "item.skipped": (d) => { + const card = items.get(d.item_id); + if (!card) return; + card.el.dataset.state = "skipped"; + card.actions.replaceChildren(el("p", "note", `Skipped: ${d.reason}`)); + for (const n of card.crows) crow(n).setPose("perched"); + }, + + "requester.pranked": (d) => prank(d.message, d.cooldown_s), + + "list.finished": (d) => { + for (const c of crows.values()) { c.setPose("perched"); c.fire("cheer"); } + meter.textContent = `The flock used ${Number(d.tokens_used).toLocaleString()} of ${Number(d.token_budget).toLocaleString()} tokens.`; + meter.hidden = false; + source?.close(); + source = null; + sendBtn.disabled = false; + }, +}; + +// ---------- toasts ---------- +let bannerTimer = 0; +function showBanner(text: string) { + banner.textContent = text; + banner.hidden = false; + clearTimeout(bannerTimer); + bannerTimer = window.setTimeout(() => (banner.hidden = true), 5000); +} + +// The prank: toast for a few seconds, and the send button counts down the cooldown. +let toastTimer = 0; +let cooldownTimer = 0; +const sendLabel = sendBtn.textContent ?? "Send the flock"; +function prank(message: string, cooldownS: number) { + crow("Sentry").fire("prank"); + toast.textContent = message; + toast.hidden = false; + clearTimeout(toastTimer); + toastTimer = window.setTimeout(() => (toast.hidden = true), 5000); + + let left = Math.max(0, Math.round(cooldownS)); + clearInterval(cooldownTimer); + const tick = () => { + if (left <= 0) { + clearInterval(cooldownTimer); + sendBtn.textContent = sendLabel; + sendBtn.disabled = !!source; + return; + } + sendBtn.disabled = true; + sendBtn.textContent = `The flock is ignoring you · ${left--}s`; + }; + tick(); + cooldownTimer = window.setInterval(tick, 1000); +} + +// ---------- send a list ---------- +form.addEventListener("submit", async (e) => { + e.preventDefault(); + const text = listInput.value.trim(); + if (!text) { listInput.focus(); return; } + const requester = requesterInput.value.trim() || "anonymous"; + store.set("requester", requester); + sendBtn.disabled = true; + + const res = await postList(text, requester).catch(() => ({ ok: false as const, pranked: false as const, message: "Can't reach the flock. Is `crows serve` running?" })); + if (!res.ok) { + if (res.pranked) { + prank(res.message, res.cooldownS); // re-enables the button when the cooldown ends + } else { + showBanner(res.message); + sendBtn.disabled = false; + } + return; + } + + // Fresh list: clear the old cards, keep the crows (they remember you). + cards.replaceChildren(); + items.clear(); + meter.hidden = true; + trust = await getTrust(); + source?.close(); + source = listEvents(res.listId); + for (const [name, fn] of Object.entries(handlers)) { + source.addEventListener(name, (ev) => { + try { fn(JSON.parse((ev as MessageEvent).data)); } catch (err) { console.error(name, err); } + }); + } +}); + +// The Sentry is always on watch. +crow("Sentry"); diff --git a/ui/src/style.css b/ui/src/style.css new file mode 100644 index 0000000..1788bf7 --- /dev/null +++ b/ui/src/style.css @@ -0,0 +1,190 @@ +:root { + --bg: #f6fbf7; + --surface: #ffffff; + --ink: #1d2721; + --muted: #5d6b63; + --line: #dfe9e2; + --mint: #b7e4c7; + --go: #2f9e5b; + --go-edge: #1f7642; + --no: #ffffff; + --no-ink: #c2363b; + --no-edge: #e3c6c7; + --sentry: #ffe08a; + --elder: #d6dde6; + --bronze: #c98a4b; + --gold: #f2b705; + --shadow: 0 2px 0 var(--line); +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #121714; + --surface: #1b221e; + --ink: #eef4f0; + --muted: #9fb0a6; + --line: #2c3731; + --no: #1b221e; + --no-edge: #4a2c2e; + --no-ink: #ff8a8e; + } + .crow .bird { fill: #0b0d0c; } +} + +* { box-sizing: border-box; } +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font: 600 16px/1.45 Nunito, system-ui, sans-serif; +} +main { max-width: 680px; margin: 0 auto; padding: 0 16px 64px; } + +.top { + display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; justify-content: space-between; + max-width: 680px; margin: 0 auto; padding: 20px 16px 8px; +} +.top h1 { margin: 0; font-weight: 900; font-size: 26px; letter-spacing: -0.5px; } +.who { color: var(--muted); font-size: 14px; } +.who input { + margin-left: 6px; width: 140px; padding: 6px 10px; font: inherit; color: var(--ink); + background: var(--surface); border: 2px solid var(--line); border-radius: 12px; +} + +/* ---------- buttons: chunky, pressable ---------- */ +.btn { + font: 800 16px/1 Nunito, system-ui, sans-serif; letter-spacing: 0.3px; text-transform: uppercase; + padding: 14px 20px; border-radius: 16px; border: 2px solid transparent; cursor: pointer; + transition: transform 80ms, box-shadow 80ms; +} +.btn:active:not(:disabled) { transform: translateY(4px); box-shadow: none; } +.btn:disabled { opacity: 0.5; cursor: default; } +.btn:focus-visible { outline: 3px solid var(--gold); outline-offset: 2px; } +.btn-go { background: var(--go); color: #fff; box-shadow: 0 4px 0 var(--go-edge); } +.btn-no { background: var(--no); color: var(--no-ink); border-color: var(--no-edge); box-shadow: 0 4px 0 var(--no-edge); } + +/* ---------- composer ---------- */ +.composer { display: grid; gap: 10px; margin: 8px 0 24px; } +.composer label { font-weight: 800; } +.composer textarea { + width: 100%; padding: 12px 14px; font: inherit; color: var(--ink); resize: vertical; + background: var(--surface); border: 2px solid var(--line); border-radius: 16px; +} +.composer textarea:focus { outline: none; border-color: var(--go); } + +/* ---------- the perch ---------- */ +.perch { + display: flex; flex-wrap: wrap; gap: 8px 4px; justify-content: center; align-items: flex-end; + min-height: 132px; padding: 16px 8px 8px; margin: 8px 0 16px; + border-bottom: 6px solid #6b4f3a; border-radius: 0 0 4px 4px; +} +.crow { margin: 0; width: 84px; display: grid; justify-items: center; position: relative; } +.crow.role-sentry { order: -1; } +.crow.role-elder { order: 99; } +.crow-body { width: 72px; height: 66px; } +.crow svg { width: 100%; height: 100%; overflow: visible; } +.crow .disc { fill: var(--mint); } +.crow.role-sentry .disc { fill: var(--sentry); } +.crow.role-elder .disc { fill: var(--elder); } +.crow .bird { fill: #000; } +.crow .jaw { transform-origin: 211px 182px; transition: transform 150ms; } +.crow figcaption { font-size: 13px; font-weight: 800; color: var(--muted); } + +.badge { + position: absolute; top: 0; right: 8px; width: 18px; height: 18px; border-radius: 50%; + border: 3px solid var(--bg); opacity: 0; transform: scale(0.4); transition: 150ms; +} +.crow[data-trust="1"] .badge { opacity: 1; transform: none; background: var(--bronze); } +.crow[data-trust="2"] .badge { opacity: 1; transform: none; background: var(--gold); animation: shimmer 2.4s infinite; } + +/* Body layer: poses */ +.pose-perched .crow-body { animation: breathe 3.2s ease-in-out infinite; } +.pose-flying .crow-body { animation: flap 0.5s ease-in-out infinite alternate; } +.pose-flying .jaw { transform: rotate(-10deg); } +.pose-waiting .crow-body { animation: tilt 1.4s ease-in-out infinite; } +.pose-waiting::after { + content: "?"; position: absolute; top: -6px; left: 6px; font-weight: 900; font-size: 18px; color: var(--go); + animation: pop 1.4s ease-in-out infinite; +} +.pose-grounded .crow-body { transform: rotate(-18deg) translateY(6px); filter: grayscale(1) opacity(0.6); } +.pose-grounded::after { content: "✦ ✦"; position: absolute; top: -4px; font-size: 12px; color: var(--gold); animation: spin 1.6s linear infinite; } + +/* Reaction layer: one-shots on top */ +.r-cheer .crow-body { animation: hop 0.8s ease-out; } +.r-cheer .jaw { transform: rotate(14deg); } +.r-sulk .crow-body { animation: droop 0.8s ease-out; } +.r-levelUp .crow-body { animation: levelup 2s ease-out; } +.r-levelUp .jaw { transform: rotate(16deg); } +.r-levelUp::before { + content: "★"; position: absolute; top: -18px; font-size: 22px; color: var(--gold); + animation: rise 2s ease-out forwards; +} +.r-levelDown .crow-body { animation: shrug 1s ease-out; } +.r-prank .crow-body { animation: prank 2.5s ease-in-out; } + +@keyframes breathe { 50% { transform: scale(1.03, 0.98); } } +@keyframes flap { from { transform: translateY(0) rotate(-3deg); } to { transform: translateY(-8px) rotate(3deg); } } +@keyframes tilt { 50% { transform: rotate(8deg); } } +@keyframes pop { 50% { transform: translateY(-4px); } } +@keyframes spin { to { transform: rotate(360deg); } } +@keyframes shimmer { 50% { box-shadow: 0 0 10px var(--gold); } } +@keyframes hop { 30% { transform: translateY(-22px) scale(0.95, 1.08); } 60% { transform: translateY(0) scale(1.08, 0.92); } } +@keyframes droop { 40%, 70% { transform: translateY(6px) rotate(-6deg) scale(1, 0.94); } } +@keyframes levelup { 20% { transform: scale(1.25) rotate(0); } 60% { transform: scale(1.25) rotate(360deg); } } +@keyframes rise { from { opacity: 1; transform: translateY(0) scale(0.6); } to { opacity: 0; transform: translateY(-30px) scale(1.4); } } +@keyframes shrug { 30% { transform: scale(0.9) rotate(-4deg); } 60% { transform: scale(0.9) rotate(4deg); } } +@keyframes prank { 25% { transform: translate(-120px, -60px) rotate(-30deg); opacity: 0; } 55% { transform: translate(120px, -60px); opacity: 0; } 80% { transform: scale(1.1); opacity: 1; } } + +@media (prefers-reduced-motion: reduce) { + .crow *, .crow::before, .crow::after { animation: none !important; transition: none !important; } +} + +/* ---------- cards ---------- */ +.cards { list-style: none; padding: 0; margin: 0; display: grid; gap: 12px; } +.card { + background: var(--surface); border: 2px solid var(--line); border-radius: 18px; padding: 14px 16px; + box-shadow: var(--shadow); +} +.card-head { display: flex; gap: 10px; align-items: flex-start; } +.card .text { margin: 0; font-weight: 800; } +.dot { flex: none; width: 14px; height: 14px; margin-top: 5px; border-radius: 50%; background: var(--line); } +.card[data-state="flying"] .dot { background: var(--mint); animation: pop 0.8s infinite; } +.card[data-state="asking"] { border-color: var(--go); } +.card[data-state="asking"] .dot { background: var(--go); } +.card[data-state="done"] .dot { background: var(--go); } +.card[data-state="rejected"] .dot, .card[data-state="skipped"] .dot { background: var(--no-ink); } +.card[data-state="skipped"] .text { text-decoration: line-through; color: var(--muted); } + +.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0 24px; } +.chips:empty { display: none; } +.chip { + font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.4px; + padding: 2px 8px; border-radius: 999px; background: var(--bg); border: 2px solid var(--line); color: var(--muted); +} +.chip.mode-murder { border-color: #1d2721; color: var(--ink); } +.chip.mode-split { border-color: var(--mint); } +.reason { margin: 6px 0 0 24px; color: var(--muted); font-size: 14px; } +.subtasks { margin: 6px 0 0 24px; padding-left: 18px; font-size: 14px; } +.subtasks:empty { display: none; } +.answer { + margin: 10px 0 0 24px; padding: 10px 12px; border-left: 4px solid var(--mint); background: var(--bg); + border-radius: 0 12px 12px 0; white-space: pre-wrap; overflow-wrap: anywhere; +} +.actions:empty { display: none; } +.actions { margin: 12px 0 0 24px; } +.question { margin: 0 0 10px; font-weight: 800; } +.buttons { display: flex; gap: 10px; } +.buttons .btn { flex: 1; } +.note { margin: 0; font-size: 14px; color: var(--muted); } +.err { margin: 8px 0 0; font-size: 14px; color: var(--no-ink); } +.err:empty { display: none; } + +.banner { + margin: 0 0 16px; padding: 10px 14px; border-radius: 14px; background: var(--sentry); color: #3d3000; + font-weight: 800; text-align: center; +} +.meter { margin: 16px 0 0; text-align: center; color: var(--muted); font-size: 14px; } +.toast { + position: fixed; left: 16px; right: 16px; bottom: 20px; max-width: 520px; margin: 0 auto; + padding: 14px 16px; border-radius: 16px; background: var(--ink); color: var(--bg); font-weight: 800; text-align: center; + box-shadow: 0 6px 24px rgb(0 0 0 / 0.25); +} diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..f22afb3 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noUnusedLocals": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..60653c5 --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite"; + +// Builds into ../web, which `crows serve` hosts. `npm run dev` proxies the API to a running server. +export default defineConfig({ + build: { outDir: "../web", emptyOutDir: true }, + server: { proxy: { "/api": "http://127.0.0.1:8080" } }, +});