commit 962dd3fb703d42bba783ef42d8601ae37ec9680d Author: Corey Johnson Date: Mon Nov 17 10:54:37 2025 -0800 Initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..25fa621 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1ee6890 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,106 @@ + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; + +// import .css files directly and it works +import './index.css'; + +import { createRoot } from "react-dom/client"; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b5f140 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Deployment Script + +This directory contains a deployment script for the Yellow Phone project to a Raspberry Pi. + +## File: deploy.ts + +A Bun-based deployment script that automates copying files to a Raspberry Pi and managing systemd services. + +### Configuration + +- **Target Host**: `yellow-phone.local` +- **Target Directory**: `/home/corey/yellow-phone` + +### What It Does + +1. **Creates directory** on the Pi at the configured path +2. **Copies files** from local `pi/` directory to the Pi +3. **Sets permissions** to make all TypeScript files executable +4. **Bootstrap (optional)**: If `--bootstrap` flag is passed, runs `bootstrap.ts` on the Pi with sudo +5. **Service management**: + - Checks if `phone-ap.service` and `phone-web.service` exist + - If they exist, restarts both services + - If they don't exist and bootstrap wasn't run, warns the user + +### Usage + +**Standard deployment** (just copy files and restart services): +```bash +bun deploy.ts +``` + +**First-time deployment** (copy files + run bootstrap): +```bash +bun deploy.ts --bootstrap +``` + +### Services + +The script manages two systemd services: +- `phone-ap.service` - Access point service +- `phone-web.service` - Web interface service + +### Access + +After deployment, the Pi is accessible at: +- **Web URL**: http://yellow-phone.local +- **WiFi Network**: yellow-phone-setup + +### Requirements + +- Bun runtime +- SSH access to `yellow-phone.local` +- Local `pi/` directory with files to deploy diff --git a/basic-test.ts b/basic-test.ts new file mode 100755 index 0000000..4a8b872 --- /dev/null +++ b/basic-test.ts @@ -0,0 +1,72 @@ +#!/usr/bin/env bun + +/** + * Basic functionality test for Buzz library + * Tests device listing, player, recorder, and tone generation + */ + +import Buzz from "./src/buzz" + +console.log("๐ŸŽต Buzz Audio Library - Basic Test\n") + +// Test 1: List devices +console.log("๐Ÿ“‹ Listing devices...") +const devices = await Buzz.listDevices() +console.log(`Found ${devices.length} device(s):`) +devices.forEach((d) => { + console.log(` ${d.type.padEnd(8)} ${d.label} (${d.id})`) +}) +console.log("") + +// Test 2: Create player +console.log("๐Ÿ”Š Creating default player...") +try { + const player = await Buzz.defaultPlayer() + console.log("โœ… Player created\n") + + // Test 3: Play sound file + console.log("๐Ÿ”Š Playing greeting sound...") + const playback = await player.play("./sounds/greeting/greet1.wav") + await playback.finished() + console.log("โœ… Sound played\n") + + // Test 4: Play tone + console.log("๐ŸŽต Playing 440Hz tone for 1 second...") + const tone = await player.playTone([440], 1000) + await tone.finished() + console.log("โœ… Tone played\n") +} catch (error) { + console.log(`โš ๏ธ Skipping player tests: ${error instanceof Error ? error.message : error}\n`) +} + +// Test 5: Create recorder +console.log("๐ŸŽค Creating default recorder...") +try { + const recorder = await Buzz.defaultRecorder() + console.log("โœ… Recorder created\n") + + // Test 6: Stream recording with RMS + console.log("๐Ÿ“Š Recording for 2 seconds with RMS monitoring...") + const recording = recorder.start() + let chunkCount = 0 + let maxRMS = 0 + + setTimeout(async () => { + await recording.stop() + }, 2000) + + for await (const chunk of recording.stream()) { + chunkCount++ + const rms = Buzz.calculateRMS(chunk) + if (rms > maxRMS) maxRMS = rms + if (chunkCount % 20 === 0) { + console.log(` RMS: ${Math.round(rms)}`) + } + } + + console.log(`โœ… Recorded ${chunkCount} chunks, max RMS: ${Math.round(maxRMS)}\n`) +} catch (error) { + console.log(`โš ๏ธ Skipping recorder tests: ${error instanceof Error ? error.message : error}\n`) +} + +console.log("โœ… All tests complete!") diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..ef6cf04 --- /dev/null +++ b/bun.lock @@ -0,0 +1,37 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "tmp", + "dependencies": { + "hono": "^4.10.4", + "openai": "^6.9.0", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="], + + "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + + "@types/react": ["@types/react@19.2.5", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw=="], + + "bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="], + + "csstype": ["csstype@3.2.2", "", {}, "sha512-D80T+tiqkd/8B0xNlbstWDG4x6aqVfO52+OlSUNIdkTvmNw0uQpJLeos2J/2XvpyidAFuTPmpad+tUxLndwj6g=="], + + "hono": ["hono@4.10.6", "", {}, "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g=="], + + "openai": ["openai@6.9.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-n2sJRYmM+xfJ0l3OfH8eNnIyv3nQY7L08gZQu3dw6wSdfPtKAk92L83M2NIP5SS8Cl/bsBBG3yKzEOjkx0O+7A=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..951e23b --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "tmp", + "private": true, + "scripts": { + "typecheck": "tsc --noEmit", + "d": "bun scripts/deploy.ts", + "start": "bun run src/operator.ts" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "hono": "^4.10.4", + "openai": "^6.9.0" + }, + "prettier": { + "semi": false, + "printWidth": 100 + } +} \ No newline at end of file diff --git a/scripts/bootstrap-bun.sh b/scripts/bootstrap-bun.sh new file mode 100644 index 0000000..989594e --- /dev/null +++ b/scripts/bootstrap-bun.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# Install Bun and make it available system-wide +set -e + +echo "========================================== + Bun Installation for Yellow Phone +========================================== +" + +# Check if already installed +if command -v bun >/dev/null 2>&1; then + echo "โœ“ Bun is already installed at: $(which bun)" + bun --version + echo "" + read -p "Reinstall anyway? (y/N): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Skipping installation." + exit 0 + fi +fi + +echo "Step 1: Installing Bun..." +echo "Running official Bun installer..." +curl -fsSL https://bun.sh/install | bash + +echo "" +echo "Step 2: Finding Bun installation..." +BUN_PATH="$HOME/.bun/bin/bun" + +if [ ! -f "$BUN_PATH" ]; then + echo "โŒ Error: Bun not found at $BUN_PATH" + echo "Installation may have failed." + exit 1 +fi + +echo "โœ“ Found Bun at: $BUN_PATH" +$BUN_PATH --version + +echo "" +echo "Step 3: Creating system-wide symlink..." +sudo ln -sf "$BUN_PATH" /usr/local/bin/bun +echo "โœ“ Symlinked to /usr/local/bin/bun" + +echo "" +echo "Step 4: Verifying installation..." +which bun +bun --version + +echo " +========================================== +โœ“ Bun installation complete! +========================================== + +Bun is now available system-wide: + Location: /usr/local/bin/bun -> $BUN_PATH + Version: $(bun --version) + +You can now run bootstrap: + sudo bun bootstrap.ts +" diff --git a/scripts/bootstrap.ts b/scripts/bootstrap.ts new file mode 100755 index 0000000..04313aa --- /dev/null +++ b/scripts/bootstrap.ts @@ -0,0 +1,110 @@ +import { $ } from "bun" +import { writeFileSync } from "fs" + +console.log(` +========================================== + Yellow Phone Setup Bootstrap +========================================== +`) + +// Check if running as root +if (process.getuid && process.getuid() !== 0) { + console.error("Please run with sudo: sudo bun bootstrap.ts [install-dir]") + console.error("Or use full path: sudo ~/.bun/bin/bun bootstrap.ts [install-dir]") + process.exit(1) +} + +// Get install directory from argument or use default +const INSTALL_DIR = process.argv[2] || "/home/corey/yellow-phone" +const AP_SERVICE_FILE = "/etc/systemd/system/phone-ap.service" +const WEB_SERVICE_FILE = "/etc/systemd/system/phone-web.service" + +console.log(`Install directory: ${INSTALL_DIR}`) + +console.log("\nStep 1: Ensuring directory exists...") +await $`mkdir -p ${INSTALL_DIR}` +console.log(`โœ“ Directory ready: ${INSTALL_DIR}`) + +console.log("\nStep 2: Installing dependencies...") +await $`cd ${INSTALL_DIR} && bun install` +console.log(`โœ“ Dependencies installed`) + +console.log("\nStep 3: Installing systemd services...") +// Find where bun is installed +const bunPath = await $`which bun` + .quiet() + .nothrow() + .text() + .then((p) => p.trim()) +if (!bunPath) { + console.error("Error: bun not found in PATH. Please ensure bun is available system-wide.") + process.exit(1) +} +console.log(`Using bun at: ${bunPath}`) + +// Create AP monitor service +const apServiceContent = `[Unit] +Description=Yellow Phone WiFi AP Monitor +After=network.target +Before=phone-web.service + +[Service] +Type=simple +ExecStart=${bunPath} ${INSTALL_DIR}/services/ap-monitor.ts +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +` +writeFileSync(AP_SERVICE_FILE, apServiceContent, "utf8") +console.log("โœ“ Created phone-ap.service") + +// Create web server service +const webServiceContent = `[Unit] +Description=Yellow Phone Web Server +After=network.target phone-ap.service + +[Service] +Type=simple +ExecStart=${bunPath} ${INSTALL_DIR}/services/server/server.tsx +WorkingDirectory=${INSTALL_DIR} +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +` +writeFileSync(WEB_SERVICE_FILE, webServiceContent, "utf8") +console.log("โœ“ Created phone-web.service") + +await $`systemctl daemon-reload` +await $`systemctl enable phone-ap.service` +await $`systemctl enable phone-web.service` +console.log("โœ“ Services enabled") + +console.log("\nStep 4: Starting the services...") +await $`systemctl start phone-ap.service` +await $`systemctl start phone-web.service` +console.log("โœ“ Services started") + +console.log(` +========================================== +โœ“ Bootstrap complete! +========================================== + +Both services are now running and will start automatically on boot: +- phone-ap.service: Monitors WiFi and manages AP +- phone-web.service: Web server for configuration + +How it works: +- If connected to WiFi: Access at http://yellow-phone.local +- If NOT connected: WiFi AP "yellow-phone-setup" will start automatically + Connect to the AP at the same address http://yellow-phone.local + +To check status use ./cli +`) diff --git a/scripts/cli.sh b/scripts/cli.sh new file mode 100644 index 0000000..212897b --- /dev/null +++ b/scripts/cli.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bun + +import {$} from "bun"; + +const SERVICES = { + ap: "phone-ap", + web: "phone-web", +}; + +const commands = { + status: "Show status of all services", + logs: "Show recent logs from all services (last 50 lines)", + tail: "Tail logs from all services in real-time", + restart: "Restart all services", + stop: "Stop all services", + start: "Start all services", + "ap-status": "Show status of AP service", + "ap-logs": "Show recent logs from AP service (last 50 lines)", + "ap-tail": "Tail logs from AP service in real-time", + "ap-restart": "Restart AP service", + "ap-stop": "Stop AP service", + "ap-start": "Start AP service", + "web-status": "Show status of web service", + "web-logs": "Show recent logs from web service (last 50 lines)", + "web-tail": "Tail logs from web service in real-time", + "web-restart": "Restart web service", + "web-stop": "Stop web service", + "web-start": "Start web service", + help: "Show this help message", +}; + +const command = process.argv[2]; + +if (!command || command === "help") { + console.log(` +Yellow Phone CLI - Service Management Tool + +Usage: bun cli + +All Services: + status Show status of all services + logs Show recent logs from all services (last 50 lines) + tail Tail logs from all services in real-time + restart Restart all services + stop Stop all services + start Start all services + +AP Service (phone-ap): + ap-status Show AP status + ap-logs Show AP logs (last 50 lines) + ap-tail Tail AP logs in real-time + ap-restart Restart AP service + ap-stop Stop AP service + ap-start Start AP service + +Web Service (phone-web): + web-status Show web status + web-logs Show web logs (last 50 lines) + web-tail Tail web logs in real-time + web-restart Restart web service + web-stop Stop web service + web-start Start web service + +Examples: + bun cli status + bun cli ap-logs + bun cli web-tail + sudo bun cli ap-restart +`); + process.exit(0); +} + +if (!Object.keys(commands).includes(command)) { + console.error(`โŒ Unknown command: ${command}`); + console.log(`Run 'bun cli.ts help' to see available commands`); + process.exit(1); +} + +console.log(`\n๐Ÿ”ง Yellow Phone CLI - ${command}\n`); + +// Parse service-specific commands +const match = command.match(/^(ap|web)-(.+)$/); +if (match) { + const [, prefix, action] = match; + const service = SERVICES[prefix as keyof typeof SERVICES]; + + switch (action) { + case "status": + console.log(`โ”โ”โ” ${service}.service โ”โ”โ”`); + await $`systemctl status ${service}.service --no-pager -l`.nothrow(); + break; + + case "logs": + console.log(`๐Ÿ“‹ Recent logs (last 50 lines):\n`); + await $`journalctl -u ${service}.service -n 50 --no-pager`.nothrow(); + break; + + case "tail": + console.log(`๐Ÿ“ก Tailing logs (Ctrl+C to stop)...\n`); + await $`journalctl -u ${service}.service -f --no-pager`.nothrow(); + break; + + case "restart": + console.log(`๐Ÿ”„ Restarting ${service}.service...\n`); + await $`sudo systemctl restart ${service}.service`; + console.log(`โœ“ ${service}.service restarted!`); + break; + + case "stop": + console.log(`๐Ÿ›‘ Stopping ${service}.service...\n`); + await $`sudo systemctl stop ${service}.service`; + console.log(`โœ“ ${service}.service stopped!`); + break; + + case "start": + console.log(`โ–ถ๏ธ Starting ${service}.service...\n`); + await $`sudo systemctl start ${service}.service`; + console.log(`โœ“ ${service}.service started!`); + break; + } +} else { + // All-services commands + const allServices = Object.values(SERVICES); + + switch (command) { + case "status": + for (const service of allServices) { + console.log(`โ”โ”โ” ${service}.service โ”โ”โ”`); + await $`systemctl status ${service}.service --no-pager -l`.nothrow(); + console.log(""); + } + break; + + case "logs": + console.log("๐Ÿ“‹ Recent logs (last 50 lines):\n"); + const serviceFlags = allServices.map(s => `-u ${s}.service`).join(" "); + await $`journalctl ${serviceFlags} -n 50 --no-pager`.nothrow(); + break; + + case "tail": + console.log("๐Ÿ“ก Tailing logs (Ctrl+C to stop)...\n"); + const tailFlags = allServices.map(s => `-u ${s}.service`).join(" "); + await $`journalctl ${tailFlags} -f --no-pager`.nothrow(); + break; + + case "restart": + console.log("๐Ÿ”„ Restarting services...\n"); + for (const service of allServices) { + console.log(`Restarting ${service}.service...`); + await $`sudo systemctl restart ${service}.service`; + console.log(`โœ“ ${service}.service restarted`); + } + console.log("\nโœ“ All services restarted!"); + break; + + case "stop": + console.log("๐Ÿ›‘ Stopping services...\n"); + for (const service of allServices) { + console.log(`Stopping ${service}.service...`); + await $`sudo systemctl stop ${service}.service`; + console.log(`โœ“ ${service}.service stopped`); + } + console.log("\nโœ“ All services stopped!"); + break; + + case "start": + console.log("โ–ถ๏ธ Starting services...\n"); + for (const service of allServices) { + console.log(`Starting ${service}.service...`); + await $`sudo systemctl start ${service}.service`; + console.log(`โœ“ ${service}.service started`); + } + console.log("\nโœ“ All services started!"); + break; + } +} + +console.log(""); diff --git a/scripts/deploy.ts b/scripts/deploy.ts new file mode 100755 index 0000000..59f9ce1 --- /dev/null +++ b/scripts/deploy.ts @@ -0,0 +1,59 @@ +#!/usr/bin/env bun + +import { $ } from "bun" + +const PI_HOST = "yellow-phone.local" +const PI_DIR = "/home/corey/yellow-phone" + +// Parse command line arguments +const shouldBootstrap = process.argv.includes("--bootstrap") + +console.log(`๐Ÿงช Run basic tests...\n`) + +await $`bun run typecheck` + +console.log(`๐Ÿ“ฆ Deploying to ${PI_HOST}...\n`) + +// Create directory on Pi +console.log("Creating directory on Pi...") +await $`ssh ${PI_HOST} "mkdir -p ${PI_DIR}"` + +// Sync files from . directory to Pi (only transfers changed files) +console.log("Syncing files from . directory...") +await $`rsync -avz --delete --exclude-from='.gitignore' . ${PI_HOST}:${PI_DIR}/` + +// Make all TypeScript files executable +console.log("Making scripts executable...") +await $`ssh ${PI_HOST} "chmod +x ${PI_DIR}/scripts/*"` + +console.log("\nโœ“ Files deployed!\n") + +// Run bootstrap if requested +if (shouldBootstrap) { + console.log("Running bootstrap on Pi...\n") + await $`ssh ${PI_HOST} "cd ${PI_DIR} && sudo bun bootstrap.ts ${PI_DIR}"` +} + +// Always check if services exist and restart them (whether we bootstrapped or not) +console.log("Checking for existing services...") +const apServiceExists = await $`ssh ${PI_HOST} "systemctl is-enabled phone-ap.service"` + .nothrow() + .quiet() +const webServiceExists = await $`ssh ${PI_HOST} "systemctl is-enabled phone-web.service"` + .nothrow() + .quiet() + +if (apServiceExists.exitCode === 0 && webServiceExists.exitCode === 0) { + console.log("Restarting services...") + await $`ssh ${PI_HOST} "sudo systemctl restart phone-ap.service phone-web.service"` + console.log("โœ“ Services restarted\n") +} else if (!shouldBootstrap) { + console.log("Services not installed. Run with --bootstrap to install.\n") +} + +console.log(` +โœ“ Deploy complete! + +Access via WiFi at http://yellow-phone.local +The Pi is discoverable as "yellow-phone-setup" +`) diff --git a/sounds/apology/excuse-me1.wav b/sounds/apology/excuse-me1.wav new file mode 100644 index 0000000..43693fc Binary files /dev/null and b/sounds/apology/excuse-me1.wav differ diff --git a/sounds/apology/excuse-me2.wav b/sounds/apology/excuse-me2.wav new file mode 100644 index 0000000..e6a86a1 Binary files /dev/null and b/sounds/apology/excuse-me2.wav differ diff --git a/sounds/apology/excuse-me3.wav b/sounds/apology/excuse-me3.wav new file mode 100644 index 0000000..d3ab00b Binary files /dev/null and b/sounds/apology/excuse-me3.wav differ diff --git a/sounds/background/background1.wav b/sounds/background/background1.wav new file mode 100644 index 0000000..9011d6c Binary files /dev/null and b/sounds/background/background1.wav differ diff --git a/sounds/background/background2.wav b/sounds/background/background2.wav new file mode 100644 index 0000000..d1da15b Binary files /dev/null and b/sounds/background/background2.wav differ diff --git a/sounds/body-noises/burp1.wav b/sounds/body-noises/burp1.wav new file mode 100644 index 0000000..0bbd034 Binary files /dev/null and b/sounds/body-noises/burp1.wav differ diff --git a/sounds/body-noises/burp2.wav b/sounds/body-noises/burp2.wav new file mode 100644 index 0000000..f3f9fc2 Binary files /dev/null and b/sounds/body-noises/burp2.wav differ diff --git a/sounds/body-noises/fart1.wav b/sounds/body-noises/fart1.wav new file mode 100644 index 0000000..e273ca4 Binary files /dev/null and b/sounds/body-noises/fart1.wav differ diff --git a/sounds/body-noises/fart2.wav b/sounds/body-noises/fart2.wav new file mode 100644 index 0000000..7d4b3b2 Binary files /dev/null and b/sounds/body-noises/fart2.wav differ diff --git a/sounds/clicking/mouse1.wav b/sounds/clicking/mouse1.wav new file mode 100644 index 0000000..cc46099 Binary files /dev/null and b/sounds/clicking/mouse1.wav differ diff --git a/sounds/clicking/mouse2.wav b/sounds/clicking/mouse2.wav new file mode 100644 index 0000000..4118fd1 Binary files /dev/null and b/sounds/clicking/mouse2.wav differ diff --git a/sounds/clicking/mouse3.wav b/sounds/clicking/mouse3.wav new file mode 100644 index 0000000..4118fd1 Binary files /dev/null and b/sounds/clicking/mouse3.wav differ diff --git a/sounds/greeting/greet1.wav b/sounds/greeting/greet1.wav new file mode 100644 index 0000000..e16dbac Binary files /dev/null and b/sounds/greeting/greet1.wav differ diff --git a/sounds/greeting/greet2.wav b/sounds/greeting/greet2.wav new file mode 100644 index 0000000..6571e46 Binary files /dev/null and b/sounds/greeting/greet2.wav differ diff --git a/sounds/greeting/greet3.wav b/sounds/greeting/greet3.wav new file mode 100644 index 0000000..0fd2db1 Binary files /dev/null and b/sounds/greeting/greet3.wav differ diff --git a/sounds/greeting/greet4.wav b/sounds/greeting/greet4.wav new file mode 100644 index 0000000..5a5b95d Binary files /dev/null and b/sounds/greeting/greet4.wav differ diff --git a/sounds/greeting/greet5.wav b/sounds/greeting/greet5.wav new file mode 100644 index 0000000..dd03f01 Binary files /dev/null and b/sounds/greeting/greet5.wav differ diff --git a/sounds/greeting/greet6.wav b/sounds/greeting/greet6.wav new file mode 100644 index 0000000..907cd88 Binary files /dev/null and b/sounds/greeting/greet6.wav differ diff --git a/sounds/greeting/greet7.wav b/sounds/greeting/greet7.wav new file mode 100644 index 0000000..e81b535 Binary files /dev/null and b/sounds/greeting/greet7.wav differ diff --git a/sounds/greeting/greet8.wav b/sounds/greeting/greet8.wav new file mode 100644 index 0000000..75e9a5b Binary files /dev/null and b/sounds/greeting/greet8.wav differ diff --git a/sounds/stalling/cough1.wav b/sounds/stalling/cough1.wav new file mode 100644 index 0000000..21e5148 Binary files /dev/null and b/sounds/stalling/cough1.wav differ diff --git a/sounds/stalling/cough2.wav b/sounds/stalling/cough2.wav new file mode 100644 index 0000000..1ae8acf Binary files /dev/null and b/sounds/stalling/cough2.wav differ diff --git a/sounds/stalling/hmm1.wav b/sounds/stalling/hmm1.wav new file mode 100644 index 0000000..155a129 Binary files /dev/null and b/sounds/stalling/hmm1.wav differ diff --git a/sounds/stalling/hmm2.wav b/sounds/stalling/hmm2.wav new file mode 100644 index 0000000..169da0e Binary files /dev/null and b/sounds/stalling/hmm2.wav differ diff --git a/sounds/stalling/hum1.wav b/sounds/stalling/hum1.wav new file mode 100644 index 0000000..b9fcd81 Binary files /dev/null and b/sounds/stalling/hum1.wav differ diff --git a/sounds/stalling/hum2.wav b/sounds/stalling/hum2.wav new file mode 100644 index 0000000..2293611 Binary files /dev/null and b/sounds/stalling/hum2.wav differ diff --git a/sounds/stalling/one-sec.wav b/sounds/stalling/one-sec.wav new file mode 100644 index 0000000..7d1b497 Binary files /dev/null and b/sounds/stalling/one-sec.wav differ diff --git a/sounds/stalling/sigh1.wav b/sounds/stalling/sigh1.wav new file mode 100644 index 0000000..66bdc8c Binary files /dev/null and b/sounds/stalling/sigh1.wav differ diff --git a/sounds/stalling/sigh2.wav b/sounds/stalling/sigh2.wav new file mode 100644 index 0000000..26bd5b3 Binary files /dev/null and b/sounds/stalling/sigh2.wav differ diff --git a/sounds/stalling/sneeze.wav b/sounds/stalling/sneeze.wav new file mode 100644 index 0000000..99076de Binary files /dev/null and b/sounds/stalling/sneeze.wav differ diff --git a/sounds/stalling/uh-huh.wav b/sounds/stalling/uh-huh.wav new file mode 100644 index 0000000..89d3cb6 Binary files /dev/null and b/sounds/stalling/uh-huh.wav differ diff --git a/sounds/stalling/yeah.wav b/sounds/stalling/yeah.wav new file mode 100644 index 0000000..e4adad0 Binary files /dev/null and b/sounds/stalling/yeah.wav differ diff --git a/sounds/typing/typing1.wav b/sounds/typing/typing1.wav new file mode 100644 index 0000000..4b48c62 Binary files /dev/null and b/sounds/typing/typing1.wav differ diff --git a/sounds/typing/typing2.wav b/sounds/typing/typing2.wav new file mode 100644 index 0000000..0ffdf1f Binary files /dev/null and b/sounds/typing/typing2.wav differ diff --git a/src/agent/README.md b/src/agent/README.md new file mode 100644 index 0000000..7166f44 --- /dev/null +++ b/src/agent/README.md @@ -0,0 +1,157 @@ +# Agent + +A clean, reusable wrapper for ElevenLabs conversational AI WebSocket protocol. Uses Signal-based events and provides simple tool registration. + +## Basic Usage + +```typescript +import { Agent } from './pi/agent' +import Buzz from './pi/buzz' + +const agent = new Agent({ + agentId: process.env.ELEVEN_AGENT_ID!, + apiKey: process.env.ELEVEN_API_KEY!, + tools: { + search_web: async (args) => { + return { results: [`Result for ${args.query}`] } + } + } +}) + +// Set up event handlers +const player = await Buzz.defaultPlayer() +let playback = player.playStream() + +agent.events.connect((event) => { + if (event.type === 'audio') { + const audioBuffer = Buffer.from(event.audioBase64, 'base64') + if (!playback.isPlaying) playback = player.playStream() + playback.write(audioBuffer) + } + else if (event.type === 'interruption') { + playback.stop() + } + else if (event.type === 'user_transcript') { + console.log(`User: ${event.transcript}`) + } + else if (event.type === 'agent_response') { + console.log(`Agent: ${event.response}`) + } +}) + +// Start conversation +await agent.start() + +// Continuously stream audio +const recorder = await Buzz.defaultRecorder() +const recording = recorder.start() +for await (const chunk of recording.stream()) { + agent.sendAudio(chunk) +} +``` + +## VAD Pattern + +```typescript +const recorder = await Buzz.defaultRecorder() +const recording = recorder.start() +const buffer = new RollingBuffer() + +let agent: Agent | undefined + +for await (const chunk of recording.stream()) { + if (!agent) { + // Waiting for voice + buffer.add(chunk) + const rms = Buzz.calculateRMS(chunk) + + if (rms > vadThreshold) { + // Speech detected! Start conversation + agent = new Agent({ agentId, apiKey, tools }) + agent.events.connect(eventHandler) + await agent.start() + + // Send buffered audio + const buffered = buffer.flush() + agent.sendAudio(buffered) + } + } else { + // In conversation - stream continuously + agent.sendAudio(chunk) + } +} +``` + +## API + +### Constructor + +```typescript +new Agent({ + agentId: string, + apiKey: string, + tools?: { + [toolName: string]: (args: Record) => Promise | unknown + }, + conversationConfig?: { + agentConfig?: object, + ttsConfig?: object, + customLlmExtraBody?: { temperature?: number, max_tokens?: number }, + dynamicVariables?: Record + } +}) +``` + +### Methods + +- `await agent.start()` - Connect WebSocket and start conversation +- `agent.sendAudio(chunk: Uint8Array)` - Send audio chunk (buffers during connection) +- `agent.sendMessage(text: string)` - Send text message to agent +- `agent.sendContextUpdate(text: string)` - Update context during conversation +- `await agent.stop()` - Close WebSocket and clean up + +### Properties + +- `agent.events: Signal` - Connect to receive all events +- `agent.isConnected: boolean` - Current connection state +- `agent.conversationId?: string` - Available after connected event + +## Events + +All events are emitted through `agent.events`: + +### Connection +- `{ type: 'connected', conversationId, audioFormat }` +- `{ type: 'disconnected' }` +- `{ type: 'error', error }` + +### Conversation +- `{ type: 'user_transcript', transcript }` +- `{ type: 'agent_response', response }` +- `{ type: 'agent_response_correction', original, corrected }` +- `{ type: 'tentative_agent_response', response }` +- `{ type: 'audio', audioBase64, eventId }` +- `{ type: 'interruption', eventId }` + +### Tools +- `{ type: 'tool_call', name, args, callId }` +- `{ type: 'tool_result', name, result, callId }` +- `{ type: 'tool_error', name, error, callId }` + +### Optional +- `{ type: 'vad_score', score }` +- `{ type: 'ping', eventId, pingMs }` + +## Design Principles + +- **Generic**: Not tied to phone systems, works in any context +- **Flexible audio**: You control when to send audio, Agent just handles WebSocket +- **Event-driven**: All communication through Signal events, no throws +- **Simple tools**: Just pass a function map to constructor +- **Automatic buffering**: Sends buffered audio when connection opens +- **Automatic chunking**: Handles 8000-byte chunking internally + +## See Also + +- Design doc: `docs/plans/2025-01-16-agent-refactor-design.md` +- Original implementation: `pi/agent/old-index.ts` diff --git a/src/agent/index.ts b/src/agent/index.ts new file mode 100644 index 0000000..a03a95a --- /dev/null +++ b/src/agent/index.ts @@ -0,0 +1,284 @@ +import { Signal } from "../utils/signal" +import type { AgentConfig, AgentEvent } from "./types" + +type AgentState = "disconnected" | "connecting" | "connected" + +export class Agent { + #config: AgentConfig + #state: AgentState = "disconnected" + #ws?: WebSocket + #audioBuffer: Uint8Array[] = [] + #chunkBuffer = new Uint8Array(0) + #chunkSize = 8000 + + public readonly events = new Signal() + public conversationId?: string + + constructor(config: AgentConfig) { + this.#config = config + } + + get isConnected(): boolean { + return this.#state === "connected" + } + + start = async (): Promise => { + if (this.#state !== "disconnected") { + return + } + + this.#state = "connecting" + this.#audioBuffer = [] + this.conversationId = undefined + + const wsUrl = `wss://api.elevenlabs.io/v1/convai/conversation?agent_id=${this.#config.agentId}` + this.#ws = new WebSocket(wsUrl, { + headers: { "xi-api-key": this.#config.apiKey }, + }) + + this.#ws.addEventListener("open", this.#handleOpen) + this.#ws.addEventListener("message", this.#handleMessage) + this.#ws.addEventListener("error", this.#handleError) + this.#ws.addEventListener("close", this.#handleClose) + } + + stop = async (): Promise => { + if (this.#state === "disconnected") { + return + } + + this.#ws?.close() + this.#cleanup() + } + + sendAudio = (chunk: Uint8Array): void => { + if (this.#state === "disconnected") { + return + } + + if (this.#state === "connecting") { + this.#audioBuffer.push(chunk) + return + } + + // Chunk audio to appropriate size + const newBuffer = new Uint8Array(this.#chunkBuffer.length + chunk.length) + newBuffer.set(this.#chunkBuffer) + newBuffer.set(chunk, this.#chunkBuffer.length) + this.#chunkBuffer = newBuffer + + while (this.#chunkBuffer.length >= this.#chunkSize) { + const audioChunk = this.#chunkBuffer.slice(0, this.#chunkSize) + this.#chunkBuffer = this.#chunkBuffer.slice(this.#chunkSize) + + const base64Audio = Buffer.from(audioChunk).toString("base64") + this.#send({ user_audio_chunk: base64Audio }) + } + } + + sendMessage = (text: string): void => { + if (this.#state !== "connected") { + return + } + + this.#send({ type: "user_message", text }) + } + + sendContextUpdate = (text: string): void => { + if (this.#state !== "connected") { + return + } + + this.#send({ type: "contextual_update", text }) + } + + #handleOpen = (): void => { + this.#state = "connected" + + // Send conversation config if provided + if (this.#config.conversationConfig) { + this.#send({ + type: "conversation_initiation_client_data", + conversation_config_override: this.#config.conversationConfig.agentConfig, + custom_llm_extra_body: this.#config.conversationConfig.customLlmExtraBody, + dynamic_variables: this.#config.conversationConfig.dynamicVariables, + }) + } + + // Flush buffered audio + for (const chunk of this.#audioBuffer) { + const base64 = Buffer.from(chunk).toString("base64") + this.#send({ user_audio_chunk: base64 }) + } + this.#audioBuffer = [] + } + + #handleMessage = async (event: MessageEvent): Promise => { + try { + const message = JSON.parse(event.data as string) + + if (message.type === "conversation_initiation_metadata") { + const metadata = message.conversation_initiation_metadata_event + this.conversationId = metadata.conversation_id + this.events.emit({ + type: "connected", + conversationId: metadata.conversation_id, + audioFormat: { + agent_output_audio_format: metadata.agent_output_audio_format, + user_input_audio_format: metadata.user_input_audio_format, + }, + }) + } else if (message.type === "user_transcript") { + this.events.emit({ + type: "user_transcript", + transcript: message.user_transcription_event.user_transcript, + }) + } else if (message.type === "agent_response") { + this.events.emit({ + type: "agent_response", + response: message.agent_response_event.agent_response, + }) + } else if (message.type === "agent_response_correction") { + this.events.emit({ + type: "agent_response_correction", + original: message.agent_response_correction_event.original_agent_response, + corrected: message.agent_response_correction_event.corrected_agent_response, + }) + } else if (message.type === "internal_tentative_agent_response") { + this.events.emit({ + type: "tentative_agent_response", + response: message.tentative_agent_response_internal_event.tentative_agent_response, + }) + } else if (message.type === "audio") { + this.events.emit({ + type: "audio", + audioBase64: message.audio_event.audio_base_64, + eventId: message.audio_event.event_id, + }) + } else if (message.type === "interruption") { + this.events.emit({ + type: "interruption", + eventId: message.interruption_event.event_id, + }) + } else if (message.type === "ping") { + const eventId = message.ping_event.event_id + this.#send({ type: "pong", event_id: eventId }) + this.events.emit({ + type: "ping", + eventId, + pingMs: message.ping_event.ping_ms, + }) + } else if (message.type === "vad_score") { + this.events.emit({ + type: "vad_score", + score: message.vad_score_event.vad_score, + }) + } else if (message.type === "client_tool_call") { + await this.#handleToolCall(message.client_tool_call) + } + } catch (error) { + this.events.emit({ + type: "error", + error: error instanceof Error ? error : new Error(String(error)), + }) + } + } + + #handleToolCall = async (toolCall: { + tool_name: string + tool_call_id: string + parameters: Record + }): Promise => { + const { tool_name, tool_call_id, parameters } = toolCall + + this.events.emit({ + type: "tool_call", + name: tool_name, + args: parameters, + callId: tool_call_id, + }) + + const handler = this.#config.tools?.[tool_name] + + if (!handler) { + const error = new Error(`Tool ${tool_name} not found`) + this.events.emit({ + type: "tool_error", + name: tool_name, + error, + callId: tool_call_id, + }) + this.#send({ + type: "client_tool_result", + tool_call_id, + result: error.message, + is_error: true, + }) + return + } + + try { + const result = await handler(parameters) + this.events.emit({ + type: "tool_result", + name: tool_name, + result, + callId: tool_call_id, + }) + this.#send({ + type: "client_tool_result", + tool_call_id, + result: typeof result === "string" ? result : JSON.stringify(result), + is_error: false, + }) + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)) + this.events.emit({ + type: "tool_error", + name: tool_name, + error: err, + callId: tool_call_id, + }) + this.#send({ + type: "client_tool_result", + tool_call_id, + result: err.message, + is_error: true, + }) + } + } + + #handleError = (error: Event): void => { + this.events.emit({ + type: "error", + error: new Error(`WebSocket error: ${error.type}`), + }) + } + + #handleClose = (): void => { + this.#cleanup() + this.events.emit({ type: "disconnected" }) + } + + #cleanup = (): void => { + this.#state = "disconnected" + this.#audioBuffer = [] + this.#chunkBuffer = new Uint8Array(0) + + if (this.#ws) { + this.#ws.removeEventListener("open", this.#handleOpen) + this.#ws.removeEventListener("message", this.#handleMessage) + this.#ws.removeEventListener("error", this.#handleError) + this.#ws.removeEventListener("close", this.#handleClose) + this.#ws = undefined + } + } + + #send = (data: object): void => { + if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) { + return + } + + this.#ws.send(JSON.stringify(data)) + } +} diff --git a/src/agent/tools.ts b/src/agent/tools.ts new file mode 100644 index 0000000..d508336 --- /dev/null +++ b/src/agent/tools.ts @@ -0,0 +1,30 @@ +import OpenAI from "openai" +import { ensure } from "../utils" + +const apiKey = process.env.OPENAI_API_KEY +ensure(apiKey, "OPENAI_API_KEY environment variable is required") + +const client = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, +}) + +export const searchWeb = async (query: string) => { + const response = await client.responses.create({ + model: "gpt-4o-mini", + tools: [ + { + type: "web_search_preview", + search_context_size: "low", + user_location: { + type: "approximate", + country: "US", + city: "San Francisco", + region: "California", + }, + }, + ], + input: `Search the web for: ${query}`, + }) + + return response.output_text +} diff --git a/src/agent/types.ts b/src/agent/types.ts new file mode 100644 index 0000000..67f383a --- /dev/null +++ b/src/agent/types.ts @@ -0,0 +1,42 @@ +// Agent configuration types +export type AgentConfig = { + agentId: string + apiKey: string + tools?: { + [toolName: string]: (args: any) => Promise | unknown + } + conversationConfig?: { + agentConfig?: object + ttsConfig?: object + customLlmExtraBody?: { temperature?: number; max_tokens?: number; [key: string]: unknown } + dynamicVariables?: Record + } +} + +// Event types emitted by the Agent +export type AgentEvent = + // Connection lifecycle + | { + type: "connected" + conversationId: string + audioFormat: { + agent_output_audio_format?: string + user_input_audio_format?: string + } + } + | { type: "disconnected" } + | { type: "error"; error: Error } + // Conversation events + | { type: "user_transcript"; transcript: string } + | { type: "agent_response"; response: string } + | { type: "agent_response_correction"; original: string; corrected: string } + | { type: "tentative_agent_response"; response: string } + | { type: "audio"; audioBase64: string; eventId: number } + | { type: "interruption"; eventId: number } + // Tool events + | { type: "tool_call"; name: string; args: Record; callId: string } + | { type: "tool_result"; name: string; result: unknown; callId: string } + | { type: "tool_error"; name: string; error: Error; callId: string } + // Optional events + | { type: "vad_score"; score: number } + | { type: "ping"; eventId: number; pingMs: number } diff --git a/src/buzz/index.ts b/src/buzz/index.ts new file mode 100644 index 0000000..f70d0b8 --- /dev/null +++ b/src/buzz/index.ts @@ -0,0 +1,95 @@ +import { Player } from "./player.js" +import { Recorder } from "./recorder.js" +import { + listDevices, + calculateRMS, + findDeviceByLabel, + type AudioFormat, + type Device, +} from "./utils.js" + +const defaultPlayer = (format?: AudioFormat) => Player.create({ format }) + +const player = (label: string, format?: AudioFormat) => Player.create({ label, format }) + +const defaultRecorder = (format?: AudioFormat) => Recorder.create({ format }) + +const recorder = (label: string, format?: AudioFormat) => Recorder.create({ label, format }) + +const getVolumeControl = async (cardNumber?: number): Promise => { + const output = cardNumber + ? await Bun.$`amixer -c ${cardNumber} scontrols`.text() + : await Bun.$`amixer scontrols`.text() + + const controls = output + .split("\n") + .map((line) => { + const match = line.match(/Simple mixer control '([^']+)'/) + return match?.[1] + }) + .filter(Boolean) + + const playbackControl = controls.find((c) => c !== "Capture") + if (!playbackControl) { + throw new Error("No playback mixer control found") + } + + return playbackControl +} + +const setVolume = async (volume: number, label?: string): Promise => { + const percent = Math.round(volume * 100) + + let cardNumber: number | undefined + if (label) { + const device = await findDeviceByLabel(label) + cardNumber = device.card + } + + const control = await getVolumeControl(cardNumber) + + const result = cardNumber + ? await Bun.$`amixer -c ${cardNumber} sset ${control} ${percent}%`.quiet() + : await Bun.$`amixer sset ${control} ${percent}%`.quiet() + + if (result.exitCode !== 0) { + throw new Error(`Failed to set volume: ${result.stderr}`) + } +} + +const getVolume = async (label?: string): Promise => { + let cardNumber: number | undefined + if (label) { + const device = await findDeviceByLabel(label) + cardNumber = device.card + } + + const control = await getVolumeControl(cardNumber) + + const output = cardNumber + ? await Bun.$`amixer -c ${cardNumber} sget ${control}`.text() + : await Bun.$`amixer sget ${control}`.text() + + const match = output.match(/\[(\d+)%\]/) + if (!match?.[1]) { + throw new Error("Failed to parse volume from amixer output") + } + + return parseInt(match[1]) / 100 +} + +const Buzz = { + listDevices, + defaultPlayer, + player, + defaultRecorder, + recorder, + setVolume, + getVolume, + calculateRMS, +} + +export default Buzz +export type { Device, AudioFormat } +export { type Player } from "./player.js" +export { type Recorder } from "./recorder.js" diff --git a/src/buzz/player.ts b/src/buzz/player.ts new file mode 100644 index 0000000..df0dcea --- /dev/null +++ b/src/buzz/player.ts @@ -0,0 +1,178 @@ +import { + DEFAULT_AUDIO_FORMAT, + type AudioFormat, + type Playback, + type StreamingPlayback, +} from "./utils.js" +import { listDevices, findDeviceByLabel, streamTone } from "./utils.js" + +export class Player { + #deviceId?: string + #format: Required + + static async create({ label, format }: { label?: string; format?: AudioFormat } = {}) { + const devices = await listDevices() + const playbackDevices = devices.filter((d) => d.type === "playback") + + if (playbackDevices.length === 0) { + throw new Error("No playback devices found") + } + + let deviceId: string | undefined + if (label) { + const device = await findDeviceByLabel(label, "playback") + deviceId = device.id + } + + return new Player({ deviceId, format }) + } + + constructor({ deviceId, format }: { deviceId?: string; format?: AudioFormat }) { + this.#deviceId = deviceId + this.#format = { + format: format?.format ?? DEFAULT_AUDIO_FORMAT.format, + sampleRate: format?.sampleRate ?? DEFAULT_AUDIO_FORMAT.sampleRate, + channels: format?.channels ?? DEFAULT_AUDIO_FORMAT.channels, + } + } + + #commonArgs(): string[] { + const args = [] + if (this.#deviceId) { + args.push("-D", this.#deviceId) + } + return args + } + + async play(filePath: string, { repeat }: { repeat?: boolean } = {}): Promise { + const args = [...this.#commonArgs(), filePath] + + let proc = Bun.spawn(["aplay", ...args], { stdout: "pipe", stderr: "pipe" }) + + const handle: Playback = { + isPlaying: true, + stop: async () => { + if (!handle.isPlaying) return + handle.isPlaying = false + proc.kill() + await proc.exited + }, + finished: async () => { + await proc.exited + }, + } + + const loop = async () => { + while (handle.isPlaying) { + await proc.exited + if (!handle.isPlaying) break + + if (repeat) { + proc = Bun.spawn(["aplay", ...args], { stdout: "pipe", stderr: "pipe" }) + } else { + handle.isPlaying = false + break + } + } + } + + loop() + + return handle + } + + async playTone(frequencies: number[], duration: number): Promise { + if (duration !== Infinity && duration <= 0) { + throw new Error("Duration must be greater than 0 or Infinity") + } + + const args = [ + ...this.#commonArgs(), + "-f", + this.#format.format, + "-r", + this.#format.sampleRate.toString(), + "-c", + this.#format.channels.toString(), + "-t", + "raw", + "-", + ] + + const proc = Bun.spawn(["aplay", ...args], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }) + + // Start streaming tone in background + streamTone(proc.stdin, frequencies, duration, this.#format) + + const handle: Playback = { + isPlaying: true, + stop: async () => { + if (!handle.isPlaying) return + handle.isPlaying = false + try { + proc.stdin.end() + } catch (e) {} + proc.kill() + await proc.exited + }, + finished: async () => { + await proc.exited + }, + } + + proc.exited.then(() => { + handle.isPlaying = false + }) + + return handle + } + + playStream(): StreamingPlayback { + const args = [ + ...this.#commonArgs(), + "-f", + this.#format.format, + "-r", + this.#format.sampleRate.toString(), + "-c", + this.#format.channels.toString(), + "-t", + "raw", + "-", + ] + + const proc = Bun.spawn(["aplay", ...args], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }) + + const handle: StreamingPlayback = { + isPlaying: true, + write: (chunk: Uint8Array) => { + if (handle.isPlaying) { + proc.stdin.write(chunk) + } + }, + stop: async () => { + if (!handle.isPlaying) return + handle.isPlaying = false + try { + proc.stdin.end() + } catch (e) {} + proc.kill() + await proc.exited + }, + } + + proc.exited.then(() => { + handle.isPlaying = false + }) + + return handle + } +} diff --git a/src/buzz/recorder.ts b/src/buzz/recorder.ts new file mode 100644 index 0000000..154ffbd --- /dev/null +++ b/src/buzz/recorder.ts @@ -0,0 +1,114 @@ +import { + DEFAULT_AUDIO_FORMAT, + type AudioFormat, + type StreamingRecording, + type FileRecording, +} from "./utils.js" +import { listDevices, findDeviceByLabel } from "./utils.js" + +export class Recorder { + #deviceId?: string + #format: Required + + static async create({ label, format }: { label?: string; format?: AudioFormat } = {}) { + const devices = await listDevices() + const captureDevices = devices.filter((d) => d.type === "capture") + + if (captureDevices.length === 0) { + throw new Error("No capture devices found") + } + + let deviceId: string | undefined + if (label) { + const device = await findDeviceByLabel(label, "capture") + deviceId = device.id + } + + return new Recorder({ deviceId, format }) + } + + constructor({ deviceId, format }: { deviceId?: string; format?: AudioFormat }) { + this.#deviceId = deviceId + this.#format = { + format: format?.format ?? DEFAULT_AUDIO_FORMAT.format, + sampleRate: format?.sampleRate ?? DEFAULT_AUDIO_FORMAT.sampleRate, + channels: format?.channels ?? DEFAULT_AUDIO_FORMAT.channels, + } + } + + #commonArgs(): string[] { + const args = [] + if (this.#deviceId) { + args.push("-D", this.#deviceId) + } + args.push( + "-f", + this.#format.format, + "-r", + this.#format.sampleRate.toString(), + "-c", + this.#format.channels.toString() + ) + return args + } + + #startStreaming(): StreamingRecording { + const args = [...this.#commonArgs(), "-t", "raw", "-"] + + const proc = Bun.spawn(["arecord", ...args], { + stdout: "pipe", + stderr: "pipe", + }) + + const handle: StreamingRecording = { + isRecording: true, + stream: () => proc.stdout, + stop: async () => { + if (!handle.isRecording) return + handle.isRecording = false + proc.kill() + await proc.exited + }, + } + + proc.exited.then(() => { + handle.isRecording = false + }) + + return handle + } + + #startFileRecording(outputFile: string): FileRecording { + const args = [...this.#commonArgs(), "-t", "wav", outputFile] + + const proc = Bun.spawn(["arecord", ...args], { + stdout: "pipe", + stderr: "pipe", + }) + + const handle: FileRecording = { + isRecording: true, + stop: async () => { + if (!handle.isRecording) return + handle.isRecording = false + proc.kill() + await proc.exited + }, + } + + proc.exited.then(() => { + handle.isRecording = false + }) + + return handle + } + + start(): StreamingRecording + start(outputFile: string): FileRecording + start(outputFile?: string): StreamingRecording | FileRecording { + if (outputFile) { + return this.#startFileRecording(outputFile) + } + return this.#startStreaming() + } +} diff --git a/src/buzz/utils.ts b/src/buzz/utils.ts new file mode 100644 index 0000000..20263ae --- /dev/null +++ b/src/buzz/utils.ts @@ -0,0 +1,166 @@ +// Audio format configuration +export type AudioFormat = { + format?: string; + sampleRate?: number; + channels?: number; +}; + +// Default audio format for recordings and tone generation +export const DEFAULT_AUDIO_FORMAT = { + format: 'S16_LE', + sampleRate: 16000, + channels: 1, +} as const; + +// Device from ALSA listing +export type Device = { + id: string; // "default" or "plughw:1,0" + card: number; // ALSA card number + device: number; // ALSA device number + label: string; // Human-readable name + type: 'playback' | 'capture'; +}; + +// Playback control handle +export type Playback = { + isPlaying: boolean; + stop: () => Promise; + finished: () => Promise; +}; + +// Streaming playback handle +export type StreamingPlayback = { + isPlaying: boolean; + write: (chunk: Uint8Array) => void; + stop: () => Promise; +}; + +// Streaming recording control handle +export type StreamingRecording = { + isRecording: boolean; + stream: () => ReadableStream; + stop: () => Promise; +}; + +// File recording control handle +export type FileRecording = { + isRecording: boolean; + stop: () => Promise; +}; + +const parseDeviceLine = (line: string, type: 'playback' | 'capture'): Device | undefined => { + if (!line.startsWith('card ')) return undefined; + + const match = line.match(/^card (\d+):\s+\w+\s+\[(.+?)\],\s+device (\d+):/); + if (!match) return undefined; + + const [, cardStr, label, deviceStr] = match; + + if (!cardStr || !label || !deviceStr) return undefined; + + const card = parseInt(cardStr); + const device = parseInt(deviceStr); + + return { + id: `plughw:${card},${device}`, + card, + device, + label, + type, + }; +}; + +const parseAlsaDevices = (output: string, type: 'playback' | 'capture'): Device[] => { + return output + .split('\n') + .map(line => parseDeviceLine(line, type)) + .filter(device => device !== undefined); +}; + +export const listDevices = async (): Promise => { + const playbackOutput = await Bun.$`aplay -l`.text(); + const captureOutput = await Bun.$`arecord -l`.text(); + + const playback = parseAlsaDevices(playbackOutput, 'playback'); + const capture = parseAlsaDevices(captureOutput, 'capture'); + + return [...playback, ...capture]; +}; + +export const findDeviceByLabel = async ( + label: string, + type?: 'playback' | 'capture' +): Promise => { + const devices = await listDevices(); + const device = devices.find(d => + d.label === label && (!type || d.type === type) + ); + + if (!device) { + const typeStr = type ? ` (type: ${type})` : ''; + throw new Error(`Device not found: ${label}${typeStr}`); + } + + return device; +}; + +export const calculateRMS = (chunk: Uint8Array): number => { + const samples = new Int16Array(chunk.buffer, chunk.byteOffset, chunk.byteLength / 2); + let sum = 0; + + for (const sample of samples) { + sum += sample * sample; + } + + return Math.sqrt(sum / samples.length); +}; + +export const generateToneSamples = ( + frequencies: number[], + sampleRate: number, + durationSeconds: number +): Uint8Array => { + const numSamples = Math.floor(sampleRate * durationSeconds); + const buffer = new ArrayBuffer(numSamples * 2); // 2 bytes per S16_LE sample + const samples = new Int16Array(buffer); + + for (let i = 0; i < numSamples; i++) { + const t = i / sampleRate; + let value = 0; + + // Mix all frequencies together + for (const freq of frequencies) { + value += Math.sin(2 * Math.PI * freq * t); + } + + // Average and scale to Int16 range + value = (value / frequencies.length) * 32767; + samples[i] = Math.round(value); + } + + return new Uint8Array(buffer); +}; + +export const streamTone = async ( + stream: { write: (chunk: Uint8Array) => void; end: () => void }, + frequencies: number[], + durationMs: number, + format: Required +): Promise => { + const infinite = durationMs === Infinity; + const durationSeconds = durationMs / 1000; + + // Continuous tone + const samples = generateToneSamples(frequencies, format.sampleRate, infinite ? 1 : durationSeconds); + + if (infinite) { + // Loop 1-second chunks forever + while (true) { + stream.write(samples); + await Bun.sleep(1000); + } + } else { + stream.write(samples); + stream.end(); + } +}; diff --git a/src/operator.ts b/src/operator.ts new file mode 100755 index 0000000..2f19d94 --- /dev/null +++ b/src/operator.ts @@ -0,0 +1,166 @@ +import Buzz from "./buzz/index.ts" +import type { Playback } from "./buzz/utils.ts" +import { Agent } from "./agent/index.ts" +import { searchWeb } from "./agent/tools.ts" +import { getSound, WaitingSounds } from "./utils/waiting-sounds.ts" + +const runPhoneSystem = async (agentId: string, apiKey: string) => { + console.log("๐Ÿ“ž Phone System Starting\n") + await Buzz.setVolume(0.4) + + const recorder = await Buzz.defaultRecorder() + const player = await Buzz.defaultPlayer() + + const agent = new Agent({ + agentId, + apiKey, + tools: { + search_web: (args: { query: string }) => searchWeb(args.query), + }, + }) + + let currentDialtone: Playback | undefined + let currentBackgroundNoise: Playback | undefined + let currentPlayback = player.playStream() + const waitingIndicator = new WaitingSounds(player) + + // Set up agent event listeners + agent.events.connect((event) => { + switch (event.type) { + case "connected": + console.log("โœ… Connected to AI agent\n") + break + + case "user_transcript": + console.log(`๐Ÿ‘ค You: ${event.transcript}`) + break + + case "agent_response": + console.log(`๐Ÿค– Agent: ${event.response}`) + break + + case "audio": + waitingIndicator.stop() + const audioBuffer = Buffer.from(event.audioBase64, "base64") + currentPlayback.write(audioBuffer) + break + + case "interruption": + console.log("๐Ÿ›‘ User interrupted") + currentPlayback?.stop() + currentPlayback = player.playStream() // Reset playback stream + break + + case "tool_call": + waitingIndicator.start() + console.log(`๐Ÿ”ง Tool call: ${event.name}(${JSON.stringify(event.args)})`) + break + + case "tool_result": + console.log(`โœ… Tool result: ${JSON.stringify(event.result)}`) + break + + case "tool_error": + console.error(`โŒ Tool error: ${event.error}`) + break + + case "disconnected": + console.log("\n๐Ÿ‘‹ Conversation ended, returning to dialtone\n") + currentPlayback?.stop() + state = "WAITING_FOR_VOICE" + startDialtone() + break + + case "error": + console.error("Agent error:", event.error) + } + }) + + const recording = recorder.start() + const audioStream = recording.stream() + console.log("๐ŸŽค Recording started\n") + + type State = "WAITING_FOR_VOICE" | "IN_CONVERSATION" + let state: State = "WAITING_FOR_VOICE" + let preConnectionBuffer: Uint8Array[] = [] + + const startDialtone = async () => { + console.log("๐Ÿ”Š Playing dialtone (waiting for speech)...\n") + await currentBackgroundNoise?.stop() + currentBackgroundNoise = undefined + currentDialtone = await player.playTone([350, 440], Infinity) + } + + const stopDialtone = async () => { + await currentDialtone?.stop() + currentDialtone = undefined + currentBackgroundNoise = await player.play(getSound("background"), { + repeat: true, + }) + } + + const startConversation = async () => { + stopDialtone() + + state = "IN_CONVERSATION" + await agent.start() + + // Send pre-buffered audio + for (const chunk of preConnectionBuffer) { + agent.sendAudio(chunk) + } + preConnectionBuffer = [] + } + + await startDialtone() + + const vadThreshold = 5000 + const maxPreBufferChunks = 4 // Keep ~1 second of audio before speech detection + + for await (const chunk of audioStream) { + if (state === "WAITING_FOR_VOICE") { + // Keep a rolling buffer of recent audio + preConnectionBuffer.push(chunk) + if (preConnectionBuffer.length > maxPreBufferChunks) { + preConnectionBuffer.shift() + } + + const rms = Buzz.calculateRMS(chunk) + if (rms > vadThreshold) { + console.log(`๐Ÿ—ฃ๏ธ Speech detected! (RMS: ${Math.round(rms)})`) + await startConversation() + } + } else if (state === "IN_CONVERSATION") { + agent.sendAudio(chunk) + } + } + + const cleanup = async () => { + console.log("\n\n๐Ÿ›‘ Shutting down phone system...") + await currentDialtone?.stop() + await currentBackgroundNoise?.stop() + await currentPlayback?.stop() + await agent.stop() + process.exit(0) + } + + process.on("SIGINT", cleanup) +} + +const apiKey = process.env.ELEVEN_API_KEY +const agentId = process.env.ELEVEN_AGENT_ID + +if (!apiKey) { + console.error("โŒ Error: ELEVEN_API_KEY environment variable is required") + process.exit(1) +} + +if (!agentId) { + console.error( + "โŒ Error: ELEVEN_AGENT_ID environELEVEN_AGENT_ID=agent_5601k4taw2cvfjzrz6snxpgeh7x8 ELEVEN_API_KEY=sk_0313740f112c5992cb62ed96c974ab19b5916f1ea172471fment variable is required" + ) + console.error(" Create an agent at https://elevenlabs.io/app/conversational-ai") + process.exit(1) +} + +await runPhoneSystem(agentId, apiKey) diff --git a/src/services/ap-monitor.ts b/src/services/ap-monitor.ts new file mode 100644 index 0000000..6b9dab4 --- /dev/null +++ b/src/services/ap-monitor.ts @@ -0,0 +1,238 @@ +#!/usr/bin/env bun +import { $ } from "bun" + +// Configuration +const CONFIG = { + checkInterval: 10_000, // 10 seconds + ap: { + ip: "192.168.4.1", + dhcpRange: "192.168.4.2,192.168.4.20", + channel: 7, + }, +} + +// Get AP SSID from hostname +const hostname = (await $`hostname`.text()).trim() +const AP_SSID = `${hostname}-setup` +const AP_CONNECTION_NAME = `${AP_SSID}-ap` + +console.log("Starting WiFi AP Monitor...") +console.log(`AP SSID will be: ${AP_SSID}`) +console.log(`AP connection name: ${AP_CONNECTION_NAME}`) +console.log(`Checking connectivity every ${CONFIG.checkInterval / 1000} seconds\n`) + +let apRunning = false + +async function isConnectedToWiFi(): Promise { + try { + // Check if wlan0 is connected to a WiFi network AS A CLIENT (not in AP mode) + // We need to check if there's an active connection that is NOT our AP + const activeConnections = await $`nmcli -t -f NAME,TYPE connection show --active`.quiet().text() + const lines = activeConnections.trim().split("\n") + + for (const line of lines) { + const [name, type] = line.split(":") + // Check if there's a wifi connection that's NOT our AP + if (type === "802-11-wireless" && name !== AP_CONNECTION_NAME) { + return true + } + } + + return false + } catch (error) { + console.error("[isConnectedToWiFi] ERROR: Failed to check WiFi status") + console.error(error) + return false + } +} + +async function startAP() { + if (apRunning) { + console.log("[startAP] AP already running, skipping") + return + } + + console.log("๐Ÿ”ด Not connected to WiFi - Starting WiFi AP...") + console.log(`[startAP] AP SSID: ${AP_SSID}`) + console.log(`[startAP] AP Connection: ${AP_CONNECTION_NAME}`) + + try { + // Check if connection profile already exists + console.log("[startAP] Checking if connection profile exists...") + const existsResult = await $`sudo nmcli connection show ${AP_CONNECTION_NAME}`.nothrow().quiet() + + if (existsResult.exitCode !== 0) { + console.log("[startAP] Connection profile does not exist, creating...") + + // Create AP connection profile (open network, no password) + await $`sudo nmcli connection add type wifi ifname wlan0 con-name ${AP_CONNECTION_NAME} autoconnect no ssid ${AP_SSID} 802-11-wireless.mode ap 802-11-wireless.band bg 802-11-wireless.channel ${CONFIG.ap.channel} ipv4.method shared ipv4.addresses ${CONFIG.ap.ip}/24` + console.log("[startAP] โœ“ Connection profile created (open network)") + } else { + console.log("[startAP] Connection profile already exists, reusing") + } + + // Bring up the AP + console.log("[startAP] Bringing up AP connection...") + await $`sudo nmcli connection up ${AP_CONNECTION_NAME}` + console.log("[startAP] โœ“ AP connection activated") + + apRunning = true + console.log(`โœ“ AP started successfully!`) + console.log(` SSID: ${AP_SSID}`) + console.log(` Password: None (open network)`) + console.log(` Connect and visit: http://${CONFIG.ap.ip}\n`) + } catch (error) { + console.error("[startAP] ERROR: Failed to start AP") + console.error(error) + apRunning = false + } +} + +async function stopAP() { + if (!apRunning) { + console.log("[stopAP] AP not running, skipping") + return + } + + console.log("๐ŸŸข Connected to WiFi - Stopping AP...") + console.log(`[stopAP] Bringing down connection: ${AP_CONNECTION_NAME}`) + + try { + // Bring down the AP connection + await $`sudo nmcli connection down ${AP_CONNECTION_NAME}`.nothrow() + console.log("[stopAP] โœ“ AP connection deactivated") + + apRunning = false + console.log("โœ“ AP stopped successfully\n") + } catch (error) { + console.error("[stopAP] ERROR: Failed to stop AP") + console.error(error) + // Set to false anyway to allow retry + apRunning = false + } +} + +async function checkAndManageAP() { + const connected = await isConnectedToWiFi() + + console.log( + `[checkAndManageAP] WiFi: ${connected ? "connected" : "disconnected"}, AP: ${ + apRunning ? "running" : "stopped" + }` + ) + + if (connected && apRunning) { + console.log("[checkAndManageAP] WiFi connected and AP running โ†’ stopping AP") + await stopAP() + } else if (!connected && !apRunning) { + console.log("[checkAndManageAP] WiFi disconnected and AP stopped โ†’ starting AP") + await startAP() + } else if (!connected && apRunning) { + // AP is running but no WiFi client connection + // Check if our saved WiFi network is available + console.log("[checkAndManageAP] AP running, checking if saved WiFi is available...") + const savedNetwork = await findAvailableSavedNetwork() + if (savedNetwork) { + console.log( + `[checkAndManageAP] Found available saved network: ${savedNetwork}, attempting connection...` + ) + + // Try to connect first + const connected = await tryConnect(savedNetwork) + + if (connected) { + console.log(`[checkAndManageAP] Successfully connected to ${savedNetwork}, stopping AP...`) + await stopAP() + } else { + console.log(`[checkAndManageAP] Failed to connect to ${savedNetwork}, keeping AP running`) + // Delete the failed connection profile + try { + await $`sudo nmcli connection delete ${savedNetwork}`.nothrow() + console.log(`[checkAndManageAP] Deleted failed connection profile for ${savedNetwork}`) + } catch (error) { + console.error(`[checkAndManageAP] Failed to delete connection profile:`, error) + } + } + } + } +} + +async function findAvailableSavedNetwork(): Promise { + try { + // Get all saved WiFi connections (exclude our AP) + const savedConnections = await $`nmcli -t -f NAME,TYPE connection show`.quiet().text() + const lines = savedConnections.trim().split("\n") + + const savedWiFiNames: string[] = [] + for (const line of lines) { + const [name, type] = line.split(":") + if (type === "802-11-wireless" && name !== AP_CONNECTION_NAME) { + savedWiFiNames.push(name!) + } + } + + if (savedWiFiNames.length === 0) { + return null + } + + // Get the actual SSIDs for logging + const savedSSIDs: string[] = [] + for (const savedName of savedWiFiNames) { + const connInfo = await $`nmcli -t -f 802-11-wireless.ssid connection show ${savedName}` + .quiet() + .nothrow() + .text() + const ssid = connInfo.split(":")[1]?.trim() + if (ssid) { + savedSSIDs.push(ssid) + } + } + + console.log(`[findAvailableSavedNetwork] Saved WiFi networks: ${savedSSIDs.join(", ")}`) + + // Scan for available networks + const scanResult = await $`nmcli -t -f SSID device wifi list`.quiet().nothrow().text() + const availableSSIDs = scanResult.trim().split("\n") + + // Check if any saved network is available + for (const savedName of savedWiFiNames) { + // NetworkManager connection names often match SSIDs, but let's also check the connection's SSID + const connInfo = await $`nmcli -t -f 802-11-wireless.ssid connection show ${savedName}` + .quiet() + .nothrow() + .text() + const ssid = connInfo.split(":")[1]?.trim() + + if (ssid && availableSSIDs.includes(ssid)) { + console.log(`[findAvailableSavedNetwork] Found available network: ${ssid}`) + return savedName // Return the connection name + } + } + + return null + } catch (error) { + console.error("[findAvailableSavedNetwork] ERROR checking for WiFi") + console.error(error) + return null + } +} + +async function tryConnect(connectionName: string): Promise { + try { + console.log(`[tryConnect] Attempting to connect to ${connectionName}...`) + await $`sudo nmcli connection up ${connectionName}` + console.log(`[tryConnect] Successfully connected to ${connectionName}`) + return true + } catch (error) { + console.error(`[tryConnect] Failed to connect to ${connectionName}:`, error) + return false + } +} + +// Initial check +await checkAndManageAP() + +// Check periodically +setInterval(checkAndManageAP, CONFIG.checkInterval) + +console.log("Monitor running...") diff --git a/src/services/server/components/ConnectingPage.tsx b/src/services/server/components/ConnectingPage.tsx new file mode 100644 index 0000000..42b4ae4 --- /dev/null +++ b/src/services/server/components/ConnectingPage.tsx @@ -0,0 +1,74 @@ +import { Layout } from "./Layout"; + +type ConnectingPageProps = { + ssid: string; +}; + +export const ConnectingPage = ({ ssid }: ConnectingPageProps) => ( + +
+

โณ Connecting to WiFi...

+

+ SSID: {ssid} +

+

Testing connection... 10s remaining

+

+ Waiting to see if connection succeeds... +

+
+ + + + + + +
+); diff --git a/src/services/server/components/IndexPage.tsx b/src/services/server/components/IndexPage.tsx new file mode 100644 index 0000000..e258767 --- /dev/null +++ b/src/services/server/components/IndexPage.tsx @@ -0,0 +1,53 @@ +import { Layout } from "./Layout"; + +export const IndexPage = () => ( + +

WiFi Configuration

+
+ + + +
+ + + +
+); diff --git a/src/services/server/server.tsx b/src/services/server/server.tsx new file mode 100644 index 0000000..a14ffc0 --- /dev/null +++ b/src/services/server/server.tsx @@ -0,0 +1,98 @@ +#!/usr/bin/env bun + +import { Hono } from "hono"; +import {join} from "node:path"; +import { $ } from "bun"; +import { IndexPage } from "./components/IndexPage"; +import { LogsPage } from "./components/LogsPage"; +import { ConnectingPage } from "./components/ConnectingPage"; + +const app = new Hono(); + +// Ping endpoint for connectivity check +app.get("/ping", (c) => { + return c.json({ ok: true }); +}); + +// Serve static CSS +app.get("/pico.css", async (c) => { + const cssPath = join(import.meta.dir, "./static/pico.min.css"); + const file = Bun.file(cssPath); + return new Response(file); +}); + +// API endpoint to get available WiFi networks +app.get("/api/networks", async (c) => { + try { + const result = await $`nmcli -t -f SSID device wifi list`.text(); + const networks = result + .trim() + .split('\n') + .filter(ssid => ssid && ssid !== 'SSID') // Remove empty and header + .filter((ssid, index, self) => self.indexOf(ssid) === index); // Remove duplicates + return c.json({ networks }); + } catch (error) { + return c.json({ networks: [], error: String(error) }, 500); + } +}); + +// API endpoint to get logs (for auto-refresh) +app.get("/api/logs", async (c) => { + try { + const logs = await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text(); + return c.json({ logs: logs.trim() }); + } catch (error) { + return c.json({ logs: '', error: String(error) }, 500); + } +}); + +// Main WiFi configuration page +app.get("/", (c) => { + return c.html(); +}); + +// Service logs with auto-refresh +app.get("/logs", async (c) => { + try { + const logs = + await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text(); + return c.html(); + } catch (error) { + throw new Error(`Failed to fetch logs: ${error}`); + } +}); + +// Handle WiFi configuration submission +app.post("/save", async (c) => { + const formData = await c.req.parseBody(); + const ssid = formData.ssid as string; + const password = formData.password as string; + + // Return the connecting page immediately + const response = c.html(); + + // Trigger connection in background after a short delay (allows response to be sent) + setTimeout(async () => { + try { + await $`sudo nmcli device wifi connect ${ssid} password ${password}`; + console.log(`[WiFi] Successfully connected to ${ssid}`); + } catch (error) { + console.error(`[WiFi] Failed to connect to ${ssid}:`, error); + + // Delete the failed connection profile so ap-monitor doesn't try to use it + try { + await $`sudo nmcli connection delete ${ssid}`.nothrow(); + console.log(`[WiFi] Deleted failed connection profile for ${ssid}`); + } catch (deleteError) { + console.error(`[WiFi] Failed to delete connection profile:`, deleteError); + } + } + }, 1000); // 1 second delay + + return response; +}); + +export default { port: 80, fetch: app.fetch }; + +console.log("Server running on http://0.0.0.0:80"); +console.log("Access via WiFi or AP at http://yellow-phone.local"); diff --git a/src/services/server/static/pico.min.css b/src/services/server/static/pico.min.css new file mode 100644 index 0000000..e10ec26 --- /dev/null +++ b/src/services/server/static/pico.min.css @@ -0,0 +1,4 @@ +@charset "UTF-8";/*! + * Pico CSS โœจ v2.1.1 (https://picocss.com) + * Copyright 2019-2025 - Licensed under MIT + */:host,:root{--pico-font-family-emoji:"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--pico-font-family-sans-serif:system-ui,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,Helvetica,Arial,"Helvetica Neue",sans-serif,var(--pico-font-family-emoji);--pico-font-family-monospace:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace,var(--pico-font-family-emoji);--pico-font-family:var(--pico-font-family-sans-serif);--pico-line-height:1.5;--pico-font-weight:400;--pico-font-size:100%;--pico-text-underline-offset:0.1rem;--pico-border-radius:0.25rem;--pico-border-width:0.0625rem;--pico-outline-width:0.125rem;--pico-transition:0.2s ease-in-out;--pico-spacing:1rem;--pico-typography-spacing-vertical:1rem;--pico-block-spacing-vertical:var(--pico-spacing);--pico-block-spacing-horizontal:var(--pico-spacing);--pico-grid-column-gap:var(--pico-spacing);--pico-grid-row-gap:var(--pico-spacing);--pico-form-element-spacing-vertical:0.75rem;--pico-form-element-spacing-horizontal:1rem;--pico-group-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-primary-focus);--pico-group-box-shadow-focus-with-input:0 0 0 0.0625rem var(--pico-form-element-border-color);--pico-modal-overlay-backdrop-filter:blur(0.375rem);--pico-nav-element-spacing-vertical:1rem;--pico-nav-element-spacing-horizontal:0.5rem;--pico-nav-link-spacing-vertical:0.5rem;--pico-nav-link-spacing-horizontal:0.5rem;--pico-nav-breadcrumb-divider:">";--pico-icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--pico-icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--pico-icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--pico-icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--pico-icon-loading:url("data:image/svg+xml,%3Csvg fill='none' height='24' width='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cstyle%3E g %7B animation: rotate 2s linear infinite; transform-origin: center center; %7D circle %7B stroke-dasharray: 75,100; stroke-dashoffset: -5; animation: dash 1.5s ease-in-out infinite; stroke-linecap: round; %7D @keyframes rotate %7B 0%25 %7B transform: rotate(0deg); %7D 100%25 %7B transform: rotate(360deg); %7D %7D @keyframes dash %7B 0%25 %7B stroke-dasharray: 1,100; stroke-dashoffset: 0; %7D 50%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -17.5; %7D 100%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -62; %7D %7D %3C/style%3E%3Cg%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='rgb(136, 145, 164)' stroke-width='4' /%3E%3C/g%3E%3C/svg%3E")}@media (min-width:576px){:host,:root{--pico-font-size:106.25%}}@media (min-width:768px){:host,:root{--pico-font-size:112.5%}}@media (min-width:1024px){:host,:root{--pico-font-size:118.75%}}@media (min-width:1280px){:host,:root{--pico-font-size:125%}}@media (min-width:1536px){:host,:root{--pico-font-size:131.25%}}a{--pico-text-decoration:underline}a.contrast,a.secondary{--pico-text-decoration:underline}small{--pico-font-size:0.875em}h1,h2,h3,h4,h5,h6{--pico-font-weight:700}h1{--pico-font-size:2rem;--pico-line-height:1.125;--pico-typography-spacing-top:3rem}h2{--pico-font-size:1.75rem;--pico-line-height:1.15;--pico-typography-spacing-top:2.625rem}h3{--pico-font-size:1.5rem;--pico-line-height:1.175;--pico-typography-spacing-top:2.25rem}h4{--pico-font-size:1.25rem;--pico-line-height:1.2;--pico-typography-spacing-top:1.874rem}h5{--pico-font-size:1.125rem;--pico-line-height:1.225;--pico-typography-spacing-top:1.6875rem}h6{--pico-font-size:1rem;--pico-line-height:1.25;--pico-typography-spacing-top:1.5rem}tfoot td,tfoot th,thead td,thead th{--pico-font-weight:600;--pico-border-width:0.1875rem}code,kbd,pre,samp{--pico-font-family:var(--pico-font-family-monospace)}kbd{--pico-font-weight:bolder}:where(select,textarea),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-outline-width:0.0625rem}[type=search]{--pico-border-radius:5rem}[type=checkbox],[type=radio]{--pico-border-width:0.125rem}[type=checkbox][role=switch]{--pico-border-width:0.1875rem}details.dropdown summary:not([role=button]){--pico-outline-width:0.0625rem}nav details.dropdown summary:focus-visible{--pico-outline-width:0.125rem}[role=search]{--pico-border-radius:5rem}[role=group]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus),[role=search]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[role=group]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus),[role=search]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=submit],[role=search] button{--pico-form-element-spacing-horizontal:2rem}details summary[role=button]:not(.outline)::after{filter:brightness(0) invert(1)}[aria-busy=true]:not(input,select,textarea):is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0) invert(1)}:host(:not([data-theme=dark])),:root:not([data-theme=dark]),[data-theme=light]{color-scheme:light;--pico-background-color:#fff;--pico-color:#373c44;--pico-text-selection-color:rgba(2, 154, 232, 0.25);--pico-muted-color:#646b79;--pico-muted-border-color:rgb(231, 234, 239.5);--pico-primary:#0172ad;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 114, 173, 0.5);--pico-primary-hover:#015887;--pico-primary-hover-background:#02659a;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(2, 154, 232, 0.5);--pico-primary-inverse:#fff;--pico-secondary:#5d6b89;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(93, 107, 137, 0.5);--pico-secondary-hover:#48536b;--pico-secondary-hover-background:#48536b;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(93, 107, 137, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#181c25;--pico-contrast-background:#181c25;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(24, 28, 37, 0.5);--pico-contrast-hover:#000;--pico-contrast-hover-background:#000;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-secondary-hover);--pico-contrast-focus:rgba(93, 107, 137, 0.25);--pico-contrast-inverse:#fff;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(129, 145, 181, 0.01698),0.0335rem 0.067rem 0.402rem rgba(129, 145, 181, 0.024),0.0625rem 0.125rem 0.75rem rgba(129, 145, 181, 0.03),0.1125rem 0.225rem 1.35rem rgba(129, 145, 181, 0.036),0.2085rem 0.417rem 2.502rem rgba(129, 145, 181, 0.04302),0.5rem 1rem 6rem rgba(129, 145, 181, 0.06),0 0 0 0.0625rem rgba(129, 145, 181, 0.015);--pico-h1-color:#2d3138;--pico-h2-color:#373c44;--pico-h3-color:#424751;--pico-h4-color:#4d535e;--pico-h5-color:#5c6370;--pico-h6-color:#646b79;--pico-mark-background-color:rgb(252.5, 230.5, 191.5);--pico-mark-color:#0f1114;--pico-ins-color:rgb(28.5, 105.5, 84);--pico-del-color:rgb(136, 56.5, 53);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(243, 244.5, 246.75);--pico-code-color:#646b79;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(251, 251.5, 252.25);--pico-form-element-selected-background-color:#dfe3eb;--pico-form-element-border-color:#cfd5e2;--pico-form-element-color:#23262c;--pico-form-element-placeholder-color:var(--pico-muted-color);--pico-form-element-active-background-color:#fff;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(183.5, 105.5, 106.5);--pico-form-element-invalid-active-border-color:rgb(200.25, 79.25, 72.25);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:rgb(76, 154.5, 137.5);--pico-form-element-valid-active-border-color:rgb(39, 152.75, 118.75);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#bfc7d9;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#dfe3eb;--pico-range-active-border-color:#bfc7d9;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:var(--pico-background-color);--pico-card-border-color:var(--pico-muted-border-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(251, 251.5, 252.25);--pico-dropdown-background-color:#fff;--pico-dropdown-border-color:#eff1f4;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#eff1f4;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(232, 234, 237, 0.75);--pico-progress-background-color:#dfe3eb;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(76, 154.5, 137.5)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(200.25, 79.25, 72.25)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme=dark])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme=dark]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),[data-theme=light] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}@media only screen and (prefers-color-scheme:dark){:host(:not([data-theme])),:root:not([data-theme]){color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}:host(:not([data-theme])) details summary[role=button].contrast:not(.outline)::after,:root:not([data-theme]) details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}:host(:not([data-theme])) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before,:root:not([data-theme]) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}}[data-theme=dark]{color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}[data-theme=dark] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}[data-theme=dark] details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}[data-theme=dark] [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}[type=checkbox],[type=radio],[type=range],progress{accent-color:var(--pico-primary)}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:host),:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family);text-underline-offset:var(--pico-text-underline-offset);text-rendering:optimizeLegibility;overflow-wrap:break-word;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{width:100%;margin:0}main{display:block}body>footer,body>header,body>main{padding-block:var(--pico-block-spacing-vertical)}section{margin-bottom:var(--pico-block-spacing-vertical)}.container,.container-fluid{width:100%;margin-right:auto;margin-left:auto;padding-right:var(--pico-spacing);padding-left:var(--pico-spacing)}@media (min-width:576px){.container{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){.container{max-width:700px}}@media (min-width:1024px){.container{max-width:950px}}@media (min-width:1280px){.container{max-width:1200px}}@media (min-width:1536px){.container{max-width:1450px}}.grid{grid-column-gap:var(--pico-grid-column-gap);grid-row-gap:var(--pico-grid-row-gap);display:grid;grid-template-columns:1fr}@media (min-width:768px){.grid{grid-template-columns:repeat(auto-fit,minmax(0%,1fr))}}.grid>*{min-width:0}.overflow-auto{overflow:auto}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}address,blockquote,dl,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-style:normal;font-weight:var(--pico-font-weight)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family)}h1{--pico-color:var(--pico-h1-color)}h2{--pico-color:var(--pico-h2-color)}h3{--pico-color:var(--pico-h3-color)}h4{--pico-color:var(--pico-h4-color)}h5{--pico-color:var(--pico-h5-color)}h6{--pico-color:var(--pico-h6-color)}:where(article,address,blockquote,dl,figure,form,ol,p,pre,table,ul)~:is(h1,h2,h3,h4,h5,h6){margin-top:var(--pico-typography-spacing-top)}p{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup>*{margin-top:0;margin-bottom:0}hgroup>:not(:first-child):last-child{--pico-color:var(--pico-muted-color);--pico-font-weight:unset;font-size:1rem}:where(ol,ul) li{margin-bottom:calc(var(--pico-typography-spacing-vertical) * .25)}:where(dl,ol,ul) :where(dl,ol,ul){margin:0;margin-top:calc(var(--pico-typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--pico-mark-background-color);color:var(--pico-mark-color);vertical-align:baseline}blockquote{display:block;margin:var(--pico-typography-spacing-vertical) 0;padding:var(--pico-spacing);border-right:none;border-left:.25rem solid var(--pico-blockquote-border-color);border-inline-start:0.25rem solid var(--pico-blockquote-border-color);border-inline-end:none}blockquote footer{margin-top:calc(var(--pico-typography-spacing-vertical) * .5);color:var(--pico-blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--pico-ins-color);text-decoration:none}del{color:var(--pico-del-color)}::-moz-selection{background-color:var(--pico-text-selection-color)}::selection{background-color:var(--pico-text-selection-color)}:where(a:not([role=button])),[role=link]{--pico-color:var(--pico-primary);--pico-background-color:transparent;--pico-underline:var(--pico-primary-underline);outline:0;background-color:var(--pico-background-color);color:var(--pico-color);-webkit-text-decoration:var(--pico-text-decoration);text-decoration:var(--pico-text-decoration);text-decoration-color:var(--pico-underline);text-underline-offset:0.125em;transition:background-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition)}:where(a:not([role=button])):is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-primary-hover);--pico-underline:var(--pico-primary-hover-underline);--pico-text-decoration:underline}:where(a:not([role=button])):focus-visible,[role=link]:focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}:where(a:not([role=button])).secondary,[role=link].secondary{--pico-color:var(--pico-secondary);--pico-underline:var(--pico-secondary-underline)}:where(a:not([role=button])).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-underline:var(--pico-secondary-hover-underline)}:where(a:not([role=button])).contrast,[role=link].contrast{--pico-color:var(--pico-contrast);--pico-underline:var(--pico-contrast-underline)}:where(a:not([role=button])).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-underline:var(--pico-contrast-hover-underline)}a[role=button]{display:inline-block}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[role=button],[type=button],[type=file]::file-selector-button,[type=reset],[type=submit],button{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);--pico-color:var(--pico-primary-inverse);--pico-box-shadow:var(--pico-button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:1rem;line-height:var(--pico-line-height);text-align:center;text-decoration:none;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}[role=button]:is(:hover,:active,:focus),[role=button]:is([aria-current]:not([aria-current=false])),[type=button]:is(:hover,:active,:focus),[type=button]:is([aria-current]:not([aria-current=false])),[type=file]::file-selector-button:is(:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])),[type=reset]:is(:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false])),[type=submit]:is(:hover,:active,:focus),[type=submit]:is([aria-current]:not([aria-current=false])),button:is(:hover,:active,:focus),button:is([aria-current]:not([aria-current=false])){--pico-background-color:var(--pico-primary-hover-background);--pico-border-color:var(--pico-primary-hover-border);--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0));--pico-color:var(--pico-primary-inverse)}[role=button]:focus,[role=button]:is([aria-current]:not([aria-current=false])):focus,[type=button]:focus,[type=button]:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus,[type=submit]:focus,[type=submit]:is([aria-current]:not([aria-current=false])):focus,button:focus,button:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}[type=button],[type=reset],[type=submit]{margin-bottom:var(--pico-spacing)}:is(button,[type=submit],[type=button],[role=button]).secondary,[type=file]::file-selector-button,[type=reset]{--pico-background-color:var(--pico-secondary-background);--pico-border-color:var(--pico-secondary-border);--pico-color:var(--pico-secondary-inverse);cursor:pointer}:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border);--pico-color:var(--pico-secondary-inverse)}:is(button,[type=submit],[type=button],[role=button]).secondary:focus,:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}:is(button,[type=submit],[type=button],[role=button]).contrast{--pico-background-color:var(--pico-contrast-background);--pico-border-color:var(--pico-contrast-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-contrast-hover-background);--pico-border-color:var(--pico-contrast-hover-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:focus,:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}:is(button,[type=submit],[type=button],[role=button]).outline,[type=reset].outline{--pico-background-color:transparent;--pico-color:var(--pico-primary);--pico-border-color:var(--pico-primary)}:is(button,[type=submit],[type=button],[role=button]).outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:transparent;--pico-color:var(--pico-primary-hover);--pico-border-color:var(--pico-primary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary,[type=reset].outline{--pico-color:var(--pico-secondary);--pico-border-color:var(--pico-secondary)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-border-color:var(--pico-secondary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast{--pico-color:var(--pico-contrast);--pico-border-color:var(--pico-contrast)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-border-color:var(--pico-contrast-hover)}:where(button,[type=submit],[type=reset],[type=button],[role=button])[disabled],:where(fieldset[disabled]) :is(button,[type=submit],[type=button],[type=reset],[role=button]){opacity:.5;pointer-events:none}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--pico-spacing)/ 2) var(--pico-spacing);border-bottom:var(--pico-border-width) solid var(--pico-table-border-color);background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);text-align:left;text-align:start}tfoot td,tfoot th{border-top:var(--pico-border-width) solid var(--pico-table-border-color);border-bottom:0}table.striped tbody tr:nth-child(odd) td,table.striped tbody tr:nth-child(odd) th{background-color:var(--pico-table-row-stripped-background-color)}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}:where(iframe){border-style:none}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:host),svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-size:.875em;font-family:var(--pico-font-family)}pre code,pre samp{font-size:inherit;font-family:inherit}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre,samp{border-radius:var(--pico-border-radius);background:var(--pico-code-background-color);color:var(--pico-code-color);font-weight:var(--pico-font-weight);line-height:initial}code,kbd,samp{display:inline-block;padding:.375rem}pre{display:block;margin-bottom:var(--pico-spacing);overflow-x:auto}pre>code,pre>samp{display:block;padding:var(--pico-spacing);background:0 0;line-height:var(--pico-line-height)}kbd{background-color:var(--pico-code-kbd-background-color);color:var(--pico-code-kbd-color);vertical-align:baseline}figure{display:block;margin:0;padding:0}figure figcaption{padding:calc(var(--pico-spacing) * .5) 0;color:var(--pico-muted-color)}hr{height:0;margin:var(--pico-typography-spacing-vertical) 0;border:0;border-top:1px solid var(--pico-muted-border-color);color:inherit}[hidden],template{display:none!important}canvas{display:inline-block}input,optgroup,select,textarea{margin:0;font-size:1rem;line-height:var(--pico-line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox],[type=radio],[type=range]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2)}fieldset{width:100%;margin:0;margin-bottom:var(--pico-spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--pico-spacing) * .375);color:var(--pico-color);font-weight:var(--pico-form-label-font-weight,var(--pico-font-weight))}fieldset legend{margin-bottom:calc(var(--pico-spacing) * .5)}button[type=submit],input:not([type=checkbox],[type=radio]),select,textarea{width:100%}input:not([type=checkbox],[type=radio],[type=range],[type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal)}input,select,textarea{--pico-background-color:var(--pico-form-element-background-color);--pico-border-color:var(--pico-form-element-border-color);--pico-color:var(--pico-form-element-color);--pico-box-shadow:none;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[readonly]):is(:active,:focus){--pico-background-color:var(--pico-form-element-active-background-color)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[role=switch],[readonly]):is(:active,:focus){--pico-border-color:var(--pico-form-element-active-border-color)}:where(select,textarea):not([readonly]):focus,input:not([type=submit],[type=button],[type=reset],[type=range],[type=file],[readonly]):focus{--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}:where(fieldset[disabled]) :is(input:not([type=submit],[type=button],[type=reset]),select,textarea),input:not([type=submit],[type=button],[type=reset])[disabled],label[aria-disabled=true],select[disabled],textarea[disabled]{opacity:var(--pico-form-element-disabled-opacity);pointer-events:none}label[aria-disabled=true] input[disabled]{opacity:1}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid]{padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal)!important;padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=false]:not(select){background-image:var(--pico-icon-valid)}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=true]:not(select){background-image:var(--pico-icon-invalid)}:where(input,select,textarea)[aria-invalid=false]{--pico-border-color:var(--pico-form-element-valid-border-color)}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus){--pico-border-color:var(--pico-form-element-valid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-valid-focus-color)!important}:where(input,select,textarea)[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus){--pico-border-color:var(--pico-form-element-invalid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-invalid-focus-color)!important}[dir=rtl] :where(input,select,textarea):not([type=checkbox],[type=radio]):is([aria-invalid],[aria-invalid=true],[aria-invalid=false]){background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--pico-form-element-placeholder-color);opacity:1}input:not([type=checkbox],[type=radio]),select,textarea{margin-bottom:var(--pico-spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple],[size]){padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal);padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);background-image:var(--pico-icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}select[multiple] option:checked{background:var(--pico-form-element-selected-background-color);color:var(--pico-form-element-color)}[dir=rtl] select:not([multiple],[size]){background-position:center left .75rem}textarea{display:block;resize:vertical}textarea[aria-invalid]{--pico-icon-height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);background-position:top right .75rem!important;background-size:1rem var(--pico-icon-height)!important}:where(input,select,textarea,fieldset,.grid)+small{display:block;width:100%;margin-top:calc(var(--pico-spacing) * -.75);margin-bottom:var(--pico-spacing);color:var(--pico-muted-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=false]+small{color:var(--pico-ins-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=true]+small{color:var(--pico-del-color)}label>:where(input,select,textarea){margin-top:calc(var(--pico-spacing) * .25)}label:has([type=checkbox],[type=radio]){width:-moz-fit-content;width:fit-content;cursor:pointer}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-inline-end:.5em;border-width:var(--pico-border-width);vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-bottom:0;cursor:pointer}[type=checkbox]~label:not(:last-of-type),[type=radio]~label:not(:last-of-type){margin-inline-end:1em}[type=checkbox]:indeterminate{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--pico-background-color:var(--pico-switch-background-color);--pico-color:var(--pico-switch-color);width:2.25em;height:1.25em;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:1.25em;background-color:var(--pico-background-color);line-height:1.25em}[type=checkbox][role=switch]:not([aria-invalid]){--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:before{display:block;aspect-ratio:1;height:100%;border-radius:50%;background-color:var(--pico-color);box-shadow:var(--pico-switch-thumb-box-shadow);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:focus{--pico-background-color:var(--pico-switch-background-color);--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:checked{--pico-background-color:var(--pico-switch-checked-background-color);--pico-border-color:var(--pico-switch-checked-background-color);background-image:none}[type=checkbox][role=switch]:checked::before{margin-inline-start:calc(2.25em - 1.25em)}[type=checkbox][role=switch][disabled]{--pico-background-color:var(--pico-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus{--pico-background-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true]{--pico-background-color:var(--pico-form-element-invalid-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus,[type=radio][aria-invalid=false]:checked,[type=radio][aria-invalid=false]:checked:active,[type=radio][aria-invalid=false]:checked:focus{--pico-border-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true],[type=radio]:checked:active[aria-invalid=true],[type=radio]:checked:focus[aria-invalid=true],[type=radio]:checked[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}[type=color]::-moz-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}input:not([type=checkbox],[type=radio],[type=range],[type=file]):is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){--pico-icon-position:0.75rem;--pico-icon-width:1rem;padding-right:calc(var(--pico-icon-width) + var(--pico-icon-position));background-image:var(--pico-icon-date);background-position:center right var(--pico-icon-position);background-size:var(--pico-icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=time]{background-image:var(--pico-icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=datetime-local]::-webkit-calendar-picker-indicator,[type=month]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator,[type=week]::-webkit-calendar-picker-indicator{width:var(--pico-icon-width);margin-right:calc(var(--pico-icon-width) * -1);margin-left:var(--pico-icon-position);opacity:0}@-moz-document url-prefix(){[type=date],[type=datetime-local],[type=month],[type=time],[type=week]{padding-right:var(--pico-form-element-spacing-horizontal)!important;background-image:none!important}}[dir=rtl] :is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){text-align:right}[type=file]{--pico-color:var(--pico-muted-color);margin-left:calc(var(--pico-outline-width) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) 0;padding-left:var(--pico-outline-width);border:0;border-radius:0;background:0 0}[type=file]::file-selector-button{margin-right:calc(var(--pico-spacing)/ 2);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal)}[type=file]:is(:hover,:active,:focus)::file-selector-button{--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border)}[type=file]:focus::file-selector-button{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-webkit-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-moz-range-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-moz-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-ms-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-ms-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-moz-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-ms-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]:active,[type=range]:focus-within{--pico-range-border-color:var(--pico-range-active-border-color);--pico-range-thumb-color:var(--pico-range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem);background-image:var(--pico-icon-search);background-position:center left calc(var(--pico-form-element-spacing-horizontal) + .125rem);background-size:1rem auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem)!important;background-position:center left 1.125rem,center right .75rem}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=false]{background-image:var(--pico-icon-search),var(--pico-icon-valid)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=true]{background-image:var(--pico-icon-search),var(--pico-icon-invalid)}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{background-position:center right 1.125rem}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{background-position:center right 1.125rem,center left .75rem}details{display:block;margin-bottom:var(--pico-spacing)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--pico-transition)}details summary:not([role]){color:var(--pico-accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;margin-inline-start:calc(var(--pico-spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--pico-transition)}details summary:focus{outline:0}details summary:focus:not([role]){color:var(--pico-accordion-active-summary-color)}details summary:focus-visible:not([role]){outline:var(--pico-outline-width) solid var(--pico-primary-focus);outline-offset:calc(var(--pico-spacing,1rem) * 0.5);color:var(--pico-primary)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--pico-line-height,1.5))}details[open]>summary{margin-bottom:var(--pico-spacing)}details[open]>summary:not([role]):not(:focus){color:var(--pico-accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary{text-align:right}[dir=rtl] details summary::after{float:left;background-position:left center}article{margin-bottom:var(--pico-block-spacing-vertical);padding:var(--pico-block-spacing-vertical) var(--pico-block-spacing-horizontal);border-radius:var(--pico-border-radius);background:var(--pico-card-background-color);box-shadow:var(--pico-card-box-shadow)}article>footer,article>header{margin-right:calc(var(--pico-block-spacing-horizontal) * -1);margin-left:calc(var(--pico-block-spacing-horizontal) * -1);padding:calc(var(--pico-block-spacing-vertical) * .66) var(--pico-block-spacing-horizontal);background-color:var(--pico-card-sectioning-background-color)}article>header{margin-top:calc(var(--pico-block-spacing-vertical) * -1);margin-bottom:var(--pico-block-spacing-vertical);border-bottom:var(--pico-border-width) solid var(--pico-card-border-color);border-top-right-radius:var(--pico-border-radius);border-top-left-radius:var(--pico-border-radius)}article>footer{margin-top:var(--pico-block-spacing-vertical);margin-bottom:calc(var(--pico-block-spacing-vertical) * -1);border-top:var(--pico-border-width) solid var(--pico-card-border-color);border-bottom-right-radius:var(--pico-border-radius);border-bottom-left-radius:var(--pico-border-radius)}details.dropdown{position:relative;border-bottom:none}details.dropdown>a::after,details.dropdown>button::after,details.dropdown>summary::after{display:block;width:1rem;height:calc(1rem * var(--pico-line-height,1.5));margin-inline-start:.25rem;float:right;transform:rotate(0) translateX(.2rem);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:""}nav details.dropdown{margin-bottom:0}details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-form-element-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-form-element-background-color);color:var(--pico-form-element-placeholder-color);line-height:inherit;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}details.dropdown>summary:not([role]):active,details.dropdown>summary:not([role]):focus{border-color:var(--pico-form-element-active-border-color);background-color:var(--pico-form-element-active-background-color)}details.dropdown>summary:not([role]):focus{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}details.dropdown>summary:not([role]):focus-visible{outline:0}details.dropdown>summary:not([role])[aria-invalid=false]{--pico-form-element-border-color:var(--pico-form-element-valid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-valid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-valid-focus-color)}details.dropdown>summary:not([role])[aria-invalid=true]{--pico-form-element-border-color:var(--pico-form-element-invalid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-invalid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-invalid-focus-color)}nav details.dropdown{display:inline;margin:calc(var(--pico-nav-element-spacing-vertical) * -1) 0}nav details.dropdown>summary::after{transform:rotate(0) translateX(0)}nav details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-nav-link-spacing-vertical) * 2);padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav details.dropdown>summary:not([role]):focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}details.dropdown>summary+ul{display:flex;z-index:99;position:absolute;left:0;flex-direction:column;width:100%;min-width:-moz-fit-content;min-width:fit-content;margin:0;margin-top:var(--pico-outline-width);padding:0;border:var(--pico-border-width) solid var(--pico-dropdown-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-dropdown-background-color);box-shadow:var(--pico-dropdown-box-shadow);color:var(--pico-dropdown-color);white-space:nowrap;opacity:0;transition:opacity var(--pico-transition),transform 0s ease-in-out 1s}details.dropdown>summary+ul[dir=rtl]{right:0;left:auto}details.dropdown>summary+ul li{width:100%;margin-bottom:0;padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);list-style:none}details.dropdown>summary+ul li:first-of-type{margin-top:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li:last-of-type{margin-bottom:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li a{display:block;margin:calc(var(--pico-form-element-spacing-vertical) * -.5) calc(var(--pico-form-element-spacing-horizontal) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);overflow:hidden;border-radius:0;color:var(--pico-dropdown-color);text-decoration:none;text-overflow:ellipsis}details.dropdown>summary+ul li a:active,details.dropdown>summary+ul li a:focus,details.dropdown>summary+ul li a:focus-visible,details.dropdown>summary+ul li a:hover,details.dropdown>summary+ul li a[aria-current]:not([aria-current=false]){background-color:var(--pico-dropdown-hover-background-color)}details.dropdown>summary+ul li label{width:100%}details.dropdown>summary+ul li:has(label):hover{background-color:var(--pico-dropdown-hover-background-color)}details.dropdown[open]>summary{margin-bottom:0}details.dropdown[open]>summary+ul{transform:scaleY(1);opacity:1;transition:opacity var(--pico-transition),transform 0s ease-in-out 0s}details.dropdown[open]>summary::before{display:block;z-index:1;position:fixed;width:100vw;height:100vh;inset:0;background:0 0;content:"";cursor:default}label>details.dropdown{margin-top:calc(var(--pico-spacing) * .25)}[role=group],[role=search]{display:inline-flex;position:relative;width:100%;margin-bottom:var(--pico-spacing);border-radius:var(--pico-border-radius);box-shadow:var(--pico-group-box-shadow,0 0 0 transparent);vertical-align:middle;transition:box-shadow var(--pico-transition)}[role=group] input:not([type=checkbox],[type=radio]),[role=group] select,[role=group]>*,[role=search] input:not([type=checkbox],[type=radio]),[role=search] select,[role=search]>*{position:relative;flex:1 1 auto;margin-bottom:0}[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=group]>:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child),[role=search]>:not(:first-child){margin-left:0;border-top-left-radius:0;border-bottom-left-radius:0}[role=group] input:not([type=checkbox],[type=radio]):not(:last-child),[role=group] select:not(:last-child),[role=group]>:not(:last-child),[role=search] input:not([type=checkbox],[type=radio]):not(:last-child),[role=search] select:not(:last-child),[role=search]>:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}[role=group] input:not([type=checkbox],[type=radio]):focus,[role=group] select:focus,[role=group]>:focus,[role=search] input:not([type=checkbox],[type=radio]):focus,[role=search] select:focus,[role=search]>:focus{z-index:2}[role=group] [role=button]:not(:first-child),[role=group] [type=button]:not(:first-child),[role=group] [type=reset]:not(:first-child),[role=group] [type=submit]:not(:first-child),[role=group] button:not(:first-child),[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=search] [role=button]:not(:first-child),[role=search] [type=button]:not(:first-child),[role=search] [type=reset]:not(:first-child),[role=search] [type=submit]:not(:first-child),[role=search] button:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child){margin-left:calc(var(--pico-border-width) * -1)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=reset],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=reset],[role=search] [type=submit],[role=search] button{width:auto}@supports selector(:has(*)){[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-button)}[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select,[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select{border-color:transparent}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus),[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-input)}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) button,[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) button{--pico-button-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-border);--pico-button-hover-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-hover-border)}[role=group] [role=button]:focus,[role=group] [type=button]:focus,[role=group] [type=reset]:focus,[role=group] [type=submit]:focus,[role=group] button:focus,[role=search] [role=button]:focus,[role=search] [type=button]:focus,[role=search] [type=reset]:focus,[role=search] [type=submit]:focus,[role=search] button:focus{box-shadow:none}}[role=search]>:first-child{border-top-left-radius:5rem;border-bottom-left-radius:5rem}[role=search]>:last-child{border-top-right-radius:5rem;border-bottom-right-radius:5rem}[aria-busy=true]:not(input,select,textarea,html,form){white-space:nowrap}[aria-busy=true]:not(input,select,textarea,html,form)::before{display:inline-block;width:1em;height:1em;background-image:var(--pico-icon-loading);background-size:1em auto;background-repeat:no-repeat;content:"";vertical-align:-.125em}[aria-busy=true]:not(input,select,textarea,html,form):not(:empty)::before{margin-inline-end:calc(var(--pico-spacing) * .5)}[aria-busy=true]:not(input,select,textarea,html,form):empty{text-align:center}[role=button][aria-busy=true],[type=button][aria-busy=true],[type=reset][aria-busy=true],[type=submit][aria-busy=true],a[aria-busy=true],button[aria-busy=true]{pointer-events:none}:host,:root{--pico-scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:0;border:0;-webkit-backdrop-filter:var(--pico-modal-overlay-backdrop-filter);backdrop-filter:var(--pico-modal-overlay-backdrop-filter);background-color:var(--pico-modal-overlay-background-color);color:var(--pico-color)}dialog>article{width:100%;max-height:calc(100vh - var(--pico-spacing) * 2);margin:var(--pico-spacing);overflow:auto}@media (min-width:576px){dialog>article{max-width:510px}}@media (min-width:768px){dialog>article{max-width:700px}}dialog>article>header>*{margin-bottom:0}dialog>article>header .close,dialog>article>header :is(a,button)[rel=prev]{margin:0;margin-left:var(--pico-spacing);padding:0;float:right}dialog>article>footer{text-align:right}dialog>article>footer [role=button],dialog>article>footer button{margin-bottom:0}dialog>article>footer [role=button]:not(:first-of-type),dialog>article>footer button:not(:first-of-type){margin-left:calc(var(--pico-spacing) * .5)}dialog>article .close,dialog>article :is(a,button)[rel=prev]{display:block;width:1rem;height:1rem;margin-top:calc(var(--pico-spacing) * -1);margin-bottom:var(--pico-spacing);margin-left:auto;border:none;background-image:var(--pico-icon-close);background-position:center;background-size:auto 1rem;background-repeat:no-repeat;background-color:transparent;opacity:.5;transition:opacity var(--pico-transition)}dialog>article .close:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),dialog>article :is(a,button)[rel=prev]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){opacity:1}dialog:not([open]),dialog[open=false]{display:none}.modal-is-open{padding-right:var(--pico-scrollbar-width,0);overflow:hidden;pointer-events:none;touch-action:none}.modal-is-open dialog{pointer-events:auto;touch-action:auto}:where(.modal-is-opening,.modal-is-closing) dialog,:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-duration:.2s;animation-timing-function:ease-in-out;animation-fill-mode:both}:where(.modal-is-opening,.modal-is-closing) dialog{animation-duration:.8s;animation-name:modal-overlay}:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-delay:.2s;animation-name:modal}.modal-is-closing dialog,.modal-is-closing dialog>article{animation-delay:0s;animation-direction:reverse}@keyframes modal-overlay{from{-webkit-backdrop-filter:none;backdrop-filter:none;background-color:transparent}}@keyframes modal{from{transform:translateY(-100%);opacity:0}}:where(nav li)::before{float:left;content:"โ€‹"}nav,nav ul{display:flex}nav{justify-content:space-between;overflow:visible}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--pico-nav-element-spacing-vertical) var(--pico-nav-element-spacing-horizontal)}nav li :where(a,[role=link]){display:inline-block;margin:calc(var(--pico-nav-link-spacing-vertical) * -1) calc(var(--pico-nav-link-spacing-horizontal) * -1);padding:var(--pico-nav-link-spacing-vertical) var(--pico-nav-link-spacing-horizontal);border-radius:var(--pico-border-radius)}nav li :where(a,[role=link]):not(:hover){text-decoration:none}nav li [role=button],nav li [type=button],nav li button,nav li input:not([type=checkbox],[type=radio],[type=range],[type=file]),nav li select{height:auto;margin-right:inherit;margin-bottom:0;margin-left:inherit;padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb]{align-items:center;justify-content:start}nav[aria-label=breadcrumb] ul li:not(:first-child){margin-inline-start:var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb] ul li a{margin:calc(var(--pico-nav-link-spacing-vertical) * -1) 0;margin-inline-start:calc(var(--pico-nav-link-spacing-horizontal) * -1)}nav[aria-label=breadcrumb] ul li:not(:last-child)::after{display:inline-block;position:absolute;width:calc(var(--pico-nav-link-spacing-horizontal) * 4);margin:0 calc(var(--pico-nav-link-spacing-horizontal) * -1);content:var(--pico-nav-breadcrumb-divider);color:var(--pico-muted-color);text-align:center;text-decoration:none;white-space:nowrap}nav[aria-label=breadcrumb] a[aria-current]:not([aria-current=false]){background-color:transparent;color:inherit;text-decoration:none;pointer-events:none}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--pico-nav-element-spacing-vertical) * .5) var(--pico-nav-element-spacing-horizontal)}aside li a{display:block}aside li [role=button]{margin:inherit}[dir=rtl] nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{content:"\\"}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;display:inline-block;appearance:none;width:100%;height:.5rem;margin-bottom:calc(var(--pico-spacing) * .5);overflow:hidden;border:0;border-radius:var(--pico-border-radius);background-color:var(--pico-progress-background-color);color:var(--pico-progress-color)}progress::-webkit-progress-bar{border-radius:var(--pico-border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--pico-progress-color);-webkit-transition:inline-size var(--pico-transition);transition:inline-size var(--pico-transition)}progress::-moz-progress-bar{background-color:var(--pico-progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--pico-progress-background-color) linear-gradient(to right,var(--pico-progress-color) 30%,var(--pico-progress-background-color) 30%) top left/150% 150% no-repeat;animation:progress-indeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@media (prefers-reduced-motion:no-preference){[dir=rtl] progress:indeterminate{animation-direction:reverse}}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}[data-tooltip]{position:relative}[data-tooltip]:not(a,button,input,[role=button]){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before,[data-tooltip][data-placement=top]::after,[data-tooltip][data-placement=top]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--pico-border-radius);background:var(--pico-tooltip-background-color);content:attr(data-tooltip);color:var(--pico-tooltip-color);font-style:normal;font-weight:var(--pico-font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after,[data-tooltip][data-placement=top]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--pico-tooltip-background-color)}[data-tooltip][data-placement=bottom]::after,[data-tooltip][data-placement=bottom]::before{top:100%;bottom:auto;transform:translate(-50%,.25rem)}[data-tooltip][data-placement=bottom]:after{transform:translate(-50%,-.3rem);border:.3rem solid transparent;border-bottom:.3rem solid}[data-tooltip][data-placement=left]::after,[data-tooltip][data-placement=left]::before{top:50%;right:100%;bottom:auto;left:auto;transform:translate(-.25rem,-50%)}[data-tooltip][data-placement=left]:after{transform:translate(.3rem,-50%);border:.3rem solid transparent;border-left:.3rem solid}[data-tooltip][data-placement=right]::after,[data-tooltip][data-placement=right]::before{top:50%;right:auto;bottom:auto;left:100%;transform:translate(.25rem,-50%)}[data-tooltip][data-placement=right]:after{transform:translate(-.3rem,-50%);border:.3rem solid transparent;border-right:.3rem solid}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{--pico-tooltip-slide-to:translate(-50%, -0.25rem);transform:translate(-50%,.75rem);animation-duration:.2s;animation-fill-mode:forwards;animation-name:tooltip-slide;opacity:0}[data-tooltip]:focus::after,[data-tooltip]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, 0rem);transform:translate(-50%,-.25rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover::after,[data-tooltip][data-placement=bottom]:hover::before{--pico-tooltip-slide-to:translate(-50%, 0.25rem);transform:translate(-50%,-.75rem);animation-name:tooltip-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, -0.3rem);transform:translate(-50%,-.5rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:focus::before,[data-tooltip][data-placement=left]:hover::after,[data-tooltip][data-placement=left]:hover::before{--pico-tooltip-slide-to:translate(-0.25rem, -50%);transform:translate(.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:hover::after{--pico-tooltip-caret-slide-to:translate(0.3rem, -50%);transform:translate(.05rem,-50%);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:focus::before,[data-tooltip][data-placement=right]:hover::after,[data-tooltip][data-placement=right]:hover::before{--pico-tooltip-slide-to:translate(0.25rem, -50%);transform:translate(-.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:hover::after{--pico-tooltip-caret-slide-to:translate(-0.3rem, -50%);transform:translate(-.05rem,-50%);animation-name:tooltip-caret-slide}}@keyframes tooltip-slide{to{transform:var(--pico-tooltip-slide-to);opacity:1}}@keyframes tooltip-caret-slide{50%{opacity:0}to{transform:var(--pico-tooltip-caret-slide-to);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;animation-duration:1ms!important;animation-delay:-1ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}} \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..0d0fcd6 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,11 @@ +export const ensure = (value: T, message: string): T => { + if (value === undefined || value === null) { + throw new Error(message) + } + + return value +} + +export const random = (arr: ReadonlyArray): T => { + return arr[Math.floor(Math.random() * arr.length)]! +} diff --git a/src/utils/signal.ts b/src/utils/signal.ts new file mode 100644 index 0000000..c10609c --- /dev/null +++ b/src/utils/signal.ts @@ -0,0 +1,57 @@ +/** + * How to use a Signal: + * + * Create a signal: + * const chatSignal = new Signal<{ username: string, message: string }>() + * + * Connect to the signal: + * const disconnect = chatSignal.connect((data) => { + * const {username, message} = data; + * console.log(`${username} said "${message}"`); + * }) + * + * Emit a signal: + * chatSignal.emit({ username: "Chad", message: "Hey everyone, how's it going?" }); + * + * Forward a signal: + * const relaySignal = new Signal<{ username: string, message: string }>() + * const disconnectRelay = chatSignal.connect(relaySignal) + * // Now, when chatSignal emits, relaySignal will also emit the same data + * + * Disconnect a single listener: + * disconnect(); // The disconnect function is returned when you connect to a signal + * + * Disconnect all listeners: + * chatSignal.disconnect() + */ + +export class Signal { + private listeners: Array<(data: T) => void> = [] + + connect(listenerOrSignal: Signal | ((data: T) => void)) { + let listener: (data: T) => void + + // If it is a signal, forward the data to the signal + if (listenerOrSignal instanceof Signal) { + listener = (data: T) => listenerOrSignal.emit(data) + } else { + listener = listenerOrSignal + } + + this.listeners.push(listener) + + return () => { + this.listeners = this.listeners.filter((l) => l !== listener) + } + } + + emit(data: T) { + for (const listener of this.listeners) { + listener(data) + } + } + + disconnect() { + this.listeners = [] + } +} diff --git a/src/utils/waiting-sounds.ts b/src/utils/waiting-sounds.ts new file mode 100644 index 0000000..9efd459 --- /dev/null +++ b/src/utils/waiting-sounds.ts @@ -0,0 +1,86 @@ +import Buzz, { type Player } from "../buzz/index.ts" +import { join } from "path" +import type { Playback } from "../buzz/utils.ts" +import { random } from "./index.ts" + +export class WaitingSounds { + currentPlayback?: Playback + + constructor(private player: Player) {} + + async start() { + if (this.currentPlayback) return // Already playing + + // Now play randomly play things + const playedSounds = new Set() + let lastSoundDir: SoundDir | undefined + do { + const dir = this.getRandomSoundDir(lastSoundDir) + const soundPath = getSound(dir, Array.from(playedSounds)) + playedSounds.add(soundPath) + lastSoundDir = dir + console.log(`๐ŸŒญ playing ${soundPath}`) + + const playback = await this.player.play(soundPath) + this.currentPlayback = playback + await playback.finished() + } while (this.currentPlayback) + } + + async stop() { + if (!this.currentPlayback) return + await this.currentPlayback.finished() + this.currentPlayback = undefined + } + + getRandomSoundDir(lastSoundDir?: SoundDir): SoundDir { + if (lastSoundDir === "body-noises") { + return "apology" + } + + const skipSpecialSounds = + (lastSoundDir !== "typing" && lastSoundDir !== "clicking") || !lastSoundDir + + const value = Math.random() * 100 + console.log(`๐ŸŽฒ got ${value}`) + if (value > 95 && !skipSpecialSounds) { + return "body-noises" + } else if (value > 66 && !skipSpecialSounds) { + return "stalling" + } else if (value > 33) { + return "clicking" + } else { + return "typing" + } + } +} + +type SoundDir = (typeof soundDirs)[number] +const soundDirs = [ + "apology", + "background", + "body-noises", + "clicking", + "greeting", + "stalling", + "typing", +] as const +export const soundDir = join(import.meta.dir, "../../", "sounds") + +export const getSound = (dir: SoundDir, exclude: string[] = []): string => { + const glob = new Bun.Glob("*.wav") + const soundPaths = Array.from(glob.scanSync({ cwd: join(soundDir, dir), absolute: true })) + + const filteredSoundPaths = soundPaths.filter((path) => !exclude.includes(path)) + if (filteredSoundPaths.length === 0) { + return random(soundPaths) + } + + return random(filteredSoundPaths) +} + +const player = await Buzz.defaultPlayer() +Buzz.setVolume(0.2) +player.play(getSound("background"), { repeat: true }) +const x = new WaitingSounds(player) +x.start() diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..545396c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}