NobGit
public imalexnord read

Slot

Calendar-first scheduling powered by Cloudflare Workers and Google Calendar.

Languages

Repository composition by tracked source files.

TypeScript
TypeScript 79% CSS 19% SQL 2% HTML 0%
Create file Wiki Documentation
Clone
https://nobgit.com/user/imalexnord/slot.git
ssh://[email protected]:2222/user/imalexnord/slot.git

Commit

Prepare Slot booking worker

45c38c3
Alex Nord <[email protected]> 3 days ago
.dev.vars.example           |    6 +
 .gitignore                  |    8 +
 ARCHITECTURE.md             |   42 +
 MIGRATING_FROM_CLOUDMEET.md |   55 +
 README.md                   |  135 +++
 index.html                  |   21 +
 migrations/0001_init.sql    |   53 +
 package-lock.json           | 2513 +++++++++++++++++++++++++++++++++++++++++++
 package.json                |   34 +
 scripts/setup.mjs           |  265 +++++
 src/App.tsx                 |  778 ++++++++++++++
 src/main.tsx                |   10 +
 src/styles.css              |  865 +++++++++++++++
 src/vite-env.d.ts           |    1 +
 tsconfig.json               |   21 +
 vite.config.ts              |    7 +
 worker/crypto.ts            |  104 ++
 worker/google.ts            |  275 +++++
 worker/index.ts             |  470 ++++++++
 worker/settings.ts          |   62 ++
 worker/time.ts              |  102 ++
 worker/types.ts             |   53 +
 wrangler.jsonc              |   18 +
 23 files changed, 5898 insertions(+)
 create mode 100644 .dev.vars.example
 create mode 100644 .gitignore
 create mode 100644 ARCHITECTURE.md
 create mode 100644 MIGRATING_FROM_CLOUDMEET.md
 create mode 100644 README.md
 create mode 100644 index.html
 create mode 100644 migrations/0001_init.sql
 create mode 100644 package-lock.json
 create mode 100644 package.json
 create mode 100644 scripts/setup.mjs
 create mode 100644 src/App.tsx
 create mode 100644 src/main.tsx
 create mode 100644 src/styles.css
 create mode 100644 src/vite-env.d.ts
 create mode 100644 tsconfig.json
 create mode 100644 vite.config.ts
 create mode 100644 worker/crypto.ts
 create mode 100644 worker/google.ts
 create mode 100644 worker/index.ts
 create mode 100644 worker/settings.ts
 create mode 100644 worker/time.ts
 create mode 100644 worker/types.ts
 create mode 100644 wrangler.jsonc

Diff

diff --git a/.dev.vars.example b/.dev.vars.example
new file mode 100644
index 0000000..f5f880b
--- /dev/null
+++ b/.dev.vars.example
@@ -0,0 +1,6 @@
+APP_URL=http://localhost:5173
[email protected]
+GOOGLE_CLIENT_ID=your-client.apps.googleusercontent.com
+GOOGLE_CLIENT_SECRET=your-google-client-secret
+SESSION_SECRET=generate-a-long-random-secret
+TOKEN_ENCRYPTION_KEY=base64-encoded-32-byte-key
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..96ae833
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules
+dist
+.wrangler
+.dev.vars
+.dev.vars.*
+!.dev.vars.example
+*.local
+.DS_Store
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
new file mode 100644
index 0000000..6c03dac
--- /dev/null
+++ b/ARCHITECTURE.md
@@ -0,0 +1,42 @@
+# Architecture
+
+```text
+Visitor
+  │
+  ▼
+meet.anord.cc
+  │
+  ├── static React UI ───────────────┐
+  │                                  │ one Worker deployment
+  └── /api/* Cloudflare Worker ──────┘
+             │
+             ├── D1
+             │    settings
+             │    bookings
+             │    OAuth token (AES-GCM encrypted)
+             │    OAuth state
+             │    short-lived slot locks
+             │    basic rate limits
+             │
+             └── Google Calendar API
+                  FreeBusy
+                  Event creation
+                  Google Meet creation
+                  attendee invitation
+```
+
+## Booking transaction
+
+1. Calculate candidate times inside weekly availability.
+2. Subtract Google FreeBusy ranges and recently-created D1 bookings.
+3. Visitor chooses a start and duration.
+4. Server recomputes availability rather than trusting the browser.
+5. D1 transaction reserves each time segment touched by the booking and buffers.
+6. Google FreeBusy is checked once more.
+7. Google Calendar event is created, with a unique Meet conference when selected.
+8. Booking is marked confirmed.
+9. D1 safety locks expire after ten minutes, after which Google Calendar is again the source of truth. This means manually moving/deleting the event in Google eventually frees the original time without maintaining a second calendar system.
+
+## Why D1 exists
+
+Google Calendar owns calendar truth. D1 only holds application state Google does not provide: UI settings, booking metadata, encrypted OAuth credentials, and concurrency locks.
diff --git a/MIGRATING_FROM_CLOUDMEET.md b/MIGRATING_FROM_CLOUDMEET.md
new file mode 100644
index 0000000..7a8d8d1
--- /dev/null
+++ b/MIGRATING_FROM_CLOUDMEET.md
@@ -0,0 +1,55 @@
+# Replacing the old CloudMeet deployment
+
+The new app is a separate Cloudflare **Worker**, not a Pages project. You can keep CloudMeet online while you prepare the Worker.
+
+## 1. Deploy Meet
+
+From this project:
+
+```bat
+npm install
+npm run setup
+```
+
+Use `https://meet.anord.cc` as the public URL when prompted.
+
+The setup deploys a temporary `*.workers.dev` endpoint too. The application treats `meet.anord.cc` as its canonical OAuth URL because that is the `APP_URL` secret.
+
+## 2. Change the Google OAuth callback
+
+The new callback is:
+
+```text
+https://meet.anord.cc/auth/google/callback
+```
+
+CloudMeet used `/auth/callback`. They are not the same route.
+
+This app uses a server-side OAuth flow, so the redirect URI is the important Google client setting. You can leave the JavaScript origin configured, but the app does not perform Google OAuth from browser JavaScript.
+
+## 3. Move the domain
+
+When the Worker is ready:
+
+1. Cloudflare → Workers & Pages → old `cloudmeet` Pages project → Custom domains.
+2. Remove `meet.anord.cc` from that Pages project.
+3. Cloudflare → Workers & Pages → `meet-anord` Worker → Settings → Domains & Routes.
+4. Add `meet.anord.cc` as a custom domain.
+
+Do the same for `meet.nobgit.com` only if you still want that alias. Keep `APP_URL=https://meet.anord.cc` so OAuth has one canonical callback.
+
+## 4. Connect Google
+
+Open:
+
+```text
+https://meet.anord.cc/admin
+```
+
+Sign in with the configured admin Google account and grant Calendar access.
+
+If your Google OAuth audience is still in Testing, the admin account must be a test user. Visitors booking meetings never see Google OAuth at all.
+
+## 5. Delete CloudMeet later
+
+Once availability, booking, invitations, and Meet links work on the new Worker, the old Pages project, D1/KV resources, and CloudMeet OAuth client can be removed separately. Do not delete shared Google Cloud projects or OAuth clients used by other applications.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..016fa6e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,135 @@
+# Meet
+
+A small calendar-first booking app built as one Cloudflare Worker deployment.
+
+The visitor flow is intentionally simple:
+
+**date → time → duration → details → booked**
+
+There are no public accounts and no Google login for visitors. Google OAuth is private to the calendar owner.
+
+## Architecture
+
+- React + Vite frontend
+- Cloudflare Worker API
+- Workers Static Assets, deployed with the Worker as one unit
+- Cloudflare D1 for settings, booking records, OAuth state, and short-lived slot locks
+- Google Calendar FreeBusy for live availability
+- Google Calendar Events API for invites
+- Google Meet generated on the event when enabled
+
+No GitHub Actions, Pages Functions, KV, Microsoft OAuth, external email service, or separately hosted backend.
+
+## What v0.1 does
+
+- Calendar-first booking page
+- Reads live busy periods from one or more Google calendars
+- Configurable weekly hours
+- Configurable 15/30/45/60/90/120 minute durations
+- Minimum notice, booking window, before/after buffers
+- Google Meet, phone, in-person, or decide-later meeting modes
+- Rechecks Google immediately before booking
+- Small invisible per-IP booking-attempt limit plus a form honeypot for basic abuse resistance
+- Uses transactional D1 slot locks to reduce double-booking races
+- Creates the Google Calendar event and emails the attendee through Google Calendar
+- Private `/admin` UI authenticated through the configured Google account
+- Stores the Google refresh token encrypted at rest in D1 using an AES-GCM key kept as a Worker secret
+
+## First setup
+
+Requirements:
+
+- Node.js
+- a Cloudflare account
+- a Google Cloud project with **Google Calendar API enabled**
+- a Google OAuth **Web application** client
+
+Install:
+
+```bash
+npm install
+npm run setup
+```
+
+`npm run setup` will:
+
+1. use your normal interactive Wrangler login
+2. build and deploy the Worker
+3. let Wrangler automatically provision the D1 binding
+4. apply the database migration
+5. ask for the public URL, admin Google account, Google Client ID and Client Secret
+6. generate cryptographic application secrets locally
+7. upload the secrets to the Worker
+
+For the default URL, configure the Google OAuth client with:
+
+```text
+Authorized JavaScript origin:
+https://meet.anord.cc
+
+Authorized redirect URI:
+https://meet.anord.cc/auth/google/callback
+```
+
+If `meet.anord.cc` is still attached to the old CloudMeet Pages project, remove that custom-domain attachment first. Then attach `meet.anord.cc` to the new `meet-anord` Worker in Cloudflare and open:
+
+```text
+https://meet.anord.cc/admin
+```
+
+Sign in with the email entered during setup. The first authorization requests offline Calendar access so the Worker can check availability and create bookings while you are not present.
+
+### Google project in Testing mode
+
+If the Google OAuth audience is **External / Testing**, add the admin account as a test user. Google may expire testing-mode authorizations, so move the OAuth app to the appropriate production state when you are satisfied with it.
+
+## Later deploys
+
+```bash
+npm run deploy
+```
+
+Apply new D1 migrations when a release adds them:
+
+```bash
+npm run db:migrate:remote
+```
+
+## Local development
+
+Copy `.dev.vars.example` to `.dev.vars` and fill in development credentials.
+
+```bash
+npm run db:migrate:local
+npm run dev
+```
+
+For local Google OAuth, add the localhost callback you actually use to the OAuth client.
+
+## Important implementation details
+
+### Availability
+
+Weekly availability is the outer boundary. Google Calendar is the source of truth for what is actually busy inside that boundary.
+
+### Booking race protection
+
+Before creating a Google event, the Worker reserves all small time segments touched by the booking (including configured buffers) in D1. Those inserts are submitted as one D1 batch. A uniqueness collision causes the reservation to fail instead of allowing two concurrent bookings to win the same time.
+
+The Worker then asks Google FreeBusy one final time before creating the event.
+
+### Google tokens
+
+The Google refresh token is encrypted with AES-GCM before it is persisted in D1. `TOKEN_ENCRYPTION_KEY` stays in Cloudflare Worker secrets and is not stored in the database or source tree.
+
+## Current limits
+
+This is a focused first version, not a Cal.com clone.
+
+- One owner/admin account
+- No self-service cancel/reschedule link yet
+- No recurring availability exceptions UI yet (block time in Google Calendar instead)
+- Admin currently accepts calendar IDs as text rather than fetching a calendar picker
+- No custom reminder emails; Google Calendar sends the invitation
+
+Those are deliberate cuts to keep deployment and operation small.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..227aade
--- /dev/null
+++ b/index.html
@@ -0,0 +1,21 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta name="theme-color" content="#0b0c0f" />
+    <meta name="description" content="Book a meeting." />
+    <title>Meet</title>
+    <script>
+      try {
+        document.documentElement.dataset.theme = localStorage.getItem('meet-theme') || 'true-black';
+      } catch {
+        document.documentElement.dataset.theme = 'true-black';
+      }
+    </script>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>
diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql
new file mode 100644
index 0000000..fce89d5
--- /dev/null
+++ b/migrations/0001_init.sql
@@ -0,0 +1,53 @@
+CREATE TABLE IF NOT EXISTS settings (
+  key TEXT PRIMARY KEY,
+  value TEXT NOT NULL,
+  updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS oauth_states (
+  state TEXT PRIMARY KEY,
+  expires_at INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS oauth_tokens (
+  provider TEXT PRIMARY KEY,
+  refresh_token_enc TEXT NOT NULL,
+  account_email TEXT NOT NULL,
+  updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS bookings (
+  id TEXT PRIMARY KEY,
+  name TEXT NOT NULL,
+  email TEXT NOT NULL,
+  phone TEXT,
+  message TEXT,
+  start_time TEXT NOT NULL,
+  end_time TEXT NOT NULL,
+  duration_minutes INTEGER NOT NULL,
+  timezone TEXT NOT NULL,
+  meeting_mode TEXT NOT NULL,
+  google_event_id TEXT,
+  meet_url TEXT,
+  status TEXT NOT NULL DEFAULT 'pending',
+  created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_bookings_start ON bookings(start_time);
+CREATE INDEX IF NOT EXISTS idx_bookings_status ON bookings(status);
+
+CREATE TABLE IF NOT EXISTS slot_locks (
+  slot_key TEXT PRIMARY KEY,
+  booking_id TEXT NOT NULL,
+  expires_at INTEGER NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_slot_locks_expiry ON slot_locks(expires_at);
+
+CREATE TABLE IF NOT EXISTS rate_limits (
+  key TEXT PRIMARY KEY,
+  count INTEGER NOT NULL DEFAULT 1,
+  reset_at INTEGER NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_rate_limits_reset ON rate_limits(reset_at);
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..5f21789
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2513 @@
+{
+  "name": "meet-worker",
+  "version": "0.1.1",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "meet-worker",
+      "version": "0.1.1",
+      "dependencies": {
+        "lucide-react": "1.26.0",
+        "react": "19.2.8",
+        "react-dom": "19.2.8"
+      },
+      "devDependencies": {
+        "@cloudflare/vite-plugin": "^1.47.0",
+        "@cloudflare/workers-types": "5.20260724.1",
+        "@types/react": "19.2.17",
+        "@types/react-dom": "19.2.3",
+        "@vitejs/plugin-react": "6.0.3",
+        "typescript": "6.0.3",
+        "vite": "8.1.5",
+        "wrangler": "4.114.0"
+      },
+      "engines": {
+        "node": ">=22.12.0"
+      }
+    },
+    "node_modules/@cloudflare/kv-asset-handler": {
+      "version": "0.5.0",
+      "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz",
+      "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==",
+      "dev": true,
+      "license": "MIT OR Apache-2.0",
+      "engines": {
+        "node": ">=22.0.0"
+      }
+    },
+    "node_modules/@cloudflare/unenv-preset": {
+      "version": "2.16.1",
+      "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz",
+      "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==",
+      "dev": true,
+      "license": "MIT OR Apache-2.0",
+      "peerDependencies": {
+        "unenv": "2.0.0-rc.24",
+        "workerd": ">1.20260305.0 <2.0.0-0"
+      },
+      "peerDependenciesMeta": {
+        "workerd": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@cloudflare/vite-plugin": {
+      "version": "1.47.0",
+      "resolved": "https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.47.0.tgz",
+      "integrity": "sha512-WyGNYtJ2nzcJXtlwCHTO5S5+flFaYhfH5WfFfx6IpGf2Y/oNwyizEW9mNSImpg65PWmDs1715Pk/vGSKKtYJ1w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@cloudflare/unenv-preset": "2.16.1",
+        "miniflare": "4.20260722.0",
+        "unenv": "2.0.0-rc.24",
+        "workerd": "1.20260722.1",
+        "wrangler": "4.114.0",
+        "ws": "8.21.0"
+      },
+      "bin": {
+        "cf-vite": "bin/cf-vite"
+      },
+      "peerDependencies": {
+        "vite": "^6.1.0 || ^7.0.0 || ^8.0.0",
+        "wrangler": "^4.114.0"
+      }
+    },
+    "node_modules/@cloudflare/workerd-darwin-64": {
+      "version": "1.20260722.1",
+      "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260722.1.tgz",
+      "integrity": "sha512-vZOP8vIS3NwnuaO+gz0FZ7kIGeiO3bZmxV35Ph9zOXKSREhDFlH7wQ7mkCdhW3O4jnXsew+XT7b+DNEI2CcJGQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/@cloudflare/workerd-darwin-arm64": {
+      "version": "1.20260722.1",
+      "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260722.1.tgz",
+      "integrity": "sha512-EmIQymihDq6WNdER4+LF8Qn80yqayBUpJ+tkOO7wmY8pmgfyXjIUFNXotl21AHovTeu2seR7HdVUgeN/BilCWw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/@cloudflare/workerd-linux-64": {
+      "version": "1.20260722.1",
+      "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260722.1.tgz",
+      "integrity": "sha512-jvZ3k9fxcnEn04s80CgIYxQfpOyAiz/8qC42DP8EBa9tR27qWyg9wmm31zIobVlrgBZn/+8NfdP73avRGcQOjQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/@cloudflare/workerd-linux-arm64": {
+      "version": "1.20260722.1",
+      "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260722.1.tgz",
+      "integrity": "sha512-BOSB55SMNdy+DA5uj2WirgiNanpHGis5PVvXH1wSfvjRKr4JGgWK+EZzxz0RFUo6QjjQQC/NimEzNZ7va7jmKg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/@cloudflare/workerd-windows-64": {
+      "version": "1.20260722.1",
+      "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260722.1.tgz",
+      "integrity": "sha512-sYM8YgUpKnRz2xjvdJLX1Ojzoi4MlA4gk8WTTExhGydjYB2UTs5NIbv0ZmpKgMoK9io3ixgmiW56ZnTbcWOdiA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/@cloudflare/workers-types": {
+      "version": "5.20260724.1",
+      "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260724.1.tgz",
+      "integrity": "sha512-gl0brZ60JhkZU3INgr1jsxaFEiWf3AB9RsCjLTMwT5RKspWRUpsFd0wxwbys7gb8Ywkfq9tORhw0y9G+7bDUYw==",
+      "dev": true,
+      "license": "MIT OR Apache-2.0"
+    },
+    "node_modules/@cspotcode/source-map-support": {
+      "version": "0.8.1",
+      "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+      "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/trace-mapping": "0.3.9"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@emnapi/core": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+      "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/wasi-threads": "1.2.2",
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@emnapi/runtime": {
+      "version": "1.11.3",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+      "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@emnapi/wasi-threads": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+      "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+      "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+      "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+      "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+      "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+      "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+      "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+      "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+      "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+      "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+      "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+      "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+      "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+      "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+      "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+      "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+      "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+      "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+      "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+      "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+      "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+      "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+      "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+      "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+      "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+      "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+      "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@img/colour": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+      "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@img/sharp-darwin-arm64": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz",
+      "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-arm64": "1.3.1"
+      }
+    },
+    "node_modules/@img/sharp-darwin-x64": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz",
+      "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-x64": "1.3.1"
+      }
+    },
+    "node_modules/@img/sharp-freebsd-wasm32": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz",
+      "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "dependencies": {
+        "@img/sharp-wasm32": "0.35.2"
+      },
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-arm64": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz",
+      "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-x64": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz",
+      "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz",
+      "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm64": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz",
+      "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-ppc64": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz",
+      "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-riscv64": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz",
+      "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-s390x": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz",
+      "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-x64": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz",
+      "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz",
+      "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz",
+      "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz",
+      "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm": "1.3.1"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm64": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz",
+      "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm64": "1.3.1"
+      }
+    },
+    "node_modules/@img/sharp-linux-ppc64": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz",
+      "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-ppc64": "1.3.1"
+      }
+    },
+    "node_modules/@img/sharp-linux-riscv64": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz",
+      "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-riscv64": "1.3.1"
+      }
+    },
+    "node_modules/@img/sharp-linux-s390x": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz",
+      "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-s390x": "1.3.1"
+      }
+    },
+    "node_modules/@img/sharp-linux-x64": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz",
+      "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-x64": "1.3.1"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-arm64": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz",
+      "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-arm64": "1.3.1"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-x64": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz",
+      "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-x64": "1.3.1"
+      }
+    },
+    "node_modules/@img/sharp-wasm32": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz",
+      "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==",
+      "dev": true,
+      "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/runtime": "^1.11.1"
+      },
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-webcontainers-wasm32": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz",
+      "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==",
+      "cpu": [
+        "wasm32"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "dependencies": {
+        "@img/sharp-wasm32": "0.35.2"
+      },
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-arm64": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz",
+      "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-ia32": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz",
+      "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-x64": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz",
+      "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.9",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+      "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.0.3",
+        "@jridgewell/sourcemap-codec": "^1.4.10"
+      }
+    },
+    "node_modules/@napi-rs/wasm-runtime": {
+      "version": "1.1.6",
+      "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
+      "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@tybys/wasm-util": "^0.10.3"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      },
+      "peerDependencies": {
+        "@emnapi/core": "^1.7.1",
+        "@emnapi/runtime": "^1.7.1"
+      }
+    },
+    "node_modules/@oxc-project/types": {
+      "version": "0.139.0",
+      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
+      "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/Boshen"
+      }
+    },
+    "node_modules/@poppinss/colors": {
+      "version": "4.1.6",
+      "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
+      "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "kleur": "^4.1.5"
+      }
+    },
+    "node_modules/@poppinss/dumper": {
+      "version": "0.6.5",
+      "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz",
+      "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@poppinss/colors": "^4.1.5",
+        "@sindresorhus/is": "^7.0.2",
+        "supports-color": "^10.0.0"
+      }
+    },
+    "node_modules/@poppinss/exception": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz",
+      "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@rolldown/binding-android-arm64": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
+      "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-arm64": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
+      "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-x64": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
+      "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-freebsd-x64": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
+      "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
+      "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-gnu": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
+      "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-musl": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
+      "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
+      "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-s390x-gnu": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
+      "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-gnu": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
+      "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-musl": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
+      "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-openharmony-arm64": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
+      "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-wasm32-wasi": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
+      "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
+      "cpu": [
+        "wasm32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/core": "1.11.1",
+        "@emnapi/runtime": "1.11.1",
+        "@napi-rs/wasm-runtime": "^1.1.6"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+      "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-arm64-msvc": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
+      "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-x64-msvc": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
+      "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+      "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@sindresorhus/is": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
+      "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sindresorhus/is?sponsor=1"
+      }
+    },
+    "node_modules/@speed-highlight/core": {
+      "version": "1.2.17",
+      "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz",
+      "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==",
+      "dev": true,
+      "license": "CC0-1.0"
+    },
+    "node_modules/@tybys/wasm-util": {
+      "version": "0.10.3",
+      "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+      "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@types/react": {
+      "version": "19.2.17",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+      "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "csstype": "^3.2.2"
+      }
+    },
+    "node_modules/@types/react-dom": {
+      "version": "19.2.3",
+      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+      "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "^19.2.0"
+      }
+    },
+    "node_modules/@vitejs/plugin-react": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
+      "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@rolldown/pluginutils": "^1.0.1"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "peerDependencies": {
+        "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+        "babel-plugin-react-compiler": "^1.0.0",
+        "vite": "^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@rolldown/plugin-babel": {
+          "optional": true
+        },
+        "babel-plugin-react-compiler": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/blake3-wasm": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
+      "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/cookie": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+      "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/error-stack-parser-es": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz",
+      "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+      "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.28.1",
+        "@esbuild/android-arm": "0.28.1",
+        "@esbuild/android-arm64": "0.28.1",
+        "@esbuild/android-x64": "0.28.1",
+        "@esbuild/darwin-arm64": "0.28.1",
+        "@esbuild/darwin-x64": "0.28.1",
+        "@esbuild/freebsd-arm64": "0.28.1",
+        "@esbuild/freebsd-x64": "0.28.1",
+        "@esbuild/linux-arm": "0.28.1",
+        "@esbuild/linux-arm64": "0.28.1",
+        "@esbuild/linux-ia32": "0.28.1",
+        "@esbuild/linux-loong64": "0.28.1",
+        "@esbuild/linux-mips64el": "0.28.1",
+        "@esbuild/linux-ppc64": "0.28.1",
+        "@esbuild/linux-riscv64": "0.28.1",
+        "@esbuild/linux-s390x": "0.28.1",
+        "@esbuild/linux-x64": "0.28.1",
+        "@esbuild/netbsd-arm64": "0.28.1",
+        "@esbuild/netbsd-x64": "0.28.1",
+        "@esbuild/openbsd-arm64": "0.28.1",
+        "@esbuild/openbsd-x64": "0.28.1",
+        "@esbuild/openharmony-arm64": "0.28.1",
+        "@esbuild/sunos-x64": "0.28.1",
+        "@esbuild/win32-arm64": "0.28.1",
+        "@esbuild/win32-ia32": "0.28.1",
+        "@esbuild/win32-x64": "0.28.1"
+      }
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/kleur": {
+      "version": "4.1.5",
+      "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+      "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/lightningcss": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+      "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+      "dev": true,
+      "license": "MPL-2.0",
+      "dependencies": {
+        "detect-libc": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "lightningcss-android-arm64": "1.33.0",
+        "lightningcss-darwin-arm64": "1.33.0",
+        "lightningcss-darwin-x64": "1.33.0",
+        "lightningcss-freebsd-x64": "1.33.0",
+        "lightningcss-linux-arm-gnueabihf": "1.33.0",
+        "lightningcss-linux-arm64-gnu": "1.33.0",
+        "lightningcss-linux-arm64-musl": "1.33.0",
+        "lightningcss-linux-x64-gnu": "1.33.0",
+        "lightningcss-linux-x64-musl": "1.33.0",
+        "lightningcss-win32-arm64-msvc": "1.33.0",
+        "lightningcss-win32-x64-msvc": "1.33.0"
+      }
+    },
+    "node_modules/lightningcss-android-arm64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+      "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-arm64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+      "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-x64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+      "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-freebsd-x64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+      "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm-gnueabihf": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+      "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-gnu": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+      "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-musl": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+      "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-gnu": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+      "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-musl": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+      "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-arm64-msvc": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+      "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-x64-msvc": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+      "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lucide-react": {
+      "version": "1.26.0",
+      "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.26.0.tgz",
+      "integrity": "sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg==",
+      "license": "ISC",
+      "peerDependencies": {
+        "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/miniflare": {
+      "version": "4.20260722.0",
+      "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260722.0.tgz",
+      "integrity": "sha512-LW6ABMhCx/yIEFBLC/DO4yAhdm2T/G7jp7pr5T2kj895+CCIaHZqpMXdW9O6YE48LcYcCJChwWc8aEs1vpbTXw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@cspotcode/source-map-support": "0.8.1",
+        "sharp": "0.35.2",
+        "undici": "7.28.0",
+        "workerd": "1.20260722.1",
+        "ws": "8.21.0",
+        "youch": "4.1.0-beta.10"
+      },
+      "bin": {
+        "miniflare": "bootstrap.js"
+      },
+      "engines": {
+        "node": ">=22.0.0"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.16",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+      "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+      "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/pathe": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+      "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+      "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.23",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
+      "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.16",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/react": {
+      "version": "19.2.8",
+      "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+      "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "19.2.8",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+      "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
+      "license": "MIT",
+      "dependencies": {
+        "scheduler": "^0.27.0"
+      },
+      "peerDependencies": {
+        "react": "^19.2.8"
+      }
+    },
+    "node_modules/rolldown": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
+      "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@oxc-project/types": "=0.139.0",
+        "@rolldown/pluginutils": "^1.0.0"
+      },
+      "bin": {
+        "rolldown": "bin/cli.mjs"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "optionalDependencies": {
+        "@rolldown/binding-android-arm64": "1.1.5",
+        "@rolldown/binding-darwin-arm64": "1.1.5",
+        "@rolldown/binding-darwin-x64": "1.1.5",
+        "@rolldown/binding-freebsd-x64": "1.1.5",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
+        "@rolldown/binding-linux-arm64-gnu": "1.1.5",
+        "@rolldown/binding-linux-arm64-musl": "1.1.5",
+        "@rolldown/binding-linux-ppc64-gnu": "1.1.5",
+        "@rolldown/binding-linux-s390x-gnu": "1.1.5",
+        "@rolldown/binding-linux-x64-gnu": "1.1.5",
+        "@rolldown/binding-linux-x64-musl": "1.1.5",
+        "@rolldown/binding-openharmony-arm64": "1.1.5",
+        "@rolldown/binding-wasm32-wasi": "1.1.5",
+        "@rolldown/binding-win32-arm64-msvc": "1.1.5",
+        "@rolldown/binding-win32-x64-msvc": "1.1.5"
+      }
+    },
+    "node_modules/scheduler": {
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+      "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+      "license": "MIT"
+    },
+    "node_modules/semver": {
+      "version": "7.8.5",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+      "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/sharp": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz",
+      "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@img/colour": "^1.1.0",
+        "detect-libc": "^2.1.2",
+        "semver": "^7.8.4"
+      },
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-darwin-arm64": "0.35.2",
+        "@img/sharp-darwin-x64": "0.35.2",
+        "@img/sharp-freebsd-wasm32": "0.35.2",
+        "@img/sharp-libvips-darwin-arm64": "1.3.1",
+        "@img/sharp-libvips-darwin-x64": "1.3.1",
+        "@img/sharp-libvips-linux-arm": "1.3.1",
+        "@img/sharp-libvips-linux-arm64": "1.3.1",
+        "@img/sharp-libvips-linux-ppc64": "1.3.1",
+        "@img/sharp-libvips-linux-riscv64": "1.3.1",
+        "@img/sharp-libvips-linux-s390x": "1.3.1",
+        "@img/sharp-libvips-linux-x64": "1.3.1",
+        "@img/sharp-libvips-linuxmusl-arm64": "1.3.1",
+        "@img/sharp-libvips-linuxmusl-x64": "1.3.1",
+        "@img/sharp-linux-arm": "0.35.2",
+        "@img/sharp-linux-arm64": "0.35.2",
+        "@img/sharp-linux-ppc64": "0.35.2",
+        "@img/sharp-linux-riscv64": "0.35.2",
+        "@img/sharp-linux-s390x": "0.35.2",
+        "@img/sharp-linux-x64": "0.35.2",
+        "@img/sharp-linuxmusl-arm64": "0.35.2",
+        "@img/sharp-linuxmusl-x64": "0.35.2",
+        "@img/sharp-webcontainers-wasm32": "0.35.2",
+        "@img/sharp-win32-arm64": "0.35.2",
+        "@img/sharp-win32-ia32": "0.35.2",
+        "@img/sharp-win32-x64": "0.35.2"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/supports-color": {
+      "version": "10.2.2",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
+      "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/supports-color?sponsor=1"
+      }
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.17",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "dev": true,
+      "license": "0BSD",
+      "optional": true
+    },
+    "node_modules/typescript": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+      "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
+      "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.18.1"
+      }
+    },
+    "node_modules/unenv": {
+      "version": "2.0.0-rc.24",
+      "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",
+      "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "pathe": "^2.0.3"
+      }
+    },
+    "node_modules/vite": {
+      "version": "8.1.5",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
+      "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "lightningcss": "^1.32.0",
+        "picomatch": "^4.0.5",
+        "postcss": "^8.5.17",
+        "rolldown": "~1.1.5",
+        "tinyglobby": "^0.2.17"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "@vitejs/devtools": "^0.3.0",
+        "esbuild": "^0.27.0 || ^0.28.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "@vitejs/devtools": {
+          "optional": true
+        },
+        "esbuild": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/workerd": {
+      "version": "1.20260722.1",
+      "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260722.1.tgz",
+      "integrity": "sha512-NycKuc1x2onvsRfGGpM093vRlLFU2zHDAM0+APpccfg4+gZxDGCH27RmdDvkeBuoZyYqgLo3oAfF6re4mvC3vQ==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "workerd": "bin/workerd"
+      },
+      "engines": {
+        "node": ">=16"
+      },
+      "optionalDependencies": {
+        "@cloudflare/workerd-darwin-64": "1.20260722.1",
+        "@cloudflare/workerd-darwin-arm64": "1.20260722.1",
+        "@cloudflare/workerd-linux-64": "1.20260722.1",
+        "@cloudflare/workerd-linux-arm64": "1.20260722.1",
+        "@cloudflare/workerd-windows-64": "1.20260722.1"
+      }
+    },
+    "node_modules/wrangler": {
+      "version": "4.114.0",
+      "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.114.0.tgz",
+      "integrity": "sha512-M65P25t5UHA1TIJfgZXDcj+YzVobgKdRguM2QPz0xnxLFuOcuE3ErgllDht0iaho7MS4o0g/Bb4YK2+GT+bibg==",
+      "dev": true,
+      "license": "MIT OR Apache-2.0",
+      "dependencies": {
+        "@cloudflare/kv-asset-handler": "0.5.0",
+        "@cloudflare/unenv-preset": "2.16.1",
+        "blake3-wasm": "2.1.5",
+        "esbuild": "0.28.1",
+        "miniflare": "4.20260722.0",
+        "path-to-regexp": "6.3.0",
+        "unenv": "2.0.0-rc.24",
+        "workerd": "1.20260722.1"
+      },
+      "bin": {
+        "cf-wrangler": "bin/cf-wrangler.js",
+        "wrangler": "bin/wrangler.js",
+        "wrangler2": "bin/wrangler.js"
+      },
+      "engines": {
+        "node": ">=22.0.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.3"
+      },
+      "peerDependencies": {
+        "@cloudflare/workers-types": "^5.20260722.1"
+      },
+      "peerDependenciesMeta": {
+        "@cloudflare/workers-types": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/ws": {
+      "version": "8.21.0",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+      "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": ">=5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
+        },
+        "utf-8-validate": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/youch": {
+      "version": "4.1.0-beta.10",
+      "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz",
+      "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@poppinss/colors": "^4.1.5",
+        "@poppinss/dumper": "^0.6.4",
+        "@speed-highlight/core": "^1.2.7",
+        "cookie": "^1.0.2",
+        "youch-core": "^0.3.3"
+      }
+    },
+    "node_modules/youch-core": {
+      "version": "0.3.3",
+      "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz",
+      "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@poppinss/exception": "^1.2.2",
+        "error-stack-parser-es": "^1.0.5"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..7421140
--- /dev/null
+++ b/package.json
@@ -0,0 +1,34 @@
+{
+  "name": "meet-worker",
+  "version": "0.1.1",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview",
+    "deploy": "vite build && wrangler deploy",
+    "setup": "node scripts/setup.mjs",
+    "db:migrate:local": "wrangler d1 migrations apply DB --local",
+    "db:migrate:remote": "wrangler d1 migrations apply DB --remote",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "lucide-react": "1.26.0",
+    "react": "19.2.8",
+    "react-dom": "19.2.8"
+  },
+  "devDependencies": {
+    "@cloudflare/vite-plugin": "^1.47.0",
+    "@cloudflare/workers-types": "5.20260724.1",
+    "@types/react": "19.2.17",
+    "@types/react-dom": "19.2.3",
+    "@vitejs/plugin-react": "6.0.3",
+    "typescript": "6.0.3",
+    "vite": "8.1.5",
+    "wrangler": "4.114.0"
+  },
+  "engines": {
+    "node": ">=22.12.0"
+  }
+}
diff --git a/scripts/setup.mjs b/scripts/setup.mjs
new file mode 100644
index 0000000..f0459e8
--- /dev/null
+++ b/scripts/setup.mjs
@@ -0,0 +1,265 @@
+import { spawnSync } from 'node:child_process';
+import { randomBytes } from 'node:crypto';
+import fs from 'node:fs';
+import path from 'node:path';
+import { stdin as input, stdout as output } from 'node:process';
+import { createInterface } from 'node:readline/promises';
+
+const root = process.cwd();
+const workerConfigPath = path.join(root, 'wrangler.jsonc');
+const wranglerCli = path.join(root, 'node_modules', 'wrangler', 'bin', 'wrangler.js');
+const viteCli = path.join(root, 'node_modules', 'vite', 'bin', 'vite.js');
+const requiredSecrets = [
+  'APP_URL',
+  'ADMIN_EMAIL',
+  'GOOGLE_CLIENT_ID',
+  'GOOGLE_CLIENT_SECRET',
+  'SESSION_SECRET',
+  'TOKEN_ENCRYPTION_KEY',
+];
+
+function fail(message) {
+  console.error(`\nSetup failed: ${message}`);
+  process.exit(1);
+}
+
+function ensureProjectRoot() {
+  if (!fs.existsSync(workerConfigPath)) {
+    fail('Run npm run setup from the Meet project root.');
+  }
+  if (!fs.existsSync(wranglerCli) || !fs.existsSync(viteCli)) {
+    fail('Dependencies are missing. Run npm install, then npm run setup again.');
+  }
+}
+
+function runNodeCli(label, scriptPath, args, options = {}) {
+  const { allowFailure = false, capture = false } = options;
+  const result = spawnSync(process.execPath, [scriptPath, ...args], {
+    cwd: root,
+    shell: false,
+    stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
+    encoding: capture ? 'utf8' : undefined,
+    env: { ...process.env, NO_COLOR: process.env.NO_COLOR ?? '1' },
+  });
+
+  if (result.error) {
+    if (allowFailure) return result;
+    throw result.error;
+  }
+
+  if ((result.status ?? 1) !== 0 && !allowFailure) {
+    process.exit(result.status ?? 1);
+  }
+
+  return result;
+}
+
+function wrangler(args, options) {
+  return runNodeCli('wrangler', wranglerCli, args, options);
+}
+
+function build() {
+  runNodeCli('vite', viteCli, ['build']);
+}
+
+function parseJsonOutput(result, fallback) {
+  const text = `${result.stdout ?? ''}`.trim();
+  if (!text) return fallback;
+  try {
+    return JSON.parse(text);
+  } catch {
+    return fallback;
+  }
+}
+
+function readConfig() {
+  return JSON.parse(fs.readFileSync(workerConfigPath, 'utf8'));
+}
+
+function writeConfig(config) {
+  fs.writeFileSync(workerConfigPath, `${JSON.stringify(config, null, 2)}\n`);
+}
+
+function dbNameFromConfig(config) {
+  return config.d1_databases?.find((db) => db.binding === 'DB')?.database_name ?? `${config.name}-db`;
+}
+
+function updateD1Config(database) {
+  const config = readConfig();
+  const databases = Array.isArray(config.d1_databases) ? config.d1_databases : [];
+  const index = databases.findIndex((db) => db.binding === 'DB');
+  const current = index >= 0 ? databases[index] : { binding: 'DB' };
+  const next = {
+    ...current,
+    binding: 'DB',
+    database_name: database.name,
+    database_id: database.uuid,
+    migrations_dir: current.migrations_dir ?? 'migrations',
+  };
+
+  if (index >= 0) databases[index] = next;
+  else databases.push(next);
+
+  writeConfig({ ...config, d1_databases: databases });
+}
+
+function ensureD1Database() {
+  const config = readConfig();
+  const configured = config.d1_databases?.find((db) => db.binding === 'DB');
+  const databaseName = configured?.database_name ?? dbNameFromConfig(config);
+
+  if (configured?.database_id) {
+    console.log(`D1 database configured: ${databaseName}`);
+    return databaseName;
+  }
+
+  const listResult = wrangler(['d1', 'list', '--json'], { capture: true });
+  const databases = parseJsonOutput(listResult, []);
+  const existing = Array.isArray(databases)
+    ? databases.find((db) => db.name === databaseName && db.uuid)
+    : null;
+
+  if (existing) {
+    updateD1Config(existing);
+    console.log(`Reusing D1 database: ${databaseName}`);
+    return databaseName;
+  }
+
+  console.log(`Creating D1 database: ${databaseName}`);
+  const createResult = wrangler(['d1', 'create', databaseName], { capture: true });
+  const outputText = `${createResult.stdout ?? ''}\n${createResult.stderr ?? ''}`;
+  const id = outputText.match(/database_id\s*=\s*"([^"]+)"/)?.[1]
+    ?? outputText.match(/"database_id"\s*:\s*"([^"]+)"/)?.[1]
+    ?? outputText.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i)?.[1];
+
+  if (!id) {
+    console.log(outputText.trim());
+    fail('Could not read the new D1 database ID from Wrangler output.');
+  }
+
+  updateD1Config({ name: databaseName, uuid: id });
+  return databaseName;
+}
+
+function listExistingSecrets() {
+  const result = wrangler(['secret', 'list', '--format', 'json'], {
+    allowFailure: true,
+    capture: true,
+  });
+  if ((result.status ?? 1) !== 0) return null;
+
+  const parsed = parseJsonOutput(result, []);
+  if (!Array.isArray(parsed)) return new Set();
+  return new Set(parsed.map((secret) => secret.name).filter(Boolean));
+}
+
+async function promptForConfiguration() {
+  const rl = createInterface({ input, output });
+  try {
+    console.log('\nGoogle + app configuration\n');
+    const defaultUrl = 'https://meet.anord.cc';
+    const enteredUrl = (await rl.question(`Public URL [${defaultUrl}]: `)).trim();
+    const appUrl = (enteredUrl || defaultUrl).replace(/\/+$/, '');
+    const adminEmail = (await rl.question('Google account allowed to administer this calendar: ')).trim();
+    const clientId = (await rl.question('Google OAuth Client ID: ')).trim();
+    const clientSecret = (await rl.question('Google OAuth Client Secret: ')).trim();
+
+    if (!/^https?:\/\//.test(appUrl)) {
+      throw new Error('Public URL must begin with https:// (or http:// for local development).');
+    }
+    if (!adminEmail || !clientId || !clientSecret) {
+      throw new Error('Admin email and Google OAuth credentials are required.');
+    }
+
+    console.log(`\nGoogle Authorized redirect URI (required):\n  ${appUrl}/auth/google/callback`);
+    console.log(`Google Authorized JavaScript origin (optional here):\n  ${appUrl}\n`);
+    return { appUrl, adminEmail, clientId, clientSecret };
+  } finally {
+    rl.close();
+  }
+}
+
+function uploadSecrets(answers, existingSecrets) {
+  const secrets = {
+    APP_URL: answers.appUrl,
+    ADMIN_EMAIL: answers.adminEmail,
+    GOOGLE_CLIENT_ID: answers.clientId,
+    GOOGLE_CLIENT_SECRET: answers.clientSecret,
+  };
+
+  if (!existingSecrets.has('SESSION_SECRET')) {
+    secrets.SESSION_SECRET = randomBytes(48).toString('base64url');
+  }
+
+  if (!existingSecrets.has('TOKEN_ENCRYPTION_KEY')) {
+    secrets.TOKEN_ENCRYPTION_KEY = randomBytes(32).toString('base64');
+  }
+
+  const secretFile = path.join(root, `.meet-secrets-${process.pid}.json`);
+  try {
+    fs.writeFileSync(secretFile, JSON.stringify(secrets), { mode: 0o600 });
+    const result = wrangler(['secret', 'bulk', secretFile], { allowFailure: true, capture: true });
+    if ((result.status ?? 1) === 0) {
+      process.stdout.write(result.stdout ?? '');
+      process.stderr.write(result.stderr ?? '');
+      return;
+    }
+
+    const outputText = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
+    if (!/Worker ".+" not found|run `wrangler deploy` first/i.test(outputText)) {
+      process.stdout.write(result.stdout ?? '');
+      process.stderr.write(result.stderr ?? '');
+      process.exit(result.status ?? 1);
+    }
+
+    console.log('Creating the Worker shell so secrets can be attached...');
+    wrangler(['deploy']);
+    wrangler(['secret', 'bulk', secretFile]);
+  } finally {
+    fs.rmSync(secretFile, { force: true });
+  }
+}
+
+function assertSecrets(existingSecrets) {
+  const missing = requiredSecrets.filter((key) => !existingSecrets.has(key));
+  if (missing.length) {
+    fail(`Missing Worker secrets after upload: ${missing.join(', ')}`);
+  }
+}
+
+ensureProjectRoot();
+
+console.log('\nMeet setup\n==========');
+console.log('Cloudflare + Google Calendar. No GitHub Actions, no Pages project, no API token.\n');
+
+if ((wrangler(['whoami'], { allowFailure: true, capture: true }).status ?? 1) !== 0) {
+  console.log('Opening Cloudflare login...');
+  wrangler(['login']);
+}
+
+console.log('\n1/6  Building the Worker and static assets...');
+build();
+
+console.log('\n2/6  Provisioning or reusing the D1 database...');
+const databaseName = ensureD1Database();
+
+console.log('\n3/6  Preparing Worker secrets...');
+const existingSecrets = listExistingSecrets() ?? new Set();
+const answers = await promptForConfiguration();
+uploadSecrets(answers, existingSecrets);
+
+console.log('\n4/6  Applying D1 migrations...');
+wrangler(['d1', 'migrations', 'apply', databaseName, '--remote']);
+
+console.log('\n5/6  Verifying Worker secrets...');
+assertSecrets(listExistingSecrets() ?? new Set());
+
+console.log('\n6/6  Deploying the Worker and static assets...');
+wrangler(['deploy']);
+
+console.log('\nDone.\n');
+console.log(`Booking page: ${answers.appUrl}`);
+console.log(`Admin:        ${answers.appUrl}/admin`);
+console.log(`OAuth route:  ${answers.appUrl}/auth/google/callback`);
+console.log('\nAttach a custom domain in Cloudflare Workers & Pages -> meet-anord -> Settings -> Domains & Routes if you have not already done so.');
+console.log('Then open /admin and connect the configured Google account.\n');
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..d379871
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,778 @@
+import {
+  ArrowLeft,
+  ArrowRight,
+  CalendarDays,
+  Check,
+  ChevronLeft,
+  ChevronRight,
+  Clock3,
+  ExternalLink,
+  Loader2,
+  LogOut,
+  MapPin,
+  MonitorUp,
+  Phone,
+  Settings2,
+  Sparkles,
+  Video,
+} from 'lucide-react';
+import { FormEvent, useEffect, useMemo, useState } from 'react';
+
+type MeetingMode = 'google_meet' | 'phone' | 'in_person' | 'decide_later';
+
+type PublicConfig = {
+  title: string;
+  subtitle: string;
+  timezone: string;
+  bookingWindowDays: number;
+  durations: number[];
+  defaultMeetingMode: MeetingMode;
+  allowMeetingModes: MeetingMode[];
+  availableWeekdays: number[];
+  connected: boolean;
+};
+
+type Slot = { start: string; time: string; durations: number[] };
+
+type AppSettings = {
+  title: string;
+  subtitle: string;
+  timezone: string;
+  bookingWindowDays: number;
+  minimumNoticeMinutes: number;
+  bufferBeforeMinutes: number;
+  bufferAfterMinutes: number;
+  slotIncrementMinutes: number;
+  durations: number[];
+  calendarId: string;
+  busyCalendarIds: string[];
+  defaultMeetingMode: MeetingMode;
+  allowMeetingModes: MeetingMode[];
+  inPersonLocation: string;
+  availability: Record<string, Array<{ start: string; end: string }>>;
+};
+
+type BookingRow = {
+  id: string;
+  name: string;
+  email: string;
+  start_time: string;
+  end_time: string;
+  duration_minutes: number;
+  meeting_mode: MeetingMode;
+  status: string;
+  meet_url?: string;
+};
+
+type AdminSession = { authenticated: boolean; email: string; connected: boolean };
+type ThemeId = 'light' | 'paper' | 'beige' | 'dark' | 'midnight' | 'rosewood' | 'forest' | 'true-black';
+
+const modeMeta: Record<MeetingMode, { label: string; icon: typeof Video; help: string }> = {
+  google_meet: { label: 'Google Meet', icon: Video, help: 'A Meet link is created automatically.' },
+  phone: { label: 'Phone', icon: Phone, help: 'Add a number for the call.' },
+  in_person: { label: 'In person', icon: MapPin, help: 'The location will be included in the invite.' },
+  decide_later: { label: 'Decide later', icon: Sparkles, help: 'Book the time first and sort out the place later.' },
+};
+
+const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
+const shortDayNames = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'];
+const timezoneOptions = [
+  'Europe/Stockholm',
+  'Europe/London',
+  'Europe/Berlin',
+  'Europe/Paris',
+  'Europe/Madrid',
+  'Europe/Amsterdam',
+  'Europe/Helsinki',
+  'UTC',
+  'America/New_York',
+  'America/Chicago',
+  'America/Denver',
+  'America/Los_Angeles',
+  'America/Toronto',
+  'Asia/Tokyo',
+  'Asia/Singapore',
+  'Australia/Sydney',
+];
+const themes: Array<{ id: ThemeId; name: string; description: string; contrast: string }> = [
+  { id: 'light', name: 'Light', description: 'Bright surfaces with dark text and clear contrast.', contrast: 'Light contrast' },
+  { id: 'paper', name: 'Paper', description: 'Cool, document-like surfaces with crisp separation.', contrast: 'Crisp contrast' },
+  { id: 'beige', name: 'Beige', description: 'Warm neutral surfaces for a softer reading experience.', contrast: 'Warm contrast' },
+  { id: 'dark', name: 'Dark', description: 'Dark grey surfaces that reduce glare while keeping depth.', contrast: 'Soft contrast' },
+  { id: 'midnight', name: 'Midnight', description: 'Deep navy surfaces for calm late-night work.', contrast: 'Cool contrast' },
+  { id: 'rosewood', name: 'Rosewood', description: 'Warm dark surfaces with copper and walnut tones.', contrast: 'Warm dark' },
+  { id: 'forest', name: 'Forest', description: 'Muted pine surfaces with restrained natural contrast.', contrast: 'Natural dark' },
+  { id: 'true-black', name: 'True black', description: 'Pure black backgrounds with maximum dark-mode contrast.', contrast: 'High contrast' },
+];
+
+async function api<T>(url: string, init?: RequestInit): Promise<T> {
+  const response = await fetch(url, {
+    ...init,
+    headers: {
+      ...(init?.body ? { 'Content-Type': 'application/json' } : {}),
+      ...(init?.headers ?? {}),
+    },
+  });
+  const data = (await response.json().catch(() => ({}))) as T & { error?: string };
+  if (!response.ok) throw new Error(data.error || `Request failed (${response.status})`);
+  return data;
+}
+
+function dateKey(date: Date): string {
+  const year = date.getFullYear();
+  const month = String(date.getMonth() + 1).padStart(2, '0');
+  const day = String(date.getDate()).padStart(2, '0');
+  return `${year}-${month}-${day}`;
+}
+
+function parseDateKey(value: string): Date {
+  const [y, m, d] = value.split('-').map(Number);
+  return new Date(y, m - 1, d, 12, 0, 0);
+}
+
+function addDays(date: Date, amount: number): Date {
+  const copy = new Date(date);
+  copy.setDate(copy.getDate() + amount);
+  return copy;
+}
+
+function todayInZone(timezone: string): string {
+  const parts = new Intl.DateTimeFormat('en-CA', {
+    timeZone: timezone,
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+  }).formatToParts(new Date());
+  const map = Object.fromEntries(parts.filter((p) => p.type !== 'literal').map((p) => [p.type, p.value]));
+  return `${map.year}-${map.month}-${map.day}`;
+}
+
+function prettyDate(key: string, timezone: string): string {
+  return new Intl.DateTimeFormat('en-US', {
+    timeZone: timezone,
+    weekday: 'long',
+    month: 'long',
+    day: 'numeric',
+  }).format(new Date(`${key}T12:00:00Z`));
+}
+
+function formatInstant(iso: string, timezone: string): string {
+  return new Intl.DateTimeFormat('en-US', {
+    timeZone: timezone,
+    weekday: 'short',
+    month: 'short',
+    day: 'numeric',
+    hour: '2-digit',
+    minute: '2-digit',
+    hourCycle: 'h23',
+  }).format(new Date(iso));
+}
+
+function monthCells(month: Date): Array<{ date: Date; current: boolean }> {
+  const first = new Date(month.getFullYear(), month.getMonth(), 1, 12);
+  const mondayIndex = (first.getDay() + 6) % 7;
+  const start = addDays(first, -mondayIndex);
+  return Array.from({ length: 42 }, (_, index) => {
+    const date = addDays(start, index);
+    return { date, current: date.getMonth() === month.getMonth() };
+  });
+}
+
+function Logo() {
+  return (
+    <a className="brand" href="/" aria-label="Meet home">
+      <span className="brand-mark">M</span>
+      <span>Meet</span>
+    </a>
+  );
+}
+
+function PublicBooking() {
+  const [config, setConfig] = useState<PublicConfig | null>(null);
+  const [month, setMonth] = useState(() => new Date());
+  const [selectedDate, setSelectedDate] = useState<string>('');
+  const [slots, setSlots] = useState<Slot[]>([]);
+  const [selectedSlot, setSelectedSlot] = useState<Slot | null>(null);
+  const [duration, setDuration] = useState<number | null>(null);
+  const [meetingMode, setMeetingMode] = useState<MeetingMode>('google_meet');
+  const [loadingSlots, setLoadingSlots] = useState(false);
+  const [booking, setBooking] = useState(false);
+  const [error, setError] = useState('');
+  const [confirmation, setConfirmation] = useState<{ start: string; duration: number; meetUrl?: string | null } | null>(null);
+
+  useEffect(() => {
+    api<PublicConfig>('/api/config')
+      .then((data) => {
+        setConfig(data);
+        setMeetingMode(data.defaultMeetingMode);
+        document.title = data.title;
+      })
+      .catch((err) => setError(err.message));
+  }, []);
+
+  const cells = useMemo(() => monthCells(month), [month]);
+  const today = config ? todayInZone(config.timezone) : dateKey(new Date());
+  const maxDate = config ? dateKey(addDays(parseDateKey(today), config.bookingWindowDays)) : today;
+
+  async function chooseDate(key: string) {
+    if (!config || key < today || key > maxDate) return;
+    setSelectedDate(key);
+    setSelectedSlot(null);
+    setDuration(null);
+    setSlots([]);
+    setError('');
+    setLoadingSlots(true);
+    try {
+      const data = await api<{ slots: Slot[] }>(`/api/availability?date=${encodeURIComponent(key)}`);
+      setSlots(data.slots);
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Could not load availability');
+    } finally {
+      setLoadingSlots(false);
+    }
+  }
+
+  async function submitBooking(event: FormEvent<HTMLFormElement>) {
+    event.preventDefault();
+    if (!config || !selectedSlot || !duration) return;
+    const form = new FormData(event.currentTarget);
+    setBooking(true);
+    setError('');
+    try {
+      const data = await api<{ booking: { start: string; duration: number; meetUrl?: string | null } }>('/api/book', {
+        method: 'POST',
+        body: JSON.stringify({
+          start: selectedSlot.start,
+          duration,
+          meetingMode,
+          name: form.get('name'),
+          email: form.get('email'),
+          phone: form.get('phone'),
+          message: form.get('message'),
+          website: form.get('website'),
+        }),
+      });
+      setConfirmation(data.booking);
+    } catch (err) {
+      const message = err instanceof Error ? err.message : 'Booking failed';
+      setError(message);
+      if (message.toLowerCase().includes('slot') || message.toLowerCase().includes('time')) {
+        await chooseDate(selectedDate);
+      }
+    } finally {
+      setBooking(false);
+    }
+  }
+
+  if (!config) {
+    return <div className="center-screen"><Loader2 className="spin" /> Loading calendar…</div>;
+  }
+
+  if (confirmation) {
+    return (
+      <main className="confirmation-shell">
+        <Logo />
+        <section className="confirmation-card">
+          <span className="success-icon"><Check /></span>
+          <p className="eyebrow">YOU'RE BOOKED</p>
+          <h1>See you then.</h1>
+          <p className="confirmation-time">{formatInstant(confirmation.start, config.timezone)} · {confirmation.duration} min</p>
+          <p className="muted">A calendar invitation has been sent to your email.</p>
+          {confirmation.meetUrl && (
+            <a className="primary-button inline-button" href={confirmation.meetUrl} target="_blank" rel="noreferrer">
+              Open Google Meet <ExternalLink size={16} />
+            </a>
+          )}
+          <button className="text-button" onClick={() => window.location.reload()}>Book another time</button>
+        </section>
+      </main>
+    );
+  }
+
+  const chosenDurations = selectedSlot?.durations ?? [];
+
+  return (
+    <main className="booking-page">
+      <header className="topbar">
+        <span className="timezone-pill"><Clock3 size={14} /> {config.timezone}</span>
+      </header>
+
+      <section className="booking-shell">
+        <aside className="intro-panel">
+          <div>
+            <p className="eyebrow">SCHEDULE A MEETING</p>
+            <h1>{config.title}</h1>
+            <p className="intro-copy">{config.subtitle}</p>
+          </div>
+          <div className="intro-note">
+            <CalendarDays size={18} />
+            <div>
+              <strong>Your calendar, not a form maze.</strong>
+              <span>Choose the day first. Everything else follows.</span>
+            </div>
+          </div>
+        </aside>
+
+        <section className="calendar-panel">
+          <div className="calendar-toolbar">
+            <div>
+              <p className="step-label">01 · PICK A DAY</p>
+              <h2>{month.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}</h2>
+            </div>
+            <div className="month-buttons">
+              <button aria-label="Previous month" onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}><ChevronLeft /></button>
+              <button aria-label="Next month" onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}><ChevronRight /></button>
+            </div>
+          </div>
+
+          <div className="weekday-grid">
+            {shortDayNames.map((name) => <span key={name}>{name}</span>)}
+          </div>
+          <div className="calendar-grid">
+            {cells.map(({ date, current }) => {
+              const key = dateKey(date);
+              const disabled = !current || key < today || key > maxDate || !config.availableWeekdays.includes(date.getDay());
+              const selected = key === selectedDate;
+              const isToday = key === today;
+              return (
+                <button
+                  key={key}
+                  className={`day ${current ? '' : 'outside'} ${selected ? 'selected' : ''} ${isToday ? 'today' : ''}`}
+                  disabled={disabled}
+                  onClick={() => chooseDate(key)}
+                >
+                  <span>{date.getDate()}</span>
+                  {isToday && <small>Today</small>}
+                </button>
+              );
+            })}
+          </div>
+        </section>
+
+        <aside className="selection-panel">
+          {!config.connected ? (
+            <div className="empty-state">
+              <MonitorUp size={28} />
+              <h3>Calendar is not connected yet</h3>
+              <p>The owner needs to connect Google Calendar before times can be booked.</p>
+            </div>
+          ) : !selectedDate ? (
+            <div className="empty-state subtle-empty">
+              <ArrowLeft size={28} />
+              <h3>Start with the calendar</h3>
+              <p>Pick a date and available times will appear here.</p>
+            </div>
+          ) : (
+            <>
+              <div className="selection-heading">
+                <p className="step-label">02 · PICK A TIME</p>
+                <h2>{prettyDate(selectedDate, config.timezone)}</h2>
+              </div>
+
+              {loadingSlots ? (
+                <div className="loading-row"><Loader2 className="spin" /> Checking Google Calendar…</div>
+              ) : slots.length === 0 ? (
+                <div className="no-slots">Nothing open on this day.</div>
+              ) : (
+                <div className="time-grid">
+                  {slots.map((slot) => (
+                    <button
+                      key={slot.start}
+                      className={`time-button ${selectedSlot?.start === slot.start ? 'active' : ''}`}
+                      onClick={() => {
+                        setSelectedSlot(slot);
+                        setDuration(null);
+                      }}
+                    >
+                      {slot.time}
+                    </button>
+                  ))}
+                </div>
+              )}
+
+              {selectedSlot && (
+                <div className="duration-section">
+                  <p className="step-label">03 · HOW LONG?</p>
+                  <div className="duration-grid">
+                    {chosenDurations.map((minutes) => (
+                      <button
+                        key={minutes}
+                        className={`duration-button ${duration === minutes ? 'active' : ''}`}
+                        onClick={() => setDuration(minutes)}
+                      >
+                        <strong>{minutes}</strong>
+                        <span>min</span>
+                      </button>
+                    ))}
+                  </div>
+                </div>
+              )}
+            </>
+          )}
+        </aside>
+      </section>
+
+      {selectedSlot && duration && (
+        <section className="details-drawer">
+          <div className="drawer-summary">
+            <p className="step-label">04 · YOUR DETAILS</p>
+            <h2>{selectedSlot.time} · {duration} minutes</h2>
+            <p>{prettyDate(selectedDate, config.timezone)}</p>
+          </div>
+          <form className="details-form" onSubmit={submitBooking}>
+            <div className="form-row">
+              <label>Name<input name="name" required autoComplete="name" placeholder="Your name" /></label>
+              <label>Email<input name="email" type="email" required autoComplete="email" placeholder="[email protected]" /></label>
+            </div>
+
+            {config.allowMeetingModes.length > 1 && (
+              <div className="mode-picker">
+                <span className="field-label">Meeting</span>
+                <div className="mode-options">
+                  {config.allowMeetingModes.map((mode) => {
+                    const Icon = modeMeta[mode].icon;
+                    return (
+                      <button
+                        key={mode}
+                        type="button"
+                        className={`mode-option ${meetingMode === mode ? 'active' : ''}`}
+                        onClick={() => setMeetingMode(mode)}
+                      >
+                        <Icon size={18} />
+                        <span><strong>{modeMeta[mode].label}</strong><small>{modeMeta[mode].help}</small></span>
+                      </button>
+                    );
+                  })}
+                </div>
+              </div>
+            )}
+
+            {meetingMode === 'phone' && (
+              <label>Phone number<input name="phone" required autoComplete="tel" placeholder="+46 …" /></label>
+            )}
+            <label>Anything I should know? <span className="optional">Optional</span>
+              <textarea name="message" rows={3} placeholder="Context, topic, links…" />
+            </label>
+            <label className="honeypot" aria-hidden="true">Website<input name="website" tabIndex={-1} autoComplete="off" /></label>
+
+            {error && <div className="error-banner">{error}</div>}
+            <button className="primary-button" type="submit" disabled={booking}>
+              {booking ? <><Loader2 className="spin" size={18} /> Booking…</> : <>Book this time <ArrowRight size={18} /></>}
+            </button>
+          </form>
+        </section>
+      )}
+
+      {error && !(selectedSlot && duration) && <div className="floating-error">{error}</div>}
+      <footer>
+        <a className="powered-by" href="https://www.nobgit.com/user/imalexnord/slot" target="_blank" rel="noreferrer">Powered by Slot</a>
+        <a className="admin-link" href="/admin">Admin</a>
+      </footer>
+    </main>
+  );
+}
+
+function Admin() {
+  const [session, setSession] = useState<AdminSession | null>(null);
+  const [settings, setSettings] = useState<AppSettings | null>(null);
+  const [bookings, setBookings] = useState<BookingRow[]>([]);
+  const [tab, setTab] = useState<'settings' | 'bookings'>('bookings');
+  const [theme, setTheme] = useState<ThemeId>(() => {
+    const stored = localStorage.getItem('meet-theme') as ThemeId | null;
+    return stored && themes.some((item) => item.id === stored) ? stored : 'true-black';
+  });
+  const [saving, setSaving] = useState(false);
+  const [notice, setNotice] = useState('');
+  const [error, setError] = useState('');
+
+  useEffect(() => {
+    document.title = 'Bookings';
+  }, []);
+
+  useEffect(() => {
+    document.documentElement.dataset.theme = theme;
+    localStorage.setItem('meet-theme', theme);
+  }, [theme]);
+
+  useEffect(() => {
+    fetch('/api/admin/session')
+      .then(async (response) => {
+        if (!response.ok) throw new Error('unauthenticated');
+        return response.json() as Promise<AdminSession>;
+      })
+      .then((data) => setSession(data))
+      .catch(() => setSession({ authenticated: false, email: '', connected: false }));
+  }, []);
+
+  useEffect(() => {
+    if (!session?.authenticated) return;
+    Promise.all([
+      api<{ settings: AppSettings }>('/api/admin/settings'),
+      api<{ bookings: BookingRow[] }>('/api/admin/bookings'),
+    ]).then(([settingsData, bookingData]) => {
+      setSettings(settingsData.settings);
+      setBookings(bookingData.bookings);
+    }).catch((err) => setError(err.message));
+  }, [session]);
+
+  async function save() {
+    if (!settings) return;
+    setSaving(true);
+    setError('');
+    setNotice('');
+    try {
+      const data = await api<{ settings: AppSettings }>('/api/admin/settings', {
+        method: 'PUT',
+        body: JSON.stringify(settings),
+      });
+      setSettings(data.settings);
+      setNotice('Saved. The public calendar is using these settings now.');
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Could not save settings');
+    } finally {
+      setSaving(false);
+    }
+  }
+
+  if (session === null) return <div className="center-screen"><Loader2 className="spin" /> Loading admin…</div>;
+
+  if (!session.authenticated) {
+    const params = new URLSearchParams(window.location.search);
+    const oauthError = params.get('oauth_error');
+    return (
+      <main className="admin-login-shell">
+        <section className="admin-login-card">
+          <span className="admin-login-icon"><Settings2 /></span>
+          <p className="eyebrow">PRIVATE</p>
+          <h1>Control your availability.</h1>
+          <p>Google login is only for the calendar owner. People booking you never need an account.</p>
+          {oauthError && <div className="error-banner">{oauthError}</div>}
+          <a className="primary-button inline-button" href="/auth/google/start">Continue with Google <ArrowRight size={18} /></a>
+          <a className="text-button" href="/">Back to booking page</a>
+        </section>
+      </main>
+    );
+  }
+
+  if (!settings) return <div className="center-screen"><Loader2 className="spin" /> Loading settings…</div>;
+
+  function setDay(day: number, patch: Partial<{ enabled: boolean; start: string; end: string }>) {
+    setSettings((current) => {
+      if (!current) return current;
+      const existing = current.availability[String(day)]?.[0] ?? { start: '09:00', end: '17:00' };
+      const enabled = patch.enabled ?? Boolean(current.availability[String(day)]?.length);
+      return {
+        ...current,
+        availability: {
+          ...current.availability,
+          [String(day)]: enabled ? [{ start: patch.start ?? existing.start, end: patch.end ?? existing.end }] : [],
+        },
+      };
+    });
+  }
+
+  return (
+    <main className="admin-page">
+      <header className="admin-topbar">
+        <div className="admin-user">
+          <span>{session.email}</span>
+          <button onClick={async () => { await api('/api/admin/logout', { method: 'POST' }); window.location.reload(); }}><LogOut size={16} /> Log out</button>
+        </div>
+      </header>
+
+      <div className="admin-layout">
+        <aside className="admin-nav">
+          <button className={tab === 'bookings' ? 'active' : ''} onClick={() => setTab('bookings')}><CalendarDays size={17} /> Bookings</button>
+          <button className={tab === 'settings' ? 'active' : ''} onClick={() => setTab('settings')}><Settings2 size={17} /> Settings</button>
+          <a href="/" target="_blank"><ExternalLink size={17} /> Open booking page</a>
+        </aside>
+
+        <section className="admin-content">
+          {tab === 'settings' ? (
+            <>
+              <div className="admin-heading">
+                <div><p className="eyebrow">SETTINGS</p><h1>Keep it simple.</h1></div>
+                <button className="primary-button compact" onClick={save} disabled={saving}>{saving ? <Loader2 className="spin" /> : <Check />} Save changes</button>
+              </div>
+              {notice && <div className="success-banner">{notice}</div>}
+              {error && <div className="error-banner">{error}</div>}
+
+              <div className="settings-grid">
+                <section className="settings-card wide-card appearance-card">
+                  <div className="appearance-heading">
+                    <div>
+                      <p className="eyebrow">APPEARANCE</p>
+                      <h2>Theme and contrast</h2>
+                      <p>Choose how this app looks on this browser. Your selection is saved locally and applied before each page is shown.</p>
+                    </div>
+                    <span>{themes.find((item) => item.id === theme)?.name} selected</span>
+                  </div>
+                  <div className="theme-grid">
+                    {themes.map((item) => (
+                      <button
+                        type="button"
+                        key={item.id}
+                        className={`theme-option ${theme === item.id ? 'active' : ''}`}
+                        onClick={() => setTheme(item.id)}
+                      >
+                        <span className={`theme-preview theme-preview-${item.id}`} aria-hidden="true">
+                          <span />
+                        </span>
+                        <span className="theme-copy">
+                          <strong>{item.name}</strong>
+                          <small>{item.description}</small>
+                          <em>{item.contrast}</em>
+                        </span>
+                      </button>
+                    ))}
+                  </div>
+                  <p className="theme-footnote">Theme settings stay on this device and do not change your booking page for other visitors.</p>
+                </section>
+
+                <section className="settings-card wide-card">
+                  <div className="card-heading"><h2>Availability</h2><p>Your Google Calendar blocks busy time inside these hours.</p></div>
+                  <div className="availability-list">
+                    {dayNames.map((name, day) => {
+                      const interval = settings.availability[String(day)]?.[0];
+                      const enabled = Boolean(interval);
+                      return (
+                        <div className="availability-row" key={name}>
+                          <label className="switch-row"><input type="checkbox" checked={enabled} onChange={(e) => setDay(day, { enabled: e.target.checked })} /><span>{name}</span></label>
+                          {enabled ? (
+                            <div className="time-range">
+                              <input type="time" value={interval.start} onChange={(e) => setDay(day, { start: e.target.value })} />
+                              <span>to</span>
+                              <input type="time" value={interval.end} onChange={(e) => setDay(day, { end: e.target.value })} />
+                            </div>
+                          ) : <span className="unavailable">Unavailable</span>}
+                        </div>
+                      );
+                    })}
+                  </div>
+                </section>
+
+                <section className="settings-card">
+                  <div className="card-heading"><h2>Page</h2><p>What visitors see first.</p></div>
+                  <label>Title<input value={settings.title} onChange={(e) => setSettings({ ...settings, title: e.target.value })} /></label>
+                  <label>Subtitle<textarea rows={3} value={settings.subtitle} onChange={(e) => setSettings({ ...settings, subtitle: e.target.value })} /></label>
+                  <label>Timezone
+                    <div className="timezone-control">
+                      <select value={settings.timezone} onChange={(e) => setSettings({ ...settings, timezone: e.target.value })}>
+                        {!timezoneOptions.includes(settings.timezone) && <option value={settings.timezone}>{settings.timezone}</option>}
+                        {timezoneOptions.map((zone) => <option key={zone} value={zone}>{zone}</option>)}
+                      </select>
+                      <button
+                        type="button"
+                        onClick={() => setSettings({ ...settings, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || settings.timezone })}
+                      >
+                        Use this browser
+                      </button>
+                    </div>
+                  </label>
+                </section>
+
+                <section className="settings-card">
+                  <div className="card-heading"><h2>Booking rules</h2><p>Enough control, no dashboard archaeology.</p></div>
+                  <div className="two-col-fields">
+                    <label>Minimum notice<input type="number" min="0" value={settings.minimumNoticeMinutes} onChange={(e) => setSettings({ ...settings, minimumNoticeMinutes: Number(e.target.value) })} /><small>minutes</small></label>
+                    <label>Book ahead<input type="number" min="1" max="365" value={settings.bookingWindowDays} onChange={(e) => setSettings({ ...settings, bookingWindowDays: Number(e.target.value) })} /><small>days</small></label>
+                    <label>Buffer before<input type="number" min="0" value={settings.bufferBeforeMinutes} onChange={(e) => setSettings({ ...settings, bufferBeforeMinutes: Number(e.target.value) })} /><small>minutes</small></label>
+                    <label>Buffer after<input type="number" min="0" value={settings.bufferAfterMinutes} onChange={(e) => setSettings({ ...settings, bufferAfterMinutes: Number(e.target.value) })} /><small>minutes</small></label>
+                  </div>
+                  <span className="field-label">Durations</span>
+                  <div className="checkbox-chips">
+                    {[15, 30, 45, 60, 90, 120].map((minutes) => (
+                      <label key={minutes} className={settings.durations.includes(minutes) ? 'active' : ''}>
+                        <input
+                          type="checkbox"
+                          checked={settings.durations.includes(minutes)}
+                          onChange={(e) => setSettings({
+                            ...settings,
+                            durations: e.target.checked
+                              ? [...settings.durations, minutes].sort((a, b) => a - b)
+                              : settings.durations.filter((value) => value !== minutes),
+                          })}
+                        />
+                        {minutes} min
+                      </label>
+                    ))}
+                  </div>
+                </section>
+
+                <section className="settings-card wide-card">
+                  <div className="card-heading"><h2>Google Calendar</h2><p>Busy time is read from Google. New bookings are created on your chosen calendar.</p></div>
+                  <div className="google-status-row">
+                    <div className={`connection-badge ${session.connected ? 'connected' : ''}`}>
+                      <span className="status-dot" />
+                      {session.connected ? 'Google connected' : 'Google disconnected'}
+                    </div>
+                    <div className="connection-actions">
+                      <a href={session.connected ? '/auth/google/start?force=1' : '/auth/google/start'}>{session.connected ? 'Reconnect' : 'Connect Google'}</a>
+                      {session.connected && (
+                        <button type="button" onClick={async () => {
+                          await api('/api/admin/disconnect-google', { method: 'POST' });
+                          setSession({ ...session, connected: false });
+                        }}>Disconnect</button>
+                      )}
+                    </div>
+                  </div>
+                  <div className="calendar-settings-row">
+                    <label>Create events in<input value={settings.calendarId} onChange={(e) => setSettings({ ...settings, calendarId: e.target.value })} placeholder="primary" /></label>
+                    <label>Calendars that block time<input value={settings.busyCalendarIds.join(', ')} onChange={(e) => setSettings({ ...settings, busyCalendarIds: e.target.value.split(',').map((v) => v.trim()).filter(Boolean) })} placeholder="primary" /></label>
+                  </div>
+                  <label>Default meeting type
+                    <select value={settings.defaultMeetingMode} onChange={(e) => setSettings({ ...settings, defaultMeetingMode: e.target.value as MeetingMode })}>
+                      {settings.allowMeetingModes.map((mode) => <option value={mode} key={mode}>{modeMeta[mode].label}</option>)}
+                    </select>
+                  </label>
+                  <span className="field-label">Allowed meeting types</span>
+                  <div className="mode-admin-grid">
+                    {(Object.keys(modeMeta) as MeetingMode[]).map((mode) => {
+                      const Icon = modeMeta[mode].icon;
+                      const active = settings.allowMeetingModes.includes(mode);
+                      return (
+                        <label key={mode} className={active ? 'active' : ''}>
+                          <input type="checkbox" checked={active} disabled={active && settings.allowMeetingModes.length === 1} onChange={(e) => setSettings({
+                            ...settings,
+                            allowMeetingModes: e.target.checked
+                              ? [...settings.allowMeetingModes, mode]
+                              : settings.allowMeetingModes.filter((value) => value !== mode),
+                            defaultMeetingMode: !e.target.checked && settings.defaultMeetingMode === mode
+                              ? (settings.allowMeetingModes.find((value) => value !== mode) ?? settings.defaultMeetingMode)
+                              : settings.defaultMeetingMode,
+                          })} />
+                          <Icon size={18} /><span>{modeMeta[mode].label}</span>
+                        </label>
+                      );
+                    })}
+                  </div>
+                  {settings.allowMeetingModes.includes('in_person') && (
+                    <label>In-person location<input value={settings.inPersonLocation} onChange={(e) => setSettings({ ...settings, inPersonLocation: e.target.value })} placeholder="Office, café, address…" /></label>
+                  )}
+                </section>
+              </div>
+            </>
+          ) : (
+            <>
+              <div className="admin-heading"><div><p className="eyebrow">BOOKINGS</p><h1>Recent meetings.</h1></div></div>
+              <section className="booking-list-card">
+                {bookings.length === 0 ? <div className="empty-list">No bookings yet.</div> : bookings.map((booking) => (
+                  <div className="booking-list-row" key={booking.id}>
+                    <div className="booking-avatar">{booking.name.slice(0, 1).toUpperCase()}</div>
+                    <div className="booking-person"><strong>{booking.name}</strong><span>{booking.email}</span></div>
+                    <div className="booking-when"><strong>{formatInstant(booking.start_time, settings.timezone)}</strong><span>{booking.duration_minutes} min · {modeMeta[booking.meeting_mode].label}</span></div>
+                    {booking.meet_url ? <a href={booking.meet_url} target="_blank" rel="noreferrer"><Video size={17} /> Meet</a> : <span />}
+                  </div>
+                ))}
+              </section>
+            </>
+          )}
+        </section>
+      </div>
+    </main>
+  );
+}
+
+export default function App() {
+  return window.location.pathname.startsWith('/admin') ? <Admin /> : <PublicBooking />;
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..cf91907
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import App from './App';
+import './styles.css';
+
+createRoot(document.getElementById('root')!).render(
+  <StrictMode>
+    <App />
+  </StrictMode>,
+);
diff --git a/src/styles.css b/src/styles.css
new file mode 100644
index 0000000..eb20172
--- /dev/null
+++ b/src/styles.css
@@ -0,0 +1,865 @@
+:root {
+  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+  color: #121317;
+  background: #f5f5f1;
+  font-synthesis: none;
+  text-rendering: optimizeLegibility;
+  --bg: #f5f5f1;
+  --surface: #ffffff;
+  --surface-soft: #f0f0eb;
+  --surface-strong: #e7e7e0;
+  --text: #121317;
+  --muted: #6d706f;
+  --faint: #999c99;
+  --border: #deded7;
+  --accent: #17181c;
+  --accent-text: #ffffff;
+  --success: #217a4f;
+  --danger: #a13b3b;
+  --shadow: 0 24px 70px rgba(18, 19, 23, 0.08);
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    color: #f5f5f2;
+    background: #0b0c0f;
+    --bg: #0b0c0f;
+    --surface: #111317;
+    --surface-soft: #17191e;
+    --surface-strong: #20232a;
+    --text: #f5f5f2;
+    --muted: #a6a8aa;
+    --faint: #73767a;
+    --border: #292c32;
+    --accent: #f4f4ef;
+    --accent-text: #111216;
+    --success: #75d6a5;
+    --danger: #ff9b9b;
+    --shadow: 0 28px 100px rgba(0, 0, 0, 0.35);
+  }
+}
+
+:root[data-theme="light"] {
+  color-scheme: light;
+  color: #14161b;
+  background: #f7f8fb;
+  --bg: #f7f8fb;
+  --surface: #ffffff;
+  --surface-soft: #eef2f7;
+  --surface-strong: #e0e7f0;
+  --text: #14161b;
+  --muted: #5b6370;
+  --faint: #8290a1;
+  --border: #d7dee8;
+  --accent: #256edc;
+  --accent-text: #ffffff;
+  --success: #19744f;
+  --danger: #a33434;
+  --shadow: 0 24px 70px rgba(30, 44, 70, 0.1);
+}
+
+:root[data-theme="paper"] {
+  color-scheme: light;
+  color: #152033;
+  background: #eef2f6;
+  --bg: #eef2f6;
+  --surface: #fbfcfe;
+  --surface-soft: #edf1f5;
+  --surface-strong: #dce5ee;
+  --text: #152033;
+  --muted: #526173;
+  --faint: #7d8ba0;
+  --border: #cfd9e4;
+  --accent: #3f6b99;
+  --accent-text: #ffffff;
+  --success: #276f55;
+  --danger: #9d3939;
+  --shadow: 0 24px 70px rgba(36, 58, 88, 0.11);
+}
+
+:root[data-theme="beige"] {
+  color-scheme: light;
+  color: #211914;
+  background: #f3ebdc;
+  --bg: #f3ebdc;
+  --surface: #fff9ed;
+  --surface-soft: #eee2cf;
+  --surface-strong: #dfcfb8;
+  --text: #211914;
+  --muted: #6a5b4d;
+  --faint: #978777;
+  --border: #d8c8b3;
+  --accent: #9b5a24;
+  --accent-text: #fff7ec;
+  --success: #527343;
+  --danger: #9b3d32;
+  --shadow: 0 24px 70px rgba(82, 56, 30, 0.12);
+}
+
+:root[data-theme="dark"] {
+  color-scheme: dark;
+  color: #f2f5fb;
+  background: #0f1117;
+  --bg: #0f1117;
+  --surface: #151821;
+  --surface-soft: #1c2130;
+  --surface-strong: #252b3a;
+  --text: #f2f5fb;
+  --muted: #aab3c2;
+  --faint: #737d8c;
+  --border: #303745;
+  --accent: #82adf6;
+  --accent-text: #10141d;
+  --success: #7bd2a2;
+  --danger: #ff9b9b;
+  --shadow: 0 28px 100px rgba(0, 0, 0, 0.36);
+}
+
+:root[data-theme="midnight"] {
+  color-scheme: dark;
+  color: #eef5ff;
+  background: #050b15;
+  --bg: #050b15;
+  --surface: #0b1424;
+  --surface-soft: #111d31;
+  --surface-strong: #172942;
+  --text: #eef5ff;
+  --muted: #9fb3cf;
+  --faint: #627894;
+  --border: #21344f;
+  --accent: #6ea8ff;
+  --accent-text: #06111f;
+  --success: #72d7b0;
+  --danger: #ff9aa8;
+  --shadow: 0 28px 100px rgba(0, 3, 10, 0.48);
+}
+
+:root[data-theme="rosewood"] {
+  color-scheme: dark;
+  color: #fff1e7;
+  background: #120c0a;
+  --bg: #120c0a;
+  --surface: #1d1411;
+  --surface-soft: #2a1b16;
+  --surface-strong: #3a261e;
+  --text: #fff1e7;
+  --muted: #c4a99a;
+  --faint: #8b6f62;
+  --border: #432d24;
+  --accent: #d27a3a;
+  --accent-text: #140b07;
+  --success: #9fca83;
+  --danger: #ff9a8b;
+  --shadow: 0 28px 100px rgba(7, 2, 1, 0.5);
+}
+
+:root[data-theme="forest"] {
+  color-scheme: dark;
+  color: #eff8ed;
+  background: #07110c;
+  --bg: #07110c;
+  --surface: #0e1a13;
+  --surface-soft: #15251b;
+  --surface-strong: #1d3325;
+  --text: #eff8ed;
+  --muted: #aec3ac;
+  --faint: #748874;
+  --border: #294132;
+  --accent: #8ec28a;
+  --accent-text: #07110c;
+  --success: #8bd38c;
+  --danger: #ff9c8f;
+  --shadow: 0 28px 100px rgba(1, 8, 4, 0.5);
+}
+
+:root[data-theme="true-black"] {
+  color-scheme: dark;
+  color: #ffffff;
+  background: #000000;
+  --bg: #000000;
+  --surface: #070707;
+  --surface-soft: #0e0e0f;
+  --surface-strong: #171719;
+  --text: #ffffff;
+  --muted: #c3c8d2;
+  --faint: #768092;
+  --border: #2a2a2d;
+  --accent: #82adff;
+  --accent-text: #020306;
+  --success: #91dfa8;
+  --danger: #ffa0a0;
+  --shadow: 0 28px 100px rgba(0, 0, 0, 0.62);
+}
+
+* { box-sizing: border-box; }
+html { min-height: 100%; background: var(--bg); }
+body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--bg); color: var(--text); }
+button, input, textarea { font: inherit; }
+button, a { -webkit-tap-highlight-color: transparent; }
+button { color: inherit; }
+a { color: inherit; text-decoration: none; }
+
+::selection { background: var(--text); color: var(--bg); }
+
+.center-screen {
+  min-height: 100vh;
+  display: grid;
+  place-items: center;
+  align-content: center;
+  gap: 12px;
+  color: var(--muted);
+}
+
+.spin { animation: spin 0.9s linear infinite; }
+@keyframes spin { to { transform: rotate(360deg); } }
+
+.brand {
+  display: inline-flex;
+  align-items: center;
+  gap: 10px;
+  font-size: 15px;
+  font-weight: 750;
+  letter-spacing: -0.02em;
+}
+.brand-mark {
+  width: 29px;
+  height: 29px;
+  display: grid;
+  place-items: center;
+  border-radius: 9px;
+  background: var(--text);
+  color: var(--bg);
+  font-size: 13px;
+  box-shadow: inset 0 0 0 1px rgba(127,127,127,.15);
+}
+
+.booking-page {
+  min-height: 100svh;
+  padding: 0 26px;
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  gap: 14px;
+}
+.topbar, .admin-topbar {
+  max-width: 1320px;
+  margin: 0 auto 20px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 44px;
+}
+.topbar {
+  position: absolute;
+  top: 28px;
+  left: 26px;
+  right: 26px;
+  width: calc(100% - 52px);
+  margin: 0 auto;
+  justify-content: flex-end;
+}
+.timezone-pill {
+  display: inline-flex;
+  align-items: center;
+  gap: 7px;
+  color: var(--muted);
+  font-size: 12px;
+  border: 1px solid var(--border);
+  background: color-mix(in srgb, var(--surface) 78%, transparent);
+  padding: 8px 11px;
+  border-radius: 999px;
+}
+
+.booking-shell {
+  width: 100%;
+  max-width: 1320px;
+  margin: 0;
+  display: grid;
+  grid-template-columns: minmax(260px, 1fr) minmax(540px, 680px) minmax(260px, 1fr);
+  min-height: 680px;
+  border: 1px solid var(--border);
+  border-radius: 26px;
+  background: var(--surface);
+  overflow: hidden;
+  box-shadow: var(--shadow);
+}
+.booking-shell > * + * { border-left: 1px solid var(--border); }
+
+.intro-panel {
+  padding: 48px 38px 36px;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  background: linear-gradient(160deg, var(--surface) 0%, var(--surface-soft) 130%);
+}
+.eyebrow, .step-label {
+  margin: 0 0 11px;
+  color: var(--faint);
+  font-weight: 750;
+  font-size: 10px;
+  letter-spacing: .14em;
+}
+.intro-panel h1, .confirmation-card h1, .admin-login-card h1, .admin-heading h1 {
+  margin: 0;
+  font-size: clamp(32px, 3vw, 52px);
+  line-height: .98;
+  letter-spacing: -0.055em;
+  font-weight: 680;
+}
+.intro-copy {
+  max-width: 390px;
+  margin: 20px 0 0;
+  color: var(--muted);
+  line-height: 1.6;
+  font-size: 15px;
+}
+.intro-note {
+  display: flex;
+  gap: 12px;
+  align-items: flex-start;
+  color: var(--muted);
+  border-top: 1px solid var(--border);
+  padding-top: 22px;
+  font-size: 12px;
+  line-height: 1.5;
+}
+.intro-note svg { margin-top: 2px; color: var(--text); }
+.intro-note div { display: grid; gap: 4px; }
+.intro-note strong { color: var(--text); font-size: 12px; }
+
+.calendar-panel { padding: 38px 40px 42px; }
+.calendar-toolbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 22px; margin-bottom: 34px; }
+.calendar-toolbar h2, .selection-heading h2, .drawer-summary h2, .settings-card h2 {
+  margin: 0;
+  font-size: 22px;
+  letter-spacing: -0.035em;
+}
+.month-buttons { display: flex; gap: 7px; }
+.month-buttons button, .admin-user button {
+  width: 36px;
+  height: 36px;
+  border-radius: 11px;
+  border: 1px solid var(--border);
+  background: var(--surface);
+  display: inline-grid;
+  place-items: center;
+  cursor: pointer;
+}
+.month-buttons button:hover { background: var(--surface-soft); }
+.month-buttons svg { width: 17px; }
+.weekday-grid, .calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); }
+.weekday-grid { margin-bottom: 9px; }
+.weekday-grid span {
+  text-align: center;
+  color: var(--faint);
+  font-size: 9px;
+  font-weight: 760;
+  letter-spacing: .12em;
+  padding: 7px 0;
+}
+.calendar-grid { gap: 7px; }
+.day {
+  aspect-ratio: 1.02;
+  min-height: 62px;
+  border: 1px solid transparent;
+  background: transparent;
+  border-radius: 14px;
+  cursor: pointer;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 3px;
+  position: relative;
+  transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
+}
+.day span { font-size: 13px; font-weight: 620; }
+.day small { font-size: 8px; color: var(--faint); }
+.day:hover:not(:disabled) { background: var(--surface-soft); border-color: var(--border); transform: translateY(-1px); }
+.day.selected { background: var(--text); color: var(--bg); border-color: var(--text); }
+.day.selected small { color: color-mix(in srgb, var(--bg) 70%, transparent); }
+.day.today:not(.selected) { border-color: var(--border); }
+.day.outside { opacity: .2; }
+.day:disabled { cursor: default; opacity: .25; }
+.day.outside:disabled { opacity: .12; }
+
+.selection-panel { padding: 38px 34px; background: color-mix(in srgb, var(--surface-soft) 52%, var(--surface)); }
+.selection-heading { margin-bottom: 25px; }
+.selection-heading h2 { font-size: 19px; }
+.empty-state {
+  min-height: 100%;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: flex-start;
+  gap: 10px;
+  color: var(--muted);
+  padding: 30px 10px;
+}
+.empty-state svg { color: var(--text); }
+.empty-state h3 { margin: 4px 0 0; color: var(--text); font-size: 18px; letter-spacing: -0.025em; }
+.empty-state p { margin: 0; max-width: 280px; font-size: 13px; line-height: 1.55; }
+.subtle-empty svg { opacity: .45; }
+.loading-row, .no-slots { color: var(--muted); font-size: 13px; display: flex; gap: 9px; align-items: center; padding: 18px 0; }
+.loading-row svg { width: 16px; }
+.time-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
+.time-button {
+  border: 1px solid var(--border);
+  background: var(--surface);
+  border-radius: 11px;
+  padding: 11px 8px;
+  cursor: pointer;
+  font-weight: 650;
+  font-size: 12px;
+  transition: 120ms ease;
+}
+.time-button:hover { border-color: color-mix(in srgb, var(--text) 45%, var(--border)); }
+.time-button.active { background: var(--text); color: var(--bg); border-color: var(--text); }
+.duration-section { border-top: 1px solid var(--border); margin-top: 28px; padding-top: 25px; }
+.duration-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
+.duration-button {
+  border: 1px solid var(--border);
+  border-radius: 12px;
+  padding: 12px 6px 10px;
+  background: var(--surface);
+  cursor: pointer;
+  display: grid;
+  gap: 1px;
+  text-align: center;
+}
+.duration-button strong { font-size: 16px; }
+.duration-button span { font-size: 9px; color: var(--faint); text-transform: uppercase; letter-spacing: .08em; }
+.duration-button.active { background: var(--text); color: var(--bg); border-color: var(--text); }
+.duration-button.active span { color: color-mix(in srgb, var(--bg) 65%, transparent); }
+
+.details-drawer {
+  width: 100%;
+  max-width: 1320px;
+  margin: 0;
+  padding: 26px 28px;
+  border: 1px solid var(--border);
+  border-radius: 22px;
+  background: var(--surface);
+  box-shadow: var(--shadow);
+  display: grid;
+  grid-template-columns: .55fr 1.45fr;
+  gap: 44px;
+  animation: drawer-in 180ms ease-out;
+}
+@keyframes drawer-in { from { opacity: 0; transform: translateY(10px); } }
+.drawer-summary { padding: 8px 4px; }
+.drawer-summary h2 { margin-top: 4px; }
+.drawer-summary p:last-child { color: var(--muted); font-size: 13px; }
+.details-form { display: grid; gap: 16px; }
+.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
+label { color: var(--muted); font-size: 11px; font-weight: 650; display: grid; gap: 7px; }
+input, textarea, select {
+  width: 100%;
+  color: var(--text);
+  background: var(--surface-soft);
+  border: 1px solid var(--border);
+  border-radius: 11px;
+  outline: 0;
+  padding: 11px 12px;
+  font-size: 13px;
+  resize: vertical;
+}
+input:focus, textarea:focus, select:focus { border-color: color-mix(in srgb, var(--text) 55%, var(--border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--text) 7%, transparent); }
+.optional { color: var(--faint); font-weight: 500; }
+.honeypot { position: absolute !important; left: -99999px !important; }
+.mode-picker { display: grid; gap: 8px; }
+.field-label { color: var(--muted); font-size: 11px; font-weight: 650; }
+.mode-options { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
+.mode-option {
+  display: flex;
+  align-items: flex-start;
+  gap: 10px;
+  text-align: left;
+  border: 1px solid var(--border);
+  background: var(--surface);
+  padding: 11px;
+  border-radius: 12px;
+  cursor: pointer;
+}
+.mode-option span { display: grid; gap: 2px; }
+.mode-option strong { font-size: 12px; }
+.mode-option small { color: var(--faint); font-size: 9px; line-height: 1.35; font-weight: 500; }
+.mode-option.active { border-color: var(--text); background: var(--surface-soft); }
+
+.primary-button {
+  border: 0;
+  border-radius: 12px;
+  padding: 12px 15px;
+  background: var(--accent);
+  color: var(--accent-text);
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  font-weight: 720;
+  font-size: 12px;
+  cursor: pointer;
+}
+.primary-button:hover { opacity: .9; }
+.primary-button:disabled { opacity: .5; cursor: wait; }
+.inline-button { width: fit-content; }
+.text-button {
+  border: 0;
+  background: transparent;
+  color: var(--muted);
+  font-size: 12px;
+  cursor: pointer;
+  padding: 8px 0;
+}
+.error-banner, .success-banner, .floating-error {
+  border-radius: 10px;
+  padding: 10px 12px;
+  font-size: 11px;
+  line-height: 1.4;
+}
+.error-banner, .floating-error { color: var(--danger); background: color-mix(in srgb, var(--danger) 9%, transparent); border: 1px solid color-mix(in srgb, var(--danger) 22%, transparent); }
+.success-banner { color: var(--success); background: color-mix(in srgb, var(--success) 8%, transparent); border: 1px solid color-mix(in srgb, var(--success) 22%, transparent); margin-bottom: 14px; }
+.floating-error { width: 100%; max-width: 1320px; margin: 0; }
+.booking-page footer {
+  position: absolute;
+  left: 26px;
+  right: 26px;
+  bottom: 28px;
+  max-width: 1320px;
+  width: calc(100% - 52px);
+  margin: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 10px;
+  color: var(--faint);
+  pointer-events: none;
+}
+.booking-page footer a { pointer-events: auto; }
+.powered-by {
+  opacity: .56;
+  transition: opacity 120ms ease, color 120ms ease;
+}
+.powered-by:hover { opacity: 1; color: var(--muted); }
+.admin-link {
+  position: absolute;
+  right: 4px;
+  opacity: .62;
+}
+.admin-link:hover { opacity: 1; color: var(--muted); }
+
+.confirmation-shell, .admin-login-shell {
+  min-height: 100vh;
+  display: flex;
+  flex-direction: column;
+  padding: 26px;
+}
+.confirmation-shell > .brand, .admin-login-shell > .brand { margin: 0 auto; width: min(100%, 1080px); }
+.confirmation-card, .admin-login-card {
+  margin: auto;
+  width: min(100%, 560px);
+  background: var(--surface);
+  border: 1px solid var(--border);
+  border-radius: 24px;
+  box-shadow: var(--shadow);
+  padding: 44px;
+  display: grid;
+  gap: 16px;
+}
+.success-icon, .admin-login-icon {
+  width: 46px;
+  height: 46px;
+  display: grid;
+  place-items: center;
+  border-radius: 14px;
+  background: var(--surface-soft);
+  margin-bottom: 6px;
+}
+.success-icon { color: var(--success); }
+.confirmation-card h1, .admin-login-card h1 { font-size: 39px; }
+.confirmation-time { font-weight: 680; font-size: 14px; margin: 2px 0 0; }
+.muted, .admin-login-card p { color: var(--muted); font-size: 13px; line-height: 1.55; margin: 0; }
+
+.admin-page { min-height: 100vh; padding: 22px 26px 40px; }
+.admin-topbar { max-width: 1380px; margin-bottom: 24px; justify-content: flex-end; }
+.admin-user { display: flex; align-items: center; gap: 13px; color: var(--muted); font-size: 11px; }
+.admin-user button { width: auto; padding: 0 11px; display: inline-flex; gap: 7px; border-radius: 9px; color: var(--muted); }
+.admin-layout { max-width: 1380px; margin: auto; display: grid; grid-template-columns: 190px 1fr; gap: 28px; }
+.admin-nav { padding: 10px 0; display: flex; flex-direction: column; gap: 5px; position: sticky; top: 18px; height: fit-content; }
+.admin-nav .eyebrow { padding: 0 10px 8px; }
+.admin-nav button, .admin-nav a {
+  border: 0;
+  background: transparent;
+  color: var(--muted);
+  display: flex;
+  align-items: center;
+  gap: 9px;
+  padding: 10px;
+  border-radius: 9px;
+  cursor: pointer;
+  font-size: 12px;
+  text-align: left;
+}
+.admin-nav button.active, .admin-nav button:hover, .admin-nav a:hover { color: var(--text); background: var(--surface-strong); }
+.admin-content { min-width: 0; }
+.admin-heading { display: flex; justify-content: space-between; align-items: end; margin-bottom: 20px; gap: 20px; }
+.admin-heading h1 { font-size: 38px; }
+.primary-button.compact { padding: 10px 13px; }
+.settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
+.settings-card, .booking-list-card {
+  border: 1px solid var(--border);
+  border-radius: 18px;
+  background: var(--surface);
+  padding: 22px;
+}
+.wide-card { grid-column: span 2; }
+.card-heading { margin-bottom: 20px; }
+.card-heading h2 { font-size: 18px; }
+.card-heading p { color: var(--muted); font-size: 11px; line-height: 1.5; margin: 5px 0 0; }
+.settings-card { display: grid; gap: 14px; }
+.appearance-card { gap: 20px; }
+.appearance-heading {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 20px;
+}
+.appearance-heading h2 { margin-top: 0; }
+.appearance-heading p:last-child {
+  color: var(--muted);
+  font-size: 13px;
+  line-height: 1.55;
+  margin: 10px 0 0;
+  max-width: 660px;
+}
+.appearance-heading > span,
+.theme-option em {
+  width: fit-content;
+  border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
+  border-radius: 999px;
+  color: var(--accent);
+  background: color-mix(in srgb, var(--accent) 8%, transparent);
+  padding: 4px 9px;
+  font-size: 10px;
+  font-style: normal;
+  font-weight: 760;
+}
+.theme-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 14px;
+}
+.theme-option {
+  min-height: 116px;
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  background: color-mix(in srgb, var(--surface) 88%, transparent);
+  color: var(--text);
+  display: grid;
+  grid-template-columns: 92px 1fr;
+  gap: 14px;
+  align-items: center;
+  padding: 18px 16px;
+  cursor: pointer;
+  text-align: left;
+}
+.theme-option:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); background: var(--surface-soft); }
+.theme-option.active { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent); }
+.theme-preview {
+  width: 92px;
+  height: 76px;
+  border: 1px solid var(--border);
+  border-radius: 7px;
+  display: grid;
+  grid-template-columns: 25px 1fr;
+  overflow: hidden;
+  background: #ffffff;
+}
+.theme-preview::before { content: ""; border-right: 1px solid rgba(80, 80, 80, .22); background: rgba(120, 130, 145, .12); }
+.theme-preview span {
+  width: 48px;
+  height: 38px;
+  border-radius: 4px;
+  align-self: center;
+  justify-self: center;
+  background: #2b74dd;
+}
+.theme-preview-light { background: #ffffff; }
+.theme-preview-paper { background: #f4f7fb; }
+.theme-preview-paper span { background: #416b98; }
+.theme-preview-beige { background: #fff3de; }
+.theme-preview-beige span { background: #9c5a24; }
+.theme-preview-dark { background: #151821; }
+.theme-preview-dark::before { background: #1f2531; }
+.theme-preview-dark span { background: #82adf6; }
+.theme-preview-midnight { background: #091528; }
+.theme-preview-midnight::before { background: #11213a; }
+.theme-preview-midnight span { background: #6ea8ff; }
+.theme-preview-rosewood { background: #2a1b16; }
+.theme-preview-rosewood::before { background: #1c1210; }
+.theme-preview-rosewood span { background: #d27a3a; }
+.theme-preview-forest { background: #102218; }
+.theme-preview-forest::before { background: #182f22; }
+.theme-preview-forest span { background: #8ec28a; }
+.theme-preview-true-black { background: #000000; }
+.theme-preview-true-black::before { background: #0c0c0d; }
+.theme-preview-true-black span { background: #82adff; }
+.theme-copy { display: grid; gap: 6px; }
+.theme-copy strong { font-size: 16px; letter-spacing: -0.02em; }
+.theme-copy small {
+  color: var(--muted);
+  font-size: 12px;
+  line-height: 1.35;
+  font-weight: 650;
+}
+.theme-footnote {
+  border-top: 1px solid var(--border);
+  color: var(--muted);
+  margin: 0;
+  padding-top: 12px;
+  font-size: 13px;
+}
+.availability-list { display: grid; }
+.availability-row {
+  min-height: 53px;
+  display: grid;
+  grid-template-columns: 1fr auto;
+  gap: 18px;
+  align-items: center;
+  border-top: 1px solid var(--border);
+}
+.availability-row:first-child { border-top: 0; }
+.switch-row { display: flex; align-items: center; gap: 10px; color: var(--text); font-size: 12px; }
+.switch-row input { width: 15px; height: 15px; accent-color: var(--text); }
+.time-range { display: flex; align-items: center; gap: 8px; color: var(--faint); font-size: 10px; }
+.time-range input { width: 108px; padding: 7px 8px; font-size: 11px; }
+.unavailable { color: var(--faint); font-size: 10px; }
+.two-col-fields, .calendar-settings-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
+.timezone-control {
+  display: grid;
+  grid-template-columns: 1fr auto;
+  gap: 8px;
+}
+.timezone-control button {
+  border: 1px solid var(--border);
+  border-radius: 11px;
+  background: var(--surface-soft);
+  color: var(--text);
+  padding: 0 12px;
+  cursor: pointer;
+  font-size: 11px;
+  font-weight: 720;
+  white-space: nowrap;
+}
+.timezone-control button:hover { border-color: color-mix(in srgb, var(--text) 45%, var(--border)); }
+.two-col-fields label { position: relative; }
+.two-col-fields label small { position: absolute; right: 10px; bottom: 11px; color: var(--faint); font-size: 9px; pointer-events: none; }
+.two-col-fields input { padding-right: 62px; }
+.checkbox-chips { display: flex; flex-wrap: wrap; gap: 7px; }
+.checkbox-chips label, .mode-admin-grid label {
+  border: 1px solid var(--border);
+  border-radius: 10px;
+  padding: 8px 10px;
+  color: var(--muted);
+  display: inline-flex;
+  align-items: center;
+  gap: 7px;
+  cursor: pointer;
+  font-size: 10px;
+}
+.checkbox-chips input, .mode-admin-grid input { display: none; }
+.checkbox-chips label.active, .mode-admin-grid label.active { border-color: var(--text); background: var(--surface-soft); color: var(--text); }
+
+.google-status-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 11px 12px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-soft); }
+.connection-badge { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 10px; font-weight: 700; }
+.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); }
+.connection-badge.connected .status-dot { background: var(--success); box-shadow: 0 0 0 4px color-mix(in srgb, var(--success) 10%, transparent); }
+.connection-actions { display: flex; align-items: center; gap: 10px; }
+.connection-actions a, .connection-actions button { border: 0; background: transparent; color: var(--muted); font-size: 10px; padding: 0; cursor: pointer; }
+.connection-actions a:hover, .connection-actions button:hover { color: var(--text); }
+select { width: 100%; color: var(--text); background: var(--surface-soft); border: 1px solid var(--border); border-radius: 11px; outline: 0; padding: 11px 12px; font-size: 13px; }
+
+.mode-admin-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
+.mode-admin-grid label { justify-content: center; padding: 11px 8px; }
+.booking-list-card { padding: 0; overflow: hidden; }
+.booking-list-row {
+  display: grid;
+  grid-template-columns: 38px minmax(170px, 1fr) minmax(210px, 1fr) 80px;
+  align-items: center;
+  gap: 13px;
+  padding: 14px 16px;
+  border-top: 1px solid var(--border);
+  font-size: 11px;
+}
+.booking-list-row:first-child { border-top: 0; }
+.booking-avatar { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; background: var(--surface-soft); font-weight: 750; }
+.booking-person, .booking-when { display: grid; gap: 3px; min-width: 0; }
+.booking-person strong, .booking-when strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.booking-person span, .booking-when span { color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.booking-list-row > a { display: flex; align-items: center; gap: 5px; color: var(--muted); }
+.empty-list { padding: 42px; color: var(--muted); text-align: center; font-size: 12px; }
+
+@media (max-width: 1180px) {
+  .booking-shell { grid-template-columns: 240px minmax(460px, 1fr); }
+  .selection-panel { grid-column: 1 / -1; border-left: 0 !important; border-top: 1px solid var(--border); min-height: 260px; }
+  .selection-panel .empty-state { min-height: 220px; }
+  .time-grid { grid-template-columns: repeat(6, 1fr); }
+}
+
+@media (max-width: 850px) {
+  .booking-page {
+    justify-content: flex-start;
+    align-items: stretch;
+    min-height: 100vh;
+    padding: 70px 14px 42px;
+  }
+  .admin-page { padding: 14px; }
+  .topbar { top: 14px; left: 14px; right: 14px; width: calc(100% - 28px); margin: 0; }
+  .booking-shell { display: block; min-height: 0; border-radius: 20px; }
+  .booking-shell > * + * { border-left: 0; border-top: 1px solid var(--border); }
+  .intro-panel { min-height: 290px; padding: 30px 24px; }
+  .calendar-panel, .selection-panel { padding: 28px 22px; }
+  .day { min-height: 47px; }
+  .time-grid { grid-template-columns: repeat(4, 1fr); }
+  .details-drawer { grid-template-columns: 1fr; gap: 14px; padding: 22px; }
+  .admin-layout { grid-template-columns: 1fr; }
+  .admin-nav { position: static; flex-direction: row; overflow-x: auto; padding: 0; }
+  .settings-grid { grid-template-columns: 1fr; }
+  .wide-card { grid-column: span 1; }
+  .appearance-heading { display: grid; }
+  .theme-grid { grid-template-columns: 1fr; }
+  .mode-admin-grid { grid-template-columns: repeat(2, 1fr); }
+  .booking-list-row { grid-template-columns: 38px 1fr; }
+  .booking-when { grid-column: 2; }
+  .booking-list-row > a { grid-column: 2; }
+}
+
+@media (max-width: 560px) {
+  .timezone-pill { display: none; }
+  .calendar-panel { padding-left: 14px; padding-right: 14px; }
+  .calendar-grid { gap: 3px; }
+  .day { min-height: 41px; border-radius: 10px; }
+  .day span { font-size: 11px; }
+  .day small { display: none; }
+  .time-grid { grid-template-columns: repeat(3, 1fr); }
+  .duration-grid { grid-template-columns: repeat(4, 1fr); }
+  .form-row, .mode-options, .two-col-fields, .calendar-settings-row { grid-template-columns: 1fr; }
+  .timezone-control { grid-template-columns: 1fr; }
+  .confirmation-card, .admin-login-card { padding: 28px 22px; }
+  .admin-user > span { display: none; }
+  .admin-heading { align-items: flex-start; flex-direction: column; }
+  .primary-button.compact { width: 100%; }
+  .theme-option { grid-template-columns: 72px 1fr; padding: 14px; }
+  .theme-preview { width: 72px; height: 60px; }
+  .theme-preview span { width: 36px; height: 30px; }
+}
+
+@media (min-width: 851px) and (max-height: 840px) {
+  .booking-page {
+    justify-content: flex-start;
+    padding-top: 86px;
+    padding-bottom: 54px;
+  }
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1 @@
+/// <reference types="vite/client" />
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..0396d0f
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,21 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "useDefineForClassFields": true,
+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
+    "allowJs": false,
+    "skipLibCheck": true,
+    "esModuleInterop": true,
+    "allowSyntheticDefaultImports": true,
+    "strict": true,
+    "forceConsistentCasingInFileNames": true,
+    "module": "ESNext",
+    "moduleResolution": "Bundler",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "noEmit": true,
+    "jsx": "react-jsx",
+    "types": ["@cloudflare/workers-types"]
+  },
+  "include": ["src", "worker", "vite.config.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..dc25296
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import { cloudflare } from '@cloudflare/vite-plugin';
+
+export default defineConfig({
+  plugins: [react(), cloudflare()],
+});
diff --git a/worker/crypto.ts b/worker/crypto.ts
new file mode 100644
index 0000000..5017901
--- /dev/null
+++ b/worker/crypto.ts
@@ -0,0 +1,104 @@
+import type { Env } from './types';
+
+const encoder = new TextEncoder();
+const decoder = new TextDecoder();
+
+function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
+  return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
+}
+
+function base64Url(bytes: Uint8Array): string {
+  let binary = '';
+  for (const byte of bytes) binary += String.fromCharCode(byte);
+  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
+}
+
+function fromBase64(value: string): Uint8Array {
+  const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
+  const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
+  const binary = atob(padded);
+  return Uint8Array.from(binary, (char) => char.charCodeAt(0));
+}
+
+async function hmacKey(secret: string): Promise<CryptoKey> {
+  return crypto.subtle.importKey(
+    'raw',
+    encoder.encode(secret),
+    { name: 'HMAC', hash: 'SHA-256' },
+    false,
+    ['sign', 'verify'],
+  );
+}
+
+export async function signSession(env: Env, email: string): Promise<string> {
+  const payload = base64Url(encoder.encode(JSON.stringify({ email, exp: Date.now() + 7 * 86400_000 })));
+  const signature = new Uint8Array(
+    await crypto.subtle.sign('HMAC', await hmacKey(env.SESSION_SECRET), encoder.encode(payload)),
+  );
+  return `${payload}.${base64Url(signature)}`;
+}
+
+export async function verifySession(env: Env, token: string | null): Promise<string | null> {
+  if (!token) return null;
+  const [payload, signature] = token.split('.');
+  if (!payload || !signature) return null;
+  const ok = await crypto.subtle.verify(
+    'HMAC',
+    await hmacKey(env.SESSION_SECRET),
+    toArrayBuffer(fromBase64(signature)),
+    encoder.encode(payload),
+  );
+  if (!ok) return null;
+
+  try {
+    const parsed = JSON.parse(decoder.decode(fromBase64(payload))) as { email?: string; exp?: number };
+    if (!parsed.email || !parsed.exp || parsed.exp < Date.now()) return null;
+    return parsed.email;
+  } catch {
+    return null;
+  }
+}
+
+async function encryptionKey(env: Env): Promise<CryptoKey> {
+  const raw = fromBase64(env.TOKEN_ENCRYPTION_KEY);
+  if (raw.byteLength !== 32) throw new Error('TOKEN_ENCRYPTION_KEY must decode to 32 bytes');
+  return crypto.subtle.importKey('raw', toArrayBuffer(raw), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
+}
+
+export async function encryptToken(env: Env, value: string): Promise<string> {
+  const iv = crypto.getRandomValues(new Uint8Array(12));
+  const ciphertext = new Uint8Array(
+    await crypto.subtle.encrypt({ name: 'AES-GCM', iv: toArrayBuffer(iv) }, await encryptionKey(env), encoder.encode(value)),
+  );
+  return `${base64Url(iv)}.${base64Url(ciphertext)}`;
+}
+
+export async function decryptToken(env: Env, value: string): Promise<string> {
+  const [ivText, ciphertextText] = value.split('.');
+  if (!ivText || !ciphertextText) throw new Error('Invalid encrypted token');
+  const plaintext = await crypto.subtle.decrypt(
+    { name: 'AES-GCM', iv: toArrayBuffer(fromBase64(ivText)) },
+    await encryptionKey(env),
+    toArrayBuffer(fromBase64(ciphertextText)),
+  );
+  return decoder.decode(plaintext);
+}
+
+export function getCookie(request: Request, key: string): string | null {
+  const cookie = request.headers.get('Cookie') ?? '';
+  for (const part of cookie.split(';')) {
+    const [name, ...rest] = part.trim().split('=');
+    if (name === key) return decodeURIComponent(rest.join('='));
+  }
+  return null;
+}
+
+export function sessionCookie(value: string, maxAge = 7 * 86400, secure = true): string {
+  const securePart = secure ? '; Secure' : '';
+  return `meet_session=${encodeURIComponent(value)}; Path=/; HttpOnly${securePart}; SameSite=Lax; Max-Age=${maxAge}`;
+}
+
+export function clearSessionCookie(secure = true): string {
+  const securePart = secure ? '; Secure' : '';
+  return `meet_session=; Path=/; HttpOnly${securePart}; SameSite=Lax; Max-Age=0`;
+}
diff --git a/worker/google.ts b/worker/google.ts
new file mode 100644
index 0000000..f04e501
--- /dev/null
+++ b/worker/google.ts
@@ -0,0 +1,275 @@
+import { decryptToken, encryptToken } from './crypto';
+import type { AppSettings, Env, MeetingMode } from './types';
+
+const GOOGLE_SCOPES = [
+  'openid',
+  'email',
+  'profile',
+  'https://www.googleapis.com/auth/calendar.events',
+  'https://www.googleapis.com/auth/calendar.events.freebusy',
+];
+
+interface TokenResponse {
+  access_token: string;
+  expires_in: number;
+  refresh_token?: string;
+  scope?: string;
+  token_type: string;
+  error?: string;
+  error_description?: string;
+}
+
+interface GoogleUser {
+  email: string;
+  name?: string;
+  picture?: string;
+}
+
+interface GoogleEventResponse {
+  id: string;
+  htmlLink?: string;
+  hangoutLink?: string;
+  conferenceData?: {
+    entryPoints?: Array<{ entryPointType: string; uri: string }>;
+  };
+}
+
+function redirectUri(env: Env): string {
+  return `${env.APP_URL.replace(/\/$/, '')}/auth/google/callback`;
+}
+
+export async function hasGoogleConnection(env: Env): Promise<boolean> {
+  const row = await env.DB.prepare('SELECT 1 AS ok FROM oauth_tokens WHERE provider = ?1')
+    .bind('google')
+    .first<{ ok: number }>();
+  return Boolean(row?.ok);
+}
+
+export async function createGoogleAuthUrl(env: Env, forceConsent = false): Promise<string> {
+  const state = crypto.randomUUID();
+  const expires = Date.now() + 10 * 60_000;
+  await env.DB.prepare('INSERT INTO oauth_states (state, expires_at) VALUES (?1, ?2)')
+    .bind(state, expires)
+    .run();
+
+  const connected = await hasGoogleConnection(env);
+  const params = new URLSearchParams({
+    client_id: env.GOOGLE_CLIENT_ID,
+    redirect_uri: redirectUri(env),
+    response_type: 'code',
+    scope: GOOGLE_SCOPES.join(' '),
+    access_type: 'offline',
+    include_granted_scopes: 'true',
+    state,
+  });
+
+  params.set('prompt', forceConsent || !connected ? 'consent' : 'select_account');
+  if (env.ADMIN_EMAIL) params.set('login_hint', env.ADMIN_EMAIL);
+  return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
+}
+
+async function exchangeCode(env: Env, code: string): Promise<TokenResponse> {
+  const body = new URLSearchParams({
+    code,
+    client_id: env.GOOGLE_CLIENT_ID,
+    client_secret: env.GOOGLE_CLIENT_SECRET,
+    redirect_uri: redirectUri(env),
+    grant_type: 'authorization_code',
+  });
+
+  const response = await fetch('https://oauth2.googleapis.com/token', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+    body,
+  });
+  const data = (await response.json()) as TokenResponse;
+  if (!response.ok || data.error) {
+    throw new Error(data.error_description || data.error || `Google token exchange failed (${response.status})`);
+  }
+  return data;
+}
+
+async function fetchGoogleUser(accessToken: string): Promise<GoogleUser> {
+  const response = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
+    headers: { Authorization: `Bearer ${accessToken}` },
+  });
+  if (!response.ok) throw new Error('Could not read Google account identity');
+  return (await response.json()) as GoogleUser;
+}
+
+export async function completeGoogleOAuth(
+  env: Env,
+  state: string,
+  code: string,
+): Promise<string> {
+  const stateRow = await env.DB.prepare('SELECT expires_at FROM oauth_states WHERE state = ?1')
+    .bind(state)
+    .first<{ expires_at: number }>();
+
+  await env.DB.prepare('DELETE FROM oauth_states WHERE state = ?1').bind(state).run();
+  if (!stateRow || stateRow.expires_at < Date.now()) throw new Error('OAuth state expired or invalid');
+
+  const token = await exchangeCode(env, code);
+  const user = await fetchGoogleUser(token.access_token);
+
+  if (user.email.toLowerCase() !== env.ADMIN_EMAIL.toLowerCase()) {
+    throw new Error(`This Google account is not the configured admin (${env.ADMIN_EMAIL})`);
+  }
+
+  if (token.refresh_token) {
+    const encrypted = await encryptToken(env, token.refresh_token);
+    await env.DB.prepare(`
+      INSERT INTO oauth_tokens (provider, refresh_token_enc, account_email, updated_at)
+      VALUES ('google', ?1, ?2, datetime('now'))
+      ON CONFLICT(provider) DO UPDATE SET
+        refresh_token_enc = excluded.refresh_token_enc,
+        account_email = excluded.account_email,
+        updated_at = excluded.updated_at
+    `).bind(encrypted, user.email).run();
+  } else {
+    const existing = await hasGoogleConnection(env);
+    if (!existing) {
+      throw new Error('Google did not return a refresh token. Revoke app access and connect again.');
+    }
+  }
+
+  return user.email;
+}
+
+async function accessToken(env: Env): Promise<string> {
+  const row = await env.DB.prepare(
+    'SELECT refresh_token_enc FROM oauth_tokens WHERE provider = ?1',
+  ).bind('google').first<{ refresh_token_enc: string }>();
+
+  if (!row?.refresh_token_enc) throw new Error('Google Calendar is not connected');
+  const refreshToken = await decryptToken(env, row.refresh_token_enc);
+
+  const response = await fetch('https://oauth2.googleapis.com/token', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+    body: new URLSearchParams({
+      client_id: env.GOOGLE_CLIENT_ID,
+      client_secret: env.GOOGLE_CLIENT_SECRET,
+      refresh_token: refreshToken,
+      grant_type: 'refresh_token',
+    }),
+  });
+
+  const data = (await response.json()) as TokenResponse;
+  if (!response.ok || !data.access_token) {
+    throw new Error(data.error_description || data.error || 'Google refresh token failed');
+  }
+  return data.access_token;
+}
+
+export async function queryBusy(
+  env: Env,
+  settings: AppSettings,
+  timeMin: string,
+  timeMax: string,
+): Promise<Array<{ start: string; end: string }>> {
+  const token = await accessToken(env);
+  const calendars = settings.busyCalendarIds.length ? settings.busyCalendarIds : [settings.calendarId];
+
+  const response = await fetch('https://www.googleapis.com/calendar/v3/freeBusy', {
+    method: 'POST',
+    headers: {
+      Authorization: `Bearer ${token}`,
+      'Content-Type': 'application/json',
+    },
+    body: JSON.stringify({
+      timeMin,
+      timeMax,
+      timeZone: settings.timezone,
+      items: calendars.map((id) => ({ id })),
+    }),
+  });
+
+  if (!response.ok) {
+    const message = await response.text();
+    throw new Error(`Google FreeBusy failed (${response.status}): ${message.slice(0, 300)}`);
+  }
+
+  const data = (await response.json()) as {
+    calendars?: Record<string, { busy?: Array<{ start: string; end: string }> }>;
+  };
+
+  return Object.values(data.calendars ?? {}).flatMap((calendar) => calendar.busy ?? []);
+}
+
+export async function createCalendarEvent(
+  env: Env,
+  settings: AppSettings,
+  booking: {
+    id: string;
+    name: string;
+    email: string;
+    phone?: string;
+    message?: string;
+    start: string;
+    end: string;
+    meetingMode: MeetingMode;
+  },
+): Promise<{ eventId: string; meetUrl: string | null; htmlLink: string | null }> {
+  const token = await accessToken(env);
+  const params = new URLSearchParams({
+    sendUpdates: 'all',
+    conferenceDataVersion: '1',
+  });
+  const calendarId = encodeURIComponent(settings.calendarId || 'primary');
+
+  const descriptionLines = [
+    `Booked through ${env.APP_URL}`,
+    booking.phone ? `Phone: ${booking.phone}` : '',
+    booking.message ? `Message: ${booking.message}` : '',
+  ].filter(Boolean);
+
+  const event: Record<string, unknown> = {
+    summary: `Meeting with ${booking.name}`,
+    description: descriptionLines.join('\n\n'),
+    start: { dateTime: booking.start, timeZone: settings.timezone },
+    end: { dateTime: booking.end, timeZone: settings.timezone },
+    attendees: [{ email: booking.email, displayName: booking.name }],
+  };
+
+  if (booking.meetingMode === 'google_meet') {
+    event.conferenceData = {
+      createRequest: {
+        requestId: booking.id,
+        conferenceSolutionKey: { type: 'hangoutsMeet' },
+      },
+    };
+  } else if (booking.meetingMode === 'in_person' && settings.inPersonLocation) {
+    event.location = settings.inPersonLocation;
+  } else if (booking.meetingMode === 'phone') {
+    event.location = 'Phone call';
+  }
+
+  const response = await fetch(
+    `https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events?${params.toString()}`,
+    {
+      method: 'POST',
+      headers: {
+        Authorization: `Bearer ${token}`,
+        'Content-Type': 'application/json',
+      },
+      body: JSON.stringify(event),
+    },
+  );
+
+  const data = (await response.json()) as GoogleEventResponse & { error?: { message?: string } };
+  if (!response.ok || !data.id) {
+    throw new Error(data.error?.message || `Google event creation failed (${response.status})`);
+  }
+
+  const meetUrl =
+    data.hangoutLink ??
+    data.conferenceData?.entryPoints?.find((entry) => entry.entryPointType === 'video')?.uri ??
+    null;
+
+  return { eventId: data.id, meetUrl, htmlLink: data.htmlLink ?? null };
+}
+
+export async function disconnectGoogle(env: Env): Promise<void> {
+  await env.DB.prepare("DELETE FROM oauth_tokens WHERE provider = 'google'").run();
+}
diff --git a/worker/index.ts b/worker/index.ts
new file mode 100644
index 0000000..a6c8feb
--- /dev/null
+++ b/worker/index.ts
@@ -0,0 +1,470 @@
+import {
+  clearSessionCookie,
+  getCookie,
+  sessionCookie,
+  signSession,
+  verifySession,
+} from './crypto';
+import {
+  completeGoogleOAuth,
+  createCalendarEvent,
+  createGoogleAuthUrl,
+  disconnectGoogle,
+  hasGoogleConnection,
+  queryBusy,
+} from './google';
+import { DEFAULT_SETTINGS, getSettings, saveSettings } from './settings';
+import {
+  addDaysToDateKey,
+  addMinutes,
+  dateKeyInZone,
+  overlaps,
+  slotKeys,
+  timeToMinutes,
+  weekdayForDate,
+  zonedDateTimeToUtc,
+} from './time';
+import type { AppSettings, BookingRequest, BusyRange, Env, MeetingMode } from './types';
+
+const JSON_HEADERS = {
+  'Content-Type': 'application/json; charset=utf-8',
+  'Cache-Control': 'no-store',
+};
+
+function json(data: unknown, status = 200, headers: HeadersInit = {}): Response {
+  return new Response(JSON.stringify(data), {
+    status,
+    headers: { ...JSON_HEADERS, ...headers },
+  });
+}
+
+function redirect(location: string, headers: HeadersInit = {}): Response {
+  return new Response(null, { status: 302, headers: { Location: location, ...headers } });
+}
+
+function errorMessage(error: unknown): string {
+  return error instanceof Error ? error.message : 'Unexpected error';
+}
+
+function isEmail(value: string): boolean {
+  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
+}
+
+async function enforceBookingRateLimit(request: Request, env: Env): Promise<boolean> {
+  const ip = request.headers.get('CF-Connecting-IP');
+  if (!ip) return true; // Local development.
+
+  const hourBucket = Math.floor(Date.now() / 3_600_000);
+  const material = new TextEncoder().encode(`${ip}:${hourBucket}:${env.SESSION_SECRET}`);
+  const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', material));
+  const key = Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('');
+  const resetAt = (hourBucket + 1) * 3_600_000;
+
+  await env.DB.prepare('DELETE FROM rate_limits WHERE reset_at < ?1').bind(Date.now()).run();
+  await env.DB.prepare(`
+    INSERT INTO rate_limits (key, count, reset_at)
+    VALUES (?1, 1, ?2)
+    ON CONFLICT(key) DO UPDATE SET count = count + 1
+  `).bind(key, resetAt).run();
+  const row = await env.DB.prepare('SELECT count FROM rate_limits WHERE key = ?1')
+    .bind(key)
+    .first<{ count: number }>();
+  return (row?.count ?? 1) <= 12;
+}
+
+async function requireAdmin(request: Request, env: Env): Promise<string | null> {
+  const email = await verifySession(env, getCookie(request, 'meet_session'));
+  if (!email || email.toLowerCase() !== env.ADMIN_EMAIL.toLowerCase()) return null;
+  return email;
+}
+
+function validateAppSettings(input: unknown): AppSettings {
+  if (!input || typeof input !== 'object') throw new Error('Invalid settings payload');
+  const raw = input as Partial<AppSettings>;
+  const allowedModes: MeetingMode[] = ['google_meet', 'phone', 'in_person', 'decide_later'];
+
+  const durations = Array.isArray(raw.durations)
+    ? [...new Set(raw.durations.map(Number).filter((value) => value >= 10 && value <= 240))].sort((a, b) => a - b)
+    : DEFAULT_SETTINGS.durations;
+  if (!durations.length) throw new Error('At least one duration is required');
+
+  const availability: AppSettings['availability'] = {};
+  for (let day = 0; day <= 6; day += 1) {
+    const intervals = raw.availability?.[String(day)] ?? [];
+    availability[String(day)] = intervals
+      .filter((interval) => /^\d{2}:\d{2}$/.test(interval.start) && /^\d{2}:\d{2}$/.test(interval.end))
+      .filter((interval) => timeToMinutes(interval.end) > timeToMinutes(interval.start))
+      .sort((a, b) => timeToMinutes(a.start) - timeToMinutes(b.start))
+      .slice(0, 3);
+  }
+
+  const allowMeetingModes = Array.isArray(raw.allowMeetingModes)
+    ? raw.allowMeetingModes.filter((mode): mode is MeetingMode => allowedModes.includes(mode as MeetingMode))
+    : DEFAULT_SETTINGS.allowMeetingModes;
+  const normalizedModes = allowMeetingModes.length ? allowMeetingModes : ['google_meet'] as MeetingMode[];
+  const defaultMeetingMode = normalizedModes.includes(raw.defaultMeetingMode as MeetingMode)
+    ? (raw.defaultMeetingMode as MeetingMode)
+    : normalizedModes[0];
+
+  return {
+    title: String(raw.title ?? DEFAULT_SETTINGS.title).trim().slice(0, 100) || DEFAULT_SETTINGS.title,
+    subtitle: String(raw.subtitle ?? DEFAULT_SETTINGS.subtitle).trim().slice(0, 240),
+    timezone: String(raw.timezone ?? DEFAULT_SETTINGS.timezone).trim() || DEFAULT_SETTINGS.timezone,
+    bookingWindowDays: Math.min(365, Math.max(1, Number(raw.bookingWindowDays ?? DEFAULT_SETTINGS.bookingWindowDays))),
+    minimumNoticeMinutes: Math.min(10080, Math.max(0, Number(raw.minimumNoticeMinutes ?? 120))),
+    bufferBeforeMinutes: Math.min(180, Math.max(0, Number(raw.bufferBeforeMinutes ?? 0))),
+    bufferAfterMinutes: Math.min(180, Math.max(0, Number(raw.bufferAfterMinutes ?? 15))),
+    slotIncrementMinutes: [5, 10, 15, 20, 30, 60].includes(Number(raw.slotIncrementMinutes))
+      ? Number(raw.slotIncrementMinutes)
+      : 15,
+    durations,
+    calendarId: String(raw.calendarId ?? 'primary').trim() || 'primary',
+    busyCalendarIds: Array.isArray(raw.busyCalendarIds)
+      ? raw.busyCalendarIds.map(String).map((value) => value.trim()).filter(Boolean).slice(0, 20)
+      : ['primary'],
+    defaultMeetingMode,
+    allowMeetingModes: normalizedModes,
+    inPersonLocation: String(raw.inPersonLocation ?? '').trim().slice(0, 240),
+    availability,
+  };
+}
+
+async function d1BusyRanges(env: Env, start: number, end: number): Promise<BusyRange[]> {
+  await env.DB.prepare("DELETE FROM slot_locks WHERE expires_at < ?1").bind(Date.now()).run();
+  await env.DB.prepare(
+    "DELETE FROM bookings WHERE status = 'pending' AND created_at < datetime('now', '-10 minutes')",
+  ).run();
+
+  const result = await env.DB.prepare(`
+    SELECT start_time, end_time
+    FROM bookings
+    WHERE (
+        status = 'pending'
+        OR status = 'confirmed'
+      )
+      AND start_time < ?1
+      AND end_time > ?2
+  `).bind(new Date(end).toISOString(), new Date(start).toISOString()).all<{
+    start_time: string;
+    end_time: string;
+  }>();
+
+  return (result.results ?? []).map((row) => ({
+    start: Date.parse(row.start_time),
+    end: Date.parse(row.end_time),
+  }));
+}
+
+async function computeAvailability(env: Env, dateKey: string) {
+  const settings = await getSettings(env);
+  const connected = await hasGoogleConnection(env);
+  if (!connected) {
+    return { settings, connected, date: dateKey, slots: [] as Array<{ start: string; time: string; durations: number[] }> };
+  }
+
+  if (!/^\d{4}-\d{2}-\d{2}$/.test(dateKey)) throw new Error('Invalid date');
+  const today = dateKeyInZone(new Date(), settings.timezone);
+  const maxDate = addDaysToDateKey(today, settings.bookingWindowDays);
+  if (dateKey < today || dateKey > maxDate) {
+    return { settings, connected, date: dateKey, slots: [] as Array<{ start: string; time: string; durations: number[] }> };
+  }
+
+  const day = weekdayForDate(dateKey, settings.timezone);
+  const intervals = settings.availability[String(day)] ?? [];
+  if (!intervals.length) {
+    return { settings, connected, date: dateKey, slots: [] as Array<{ start: string; time: string; durations: number[] }> };
+  }
+
+  const queryStart = zonedDateTimeToUtc(dateKey, intervals[0].start, settings.timezone).getTime() - 6 * 3600_000;
+  const queryEnd = zonedDateTimeToUtc(dateKey, intervals[intervals.length - 1].end, settings.timezone).getTime() + 6 * 3600_000;
+  const [googleBusy, storedBusy] = await Promise.all([
+    queryBusy(env, settings, new Date(queryStart).toISOString(), new Date(queryEnd).toISOString()),
+    d1BusyRanges(env, queryStart, queryEnd),
+  ]);
+
+  const busy: BusyRange[] = [
+    ...googleBusy.map((range) => ({ start: Date.parse(range.start), end: Date.parse(range.end) })),
+    ...storedBusy,
+  ];
+  const earliestAllowed = Date.now() + settings.minimumNoticeMinutes * 60_000;
+  const slots: Array<{ start: string; time: string; durations: number[] }> = [];
+
+  for (const interval of intervals) {
+    const intervalStartMinutes = timeToMinutes(interval.start);
+    const intervalEndMinutes = timeToMinutes(interval.end);
+
+    for (
+      let minute = intervalStartMinutes;
+      minute < intervalEndMinutes;
+      minute += settings.slotIncrementMinutes
+    ) {
+      const hh = String(Math.floor(minute / 60)).padStart(2, '0');
+      const mm = String(minute % 60).padStart(2, '0');
+      const time = `${hh}:${mm}`;
+      const start = zonedDateTimeToUtc(dateKey, time, settings.timezone).getTime();
+      if (start < earliestAllowed) continue;
+
+      const availableDurations = settings.durations.filter((duration) => {
+        const endMinute = minute + duration;
+        if (endMinute > intervalEndMinutes) return false;
+        const bufferedStart = addMinutes(start, -settings.bufferBeforeMinutes);
+        const bufferedEnd = addMinutes(start, duration + settings.bufferAfterMinutes);
+        return !overlaps(bufferedStart, bufferedEnd, busy);
+      });
+
+      if (availableDurations.length) {
+        slots.push({ start: new Date(start).toISOString(), time, durations: availableDurations });
+      }
+    }
+  }
+
+  return { settings, connected, date: dateKey, slots };
+}
+
+async function createBooking(env: Env, httpRequest: Request, request: BookingRequest): Promise<Response> {
+  if (request.website) return json({ ok: true, message: 'Booked' }); // honeypot
+  if (!(await enforceBookingRateLimit(httpRequest, env))) {
+    return json({ error: 'Too many booking attempts from this connection. Try again later.' }, 429);
+  }
+  const name = String(request.name ?? '').trim().slice(0, 120);
+  const email = String(request.email ?? '').trim().toLowerCase().slice(0, 254);
+  const phone = String(request.phone ?? '').trim().slice(0, 80);
+  const message = String(request.message ?? '').trim().slice(0, 2000);
+  const duration = Number(request.duration);
+  const startMs = Date.parse(request.start);
+
+  if (!name || !isEmail(email) || !Number.isFinite(startMs)) {
+    return json({ error: 'Name, a valid email, and a start time are required.' }, 400);
+  }
+
+  const settings = await getSettings(env);
+  if (!settings.durations.includes(duration)) return json({ error: 'That duration is not available.' }, 400);
+  if (!settings.allowMeetingModes.includes(request.meetingMode)) {
+    return json({ error: 'That meeting type is not available.' }, 400);
+  }
+  if (request.meetingMode === 'phone' && !phone) {
+    return json({ error: 'A phone number is required for a phone meeting.' }, 400);
+  }
+
+  const dateKey = dateKeyInZone(new Date(startMs), settings.timezone);
+  const availability = await computeAvailability(env, dateKey);
+  const matchingSlot = availability.slots.find((slot) => slot.start === new Date(startMs).toISOString());
+  if (!matchingSlot || !matchingSlot.durations.includes(duration)) {
+    return json({ error: 'That slot is no longer available. Pick another time.' }, 409);
+  }
+
+  const endMs = addMinutes(startMs, duration);
+  const lockStart = addMinutes(startMs, -settings.bufferBeforeMinutes);
+  const lockEnd = addMinutes(endMs, settings.bufferAfterMinutes);
+  const id = crypto.randomUUID();
+  const lockExpiry = Date.now() + 3 * 60_000;
+  const keys = slotKeys(lockStart, lockEnd, settings.slotIncrementMinutes);
+
+  await env.DB.prepare('DELETE FROM slot_locks WHERE expires_at < ?1').bind(Date.now()).run();
+
+  const statements: D1PreparedStatement[] = [
+    env.DB.prepare(`
+      INSERT INTO bookings (
+        id, name, email, phone, message, start_time, end_time,
+        duration_minutes, timezone, meeting_mode, status
+      ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'pending')
+    `).bind(
+      id,
+      name,
+      email,
+      phone || null,
+      message || null,
+      new Date(startMs).toISOString(),
+      new Date(endMs).toISOString(),
+      duration,
+      settings.timezone,
+      request.meetingMode,
+    ),
+    ...keys.map((key) =>
+      env.DB.prepare('INSERT INTO slot_locks (slot_key, booking_id, expires_at) VALUES (?1, ?2, ?3)')
+        .bind(key, id, lockExpiry),
+    ),
+  ];
+
+  try {
+    await env.DB.batch(statements);
+  } catch {
+    return json({ error: 'Someone just took that time. Please choose another slot.' }, 409);
+  }
+
+  try {
+    // Google is checked once more after the D1 reservation, limiting double-booking races.
+    const fresh = await queryBusy(
+      env,
+      settings,
+      new Date(lockStart).toISOString(),
+      new Date(lockEnd).toISOString(),
+    );
+    const conflicts = fresh.some((range) =>
+      lockStart < Date.parse(range.end) && lockEnd > Date.parse(range.start),
+    );
+    if (conflicts) throw new Error('That slot became busy in Google Calendar.');
+
+    const created = await createCalendarEvent(env, settings, {
+      id,
+      name,
+      email,
+      phone: phone || undefined,
+      message: message || undefined,
+      start: new Date(startMs).toISOString(),
+      end: new Date(endMs).toISOString(),
+      meetingMode: request.meetingMode,
+    });
+
+    const keepLockUntil = Date.now() + 10 * 60_000;
+    await env.DB.batch([
+      env.DB.prepare(`
+        UPDATE bookings
+        SET status = 'confirmed', google_event_id = ?1, meet_url = ?2
+        WHERE id = ?3
+      `).bind(created.eventId, created.meetUrl, id),
+      env.DB.prepare('UPDATE slot_locks SET expires_at = ?1 WHERE booking_id = ?2')
+        .bind(keepLockUntil, id),
+    ]);
+
+    return json({
+      ok: true,
+      booking: {
+        id,
+        start: new Date(startMs).toISOString(),
+        end: new Date(endMs).toISOString(),
+        duration,
+        timezone: settings.timezone,
+        meetUrl: created.meetUrl,
+      },
+    }, 201);
+  } catch (error) {
+    await env.DB.batch([
+      env.DB.prepare('DELETE FROM slot_locks WHERE booking_id = ?1').bind(id),
+      env.DB.prepare('DELETE FROM bookings WHERE id = ?1').bind(id),
+    ]).catch(() => undefined);
+    const message = errorMessage(error);
+    const status = message.includes('became busy') ? 409 : 502;
+    return json({ error: message }, status);
+  }
+}
+
+async function handleApi(request: Request, env: Env, url: URL): Promise<Response> {
+  if (request.method === 'GET' && url.pathname === '/api/config') {
+    const settings = await getSettings(env);
+    const connected = await hasGoogleConnection(env);
+    return json({
+      title: settings.title,
+      subtitle: settings.subtitle,
+      timezone: settings.timezone,
+      bookingWindowDays: settings.bookingWindowDays,
+      durations: settings.durations,
+      defaultMeetingMode: settings.defaultMeetingMode,
+      allowMeetingModes: settings.allowMeetingModes,
+      availableWeekdays: Object.entries(settings.availability)
+        .filter(([, intervals]) => intervals.length > 0)
+        .map(([day]) => Number(day)),
+      connected,
+    });
+  }
+
+  if (request.method === 'GET' && url.pathname === '/api/availability') {
+    const date = url.searchParams.get('date') ?? '';
+    const result = await computeAvailability(env, date);
+    return json({
+      date: result.date,
+      timezone: result.settings.timezone,
+      connected: result.connected,
+      slots: result.slots,
+    });
+  }
+
+  if (request.method === 'POST' && url.pathname === '/api/book') {
+    const body = (await request.json()) as BookingRequest;
+    return createBooking(env, request, body);
+  }
+
+  if (url.pathname.startsWith('/api/admin/')) {
+    const admin = await requireAdmin(request, env);
+    if (!admin) return json({ error: 'Unauthorized' }, 401);
+
+    if (request.method === 'GET' && url.pathname === '/api/admin/session') {
+      const connected = await hasGoogleConnection(env);
+      return json({ authenticated: true, email: admin, connected });
+    }
+
+    if (request.method === 'GET' && url.pathname === '/api/admin/settings') {
+      return json({ settings: await getSettings(env) });
+    }
+
+    if (request.method === 'PUT' && url.pathname === '/api/admin/settings') {
+      const settings = validateAppSettings(await request.json());
+      await saveSettings(env, settings);
+      return json({ ok: true, settings });
+    }
+
+    if (request.method === 'GET' && url.pathname === '/api/admin/bookings') {
+      const result = await env.DB.prepare(`
+        SELECT id, name, email, start_time, end_time, duration_minutes,
+               meeting_mode, status, meet_url, created_at
+        FROM bookings
+        WHERE status = 'confirmed'
+        ORDER BY start_time DESC
+        LIMIT 50
+      `).all();
+      return json({ bookings: result.results ?? [] });
+    }
+
+    if (request.method === 'POST' && url.pathname === '/api/admin/disconnect-google') {
+      await disconnectGoogle(env);
+      return json({ ok: true });
+    }
+
+    if (request.method === 'POST' && url.pathname === '/api/admin/logout') {
+      return json({ ok: true }, 200, { 'Set-Cookie': clearSessionCookie(env.APP_URL.startsWith('https://')) });
+    }
+  }
+
+  return json({ error: 'Not found' }, 404);
+}
+
+async function handleAuth(request: Request, env: Env, url: URL): Promise<Response> {
+  if (request.method === 'GET' && url.pathname === '/auth/google/start') {
+    return redirect(await createGoogleAuthUrl(env, url.searchParams.get('force') === '1'));
+  }
+
+  if (request.method === 'GET' && url.pathname === '/auth/google/callback') {
+    const error = url.searchParams.get('error');
+    if (error) return redirect(`/admin?oauth_error=${encodeURIComponent(error)}`);
+    const state = url.searchParams.get('state');
+    const code = url.searchParams.get('code');
+    if (!state || !code) return redirect('/admin?oauth_error=missing_callback_parameters');
+
+    try {
+      const email = await completeGoogleOAuth(env, state, code);
+      const token = await signSession(env, email);
+      return redirect('/admin?connected=1', {
+        'Set-Cookie': sessionCookie(token, 7 * 86400, env.APP_URL.startsWith('https://')),
+      });
+    } catch (error) {
+      return redirect(`/admin?oauth_error=${encodeURIComponent(errorMessage(error))}`);
+    }
+  }
+
+  return new Response('Not found', { status: 404 });
+}
+
+export default {
+  async fetch(request: Request, env: Env): Promise<Response> {
+    const url = new URL(request.url);
+    try {
+      if (url.pathname.startsWith('/api/')) return await handleApi(request, env, url);
+      if (url.pathname.startsWith('/auth/')) return await handleAuth(request, env, url);
+      return new Response('Not found', { status: 404 });
+    } catch (error) {
+      console.error(error);
+      if (url.pathname.startsWith('/api/')) return json({ error: errorMessage(error) }, 500);
+      return new Response(errorMessage(error), { status: 500 });
+    }
+  },
+} satisfies ExportedHandler<Env>;
diff --git a/worker/settings.ts b/worker/settings.ts
new file mode 100644
index 0000000..e381af3
--- /dev/null
+++ b/worker/settings.ts
@@ -0,0 +1,62 @@
+import type { AppSettings, Env } from './types';
+
+export const DEFAULT_SETTINGS: AppSettings = {
+  title: 'Book time with Alex',
+  subtitle: 'Pick a day, choose a time, and we are set.',
+  timezone: 'Europe/Stockholm',
+  bookingWindowDays: 365,
+  minimumNoticeMinutes: 120,
+  bufferBeforeMinutes: 0,
+  bufferAfterMinutes: 15,
+  slotIncrementMinutes: 15,
+  durations: [15, 30, 45, 60],
+  calendarId: 'primary',
+  busyCalendarIds: ['primary'],
+  defaultMeetingMode: 'google_meet',
+  allowMeetingModes: ['google_meet'],
+  inPersonLocation: '',
+  availability: {
+    '0': [],
+    '1': [{ start: '09:00', end: '17:00' }],
+    '2': [{ start: '09:00', end: '17:00' }],
+    '3': [{ start: '09:00', end: '17:00' }],
+    '4': [{ start: '09:00', end: '17:00' }],
+    '5': [{ start: '09:00', end: '15:00' }],
+    '6': [],
+  },
+};
+
+export async function getSettings(env: Env): Promise<AppSettings> {
+  const row = await env.DB.prepare('SELECT value FROM settings WHERE key = ?1')
+    .bind('app_settings')
+    .first<{ value: string }>();
+
+  if (!row?.value) return DEFAULT_SETTINGS;
+
+  try {
+    const parsed = JSON.parse(row.value) as Partial<AppSettings>;
+    return {
+      ...DEFAULT_SETTINGS,
+      ...parsed,
+      durations: Array.isArray(parsed.durations) ? parsed.durations : DEFAULT_SETTINGS.durations,
+      busyCalendarIds: Array.isArray(parsed.busyCalendarIds) ? parsed.busyCalendarIds : DEFAULT_SETTINGS.busyCalendarIds,
+      allowMeetingModes: Array.isArray(parsed.allowMeetingModes)
+        ? parsed.allowMeetingModes
+        : DEFAULT_SETTINGS.allowMeetingModes,
+      availability: {
+        ...DEFAULT_SETTINGS.availability,
+        ...(parsed.availability ?? {}),
+      },
+    };
+  } catch {
+    return DEFAULT_SETTINGS;
+  }
+}
+
+export async function saveSettings(env: Env, settings: AppSettings): Promise<void> {
+  await env.DB.prepare(`
+    INSERT INTO settings (key, value, updated_at)
+    VALUES (?1, ?2, datetime('now'))
+    ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
+  `).bind('app_settings', JSON.stringify(settings)).run();
+}
diff --git a/worker/time.ts b/worker/time.ts
new file mode 100644
index 0000000..3c40800
--- /dev/null
+++ b/worker/time.ts
@@ -0,0 +1,102 @@
+import type { BusyRange } from './types';
+
+const formatterCache = new Map<string, Intl.DateTimeFormat>();
+
+function formatter(timeZone: string): Intl.DateTimeFormat {
+  const cached = formatterCache.get(timeZone);
+  if (cached) return cached;
+  const next = new Intl.DateTimeFormat('en-CA', {
+    timeZone,
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+    second: '2-digit',
+    hourCycle: 'h23',
+    weekday: 'short',
+  });
+  formatterCache.set(timeZone, next);
+  return next;
+}
+
+export function partsInZone(date: Date, timeZone: string): Record<string, string> {
+  return Object.fromEntries(
+    formatter(timeZone)
+      .formatToParts(date)
+      .filter((part) => part.type !== 'literal')
+      .map((part) => [part.type, part.value]),
+  );
+}
+
+export function dateKeyInZone(date: Date, timeZone: string): string {
+  const parts = partsInZone(date, timeZone);
+  return `${parts.year}-${parts.month}-${parts.day}`;
+}
+
+
+export function addDaysToDateKey(dateKey: string, days: number): string {
+  const [year, month, day] = dateKey.split('-').map(Number);
+  return new Date(Date.UTC(year, month - 1, day + days, 12, 0, 0)).toISOString().slice(0, 10);
+}
+
+export function weekdayForDate(dateKey: string, timeZone: string): number {
+  const noon = zonedDateTimeToUtc(dateKey, '12:00', timeZone);
+  const label = partsInZone(noon, timeZone).weekday;
+  return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(label);
+}
+
+export function zonedDateTimeToUtc(dateKey: string, time: string, timeZone: string): Date {
+  const [year, month, day] = dateKey.split('-').map(Number);
+  const [hour, minute] = time.split(':').map(Number);
+  let guess = Date.UTC(year, month - 1, day, hour, minute, 0);
+
+  // Iterate to account for the zone offset, including DST transitions.
+  for (let i = 0; i < 3; i += 1) {
+    const parts = partsInZone(new Date(guess), timeZone);
+    const representedAsUtc = Date.UTC(
+      Number(parts.year),
+      Number(parts.month) - 1,
+      Number(parts.day),
+      Number(parts.hour),
+      Number(parts.minute),
+      Number(parts.second),
+    );
+    const targetAsUtc = Date.UTC(year, month - 1, day, hour, minute, 0);
+    const delta = targetAsUtc - representedAsUtc;
+    if (delta === 0) break;
+    guess += delta;
+  }
+
+  return new Date(guess);
+}
+
+export function minutesToTime(total: number): string {
+  const hour = Math.floor(total / 60) % 24;
+  const minute = total % 60;
+  return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
+}
+
+export function timeToMinutes(value: string): number {
+  const [hour, minute] = value.split(':').map(Number);
+  return hour * 60 + minute;
+}
+
+export function overlaps(start: number, end: number, busy: BusyRange[]): boolean {
+  return busy.some((range) => start < range.end && end > range.start);
+}
+
+export function addMinutes(timestamp: number, minutes: number): number {
+  return timestamp + minutes * 60_000;
+}
+
+export function slotKeys(start: number, end: number, incrementMinutes = 15): string[] {
+  const increment = incrementMinutes * 60_000;
+  const first = Math.floor(start / increment) * increment;
+  const last = Math.ceil(end / increment) * increment;
+  const keys: string[] = [];
+  for (let cursor = first; cursor < last; cursor += increment) {
+    keys.push(new Date(cursor).toISOString());
+  }
+  return keys;
+}
diff --git a/worker/types.ts b/worker/types.ts
new file mode 100644
index 0000000..2e64a97
--- /dev/null
+++ b/worker/types.ts
@@ -0,0 +1,53 @@
+export interface Env {
+  DB: D1Database;
+  ASSETS?: Fetcher;
+  APP_URL: string;
+  ADMIN_EMAIL: string;
+  GOOGLE_CLIENT_ID: string;
+  GOOGLE_CLIENT_SECRET: string;
+  SESSION_SECRET: string;
+  TOKEN_ENCRYPTION_KEY: string;
+}
+
+export interface AppSettings {
+  title: string;
+  subtitle: string;
+  timezone: string;
+  bookingWindowDays: number;
+  minimumNoticeMinutes: number;
+  bufferBeforeMinutes: number;
+  bufferAfterMinutes: number;
+  slotIncrementMinutes: number;
+  durations: number[];
+  calendarId: string;
+  busyCalendarIds: string[];
+  defaultMeetingMode: MeetingMode;
+  allowMeetingModes: MeetingMode[];
+  inPersonLocation: string;
+  availability: WeeklyAvailability;
+}
+
+export type MeetingMode = 'google_meet' | 'phone' | 'in_person' | 'decide_later';
+
+export interface AvailabilityInterval {
+  start: string;
+  end: string;
+}
+
+export type WeeklyAvailability = Record<string, AvailabilityInterval[]>;
+
+export interface BusyRange {
+  start: number;
+  end: number;
+}
+
+export interface BookingRequest {
+  start: string;
+  duration: number;
+  name: string;
+  email: string;
+  phone?: string;
+  message?: string;
+  meetingMode: MeetingMode;
+  website?: string;
+}
diff --git a/wrangler.jsonc b/wrangler.jsonc
new file mode 100644
index 0000000..79e013b
--- /dev/null
+++ b/wrangler.jsonc
@@ -0,0 +1,18 @@
+{
+  "$schema": "./node_modules/wrangler/config-schema.json",
+  "name": "meet-anord",
+  "main": "./worker/index.ts",
+  "compatibility_date": "2026-07-25",
+  "assets": {
+    "not_found_handling": "single-page-application",
+    "run_worker_first": ["/api/*", "/auth/*"]
+  },
+  "d1_databases": [
+    {
+      "binding": "DB",
+      "database_name": "meet-anord-db",
+      "database_id": "99e956bf-b2d5-47b3-aaca-5c7dc5aab2af",
+      "migrations_dir": "migrations"
+    }
+  ]
+}