commit b69131c5401326c2d633efeaa3e72c6c6e1d7d8c Author: Fredrik Johansson Date: Mon Jul 20 19:45:59 2026 +0200 Build Retur dead-letter sorting game diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..d1d0198 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,52 @@ +name: Docker + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Derive image name and tags + id: meta + shell: bash + run: | + set -euo pipefail + SERVER_URL="${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-}}" + REPO="${GITHUB_REPOSITORY:-${GITEA_REPOSITORY:-}}" + SHA="${GITHUB_SHA:-${GITEA_SHA:-}}" + if [[ -z "${SERVER_URL}" || -z "${REPO}" || -z "${SHA}" ]]; then + echo "Missing SERVER_URL/REPO/SHA env vars." >&2 + exit 1 + fi + HOST=$(echo "${SERVER_URL}" | sed 's|https://||;s|http://||') + IMAGE=$(echo "${HOST}/${REPO}" | tr '[:upper:]' '[:lower:]') + SHORT_SHA=$(echo "${SHA}" | cut -c1-7) + echo "host=${HOST}" >> "$GITHUB_OUTPUT" + echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" + echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" + + - name: Log in to Gitea container registry + uses: docker/login-action@v3 + with: + registry: ${{ steps.meta.outputs.host }} + username: ${{ github.actor }} + password: ${{ secrets.TKNTKN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + push: true + tags: | + ${{ steps.meta.outputs.image }}:latest + ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01473fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.env +*.local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b630398 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM node:24-alpine AS build +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci || npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/README.md b/README.md new file mode 100644 index 0000000..65b9552 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Retur + +**Avdelningen för obeställbara försändelser.** + +A short bureaucratic supernatural sorting game. You have five shifts at a municipal dead-letter office. Read each letter, consult an expanding and occasionally contradictory rulebook, then route it to delivery, sender, Archive 9, or quarantine. + +Later regulations override earlier ones. The letters know this too. + +## Stack + +Vanilla TypeScript + Vite. No framework, backend, or runtime dependencies. State is kept in `localStorage`. + +The visual language deliberately follows `djupet`: JetBrains Mono, near-black blue panels, restrained eldritch color, compact administrative labels, and pressure expressed through a screen-edge vignette. Retur replaces Djupet's Malmö map and idle resources with a paper desk, physical stamps, and a regulation sidebar. + +## Development + +```bash +npm install +npm run dev # http://localhost:5691 +npm run typecheck +npm test +npm run build +``` + +## Rules + +- Inspect the sender, recipient, address, postmark, letter text, and marks. +- Select one of four routes and stamp the decision. +- New regulations arrive each morning. Later and more specific rules take precedence. +- Incorrect sorting costs composure and raises suspicion. +- A completed day restores one composure; suspicion persists. +- Survive all five days to receive a permanent position. This is not necessarily a reward. + +Keyboard: `1`–`4` selects a route, `Enter` stamps or continues, `?` opens help. + +## Deployment + +The production image is a static Vite build served by nginx. + +```bash +docker compose up -d --build +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3e7e0f3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + retur: + build: . + image: retur:latest + container_name: retur + restart: unless-stopped + ports: + - "${HOST_PORT:-5694}:80" diff --git a/index.html b/index.html new file mode 100644 index 0000000..f6caf8f --- /dev/null +++ b/index.html @@ -0,0 +1,15 @@ + + + + + + + + + RETUR — Avdelningen för obeställbara försändelser + + +
+ + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..4be4f5f --- /dev/null +++ b/nginx.conf @@ -0,0 +1,16 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(?:css|js|svg|webp|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8918556 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1507 @@ +{ + "name": "retur", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "retur", + "version": "0.1.0", + "devDependencies": { + "typescript": "^5.5.0", + "vite": "^6.0.0", + "vitest": "^4.1.10" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@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/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "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/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "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/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "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", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "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/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1bd325e --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "retur", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 5691", + "build": "tsc && vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview --port 4691", + "test": "vitest run" + }, + "devDependencies": { + "typescript": "^5.5.0", + "vite": "^6.0.0", + "vitest": "^4.1.10" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..845808f --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + RETUR + diff --git a/src/data/content.ts b/src/data/content.ts new file mode 100644 index 0000000..c885254 --- /dev/null +++ b/src/data/content.ts @@ -0,0 +1,135 @@ +import type { Letter, Regulation, Route } from '../types.ts'; + +export const ROUTES: Record = { + deliver: { label: 'VIDAREBEFORDRA', stamp: 'VIDARE', description: 'Adressen är giltig. Låt någon annan få problemet.' }, + return: { label: 'ÅTERSÄND', stamp: 'RETUR', description: 'Avsändaren finns och bör få tillbaka sin försändelse.' }, + archive: { label: 'ARKIV 9', stamp: 'ARKIV', description: 'Försändelser som gäller det förflutna. Eller kommer därifrån.' }, + quarantine: { label: 'KARANTÄN', stamp: 'SPÄRRA', description: 'Öppna inte igen. Särskilt inte om brevet ber snällt.' }, +}; + +export const REGULATIONS: Regulation[] = [ + { id: 'r1', fromDay: 1, number: '1.1', text: 'Fullständig gatuadress och nutida mottagare: vidarebefordra.' }, + { id: 'r2', fromDay: 1, number: '1.4', text: 'Saknad eller uppenbart ogiltig adress: återsänd om avsändare finns.' }, + { id: 'r3', fromDay: 1, number: '2.2', text: 'Poststämplar äldre än femtio år tillhör Arkiv 9.' }, + { id: 'r4', fromDay: 1, number: '3.1', text: 'Försändelser som uttryckligen ber att inte öppnas placeras i karantän.' }, + { id: 'r5', fromDay: 2, number: '4.3', text: 'Mottagare registrerade som avlidna behandlas som historiska ärenden: Arkiv 9.' }, + { id: 'r6', fromDay: 2, number: '4.7', text: 'Om den avlidne själv anges som avsändare gäller karantän.', note: 'Senare föreskrift har företräde.' }, + { id: 'r6b', fromDay: 2, number: '4.9', text: 'Försändelser som saknar både giltig avsändare och adress placeras i karantän.' }, + { id: 'r7', fromDay: 3, number: '5.2', text: 'Adresser med negativt gatunummer är ogiltiga och skall återsändas.' }, + { id: 'r8', fromDay: 3, number: '5.9', text: 'Internpost från Avdelning Noll får alltid vidarebefordras.', note: 'Även när adressen i övrigt är otillåten.' }, + { id: 'r9', fromDay: 4, number: '6.6', text: 'Brev vars text förändras mellan läsningar placeras i karantän.' }, + { id: 'r10', fromDay: 4, number: '6.8', text: 'Tre eller fler svarta stämplar innebär Arkiv 9, utom när §6.6 gäller.' }, + { id: 'r11', fromDay: 5, number: '7.1', text: 'Post från ett framtida datum skall återsändas till avsändaren.' }, + { id: 'r12', fromDay: 5, number: '7.4', text: 'Framtida post som beskriver den pågående arbetsdagen skall sättas i karantän.', note: 'Läs inte sista stycket.' }, +]; + +export const LETTERS: Letter[] = [ + { + id: 'l01', day: 1, sender: 'Britta Nilsson', recipient: 'Ture Andersson', address: 'Bokgatan 14, Malmö', postmark: '2026-07-21', + body: 'Ture, din kofta ligger fortfarande här. Jag skickar den inte igen. Hämta den innan torsdag.', marks: ['Porto betalt'], + correctRoute: 'deliver', explanation: 'Fullständig adress och inget annat anmärkningsvärt. Det är nästan misstänkt normalt.', + }, + { + id: 'l02', day: 1, sender: 'K. Lund', recipient: 'Den som bor ovanför vinden', address: 'Huset med den gröna dörren', postmark: '2026-07-20', + body: 'Ni har fått fel nyckel. Använd den inte i källardörren.', marks: ['Adress saknas'], + correctRoute: 'return', explanation: 'Adressen är ogiltig och avsändaren finns. Återsänd. Nyckeln nämns inte i föreskrifterna.', + }, + { + id: 'l03', day: 1, sender: 'Ester Malm', recipient: 'Oskar Malm', address: 'Södra Förstadsgatan 81', postmark: '1912-11-03', + body: 'Käre Oskar. De knackar under golvet igen. Far säger att huset sätter sig.', marks: ['BLEKINGE BATALJON', 'Öppnat av censuren'], + correctRoute: 'archive', explanation: 'Poststämpeln är äldre än femtio år. Arkiv 9 hanterar resten.', + }, + { + id: 'l04', day: 1, sender: 'Fastighetskontoret', recipient: 'Nattvakt 3', address: 'Förvaltningen, internfack 12', postmark: '2026-07-21', + body: 'ÖPPNA INTE I BELYST RUM. Förvaras svalt. Om innehållet börjar nynna, kontakta ingen.', marks: ['ÖPPNA EJ'], + correctRoute: 'quarantine', explanation: 'Brevet säger uttryckligen att det inte skall öppnas. Det var generöst av det.', + }, + { + id: 'l05', day: 2, sender: 'Malmö Stad', recipient: 'Signe Persson (1888–1961)', address: 'Föreningsgatan 32', postmark: '2026-07-22', + body: 'Påminnelse om utebliven närvaro vid den årliga medborgardialogen.', marks: ['ANDRA PÅMINNELSEN'], + correctRoute: 'archive', explanation: 'Mottagaren är registrerad som avliden. Historiskt ärende, trots kommunens optimism.', + }, + { + id: 'l06', day: 2, sender: 'Signe Persson (1888–1961)', recipient: 'Malmö Stad', address: 'August Palms plats 1', postmark: '2026-07-22', + body: 'Jag har fått era båda påminnelser. Jag kommer inte. Vänligen uppdatera era register.', marks: ['JORD UNDER FRIMÄRKET'], + correctRoute: 'quarantine', explanation: 'Den avlidne är avsändare. Den senare föreskriften §4.7 har företräde.', + }, + { + id: 'l07', day: 2, sender: 'Lars Dahl', recipient: 'Lars Dahl', address: 'Regementsgatan 19', postmark: '2026-07-22', + body: 'Till mig själv: glöm inte att köpa mjölk. Och svara inte när telefonen ringer 03:14.', marks: ['EGENPOST'], + correctRoute: 'deliver', explanation: 'Självkorrespondens är beklaglig men inte förbjuden.', + }, + { + id: 'l08', day: 2, sender: '— SAKNAS —', recipient: 'Föreståndaren', address: '', postmark: '2026-07-22', + body: 'Det finns inget returkontor på plan fyra. Sluta skicka personal dit.', marks: ['AVSÄNDARE SAKNAS'], + correctRoute: 'quarantine', explanation: 'Både giltig avsändare och adress saknas. §4.9 tar över returregeln.', + }, + { + id: 'l09', day: 3, sender: 'Asta Berg', recipient: 'Hjalmar Berg', address: 'Kyrkogatan -4, Malmö', postmark: '2026-07-23', + body: 'Trappan fortsätter fyra våningar nedanför källaren. Ta med lampan den här gången.', marks: ['FELVÄNT FRIMÄRKE'], + correctRoute: 'return', explanation: 'Negativa gatunummer är ogiltiga. Var trappan leder är inte ert ansvarsområde.', + }, + { + id: 'l10', day: 3, sender: 'Avdelning Noll', recipient: 'Arkivarien', address: 'Korridor -2, dörr 0', postmark: '2026-07-23', + body: 'Inventeringen är slutförd. Vi saknar fortfarande en anställd. Namnet är ditt.', marks: ['INTERNPOST', '0'], + correctRoute: 'deliver', explanation: 'Internpost från Avdelning Noll går alltid vidare, även till negativa adresser.', + }, + { + id: 'l11', day: 3, sender: 'PostNord Kundservice', recipient: 'PostNord Kundservice', address: 'Ej utdelningsbart', postmark: '2026-07-23', + body: 'Vi beklagar att ditt brev inte kunde levereras. Ditt ärende är viktigt för oss.', marks: ['AUTOMATISKT SVAR'], + correctRoute: 'return', explanation: 'Ogiltig adress, giltig avsändare. Kretsloppet är inte ert problem.', + }, + { + id: 'l12', day: 3, sender: 'Avdelning Noll', recipient: 'Ingen särskild', address: '', postmark: '2026-07-23', + body: 'Vidarebefordra detta. Det behöver inte komma fram.', marks: ['INTERNPOST', '0'], + correctRoute: 'deliver', explanation: '§5.9 säger alltid. Myndigheten har fetmarkerat ordet i originalet.', + }, + { + id: 'l13', day: 4, sender: 'M. Ek', recipient: 'M. Ek', address: 'Amiralsgatan 7', postmark: '2026-07-24', + body: 'Första läsningen: Lägg brevet i Arkiv 9. Andra läsningen: Du gjorde inte det.', marks: ['TEXTEN HAR ÄNDRATS'], + correctRoute: 'quarantine', explanation: 'Föränderlig text omfattas av §6.6. Följ inte brevets egen sorteringsinstruktion.', + }, + { + id: 'l14', day: 4, sender: 'Riksarkivet', recipient: 'Lokalkontoret', address: 'Arkivgatan 9', postmark: '1948-02-09', + body: 'Överföring av handlingar rörande det rum som avlägsnades ur ritningen.', marks: ['SVART STÄMPEL', 'SVART STÄMPEL', 'SVART STÄMPEL'], + correctRoute: 'archive', explanation: 'Både ålder och tre svarta stämplar pekar mot Arkiv 9. För en gångs skull är reglerna överens.', + }, + { + id: 'l15', day: 4, sender: 'Någon bakom dig', recipient: 'Du', address: 'Sorteringsbordet', postmark: '2026-07-24', + body: 'Vänd dig inte om. Texten kommer vara annorlunda när du tittar tillbaka.', marks: ['SVART STÄMPEL', 'SVART STÄMPEL', 'SVART STÄMPEL', 'TEXTEN HAR ÄNDRATS'], + correctRoute: 'quarantine', explanation: '§6.6 undantar brevet från trestämpelregeln. Du vände dig väl inte om?', + }, + { + id: 'l16', day: 4, sender: 'Kammarkollegiet', recipient: 'Vederbörande', address: 'Stora Nygatan 1', postmark: '2026-07-24', + body: 'Härmed översändes blankett 14-C. Blanketten bekräftar att blankett 14-C saknas.', marks: ['DIARIEFÖRD'], + correctRoute: 'deliver', explanation: 'Fullständig adress, nutida mottagare, vanlig svensk förvaltning.', + }, + { + id: 'l17', day: 5, sender: 'Ture Andersson', recipient: 'Britta Nilsson', address: 'Bokgatan 12, Malmö', postmark: '2037-07-25', + body: 'Skicka inte koftan. Det är så den hittar hit.', marks: ['PORTO GÄLLER FRÅN 2037'], + correctRoute: 'return', explanation: 'Framtida post återsänds. Familjens koftproblem får lösas kronologiskt.', + }, + { + id: 'l18', day: 5, sender: 'Du', recipient: 'Du', address: 'Avdelningen för obeställbara försändelser', postmark: '2026-07-26', + body: 'Klockan är 16:42. Du har två brev kvar. Lägg inte detta i returhögen. Det är där handen finns.', marks: ['IMORGON', 'TEXTEN HAR ÄNDRATS'], + correctRoute: 'quarantine', explanation: 'Framtida post som beskriver den pågående arbetsdagen omfattas av §7.4.', + }, + { + id: 'l19', day: 5, sender: 'Avdelning Noll', recipient: 'Föreståndaren', address: 'Kontor 4', postmark: '2031-01-01', + body: 'Gratulerar till fem års prickfri tjänst. Beklagar resten.', marks: ['INTERNPOST', '0', 'FRAMTIDA'], + correctRoute: 'deliver', explanation: '§5.9 säger alltid. Avdelning Noll har företräde även framför framtiden.', + }, + { + id: 'l20', day: 5, sender: 'Föreståndaren', recipient: 'Ny handläggare', address: 'Sorteringsbordet', postmark: '1956-07-25', + body: 'Om du läser detta har jag fått gå hem. Det händer sällan här. Bordet behöver någon.', marks: ['PERSONALAKT', 'SVART STÄMPEL', 'SVART STÄMPEL', 'SVART STÄMPEL'], + correctRoute: 'archive', explanation: 'Äldre än femtio år och tre svarta stämplar. Arkiv 9 tar hand om företrädaren. Förmodligen.', + }, +]; + +export const DAY_BRIEFINGS = [ + 'Vanliga brev. Vanliga regler. Försök att inte vänja dig.', + 'Personalregistret har uppdaterats. Några mottagare invände skriftligen.', + 'Avdelning Noll har åter öppnat för internpost. Avdelning Noll finns inte.', + 'Nattpersonalen rapporterar att vissa brev behöver läsas mer än en gång. Gör inte det.', + 'Fredag. Framtiden har börjat svara. Stäng luckan 17:00, oavsett vem som knackar.', +]; diff --git a/src/engine.test.ts b/src/engine.test.ts new file mode 100644 index 0000000..4f0983a --- /dev/null +++ b/src/engine.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { createState, currentLetter, lettersForDay, nextDay, routeLetter, startGame } from './engine.ts'; + +describe('sorting engine', () => { + it('starts on the first letter of day one', () => { + const state = startGame(createState()); + expect(state.phase).toBe('playing'); + expect(currentLetter(state)?.id).toBe('l01'); + }); + + it('advances and records a correct route', () => { + const state = routeLetter(startGame(createState()), 'deliver'); + expect(state.letterIndex).toBe(1); + expect(state.decisions[0].correct).toBe(true); + expect(state.composure).toBe(3); + }); + + it('penalizes an incorrect route', () => { + const state = routeLetter(startGame(createState()), 'archive'); + expect(state.decisions[0].correct).toBe(false); + expect(state.composure).toBe(2); + expect(state.suspicion).toBe(1); + }); + + it('ends a day and restores one composure for the next', () => { + let state = startGame(createState()); + const routes = lettersForDay(1).map((letter) => letter.correctRoute); + state = routeLetter(state, 'archive'); + for (const route of routes.slice(1)) state = routeLetter(state, route); + expect(state.phase).toBe('day-end'); + state = nextDay(state); + expect(state.day).toBe(2); + expect(state.composure).toBe(3); + }); +}); diff --git a/src/engine.ts b/src/engine.ts new file mode 100644 index 0000000..c6a31a6 --- /dev/null +++ b/src/engine.ts @@ -0,0 +1,84 @@ +import { LETTERS } from './data/content.ts'; +import type { GameState, Letter, Route } from './types.ts'; + +export const MAX_COMPOSURE = 3; +export const MAX_SUSPICION = 5; +export const LAST_DAY = Math.max(...LETTERS.map((letter) => letter.day)); + +export function createState(): GameState { + return { + phase: 'intro', day: 1, letterIndex: 0, accuracy: 100, + composure: MAX_COMPOSURE, suspicion: 0, decisions: [], lastDecision: null, + }; +} + +export function lettersForDay(day: number): Letter[] { + return LETTERS.filter((letter) => letter.day === day); +} + +export function currentLetter(state: GameState): Letter | null { + return lettersForDay(state.day)[state.letterIndex] ?? null; +} + +export function startGame(state: GameState): GameState { + return state.phase === 'intro' ? { ...state, phase: 'playing' } : state; +} + +export function routeLetter(state: GameState, route: Route): GameState { + if (state.phase !== 'playing') return state; + const letter = currentLetter(state); + if (!letter) return state; + + const correct = letter.correctRoute === route; + const decisions = [...state.decisions, { letterId: letter.id, route, correct }]; + const correctCount = decisions.filter((decision) => decision.correct).length; + const accuracy = Math.round((correctCount / decisions.length) * 100); + const composure = Math.max(0, state.composure - (correct ? 0 : 1)); + const suspicion = Math.min(MAX_SUSPICION, state.suspicion + (correct ? 0 : 1)); + const dayLetters = lettersForDay(state.day); + const isLastLetter = state.letterIndex >= dayLetters.length - 1; + + let phase: GameState['phase'] = 'playing'; + if (composure <= 0 || suspicion >= MAX_SUSPICION) phase = 'lost'; + else if (isLastLetter) phase = state.day >= LAST_DAY ? 'won' : 'day-end'; + + return { + ...state, + phase, + letterIndex: isLastLetter ? state.letterIndex : state.letterIndex + 1, + accuracy, + composure, + suspicion, + decisions, + lastDecision: { letterId: letter.id, route, correct }, + }; +} + +export function nextDay(state: GameState): GameState { + if (state.phase !== 'day-end') return state; + return { + ...state, + phase: 'playing', + day: state.day + 1, + letterIndex: 0, + composure: Math.min(MAX_COMPOSURE, state.composure + 1), + lastDecision: null, + }; +} + +export function saveState(state: GameState): void { + localStorage.setItem('retur-save-v1', JSON.stringify(state)); +} + +export function loadState(): GameState | null { + try { + const value = localStorage.getItem('retur-save-v1'); + return value ? JSON.parse(value) as GameState : null; + } catch { + return null; + } +} + +export function clearState(): void { + localStorage.removeItem('retur-save-v1'); +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..9ba547f --- /dev/null +++ b/src/main.ts @@ -0,0 +1,186 @@ +import './style.css'; +import { DAY_BRIEFINGS, REGULATIONS, ROUTES } from './data/content.ts'; +import { clearState, createState, currentLetter, lettersForDay, loadState, nextDay, routeLetter, saveState, startGame } from './engine.ts'; +import type { Route } from './types.ts'; + +let state = loadState() ?? createState(); +let selectedRoute: Route | null = null; +let showingResult = false; + +const app = document.getElementById('app')!; + +function escapeHtml(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); +} + +function render(): void { + document.documentElement.style.setProperty('--suspicion', String(state.suspicion / 5)); + if (state.phase === 'intro') return renderIntro(); + if (state.phase === 'day-end') return renderDayEnd(); + if (state.phase === 'won' || state.phase === 'lost') return renderEnding(); + renderDesk(); +} + +function shell(content: string): void { + app.innerHTML = ` + + ${content} +
+ + +
Arbetsinstruktion 1-A
+

Sortera. Stämpla. Fråga inte.

+

Läs försändelsen och dagens föreskrifter. Välj rätt destination och tryck STÄMPLA. Senare föreskrifter har företräde när reglerna motsäger varandra.

+
  • Tre fel och ditt lugn tar slut.
  • Misstanken följer med mellan arbetsdagarna.
  • En avslutad dag återställer ett lugn.
  • Tangent 1–4 väljer hög, Enter stämplar.
+

Avdelningen ansvarar inte för skuggor, viskningar eller felaktigt placerade kroppsdelar.

+
`; + bindShell(); +} + +function renderIntro(): void { + shell(`
+
R
+
+
PERSONALPLACERING 9-B / EJ ÖVERKLAGBAR
+

RETUR

+

Avdelningen för obeställbara försändelser

+

Din föregångare har gått hem. Det finns ingen uppgift om att hon kom hit.

+

Breven på ditt bord saknar giltig destination. Föreskrifterna säger vart de ska. Vanligtvis.

+ +
+
`); + document.getElementById('start-button')?.addEventListener('click', () => { state = startGame(state); saveState(state); render(); }); +} + +function renderDesk(): void { + const letter = currentLetter(state)!; + const dayLetters = lettersForDay(state.day); + const regulations = REGULATIONS.filter((rule) => rule.fromDay <= state.day); + const result = showingResult && state.lastDecision + ? `
+ ${state.lastDecision.correct ? 'KORREKT SORTERAD' : 'ANMÄRKNING REGISTRERAD'} +

${escapeHtml(letter.explanation)}

+ +
` : ''; + + shell(`
+ +
+
INKOMMET ${state.letterIndex + 1}/${dayLetters.length}DIARIENUMMER ${letter.id.toUpperCase()}-${state.day}26
+
+
${escapeHtml(letter.postmark)}
+
+
Från${escapeHtml(letter.sender)}
+
Till${escapeHtml(letter.recipient)}
+
Adress${escapeHtml(letter.address || '— SAKNAS —')}
+
+
${escapeHtml(letter.body)}
+
${letter.marks.map((mark) => `${escapeHtml(mark)}`).join('')}
+
+ ${result} +
+ +
`); + + document.querySelectorAll('[data-route]').forEach((button) => button.addEventListener('click', () => { + selectedRoute = button.dataset.route as Route; + render(); + })); + document.getElementById('stamp-button')?.addEventListener('click', stamp); +} + +function stamp(): void { + if (!selectedRoute || showingResult) return; + const before = currentLetter(state); + state = routeLetter(state, selectedRoute); + saveState(state); + if (state.phase === 'playing' && before?.id !== currentLetter(state)?.id) { + // The engine has advanced; temporarily step the view back to show why the previous decision scored as it did. + state = { ...state, letterIndex: state.letterIndex - 1 }; + showingResult = true; + } else if (state.phase === 'day-end' || state.phase === 'won' || state.phase === 'lost') { + render(); + return; + } + render(); +} + +function advanceAfterResult(): void { + if (!showingResult) return; + state = { ...state, letterIndex: state.letterIndex + 1 }; + showingResult = false; + selectedRoute = null; + saveState(state); + render(); +} + +function renderDayEnd(): void { + const dayDecisions = state.decisions.slice(-lettersForDay(state.day).length); + const correct = dayDecisions.filter((decision) => decision.correct).length; + shell(`
+
DAGSRAPPORT / DAG 0${state.day}
+
GODKÄND
+

Luckan är stängd.

+

${correct} av ${dayDecisions.length} försändelser sorterades enligt gällande verklighet.

+

Ett lugn återställs över natten. Misstanken gör det inte.

+ +
`); + document.getElementById('next-day-button')?.addEventListener('click', () => { state = nextDay(state); saveState(state); render(); }); +} + +function renderEnding(): void { + const won = state.phase === 'won'; + shell(`
+
SLUTLIG PERSONALAKT
+
${won ? 'TILLSVIDARE' : 'OMPLACERAD'}
+

${won ? 'Du får behålla bordet.' : 'Din stol behövs av någon annan.'}

+

${won ? 'Fem arbetsdagar avslutade. Föreståndaren lämnar en nyckel till plan fyra. Hissen har fortfarande bara tre knappar.' : 'För många avvikelser. En kartong står redan vid din plats. Den är adresserad till din efterträdare.'}

+
Noggrannhet
${state.accuracy}%
Misstanke
${state.suspicion}/5
Försändelser
${state.decisions.length}
+ +
`); + document.getElementById('restart-button')?.addEventListener('click', () => { clearState(); state = createState(); selectedRoute = null; showingResult = false; render(); }); +} + +function bindShell(): void { + const dialog = document.getElementById('help-dialog') as HTMLDialogElement | null; + document.getElementById('help-button')?.addEventListener('click', () => dialog?.showModal()); + document.querySelector('.dialog-close')?.addEventListener('click', () => dialog?.close()); + dialog?.addEventListener('click', (event) => { if (event.target === dialog) dialog.close(); }); +} + +window.addEventListener('keydown', (event) => { + if (event.key === '?') (document.getElementById('help-dialog') as HTMLDialogElement | null)?.showModal(); + if (state.phase !== 'playing') return; + if (showingResult && event.key === 'Enter') { event.preventDefault(); advanceAfterResult(); return; } + const routes = Object.keys(ROUTES) as Route[]; + const index = Number(event.key) - 1; + if (index >= 0 && index < routes.length && !showingResult) { selectedRoute = routes[index]; render(); } + if (event.key === 'Enter' && selectedRoute) { event.preventDefault(); stamp(); } +}); + +// Click continuation uses the same advancement path as Enter. +document.addEventListener('click', (event) => { + if ((event.target as HTMLElement).id === 'continue-button') advanceAfterResult(); +}); + +render(); diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..2bb4422 --- /dev/null +++ b/src/style.css @@ -0,0 +1,89 @@ +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Special+Elite&display=swap'); + +:root { + --bg: #0d0d1a; --panel: #12121f; --paper: #d8d0b8; --paper-ink: #25221d; + --border: #2a2a42; --text: #e2e2f0; --dim: #8888aa; --accent: #aa66dd; + --stamp: #d95462; --archive: #c49a45; --safe: #44bb66; --danger: #ee4444; +} +*, *::before, *::after { box-sizing: border-box; } +html { background: var(--bg); } +body { margin: 0; min-height: 100vh; overflow-x: hidden; background: var(--bg); color: var(--text); font: 13px/1.55 'JetBrains Mono', monospace; } +button { font: inherit; } +#app { min-height: 100vh; max-width: 1280px; margin: auto; border-inline: 1px solid var(--border); position: relative; } +#header { min-height: 62px; padding: 14px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: baseline; justify-content: space-between; gap: 18px; background: rgba(13,13,26,.94); } +.brand { color: var(--stamp); font-size: 22px; font-weight: 700; letter-spacing: 5px; text-shadow: 0 0 18px rgba(217,84,98,.35); } +.brand-sub { margin-left: 18px; color: var(--dim); font-size: 9px; letter-spacing: .12em; text-transform: uppercase; } +.text-button { border: 0; background: none; color: var(--dim); cursor: pointer; font-size: 10px; text-transform: uppercase; } +.text-button:hover { color: var(--text); } +.panel { padding: 16px; overflow-y: auto; background: var(--panel); } +.panel-title { color: var(--dim); border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-bottom: 12px; font-size: 9px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; } +.desk { display: grid; grid-template-columns: 275px minmax(360px, 1fr) 275px; min-height: calc(100vh - 62px); } +.status-panel { border-right: 1px solid var(--border); } +.route-panel { border-left: 1px solid var(--border); } +.day-number { color: var(--accent); font-size: 48px; line-height: 1; font-weight: 700; } +.briefing { min-height: 76px; color: var(--dim); font-size: 11px; } +.meter { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin: 12px 0; color: var(--dim); font-size: 10px; text-transform: uppercase; position: relative; } +.meter b { color: var(--text); letter-spacing: 2px; } +.meter.suspicion { padding-bottom: 7px; } +.meter.suspicion::after, .meter.suspicion i { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: var(--border); } +.meter.suspicion i { right: auto; background: var(--danger); z-index: 1; transition: width .25s; } +.regulations-title { margin-top: 24px; } +.regulations { padding: 0; margin: 0; list-style: none; display: grid; gap: 9px; } +.regulations li { padding-left: 36px; color: #aaaacc; font-size: 10px; position: relative; } +.regulations b { position: absolute; left: 0; color: var(--accent); } +.regulations em { display: block; color: var(--archive); font-style: normal; margin-top: 2px; } +.regulations .new-rule { padding-block: 5px; border-block: 1px dashed rgba(217,84,98,.35); } +.regulations .new-rule::after { content: 'NY'; position: absolute; right: 0; top: -7px; color: var(--stamp); font-size: 8px; } +.work-area { padding: clamp(18px, 4vw, 42px); background: radial-gradient(circle at 50% 38%, #1c1a27 0%, #0b0b15 72%); position: relative; overflow: hidden; } +.work-area::before { content: ''; position: absolute; inset: 0; pointer-events: none; opacity: .22; background-image: repeating-linear-gradient(93deg, transparent 0 47px, rgba(255,255,255,.015) 48px); } +.queue-meta { display: flex; justify-content: space-between; gap: 10px; margin-bottom: 12px; color: var(--dim); font-size: 9px; letter-spacing: .08em; } +.letter { min-height: 440px; padding: clamp(22px, 4vw, 42px); background-color: var(--paper); color: var(--paper-ink); box-shadow: 0 20px 60px rgba(0,0,0,.5), inset 0 0 80px rgba(87,63,31,.12); transform: rotate(-.35deg); position: relative; font-family: 'Special Elite', 'Courier New', monospace; } +.letter::before { content: ''; position: absolute; inset: 0; pointer-events: none; opacity: .28; background-image: repeating-linear-gradient(0deg, transparent 0 4px, rgba(72,56,34,.035) 5px); } +.postmark { position: absolute; top: 24px; right: 28px; padding: 8px 10px; border: 2px solid rgba(120,38,45,.65); border-radius: 50%; color: rgba(120,38,45,.78); transform: rotate(8deg); font-size: 10px; } +.letter-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding-right: 100px; } +.letter-grid div { display: grid; gap: 3px; } +.letter-grid span { color: #766b58; font-size: 9px; text-transform: uppercase; } +.letter-grid strong { font-size: 14px; } +.letter-grid .address { grid-column: 1 / -1; } +.letter-body { max-width: 580px; margin: 52px auto 40px; font-size: clamp(15px, 2vw, 18px); line-height: 1.85; white-space: pre-line; } +.marks { display: flex; flex-wrap: wrap; gap: 9px; position: absolute; left: 28px; right: 28px; bottom: 25px; } +.marks span { padding: 5px 8px; border: 2px solid rgba(120,38,45,.55); color: rgba(120,38,45,.75); font-size: 9px; font-weight: 700; transform: rotate(-2deg); } +.routes { display: grid; gap: 9px; } +.route-button { width: 100%; padding: 10px; border: 1px solid var(--border); background: #0e0e1a; color: var(--text); text-align: left; cursor: pointer; position: relative; } +.route-button:hover:not(:disabled), .route-button.selected { border-color: var(--accent); } +.route-button.selected { box-shadow: inset 3px 0 var(--accent); } +.route-button .key { position: absolute; right: 7px; top: 6px; color: var(--dim); font-size: 9px; } +.route-button strong { display: block; color: var(--accent); font-size: 11px; } +.route-button small { display: block; margin-top: 3px; color: var(--dim); font-size: 9px; line-height: 1.45; } +.route-button:disabled { opacity: .45; cursor: default; } +.stamp-button, .primary { width: 100%; margin-top: 16px; padding: 13px; border: 2px solid var(--stamp); background: rgba(217,84,98,.08); color: var(--stamp); font-weight: 700; letter-spacing: .15em; cursor: pointer; } +.stamp-button:hover:not(:disabled), .primary:hover { background: var(--stamp); color: #fff; } +.stamp-button:disabled { opacity: .25; cursor: not-allowed; } +.desk-note { margin-top: 24px; color: #57576f; font-size: 9px; text-align: center; } +.result { position: absolute; inset: 0; z-index: 3; display: grid; place-content: center; padding: 30px; background: rgba(8,8,16,.93); text-align: center; } +.result strong { font-size: clamp(22px, 4vw, 38px); letter-spacing: .12em; } +.result.correct strong { color: var(--safe); } .result.wrong strong { color: var(--danger); } +.result p { max-width: 540px; color: var(--dim); } +.result button { border: 0; background: none; color: var(--text); cursor: pointer; } +.intro-screen, .end-screen { min-height: calc(100vh - 62px); display: grid; place-items: center; padding: 30px; background: radial-gradient(circle, #20192c 0, var(--bg) 66%); } +.intro-screen { grid-template-columns: auto minmax(280px, 540px); gap: clamp(30px, 7vw, 90px); } +.seal { width: clamp(160px, 25vw, 260px); aspect-ratio: 1; display: grid; place-items: center; border: 8px double var(--stamp); border-radius: 50%; color: var(--stamp); font: 700 clamp(90px, 16vw, 160px)/1 'Special Elite'; transform: rotate(-8deg); opacity: .78; box-shadow: 0 0 60px rgba(217,84,98,.12); } +.intro-copy h1 { margin: 0; color: var(--stamp); font-size: clamp(52px, 10vw, 92px); letter-spacing: .15em; line-height: 1; } +.intro-sub { color: var(--dim); font-size: 10px; letter-spacing: .15em; text-transform: uppercase; } +.intro-copy > p:not(.intro-sub) { color: #b0b0c4; } +.end-card { width: min(620px, 100%); padding: clamp(24px, 5vw, 50px); border: 1px solid var(--border); background: var(--panel); text-align: center; position: relative; } +.end-card h1 { color: var(--text); } +.end-card > p { color: var(--dim); } +.audit-stamp { display: inline-block; padding: 8px 16px; border: 3px solid var(--safe); color: var(--safe); font-size: 18px; font-weight: 700; letter-spacing: .12em; transform: rotate(-4deg); } +.failure .audit-stamp { border-color: var(--danger); color: var(--danger); } +.final-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 25px 0; } +.final-stats div { padding: 12px; border: 1px solid var(--border); } +.final-stats dt { color: var(--dim); font-size: 9px; text-transform: uppercase; }.final-stats dd { margin: 4px 0 0; font-size: 20px; } +.dim { color: var(--dim); font-size: 11px; } +#danger-vignette { position: fixed; inset: 0; z-index: 10; pointer-events: none; background: radial-gradient(ellipse, transparent 55%, var(--danger) 150%); opacity: calc(var(--suspicion, 0) * .32); transition: opacity .5s; } +dialog { width: min(560px, calc(100% - 30px)); padding: 25px; border: 1px solid var(--border); background: var(--panel); color: var(--text); font: 12px/1.7 'JetBrains Mono', monospace; } +dialog::backdrop { background: rgba(0,0,0,.8); } +.dialog-close { position: absolute; right: 12px; top: 8px; border: 0; background: none; color: var(--dim); font-size: 24px; cursor: pointer; } +@media (max-width: 980px) { .desk { grid-template-columns: 220px 1fr; }.route-panel { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--border); }.routes { grid-template-columns: repeat(4, 1fr); }.route-panel .stamp-button { max-width: 280px; }.desk-note { display: none; } } +@media (max-width: 700px) { #app { border: 0; }.brand-sub { display: block; margin: 2px 0 0; }.desk { display: block; }.status-panel { border-right: 0; border-bottom: 1px solid var(--border); }.regulations { max-height: 180px; overflow-y: auto; }.work-area { min-height: 560px; padding: 18px 12px; }.letter { min-height: 500px; padding: 24px 18px; }.letter-grid { display: grid; grid-template-columns: 1fr; gap: 10px; padding-right: 80px; }.letter-grid .address { grid-column: auto; }.letter-body { margin-top: 36px; }.routes { grid-template-columns: 1fr 1fr; }.intro-screen { grid-template-columns: 1fr; text-align: center; }.seal { width: 130px; margin: auto; }.final-stats { grid-template-columns: 1fr; } } +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; } } diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..ba6b45d --- /dev/null +++ b/src/types.ts @@ -0,0 +1,39 @@ +export type Route = 'deliver' | 'return' | 'archive' | 'quarantine'; + +export interface Letter { + id: string; + day: number; + sender: string; + recipient: string; + address: string; + postmark: string; + body: string; + marks: string[]; + correctRoute: Route; + explanation: string; +} + +export interface Regulation { + id: string; + fromDay: number; + number: string; + text: string; + note?: string; +} + +export interface Decision { + letterId: string; + route: Route; + correct: boolean; +} + +export interface GameState { + phase: 'intro' | 'playing' | 'day-end' | 'won' | 'lost'; + day: number; + letterIndex: number; + accuracy: number; + composure: number; + suspicion: number; + decisions: Decision[]; + lastDecision: Decision | null; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ed0c191 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..5d8e681 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { host: true }, +});