diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..612bbcc --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.svelte-kit/ +*.tsbuildinfo +test-results/ +playwright-report/ +playwright/.cache/ +.DS_Store diff --git a/ui/.npmrc b/ui/.npmrc new file mode 100644 index 0000000..1271ab4 --- /dev/null +++ b/ui/.npmrc @@ -0,0 +1,5 @@ +# Do not run an implicit install before `pnpm run + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..04a1dfe --- /dev/null +++ b/ui/package.json @@ -0,0 +1,27 @@ +{ + "name": "scrabble-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Scrabble game client (plain Svelte 5 + Vite). Talks to the gateway over Connect-RPC + FlatBuffers.", + "scripts": { + "dev": "vite", + "start": "vite --mode mock", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json", + "test:unit": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.49.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@types/node": "^22.10.0", + "svelte": "^5.15.0", + "svelte-check": "^4.1.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vitest": "^3.0.0" + } +} diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts new file mode 100644 index 0000000..bac1679 --- /dev/null +++ b/ui/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from '@playwright/test'; + +// Hermetic e2e: Playwright boots the Vite dev server in `mock` mode (the in-memory +// fake transport), so the smoke needs no backend/gateway/Postgres. +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: 'list', + use: { + baseURL: 'http://localhost:4173', + trace: 'on-first-retry', + }, + webServer: { + command: 'pnpm exec vite --mode mock --port 4173 --strictPort', + url: 'http://localhost:4173', + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml new file mode 100644 index 0000000..eea65f0 --- /dev/null +++ b/ui/pnpm-lock.yaml @@ -0,0 +1,1316 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@playwright/test': + specifier: ^1.49.0 + version: 1.60.0 + '@sveltejs/vite-plugin-svelte': + specifier: ^5.0.0 + version: 5.1.1(svelte@5.56.0)(vite@6.4.3(@types/node@22.19.19)) + '@types/node': + specifier: ^22.10.0 + version: 22.19.19 + svelte: + specifier: ^5.15.0 + version: 5.56.0 + svelte-check: + specifier: ^4.1.0 + version: 4.5.0(picomatch@4.0.4)(svelte@5.56.0)(typescript@5.9.3) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.4.3(@types/node@22.19.19) + vitest: + specifier: ^3.0.0 + version: 3.2.6(@types/node@22.19.19) + +packages: + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + + '@rollup/rollup-android-arm-eabi@4.61.0': + resolution: {integrity: sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.61.0': + resolution: {integrity: sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.61.0': + resolution: {integrity: sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.61.0': + resolution: {integrity: sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.61.0': + resolution: {integrity: sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.61.0': + resolution: {integrity: sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.61.0': + resolution: {integrity: sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.61.0': + resolution: {integrity: sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.61.0': + resolution: {integrity: sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.61.0': + resolution: {integrity: sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.61.0': + resolution: {integrity: sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.61.0': + resolution: {integrity: sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.61.0': + resolution: {integrity: sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.61.0': + resolution: {integrity: sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.61.0': + resolution: {integrity: sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.61.0': + resolution: {integrity: sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.61.0': + resolution: {integrity: sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.61.0': + resolution: {integrity: sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.61.0': + resolution: {integrity: sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.61.0': + resolution: {integrity: sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.61.0': + resolution: {integrity: sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.61.0': + resolution: {integrity: sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.61.0': + resolution: {integrity: sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.61.0': + resolution: {integrity: sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.61.0': + resolution: {integrity: sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==} + cpu: [x64] + os: [win32] + + '@sveltejs/acorn-typescript@1.0.10': + resolution: {integrity: sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1': + resolution: {integrity: sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^5.0.0 + svelte: ^5.0.0 + vite: ^6.0.0 + + '@sveltejs/vite-plugin-svelte@5.1.1': + resolution: {integrity: sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + svelte: ^5.0.0 + vite: ^6.0.0 + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@22.19.19': + resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@vitest/expect@3.2.6': + resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==} + + '@vitest/mocker@3.2.6': + resolution: {integrity: sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.6': + resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==} + + '@vitest/runner@3.2.6': + resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==} + + '@vitest/snapshot@3.2.6': + resolution: {integrity: sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==} + + '@vitest/spy@3.2.6': + resolution: {integrity: sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==} + + '@vitest/utils@3.2.6': + resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + devalue@5.8.1: + resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + esrap@2.2.9: + resolution: {integrity: sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + rollup@4.61.0: + resolution: {integrity: sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + svelte-check@4.5.0: + resolution: {integrity: sha512-9lNwPxCLWniFvQIcEv1LFqjIxcFtO3smb5+5BKbRJ3ttL4o2lXCej5rLF4DAnfLPI66oaA81vAxw6ILdIWI7kA==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: '>=5.0.0' + + svelte@5.56.0: + resolution: {integrity: sha512-kTXr26t1bchFp28ROrb957LtbujpBmBDibmqMGziVpUs7awBi96TGgX6SovrA8BNoEUDVRK2Fb9FkeYlGspoVg==} + engines: {node: '>=18'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + 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 + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + vitest@3.2.6: + resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.6 + '@vitest/ui': 3.2.6 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + + '@rollup/rollup-android-arm-eabi@4.61.0': + optional: true + + '@rollup/rollup-android-arm64@4.61.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.61.0': + optional: true + + '@rollup/rollup-darwin-x64@4.61.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.61.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.61.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.61.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.61.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.61.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.61.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.61.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.61.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.61.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.61.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.61.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.61.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.61.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.61.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.61.0': + optional: true + + '@sveltejs/acorn-typescript@1.0.10(acorn@8.16.0)': + dependencies: + acorn: 8.16.0 + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.56.0)(vite@6.4.3(@types/node@22.19.19)))(svelte@5.56.0)(vite@6.4.3(@types/node@22.19.19))': + dependencies: + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.56.0)(vite@6.4.3(@types/node@22.19.19)) + debug: 4.4.3 + svelte: 5.56.0 + vite: 6.4.3(@types/node@22.19.19) + transitivePeerDependencies: + - supports-color + + '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.56.0)(vite@6.4.3(@types/node@22.19.19))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.56.0)(vite@6.4.3(@types/node@22.19.19)))(svelte@5.56.0)(vite@6.4.3(@types/node@22.19.19)) + debug: 4.4.3 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.21 + svelte: 5.56.0 + vite: 6.4.3(@types/node@22.19.19) + vitefu: 1.1.3(vite@6.4.3(@types/node@22.19.19)) + transitivePeerDependencies: + - supports-color + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@22.19.19': + dependencies: + undici-types: 6.21.0 + + '@types/trusted-types@2.0.7': {} + + '@vitest/expect@3.2.6': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.6(vite@6.4.3(@types/node@22.19.19))': + dependencies: + '@vitest/spy': 3.2.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.3(@types/node@22.19.19) + + '@vitest/pretty-format@3.2.6': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.6': + dependencies: + '@vitest/utils': 3.2.6 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.6': + dependencies: + '@vitest/pretty-format': 3.2.6 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.6': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.6': + dependencies: + '@vitest/pretty-format': 3.2.6 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + acorn@8.16.0: {} + + aria-query@5.3.1: {} + + assertion-error@2.0.1: {} + + axobject-query@4.1.0: {} + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + clsx@2.1.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + deepmerge@4.3.1: {} + + devalue@5.8.1: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.25.12: + 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 + + esm-env@1.2.2: {} + + esrap@2.2.9: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + js-tokens@9.0.1: {} + + kleur@4.1.5: {} + + locate-character@3.0.0: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mri@1.2.0: {} + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + readdirp@4.1.2: {} + + rollup@4.61.0: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.61.0 + '@rollup/rollup-android-arm64': 4.61.0 + '@rollup/rollup-darwin-arm64': 4.61.0 + '@rollup/rollup-darwin-x64': 4.61.0 + '@rollup/rollup-freebsd-arm64': 4.61.0 + '@rollup/rollup-freebsd-x64': 4.61.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.61.0 + '@rollup/rollup-linux-arm-musleabihf': 4.61.0 + '@rollup/rollup-linux-arm64-gnu': 4.61.0 + '@rollup/rollup-linux-arm64-musl': 4.61.0 + '@rollup/rollup-linux-loong64-gnu': 4.61.0 + '@rollup/rollup-linux-loong64-musl': 4.61.0 + '@rollup/rollup-linux-ppc64-gnu': 4.61.0 + '@rollup/rollup-linux-ppc64-musl': 4.61.0 + '@rollup/rollup-linux-riscv64-gnu': 4.61.0 + '@rollup/rollup-linux-riscv64-musl': 4.61.0 + '@rollup/rollup-linux-s390x-gnu': 4.61.0 + '@rollup/rollup-linux-x64-gnu': 4.61.0 + '@rollup/rollup-linux-x64-musl': 4.61.0 + '@rollup/rollup-openbsd-x64': 4.61.0 + '@rollup/rollup-openharmony-arm64': 4.61.0 + '@rollup/rollup-win32-arm64-msvc': 4.61.0 + '@rollup/rollup-win32-ia32-msvc': 4.61.0 + '@rollup/rollup-win32-x64-gnu': 4.61.0 + '@rollup/rollup-win32-x64-msvc': 4.61.0 + fsevents: 2.3.3 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + svelte-check@4.5.0(picomatch@4.0.4)(svelte@5.56.0)(typescript@5.9.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.56.0 + typescript: 5.9.3 + transitivePeerDependencies: + - picomatch + + svelte@5.56.0: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.10(acorn@8.16.0) + '@types/estree': 1.0.9 + '@types/trusted-types': 2.0.7 + acorn: 8.16.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.8.1 + esm-env: 1.2.2 + esrap: 2.2.9 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + transitivePeerDependencies: + - '@typescript-eslint/types' + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + vite-node@3.2.4(@types/node@22.19.19): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.3(@types/node@22.19.19) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@6.4.3(@types/node@22.19.19): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.61.0 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.19.19 + fsevents: 2.3.3 + + vitefu@1.1.3(vite@6.4.3(@types/node@22.19.19)): + optionalDependencies: + vite: 6.4.3(@types/node@22.19.19) + + vitest@3.2.6(@types/node@22.19.19): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.6 + '@vitest/mocker': 3.2.6(vite@6.4.3(@types/node@22.19.19)) + '@vitest/pretty-format': 3.2.6 + '@vitest/runner': 3.2.6 + '@vitest/snapshot': 3.2.6 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.3(@types/node@22.19.19) + vite-node: 3.2.4(@types/node@22.19.19) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.19 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + zimmerframe@1.1.4: {} diff --git a/ui/pnpm-workspace.yaml b/ui/pnpm-workspace.yaml new file mode 100644 index 0000000..34a05e7 --- /dev/null +++ b/ui/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +# pnpm 11 records build-script approval here. esbuild's postinstall materialises +# its CLI shim; the platform binary itself ships as an optional dependency. +allowBuilds: + esbuild: true diff --git a/ui/src/App.svelte b/ui/src/App.svelte new file mode 100644 index 0000000..267b2e6 --- /dev/null +++ b/ui/src/App.svelte @@ -0,0 +1,47 @@ + + +{#if !app.ready} +
{t('common.loading')}
+{:else if router.route.name === 'login'} + +{:else if router.route.name === 'new'} + +{:else if router.route.name === 'game'} + +{:else if router.route.name === 'profile'} + +{:else if router.route.name === 'settings'} + +{:else if router.route.name === 'about'} + +{:else} + +{/if} + + + + diff --git a/ui/src/app.css b/ui/src/app.css new file mode 100644 index 0000000..4e4ab26 --- /dev/null +++ b/ui/src/app.css @@ -0,0 +1,144 @@ +/* + * Design tokens — pure CSS custom properties, no framework, no image/font/SVG + * assets. Light is the default; dark is applied either by the OS + * (prefers-color-scheme) or by an explicit [data-theme] set from Settings. A + * Telegram Mini App can override these same variables at runtime from + * WebApp.themeParams (see lib/theme — SDK wiring lands in the Telegram stage), so + * the whole UI re-themes without touching components. + */ +:root { + --bg: #f3f4f6; + --bg-elev: #ffffff; + --surface: #ffffff; + --surface-2: #eef0f3; + --text: #14181f; + --text-muted: #6b7280; + --border: #d8dce2; + --accent: #2f6df6; + --accent-text: #ffffff; + --danger: #d6453d; + --ok: #1f9d57; + --warn: #c9881b; + + /* board + tiles (all drawn with CSS primitives) */ + --board-bg: #cdd6cf; + --cell-bg: #e7ece8; + --cell-line: #b6c0b8; + --tile-bg: #f4e2b8; + --tile-edge: #d8c190; + --tile-text: #2a2113; + --tile-pending: #ffe7a3; + --tile-recent: #fff6d8; + --prem-tw: #e06a5b; /* triple word */ + --prem-dw: #efa6a0; /* double word + centre */ + --prem-tl: #4f8fd6; /* triple letter */ + --prem-dl: #a8cdec; /* double letter */ + --prem-text: #2a2113; + + /* shape + type */ + --radius: 10px; + --radius-sm: 6px; + --gap: 8px; + --pad: 12px; + --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, + "Noto Sans", "Liberation Sans", sans-serif; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #0f1115; + --bg-elev: #171a21; + --surface: #171a21; + --surface-2: #1f242d; + --text: #e7eaf0; + --text-muted: #9aa3b2; + --border: #2a313c; + --accent: #5b8cff; + --accent-text: #0b0e13; + --danger: #f0635a; + --ok: #44c87f; + --warn: #e0a93a; + + --board-bg: #2a3330; + --cell-bg: #222a27; + --cell-line: #38433d; + --tile-bg: #d9c79a; + --tile-edge: #b6a473; + --tile-text: #20190d; + --tile-pending: #f0d98f; + --tile-recent: #4a4636; + --prem-tw: #b1493d; + --prem-dw: #8c5450; + --prem-tl: #34608f; + --prem-dl: #3b5a72; + --prem-text: #e7eaf0; + } +} + +/* Explicit dark chosen in Settings (overrides OS preference). */ +:root[data-theme="dark"] { + --bg: #0f1115; + --bg-elev: #171a21; + --surface: #171a21; + --surface-2: #1f242d; + --text: #e7eaf0; + --text-muted: #9aa3b2; + --border: #2a313c; + --accent: #5b8cff; + --accent-text: #0b0e13; + --danger: #f0635a; + --ok: #44c87f; + --warn: #e0a93a; + --board-bg: #2a3330; + --cell-bg: #222a27; + --cell-line: #38433d; + --tile-bg: #d9c79a; + --tile-edge: #b6a473; + --tile-text: #20190d; + --tile-pending: #f0d98f; + --tile-recent: #4a4636; + --prem-tw: #b1493d; + --prem-dw: #8c5450; + --prem-tl: #34608f; + --prem-dl: #3b5a72; + --prem-text: #e7eaf0; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; +} + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 16px; + line-height: 1.4; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + /* never let the page scroll/zoom out from under the board */ + overscroll-behavior: none; + touch-action: manipulation; +} + +#app { + height: 100%; +} + +button { + font: inherit; + color: inherit; + cursor: pointer; +} + +.reduce-motion * { + animation-duration: 0.001ms !important; + transition-duration: 0.001ms !important; +} diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte new file mode 100644 index 0000000..692b282 --- /dev/null +++ b/ui/src/components/Header.svelte @@ -0,0 +1,60 @@ + + +
+ {#if back} + + {:else} + + {/if} +

{title}

+
{#if menu}{@render menu()}{/if}
+
+ + diff --git a/ui/src/components/Modal.svelte b/ui/src/components/Modal.svelte new file mode 100644 index 0000000..5d7906c --- /dev/null +++ b/ui/src/components/Modal.svelte @@ -0,0 +1,46 @@ + + + + +
onclose?.()}> + +
+ + diff --git a/ui/src/components/Toast.svelte b/ui/src/components/Toast.svelte new file mode 100644 index 0000000..72e8e32 --- /dev/null +++ b/ui/src/components/Toast.svelte @@ -0,0 +1,29 @@ + + +{#if app.toast} +
{app.toast.text}
+{/if} + + diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte new file mode 100644 index 0000000..7a12ebb --- /dev/null +++ b/ui/src/game/Board.svelte @@ -0,0 +1,213 @@ + + + +
+
+ {#each board as rowCells, r (r)} + {#each rowCells as cell, c (c)} + {@const p = pending.get(key(r, c))} + {@const letter = cell?.letter ?? p?.letter ?? ''} + {@const blank = cell?.blank ?? p?.blank ?? false} + + {/each} + {/each} +
+
+ + diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte new file mode 100644 index 0000000..7b50778 --- /dev/null +++ b/ui/src/game/Chat.svelte @@ -0,0 +1,112 @@ + + +
+
+ {#if messages.length === 0} +

{t('chat.empty')}

+ {/if} + {#each messages as m (m.id)} + {#if m.kind === 'nudge'} +
{t('chat.nudge')}
+ {:else} +
{m.body}
+ {/if} + {/each} +
+
+ e.key === 'Enter' && send()} + /> + + +
+
+ + diff --git a/ui/src/game/Controls.svelte b/ui/src/game/Controls.svelte new file mode 100644 index 0000000..2edc105 --- /dev/null +++ b/ui/src/game/Controls.svelte @@ -0,0 +1,101 @@ + + +
+
+ {#if preview} + {#if preview.legal} + {t('game.preview', { n: preview.score })} + {:else} + {t('game.previewIllegal')} + {/if} + {/if} + {#if ambiguous} + + {/if} +
+
+ + + + +
+
+ + diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte new file mode 100644 index 0000000..95726f3 --- /dev/null +++ b/ui/src/game/Game.svelte @@ -0,0 +1,741 @@ + + +
+ {#snippet menu()} + + {#if menuOpen} + + +
(menuOpen = false)}>
+ + {/if} + {/snippet} +
+ +{#if view} +
+ {#each view.game.seats as s (s.seat)} +
+
{s.accountId === app.session?.userId ? t('common.you') : s.displayName}
+
{s.score}
+
+ {/each} +
+ +
+ (zoomed = !zoomed)} + /> +
+ +
+ {t('game.bag', { n: view.bagLen })} + {#if gameOver} + {t('game.over')} — {resultText()} + {:else} + {isMyTurn ? t('game.yourTurn') : t('game.waiting', { name: view.game.seats[view.game.toMove]?.displayName ?? '' })} + {/if} + {t('game.hints', { n: view.hintsRemaining })} +
+ + {#if !gameOver} +
+
+ +
+ {#if placement.pending.length > 0} + + {/if} +
+ + + {/if} +{:else} +

{t('common.loading')}

+{/if} + +{#if drag} +
+ {drag.blank ? '' : drag.letter} +
+{/if} + +{#if blankPrompt} + (blankPrompt = null)}> +
+ {#each alphabet(variant) as ch (ch)} + + {/each} +
+
+{/if} + +{#if exchangeOpen && view} + (exchangeOpen = false)}> +
+ {#each view.rack as letter, i (i)} + + {/each} +
+ +
+{/if} + +{#if checkOpen} + (checkOpen = false)}> +
+ e.key === 'Enter' && runCheck()} /> + +
+ {#if checkResult} +

+ {checkResult.legal + ? t('game.wordLegal', { word: checkResult.word }) + : t('game.wordIllegal', { word: checkResult.word })} +

+ + {/if} +
+{/if} + +{#if resignOpen} + (resignOpen = false)}> +
+ + +
+
+{/if} + +{#if panel === 'chat'} + (panel = 'none')}> + + +{/if} + +{#if panel === 'history' && view} + (panel = 'none')}> +
    + {#each moves as m, i (i)} +
  1. + {view.game.seats[m.player]?.displayName ?? m.player} + {m.action === 'play' ? m.words.join(', ') : m.action} + {m.score} +
  2. + {/each} +
+
+{/if} + + diff --git a/ui/src/game/MakeMove.svelte b/ui/src/game/MakeMove.svelte new file mode 100644 index 0000000..a8f4a7a --- /dev/null +++ b/ui/src/game/MakeMove.svelte @@ -0,0 +1,122 @@ + + +
+ + + {#if popup} + + +
(popup = false)}>
+ + {/if} +
+ + diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte new file mode 100644 index 0000000..e00beae --- /dev/null +++ b/ui/src/game/Rack.svelte @@ -0,0 +1,74 @@ + + +
+ {#each slots as slot (slot.index)} + {#if slot.used} + + {:else} + + {/if} + {/each} +
+ + diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts new file mode 100644 index 0000000..d164955 --- /dev/null +++ b/ui/src/lib/app.svelte.ts @@ -0,0 +1,186 @@ +// Central app state + actions. Holds the session/profile, client preferences, a +// transient toast, and the latest live event (screens react to it via $effect). All +// gateway calls funnel through here so errors map to one user-facing toast and an +// expired session logs out. + +import type { Profile, PushEvent, Session } from './model'; +import { gateway } from './gateway'; +import { GatewayError } from './client'; +import { navigate, router } from './router.svelte'; +import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte'; +import { applyReduceMotion, applyTheme, type ThemePref } from './theme'; +import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; + +export interface Toast { + kind: 'error' | 'info'; + text: string; +} + +export const app = $state<{ + ready: boolean; + session: Session | null; + profile: Profile | null; + toast: Toast | null; + lastEvent: PushEvent | null; + theme: ThemePref; + locale: Locale; + reduceMotion: boolean; + localeLocked: boolean; +}>({ + ready: false, + session: null, + profile: null, + toast: null, + lastEvent: null, + theme: 'auto', + locale: 'en', + reduceMotion: false, + localeLocked: false, +}); + +let unsubscribeStream: (() => void) | null = null; +let toastTimer: ReturnType | null = null; + +export function showToast(text: string, kind: Toast['kind'] = 'info'): void { + app.toast = { kind, text }; + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(() => (app.toast = null), 4000); +} + +/** handleError maps a GatewayError to a toast; an invalid session logs out. */ +export function handleError(err: unknown): void { + if (err instanceof GatewayError) { + if (err.code === 'session_invalid' || err.code === 'unauthenticated') { + void logout(); + return; + } + showToast(t(errorKey(err.code)), 'error'); + return; + } + showToast(t('error.generic'), 'error'); +} + +function openStream(): void { + closeStream(); + unsubscribeStream = gateway.subscribe( + (e) => { + app.lastEvent = e; + if (e.kind === 'chat_message' && e.message.senderId !== app.session?.userId) { + showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info'); + } else if (e.kind === 'nudge') { + showToast(t('chat.nudge'), 'info'); + } else if (e.kind === 'your_turn') { + showToast(t('game.yourTurn'), 'info'); + } else if (e.kind === 'match_found') { + navigate(`/game/${e.gameId}`); + } + }, + () => showToast(t('error.unavailable'), 'error'), + ); +} + +function closeStream(): void { + unsubscribeStream?.(); + unsubscribeStream = null; +} + +async function adoptSession(s: Session): Promise { + gateway.setToken(s.token); + app.session = s; + await saveSession(s); + try { + app.profile = await gateway.profileGet(); + if (!app.localeLocked) setLocale(localeFrom(app.profile.preferredLanguage, app.locale)); + } catch (err) { + handleError(err); + } + openStream(); +} + +export async function bootstrap(): Promise { + const prefs = await loadPrefs(); + app.theme = prefs.theme ?? 'auto'; + app.reduceMotion = prefs.reduceMotion ?? false; + applyTheme(app.theme); + applyReduceMotion(app.reduceMotion); + if (prefs.locale) { + app.locale = prefs.locale; + app.localeLocked = true; + setLocale(prefs.locale); + } else { + const guess = localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en'); + app.locale = guess; + setLocale(guess); + } + + const saved = await loadSession(); + if (saved) { + await adoptSession(saved); + if (router.route.name === 'login') navigate('/'); + } else if (router.route.name !== 'login') { + navigate('/login'); + } + app.ready = true; +} + +export async function loginGuest(): Promise { + try { + const s = await gateway.authGuest(app.locale); + await adoptSession(s); + navigate('/'); + } catch (err) { + handleError(err); + } +} + +export async function requestEmailCode(email: string): Promise { + try { + await gateway.authEmailRequest(email); + return true; + } catch (err) { + handleError(err); + return false; + } +} + +export async function loginEmail(email: string, code: string): Promise { + try { + const s = await gateway.authEmailLogin(email, code); + await adoptSession(s); + navigate('/'); + } catch (err) { + handleError(err); + } +} + +export async function logout(): Promise { + closeStream(); + gateway.setToken(null); + await clearSession(); + app.session = null; + app.profile = null; + navigate('/login'); +} + +function persistPrefs(): void { + void savePrefs({ theme: app.theme, locale: app.locale, reduceMotion: app.reduceMotion }); +} + +export function setTheme(theme: ThemePref): void { + app.theme = theme; + applyTheme(theme); + persistPrefs(); +} + +export function setLocalePref(locale: Locale): void { + app.locale = locale; + app.localeLocked = true; + setLocale(locale); + persistPrefs(); +} + +export function setReduceMotion(on: boolean): void { + app.reduceMotion = on; + applyReduceMotion(on); + persistPrefs(); +} diff --git a/ui/src/lib/board.ts b/ui/src/lib/board.ts new file mode 100644 index 0000000..1557c1c --- /dev/null +++ b/ui/src/lib/board.ts @@ -0,0 +1,45 @@ +// Pure board reconstruction. The wire carries no board (StateView is summary + rack +// only), so the live grid is rebuilt by replaying the decoded move journal — exactly +// the dictionary-independent history invariant (ARCHITECTURE §9.1): apply each play's +// placed tiles onto an empty grid. + +import type { MoveRecord, Tile } from './model'; +import { BOARD_SIZE } from './premiums'; + +export interface BoardCell { + letter: string; + blank: boolean; +} + +export type Board = (BoardCell | null)[][]; + +export function emptyBoard(): Board { + return Array.from({ length: BOARD_SIZE }, () => + Array.from({ length: BOARD_SIZE }, () => null as BoardCell | null), + ); +} + +function inBounds(r: number, c: number): boolean { + return r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE; +} + +/** replay folds every play move's tiles onto an empty board (pass/exchange/resign + * change no squares). */ +export function replay(moves: MoveRecord[]): Board { + const b = emptyBoard(); + for (const m of moves) { + if (m.action !== 'play') continue; + for (const t of m.tiles) { + if (inBounds(t.row, t.col)) b[t.row][t.col] = { letter: t.letter, blank: t.blank }; + } + } + return b; +} + +/** lastPlayTiles returns the tiles of the most recent play (for highlighting). */ +export function lastPlayTiles(moves: MoveRecord[]): Tile[] { + for (let i = moves.length - 1; i >= 0; i--) { + if (moves[i].action === 'play') return moves[i].tiles; + } + return []; +} diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts new file mode 100644 index 0000000..9dc5578 --- /dev/null +++ b/ui/src/lib/client.ts @@ -0,0 +1,84 @@ +// GatewayClient — the typed facade the screens call. Both the real Connect/ +// FlatBuffers transport and the in-memory mock implement it. Domain failures (the +// gateway's result_code) and edge failures (Connect error codes) are normalised +// into a thrown GatewayError carrying a stable `code` the UI maps to an i18n +// message. + +import type { + ChatMessage, + EvalResult, + GameList, + GameView, + History, + HintResult, + MatchResult, + MoveResult, + Profile, + PushEvent, + Session, + StateView, + Tile, + Variant, + WordCheckResult, +} from './model'; + +/** GatewayError carries a stable code (the gateway result_code, or an edge code). */ +export class GatewayError extends Error { + readonly code: string; + constructor(code: string, message?: string) { + super(message ?? code); + this.name = 'GatewayError'; + this.code = code; + } +} + +/** A tile the player is submitting (rack/blank already resolved to a letter). */ +export interface PlacedTile { + row: number; + col: number; + letter: string; + blank: boolean; +} + +/** Unsubscribe handle for the live stream. */ +export type Unsubscribe = () => void; + +export interface GatewayClient { + // --- auth (unauthenticated) --- + authGuest(locale?: string): Promise; + authEmailRequest(email: string): Promise; + authEmailLogin(email: string, code: string): Promise; + + // --- profile / lists --- + profileGet(): Promise; + gamesList(): Promise; + + // --- lobby --- + lobbyEnqueue(variant: Variant): Promise; + lobbyPoll(): Promise; + + // --- game --- + gameState(gameId: string): Promise; + gameHistory(gameId: string): Promise; + submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Promise; + pass(gameId: string): Promise; + exchange(gameId: string, tiles: string[]): Promise; + resign(gameId: string): Promise; + hint(gameId: string): Promise; + evaluate(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Promise; + checkWord(gameId: string, word: string): Promise; + complaint(gameId: string, word: string, note: string): Promise; + + // --- chat --- + chatPost(gameId: string, body: string): Promise; + chatList(gameId: string): Promise; + nudge(gameId: string): Promise; + + // --- live stream --- + subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe; + + /** Set or clear the bearer token used for authenticated calls and the stream. */ + setToken(token: string | null): void; +} + +export type { GameView, Tile }; diff --git a/ui/src/lib/gateway.ts b/ui/src/lib/gateway.ts new file mode 100644 index 0000000..9178fed --- /dev/null +++ b/ui/src/lib/gateway.ts @@ -0,0 +1,13 @@ +// The single GatewayClient the app uses. In `mock` mode (pnpm start) it is the +// in-memory fake; otherwise it is the real Connect/FlatBuffers transport. MODE is a +// build-time constant, so a production build tree-shakes the mock away. + +import type { GatewayClient } from './client'; +import { MockGateway } from './mock/client'; +import { createTransport } from './transport'; + +const isMock = import.meta.env.MODE === 'mock'; + +export const gateway: GatewayClient = isMock + ? new MockGateway() + : createTransport(import.meta.env.VITE_GATEWAY_URL ?? ''); diff --git a/ui/src/lib/i18n/catalog.ts b/ui/src/lib/i18n/catalog.ts new file mode 100644 index 0000000..f3f13c4 --- /dev/null +++ b/ui/src/lib/i18n/catalog.ts @@ -0,0 +1,37 @@ +// Pure i18n catalog + lookup (no runes) so any module can import the types and +// translate without depending on the reactive layer. + +import { en, type MessageKey } from './en'; +import { ru } from './ru'; + +export type Locale = 'en' | 'ru'; +export type { MessageKey }; + +export const catalogs: Record> = { en, ru }; + +export function translate( + locale: Locale, + key: MessageKey, + params?: Record, +): string { + const dict = catalogs[locale] ?? en; + let s: string = dict[key] ?? en[key] ?? key; + if (params) { + for (const [k, v] of Object.entries(params)) { + s = s.replaceAll(`{${k}}`, String(v)); + } + } + return s; +} + +/** errorKey maps a gateway result/edge code to a message key, falling back to generic. */ +export function errorKey(code: string): MessageKey { + const key = `error.${code}` as MessageKey; + return key in en ? key : 'error.generic'; +} + +/** localeFrom picks a supported locale from a free-form hint (e.g. 'ru-RU' -> 'ru'). */ +export function localeFrom(hint: string | undefined | null, fallback: Locale = 'en'): Locale { + const l = (hint ?? '').slice(0, 2).toLowerCase(); + return l === 'ru' ? 'ru' : l === 'en' ? 'en' : fallback; +} diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts new file mode 100644 index 0000000..aeef360 --- /dev/null +++ b/ui/src/lib/i18n/en.ts @@ -0,0 +1,128 @@ +// English message catalog (authoritative). Keys are flat dotted strings; ru.ts must +// provide exactly the same keys (enforced by its type and a Vitest parity test). +// {name} placeholders are filled by t(key, params). + +export const en = { + 'app.title': 'Scrabble', + + 'common.back': 'Back', + 'common.cancel': 'Cancel', + 'common.ok': 'OK', + 'common.close': 'Close', + 'common.loading': 'Loading…', + 'common.retry': 'Retry', + 'common.you': 'You', + 'common.save': 'Save', + + 'login.title': 'Sign in', + 'login.guest': 'Play as guest', + 'login.email': 'Email', + 'login.emailPlaceholder': 'you@example.com', + 'login.sendCode': 'Send code', + 'login.codePlaceholder': '6-digit code', + 'login.signIn': 'Sign in', + 'login.codeSent': 'We sent a code to {email}.', + + 'lobby.activeGames': 'Active games', + 'lobby.finishedGames': 'Finished games', + 'lobby.noActive': 'No active games yet.', + 'lobby.noFinished': 'No finished games yet.', + 'lobby.new': 'New', + 'lobby.stats': 'Stats', + 'lobby.tournaments': 'Tourn.', + 'lobby.profile': 'Profile', + 'lobby.settings': 'Settings', + 'lobby.about': 'About', + 'lobby.yourTurn': 'Your turn', + 'lobby.theirTurn': 'Their turn', + 'lobby.vs': 'vs {opponents}', + 'lobby.soon': 'Coming soon', + + 'new.title': 'New game', + 'new.subtitle': 'Auto-match with another player', + 'new.english': 'English', + 'new.russian': 'Russian', + 'new.erudit': 'Эрудит', + 'new.find': 'Find a game', + 'new.searching': 'Looking for an opponent…', + + 'game.bag': 'Bag {n}', + 'game.hints': 'Hints {n}', + 'game.yourTurn': 'Your turn', + 'game.waiting': "Waiting for {name}", + 'game.makeMove': 'Make move', + 'game.reset': 'Reset', + 'game.draw': 'Draw', + 'game.skip': 'Skip', + 'game.shuffle': 'Shuffle', + 'game.hint': 'Hint', + 'game.history': 'History', + 'game.chat': 'Chat', + 'game.checkWord': 'Check word', + 'game.dropGame': 'Drop game', + 'game.preview': 'Scores {n}', + 'game.previewIllegal': 'Not a legal move', + 'game.chooseBlank': 'Choose a letter for the blank', + 'game.exchangeTitle': 'Select tiles to exchange', + 'game.exchangeConfirm': 'Exchange {n}', + 'game.confirmResign': 'Resign this game?', + 'game.hintShown': 'Best move: {word} for {n}', + 'game.over': 'Game over', + 'game.won': 'You won', + 'game.lost': 'You lost', + 'game.tied': 'Draw', + 'game.checkWordPrompt': 'Enter a word', + 'game.wordLegal': '“{word}” is valid', + 'game.wordIllegal': '“{word}” is not valid', + 'game.complain': 'Disagree', + 'game.complaintSent': 'Thanks, sent for review.', + + 'chat.placeholder': 'Quick message…', + 'chat.send': 'Send', + 'chat.nudge': 'Nudge', + 'chat.empty': 'No messages yet.', + 'chat.nudged': '{name} nudged you', + + 'profile.title': 'Profile', + 'profile.language': 'Language', + 'profile.timezone': 'Time zone', + 'profile.hintBalance': 'Hint balance', + 'profile.guest': 'Guest account', + 'profile.readonly': 'Editing your profile arrives in a later update.', + + 'settings.title': 'Settings', + 'settings.theme': 'Theme', + 'settings.themeAuto': 'Auto', + 'settings.themeLight': 'Light', + 'settings.themeDark': 'Dark', + 'settings.language': 'Interface language', + 'settings.reduceMotion': 'Reduce motion', + + 'about.title': 'About', + 'about.description': 'A multiplatform Scrabble game.', + 'about.version': 'Version {v}', + + 'lang.en': 'English', + 'lang.ru': 'Русский', + + 'error.not_your_turn': "It is not your turn.", + 'error.illegal_play': 'That is not a legal play.', + 'error.hint_unavailable': 'No hints available.', + 'error.chat_rejected': 'Message rejected (too long or contains contact info).', + 'error.game_finished': 'This game is finished.', + 'error.not_a_player': 'You are not a player in this game.', + 'error.already_queued': 'You are already in the queue.', + 'error.email_taken': 'That email belongs to another account.', + 'error.code_invalid': 'Invalid or expired code.', + 'error.invalid_email': 'Enter a valid email address.', + 'error.invalid_config': 'Invalid game settings.', + 'error.not_found': 'Not found.', + 'error.session_invalid': 'Your session expired. Please sign in again.', + 'error.unauthenticated': 'Please sign in.', + 'error.rate_limited': 'Too many requests, slow down.', + 'error.unavailable': 'Connection problem. Retrying…', + 'error.internal': 'Something went wrong.', + 'error.generic': 'Something went wrong.', +} as const; + +export type MessageKey = keyof typeof en; diff --git a/ui/src/lib/i18n/index.svelte.ts b/ui/src/lib/i18n/index.svelte.ts new file mode 100644 index 0000000..b72baff --- /dev/null +++ b/ui/src/lib/i18n/index.svelte.ts @@ -0,0 +1,17 @@ +// Reactive i18n layer. The locale is a rune, so any component that calls t() +// re-renders when the locale changes. The catalog + lookup are pure (see catalog.ts). + +import { translate, type Locale, type MessageKey } from './catalog'; + +export { errorKey, localeFrom } from './catalog'; +export type { Locale, MessageKey }; + +export const i18n = $state<{ locale: Locale }>({ locale: 'en' }); + +export function setLocale(locale: Locale): void { + i18n.locale = locale; +} + +export function t(key: MessageKey, params?: Record): string { + return translate(i18n.locale, key, params); +} diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts new file mode 100644 index 0000000..b8c075e --- /dev/null +++ b/ui/src/lib/i18n/ru.ts @@ -0,0 +1,127 @@ +// Russian message catalog. Typed as Record so it must cover every +// key the English catalog defines (a Vitest test asserts parity too). + +import type { MessageKey } from './en'; + +export const ru: Record = { + 'app.title': 'Scrabble', + + 'common.back': 'Назад', + 'common.cancel': 'Отмена', + 'common.ok': 'ОК', + 'common.close': 'Закрыть', + 'common.loading': 'Загрузка…', + 'common.retry': 'Повторить', + 'common.you': 'Вы', + 'common.save': 'Сохранить', + + 'login.title': 'Вход', + 'login.guest': 'Играть как гость', + 'login.email': 'Эл. почта', + 'login.emailPlaceholder': 'you@example.com', + 'login.sendCode': 'Отправить код', + 'login.codePlaceholder': 'Код из 6 цифр', + 'login.signIn': 'Войти', + 'login.codeSent': 'Мы отправили код на {email}.', + + 'lobby.activeGames': 'Активные игры', + 'lobby.finishedGames': 'Завершённые игры', + 'lobby.noActive': 'Пока нет активных игр.', + 'lobby.noFinished': 'Пока нет завершённых игр.', + 'lobby.new': 'Новая', + 'lobby.stats': 'Статы', + 'lobby.tournaments': 'Турниры', + 'lobby.profile': 'Профиль', + 'lobby.settings': 'Настройки', + 'lobby.about': 'О программе', + 'lobby.yourTurn': 'Ваш ход', + 'lobby.theirTurn': 'Ход соперника', + 'lobby.vs': 'против {opponents}', + 'lobby.soon': 'Скоро', + + 'new.title': 'Новая игра', + 'new.subtitle': 'Автоподбор соперника', + 'new.english': 'Английский', + 'new.russian': 'Русский', + 'new.erudit': 'Эрудит', + 'new.find': 'Найти игру', + 'new.searching': 'Ищем соперника…', + + 'game.bag': 'Мешок {n}', + 'game.hints': 'Подсказки {n}', + 'game.yourTurn': 'Ваш ход', + 'game.waiting': 'Ожидаем {name}', + 'game.makeMove': 'Сделать ход', + 'game.reset': 'Сброс', + 'game.draw': 'Обмен', + 'game.skip': 'Пас', + 'game.shuffle': 'Перемешать', + 'game.hint': 'Подсказка', + 'game.history': 'История', + 'game.chat': 'Чат', + 'game.checkWord': 'Проверить слово', + 'game.dropGame': 'Покинуть игру', + 'game.preview': 'Очков: {n}', + 'game.previewIllegal': 'Недопустимый ход', + 'game.chooseBlank': 'Выберите букву для бланка', + 'game.exchangeTitle': 'Выберите фишки для обмена', + 'game.exchangeConfirm': 'Обменять {n}', + 'game.confirmResign': 'Сдаться в этой игре?', + 'game.hintShown': 'Лучший ход: {word} на {n}', + 'game.over': 'Игра окончена', + 'game.won': 'Вы выиграли', + 'game.lost': 'Вы проиграли', + 'game.tied': 'Ничья', + 'game.checkWordPrompt': 'Введите слово', + 'game.wordLegal': '«{word}» допустимо', + 'game.wordIllegal': '«{word}» недопустимо', + 'game.complain': 'Не согласен', + 'game.complaintSent': 'Спасибо, отправлено на проверку.', + + 'chat.placeholder': 'Короткое сообщение…', + 'chat.send': 'Отправить', + 'chat.nudge': 'Поторопить', + 'chat.empty': 'Сообщений пока нет.', + 'chat.nudged': '{name} торопит вас', + + 'profile.title': 'Профиль', + 'profile.language': 'Язык', + 'profile.timezone': 'Часовой пояс', + 'profile.hintBalance': 'Баланс подсказок', + 'profile.guest': 'Гостевой аккаунт', + 'profile.readonly': 'Редактирование профиля появится в следующем обновлении.', + + 'settings.title': 'Настройки', + 'settings.theme': 'Тема', + 'settings.themeAuto': 'Авто', + 'settings.themeLight': 'Светлая', + 'settings.themeDark': 'Тёмная', + 'settings.language': 'Язык интерфейса', + 'settings.reduceMotion': 'Меньше анимаций', + + 'about.title': 'О программе', + 'about.description': 'Мультиплатформенная игра в скрабл.', + 'about.version': 'Версия {v}', + + 'lang.en': 'English', + 'lang.ru': 'Русский', + + 'error.not_your_turn': 'Сейчас не ваш ход.', + 'error.illegal_play': 'Это недопустимый ход.', + 'error.hint_unavailable': 'Подсказки недоступны.', + 'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).', + 'error.game_finished': 'Эта игра уже завершена.', + 'error.not_a_player': 'Вы не участник этой игры.', + 'error.already_queued': 'Вы уже в очереди.', + 'error.email_taken': 'Эта почта принадлежит другому аккаунту.', + 'error.code_invalid': 'Неверный или истёкший код.', + 'error.invalid_email': 'Введите корректный адрес почты.', + 'error.invalid_config': 'Неверные настройки игры.', + 'error.not_found': 'Не найдено.', + 'error.session_invalid': 'Сессия истекла. Войдите снова.', + 'error.unauthenticated': 'Пожалуйста, войдите.', + 'error.rate_limited': 'Слишком много запросов, помедленнее.', + 'error.unavailable': 'Проблема соединения. Повторяем…', + 'error.internal': 'Что-то пошло не так.', + 'error.generic': 'Что-то пошло не так.', +}; diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts new file mode 100644 index 0000000..c948cf0 --- /dev/null +++ b/ui/src/lib/mock/client.ts @@ -0,0 +1,350 @@ +// In-memory mock implementation of GatewayClient. Drives the playable slice with no +// backend: it serves the seed data, applies plays/passes/exchanges/resigns to local +// state, fabricates plausible scores, and emits live events (a canned opponent reply, +// a match-found after enqueue) so the stream path is exercised too. This same fake is +// reused by the Playwright smoke. It is tree-shaken out of a production (non-mock) +// build. + +import type { + GatewayClient, + PlacedTile, + Unsubscribe, +} from '../client'; +import { GatewayError } from '../client'; +import type { + ChatMessage, + EvalResult, + GameList, + History, + HintResult, + MatchResult, + MoveResult, + Profile, + PushEvent, + Session, + StateView, + Variant, + WordCheckResult, +} from '../model'; +import { tileValue } from '../premiums'; +import { ME, PROFILE, SESSION, seedGames, type MockGame } from './data'; + +const POOL: Record = { + english: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK', + russian: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ', + erudit: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ', +}; + +function draw(variant: Variant, n: number): string[] { + const pool = POOL[variant]; + const out: string[] = []; + for (let i = 0; i < n; i++) out.push(pool[Math.floor(Math.random() * pool.length)]); + return out; +} + +function removeFromRack(rack: string[], tiles: PlacedTile[]): string[] { + const next = [...rack]; + for (const t of tiles) { + const want = t.blank ? '?' : t.letter.toUpperCase(); + const i = next.indexOf(want); + if (i >= 0) next.splice(i, 1); + } + return next; +} + +export class MockGateway implements GatewayClient { + private readonly games = seedGames(); + private readonly profile: Profile = { ...PROFILE }; + private readonly subs = new Set<(e: PushEvent) => void>(); + private pendingMatch: string | null = null; + + setToken(_token: string | null): void { + // The mock needs no auth; the real transport stores the bearer token. + } + + private emit(e: PushEvent): void { + for (const cb of this.subs) cb(e); + } + + private game(id: string): MockGame { + const g = this.games.get(id); + if (!g) throw new GatewayError('not_found'); + return g; + } + + private mySeat(g: MockGame): number { + const s = g.view.seats.find((x) => x.accountId === ME); + return s ? s.seat : 0; + } + + // --- auth --- + async authGuest(): Promise { + return { ...SESSION }; + } + async authEmailRequest(): Promise {} + async authEmailLogin(): Promise { + return { ...SESSION, isGuest: false }; + } + + // --- profile / lists --- + async profileGet(): Promise { + return { ...this.profile }; + } + async gamesList(): Promise { + return { games: [...this.games.values()].map((g) => structuredClone(g.view)) }; + } + + // --- lobby --- + async lobbyEnqueue(variant: Variant): Promise { + // Simulate a 10s-style robot substitution, sped up: match found shortly. + const id = crypto.randomUUID(); + const g: MockGame = { + view: { + id, + variant, + dictVersion: 'v1', + status: 'active', + players: 2, + toMove: 0, + turnTimeoutSecs: 86400, + moveCount: 0, + endReason: '', + seats: [ + { seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false }, + { seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false }, + ], + }, + moves: [], + rack: draw(variant, 7), + bagLen: 86, + hintsRemaining: 1, + chat: [], + }; + this.games.set(id, g); + this.pendingMatch = id; + setTimeout(() => this.emit({ kind: 'match_found', gameId: id }), 1400); + return { matched: false }; + } + + async lobbyPoll(): Promise { + if (this.pendingMatch) { + const g = this.games.get(this.pendingMatch); + this.pendingMatch = null; + if (g) return { matched: true, game: structuredClone(g.view) }; + } + return { matched: false }; + } + + // --- game --- + async gameState(gameId: string): Promise { + const g = this.game(gameId); + return { + game: structuredClone(g.view), + seat: this.mySeat(g), + rack: [...g.rack], + bagLen: g.bagLen, + hintsRemaining: g.hintsRemaining, + }; + } + + async gameHistory(gameId: string): Promise { + const g = this.game(gameId); + return { gameId, moves: structuredClone(g.moves) }; + } + + async submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Promise { + const g = this.game(gameId); + const seat = this.mySeat(g); + if (g.view.toMove !== seat) throw new GatewayError('not_your_turn'); + const variant = g.view.variant; + let score = tiles.reduce((s, t) => s + tileValue(variant, t.blank ? '?' : t.letter), 0); + if (tiles.length === 7) score += 50; + const total = g.view.seats[seat].score + score; + const move = { + player: seat, + action: 'play' as const, + dir, + mainRow: tiles[0]?.row ?? 7, + mainCol: tiles[0]?.col ?? 7, + tiles: tiles.map((t) => ({ row: t.row, col: t.col, letter: t.letter, blank: t.blank })), + words: [tiles.map((t) => t.letter).join('')], + count: 1, + score, + total, + }; + g.moves.push(move); + g.view.seats[seat].score = total; + g.view.moveCount += 1; + g.rack = removeFromRack(g.rack, tiles); + const drawn = Math.min(7 - g.rack.length, g.bagLen); + g.rack.push(...draw(variant, drawn)); + g.bagLen -= drawn; + g.view.toMove = (seat + 1) % g.view.players; + this.scheduleOpponentReply(gameId); + return { move: structuredClone(move), game: structuredClone(g.view) }; + } + + private async simpleAction( + gameId: string, + action: 'pass' | 'exchange' | 'resign', + tiles: string[] = [], + ): Promise { + const g = this.game(gameId); + const seat = this.mySeat(g); + if (g.view.toMove !== seat) throw new GatewayError('not_your_turn'); + if (action === 'exchange' && tiles.length > 0) { + g.rack = removeFromRack( + g.rack, + tiles.map((l) => ({ row: 0, col: 0, letter: l, blank: l === '?' })), + ); + g.rack.push(...draw(g.view.variant, tiles.length)); + } + const move = { + player: seat, + action, + dir: '', + mainRow: 0, + mainCol: 0, + tiles: [], + words: [], + count: 0, + score: 0, + total: g.view.seats[seat].score, + }; + g.moves.push(move); + g.view.moveCount += 1; + if (action === 'resign') { + g.view.status = 'finished'; + g.view.endReason = 'resignation'; + for (const s of g.view.seats) s.isWinner = s.seat !== seat; + } else { + g.view.toMove = (seat + 1) % g.view.players; + this.scheduleOpponentReply(gameId); + } + return { move: structuredClone(move), game: structuredClone(g.view) }; + } + + pass(gameId: string): Promise { + return this.simpleAction(gameId, 'pass'); + } + exchange(gameId: string, tiles: string[]): Promise { + return this.simpleAction(gameId, 'exchange', tiles); + } + resign(gameId: string): Promise { + return this.simpleAction(gameId, 'resign'); + } + + async hint(gameId: string): Promise { + const g = this.game(gameId); + if (g.hintsRemaining <= 0) throw new GatewayError('hint_unavailable'); + g.hintsRemaining -= 1; + const letter = g.rack.find((l) => l !== '?') ?? 'A'; + return { + move: { + player: this.mySeat(g), + action: 'play', + dir: 'H', + mainRow: 7, + mainCol: 7, + tiles: [{ row: 7, col: 7, letter, blank: false }], + words: [letter], + count: 1, + score: tileValue(g.view.variant, letter), + total: 0, + }, + hintsRemaining: g.hintsRemaining, + }; + } + + async evaluate(gameId: string, _dir: 'H' | 'V', tiles: PlacedTile[]): Promise { + const g = this.game(gameId); + if (tiles.length === 0) return { legal: false, score: 0, words: [] }; + let score = tiles.reduce((s, t) => s + tileValue(g.view.variant, t.blank ? '?' : t.letter), 0); + if (tiles.length === 7) score += 50; + return { legal: true, score, words: [tiles.map((t) => t.letter).join('')] }; + } + + async checkWord(_gameId: string, word: string): Promise { + return { word, legal: word.trim().length >= 2 }; + } + async complaint(): Promise {} + + // --- chat --- + async chatPost(gameId: string, body: string): Promise { + const g = this.game(gameId); + const msg: ChatMessage = { + id: crypto.randomUUID(), + gameId, + senderId: ME, + kind: 'message', + body, + createdAtUnix: Math.floor(Date.now() / 1000), + }; + g.chat.push(msg); + return msg; + } + async chatList(gameId: string): Promise { + return [...this.game(gameId).chat]; + } + async nudge(gameId: string): Promise { + const g = this.game(gameId); + const msg: ChatMessage = { + id: crypto.randomUUID(), + gameId, + senderId: ME, + kind: 'nudge', + body: '', + createdAtUnix: Math.floor(Date.now() / 1000), + }; + g.chat.push(msg); + return msg; + } + + // --- live stream --- + subscribe(onEvent: (e: PushEvent) => void): Unsubscribe { + this.subs.add(onEvent); + return () => this.subs.delete(onEvent); + } + + // Fabricate an opponent reply shortly after the human moves, then hand the turn back. + private scheduleOpponentReply(gameId: string): void { + setTimeout(() => { + const g = this.games.get(gameId); + if (!g || g.view.status !== 'active') return; + const opp = (this.mySeat(g) + 1) % g.view.players; + if (g.view.toMove !== opp) return; + const cell = this.firstEmptyPair(g); + const move = { + player: opp, + action: 'play' as const, + dir: 'H' as const, + mainRow: cell.row, + mainCol: cell.col, + tiles: [ + { row: cell.row, col: cell.col, letter: 'O', blank: false }, + { row: cell.row, col: cell.col + 1, letter: 'K', blank: false }, + ], + words: ['OK'], + count: 1, + score: 6, + total: g.view.seats[opp].score + 6, + }; + g.moves.push(move); + g.view.seats[opp].score = move.total; + g.view.moveCount += 1; + g.view.toMove = this.mySeat(g); + this.emit({ kind: 'opponent_moved', gameId, seat: opp, action: 'play', score: 6, total: move.total }); + this.emit({ kind: 'your_turn', gameId, deadlineUnix: Math.floor(Date.now() / 1000) + 86400 }); + }, 1600); + } + + private firstEmptyPair(g: MockGame): { row: number; col: number } { + const occupied = new Set(g.moves.flatMap((m) => m.tiles.map((t) => `${t.row},${t.col}`))); + for (let row = 11; row < 15; row++) { + for (let col = 0; col < 14; col++) { + if (!occupied.has(`${row},${col}`) && !occupied.has(`${row},${col + 1}`)) return { row, col }; + } + } + return { row: 0, col: 0 }; + } +} diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts new file mode 100644 index 0000000..42801f8 --- /dev/null +++ b/ui/src/lib/mock/data.ts @@ -0,0 +1,192 @@ +// Seed data for the mock transport. Enough to exercise the playable slice locally +// (pnpm start) with no backend: a profile, one active mid-game whose board already +// has tiles, and two finished games. Coordinates are 0-indexed (centre 7,7). Words do +// not need to be strictly legal here — this is a visual/interaction fixture; real +// legality and scoring come from the backend. + +import type { ChatMessage, GameView, MoveRecord, Profile, Seat, Session } from '../model'; + +export const ME = 'me'; + +export const SESSION: Session = { + token: 'mock-token', + userId: ME, + isGuest: true, + displayName: 'You', +}; + +export const PROFILE: Profile = { + userId: ME, + displayName: 'You', + preferredLanguage: 'en', + timeZone: 'UTC', + hintBalance: 3, + blockChat: false, + blockFriendRequests: false, + isGuest: true, +}; + +function seat(s: number, accountId: string, displayName: string, score: number, isWinner = false): Seat { + return { seat: s, accountId, displayName, score, hintsUsed: 0, isWinner }; +} + +function play( + player: number, + dir: 'H' | 'V', + tiles: Array<[number, number, string]>, + words: string[], + score: number, + total: number, +): MoveRecord { + const ts = tiles.map(([row, col, letter]) => ({ row, col, letter, blank: false })); + return { + player, + action: 'play', + dir, + mainRow: ts[0]?.row ?? 7, + mainCol: ts[0]?.col ?? 7, + tiles: ts, + words, + count: words.length, + score, + total, + }; +} + +export interface MockGame { + view: GameView; + moves: MoveRecord[]; + rack: string[]; + bagLen: number; + hintsRemaining: number; + chat: ChatMessage[]; +} + +// --- active game G1: english, You (seat 0) vs Ann (seat 1), your turn --- + +const G1_MOVES: MoveRecord[] = [ + play(0, 'H', [ + [7, 5, 'H'], + [7, 6, 'E'], + [7, 7, 'L'], + [7, 8, 'L'], + [7, 9, 'O'], + ], ['HELLO'], 16, 16), + play(1, 'V', [ + [6, 9, 'W'], + [8, 9, 'R'], + [9, 9, 'L'], + [10, 9, 'D'], + ], ['WORLD'], 9, 9), + play(0, 'H', [ + [8, 10, 'A'], + [8, 11, 'T'], + ], ['RAT'], 3, 19), + play(1, 'V', [ + [9, 10, 'N'], + [10, 10, 'D'], + ], ['AND'], 4, 13), +]; + +function activeGame(): MockGame { + return { + view: { + id: 'g1', + variant: 'english', + dictVersion: 'v1', + status: 'active', + players: 2, + toMove: 0, + turnTimeoutSecs: 86400, + moveCount: G1_MOVES.length, + endReason: '', + seats: [seat(0, ME, 'You', 19), seat(1, 'ann', 'Ann', 13)], + }, + moves: G1_MOVES, + rack: ['R', 'E', 'T', 'I', 'N', 'A', '?'], + bagLen: 58, + hintsRemaining: 1, + chat: [ + { + id: 'c1', + gameId: 'g1', + senderId: 'ann', + kind: 'message', + body: 'good luck!', + createdAtUnix: Math.floor(Date.now() / 1000) - 3600, + }, + ], + }; +} + +// --- finished games --- + +function finishedG2(): MockGame { + return { + view: { + id: 'g2', + variant: 'english', + dictVersion: 'v1', + status: 'finished', + players: 2, + toMove: 0, + turnTimeoutSecs: 86400, + moveCount: 2, + endReason: 'normal', + seats: [seat(0, ME, 'You', 320, true), seat(1, 'kaya', 'Kaya', 281)], + }, + moves: [ + play(0, 'H', [ + [7, 6, 'Q'], + [7, 7, 'U'], + [7, 8, 'I'], + [7, 9, 'Z'], + ], ['QUIZ'], 48, 48), + play(1, 'V', [ + [6, 9, 'J'], + [8, 9, 'A'], + [9, 9, 'M'], + ], ['JAZM'], 30, 30), + ], + rack: [], + bagLen: 0, + hintsRemaining: 0, + chat: [], + }; +} + +function finishedG3(): MockGame { + return { + view: { + id: 'g3', + variant: 'russian', + dictVersion: 'v1', + status: 'finished', + players: 2, + toMove: 0, + turnTimeoutSecs: 86400, + moveCount: 1, + endReason: 'resignation', + seats: [seat(0, ME, 'You', 150), seat(1, 'rick', 'Rick', 212, true)], + }, + moves: [ + play(0, 'H', [ + [7, 6, 'С'], + [7, 7, 'Л'], + [7, 8, 'О'], + [7, 9, 'В'], + [7, 10, 'О'], + ], ['СЛОВО'], 12, 12), + ], + rack: [], + bagLen: 0, + hintsRemaining: 0, + chat: [], + }; +} + +export function seedGames(): Map { + const m = new Map(); + for (const g of [activeGame(), finishedG2(), finishedG3()]) m.set(g.view.id, g); + return m; +} diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts new file mode 100644 index 0000000..ea3e8b1 --- /dev/null +++ b/ui/src/lib/model.ts @@ -0,0 +1,137 @@ +// Domain model — plain TypeScript shapes the screens use, deliberately decoupled +// from the FlatBuffers wire types. Both the real transport (which decodes +// FlatBuffers) and the mock transport speak this model, so the UI never touches +// generated wire code directly. + +export type Variant = 'english' | 'russian' | 'erudit'; + +/** Backend game status strings. */ +export type GameStatus = 'active' | 'finished' | string; + +/** Decoded move action kinds (history-independent, see ARCHITECTURE §9.1). */ +export type MoveAction = 'play' | 'pass' | 'exchange' | 'resign' | 'timeout' | string; + +/** Play orientation: H is across a row, V is down a column. */ +export type Direction = 'H' | 'V'; + +export interface Tile { + row: number; + col: number; + letter: string; + blank: boolean; +} + +export interface Seat { + seat: number; + accountId: string; + displayName: string; + score: number; + hintsUsed: number; + isWinner: boolean; +} + +export interface GameView { + id: string; + variant: Variant; + dictVersion: string; + status: GameStatus; + players: number; + toMove: number; + turnTimeoutSecs: number; + moveCount: number; + endReason: string; + seats: Seat[]; +} + +export interface MoveRecord { + player: number; + action: MoveAction; + dir: string; + mainRow: number; + mainCol: number; + tiles: Tile[]; + words: string[]; + count: number; + score: number; + total: number; +} + +/** A seated player's private view of a game. */ +export interface StateView { + game: GameView; + seat: number; + rack: string[]; + bagLen: number; + hintsRemaining: number; +} + +export interface MoveResult { + move: MoveRecord; + game: GameView; +} + +export interface HintResult { + move: MoveRecord; + hintsRemaining: number; +} + +export interface EvalResult { + legal: boolean; + score: number; + words: string[]; +} + +export interface WordCheckResult { + word: string; + legal: boolean; +} + +export interface ChatMessage { + id: string; + gameId: string; + senderId: string; + kind: string; + body: string; + createdAtUnix: number; +} + +export interface Profile { + userId: string; + displayName: string; + preferredLanguage: string; + timeZone: string; + hintBalance: number; + blockChat: boolean; + blockFriendRequests: boolean; + isGuest: boolean; +} + +export interface Session { + token: string; + userId: string; + isGuest: boolean; + displayName: string; +} + +export interface MatchResult { + matched: boolean; + game?: GameView; +} + +export interface History { + gameId: string; + moves: MoveRecord[]; +} + +export interface GameList { + games: GameView[]; +} + +/** A live event delivered over the Subscribe stream. */ +export type PushEvent = + | { kind: 'your_turn'; gameId: string; deadlineUnix: number } + | { kind: 'opponent_moved'; gameId: string; seat: number; action: string; score: number; total: number } + | { kind: 'chat_message'; message: ChatMessage } + | { kind: 'nudge'; gameId: string; fromUserId: string } + | { kind: 'match_found'; gameId: string } + | { kind: 'heartbeat' }; diff --git a/ui/src/lib/placement.ts b/ui/src/lib/placement.ts new file mode 100644 index 0000000..5d987b9 --- /dev/null +++ b/ui/src/lib/placement.ts @@ -0,0 +1,116 @@ +// Pure placement state machine for composing a play. The UI lifts tiles from the +// rack onto board cells (via drag or tap); this tracks the pending tiles, infers the +// play direction, supports per-tile recall and a full reset, and builds the submit +// payload. It is board-agnostic (the gateway/engine does full legality validation at +// submit), which keeps it trivially unit-testable. + +import type { Direction } from './model'; +import type { PlacedTile } from './client'; + +export interface PendingTile { + /** Index of the rack slot this tile was lifted from. */ + rackIndex: number; + row: number; + col: number; + /** Designated concrete letter (for a blank, the letter the player chose). */ + letter: string; + /** Whether this tile came from a blank rack slot ("?"). */ + blank: boolean; +} + +export interface Placement { + /** The player's rack as dealt, e.g. ['A','Q','?','N','I','W','E']. */ + rack: string[]; + pending: PendingTile[]; +} + +export interface RackSlot { + index: number; + letter: string; + used: boolean; +} + +export const BLANK = '?'; + +export function newPlacement(rack: string[]): Placement { + return { rack: [...rack], pending: [] }; +} + +function usedIndexes(p: Placement): Set { + return new Set(p.pending.map((t) => t.rackIndex)); +} + +/** rackView lists each rack slot with whether it is currently placed on the board. */ +export function rackView(p: Placement): RackSlot[] { + const used = usedIndexes(p); + return p.rack.map((letter, index) => ({ index, letter, used: used.has(index) })); +} + +export function isBlankSlot(p: Placement, rackIndex: number): boolean { + return p.rack[rackIndex] === BLANK; +} + +export function cellOccupied(p: Placement, row: number, col: number): boolean { + return p.pending.some((t) => t.row === row && t.col === col); +} + +/** + * place lifts a rack slot onto (row, col). For a blank slot the caller must pass the + * designated letter. Returns the unchanged placement if the move is invalid (slot out + * of range, already used, target occupied, or a blank with no letter). + */ +export function place( + p: Placement, + rackIndex: number, + row: number, + col: number, + blankLetter?: string, +): Placement { + if (rackIndex < 0 || rackIndex >= p.rack.length) return p; + if (usedIndexes(p).has(rackIndex)) return p; + if (cellOccupied(p, row, col)) return p; + const blank = p.rack[rackIndex] === BLANK; + const letter = blank ? (blankLetter ?? '').toUpperCase() : p.rack[rackIndex]; + if (blank && !letter) return p; + return { ...p, pending: [...p.pending, { rackIndex, row, col, letter, blank }] }; +} + +export function recallAt(p: Placement, row: number, col: number): Placement { + return { ...p, pending: p.pending.filter((t) => !(t.row === row && t.col === col)) }; +} + +export function recallIndex(p: Placement, rackIndex: number): Placement { + return { ...p, pending: p.pending.filter((t) => t.rackIndex !== rackIndex) }; +} + +export function reset(p: Placement): Placement { + return { ...p, pending: [] }; +} + +/** + * direction infers the play orientation from the pending tiles: H if they share a row, + * V if they share a column, null if a single tile (ambiguous) or non-linear (invalid). + */ +export function direction(p: Placement): Direction | null { + if (p.pending.length < 2) return null; + const rows = new Set(p.pending.map((t) => t.row)); + const cols = new Set(p.pending.map((t) => t.col)); + if (rows.size === 1 && cols.size === p.pending.length) return 'H'; + if (cols.size === 1 && rows.size === p.pending.length) return 'V'; + return null; +} + +/** toSubmit builds the submit payload. dirOverride resolves a single-tile play, where + * the orientation cannot be inferred; otherwise the inferred direction is used. */ +export function toSubmit( + p: Placement, + dirOverride?: Direction, +): { dir: Direction; tiles: PlacedTile[] } | null { + if (p.pending.length === 0) return null; + const dir = dirOverride ?? direction(p) ?? 'H'; + const tiles: PlacedTile[] = p.pending + .slice() + .sort((a, b) => a.row - b.row || a.col - b.col) + .map((t) => ({ row: t.row, col: t.col, letter: t.letter, blank: t.blank })); + return { dir, tiles }; +} diff --git a/ui/src/lib/premiums.ts b/ui/src/lib/premiums.ts new file mode 100644 index 0000000..dd4f1e3 --- /dev/null +++ b/ui/src/lib/premiums.ts @@ -0,0 +1,126 @@ +// Board premium layout and tile values — ported verbatim from the engine source of +// truth, scrabble-solver/rules/rules.go (standardBoard / eruditBoard, and the +// per-variant value tables). These are NOT transmitted on the wire (StateView has +// no board), so the client renders them locally. A Vitest parity test pins the +// layout against the known geometry. Keep this in lockstep with the solver. + +import type { Variant } from './model'; + +export const BOARD_SIZE = 15; + +export type Premium = '' | 'TW' | 'DW' | 'TL' | 'DL'; + +// Legend (rules.go): T=triple word, D=double word, t=triple letter, d=double +// letter, .=plain, *=centre (a double word), +=centre with no premium. +const standardBoard = [ + 'T..d...T...d..T', + '.D...t...t...D.', + '..D...d.d...D..', + 'd..D...d...D..d', + '....D.....D....', + '.t...t...t...t.', + '..d...d.d...d..', + 'T..d...*...d..T', + '..d...d.d...d..', + '.t...t...t...t.', + '....D.....D....', + 'd..D...d...D..d', + '..D...d.d...D..', + '.D...t...t...D.', + 'T..d...T...d..T', +]; + +// Эрудит: the standard layout but a non-doubling centre ('+'). +const eruditBoard = [ + 'T..d...T...d..T', + '.D...t...t...D.', + '..D...d.d...D..', + 'd..D...d...D..d', + '....D.....D....', + '.t...t...t...t.', + '..d...d.d...d..', + 'T..d...+...d..T', + '..d...d.d...d..', + '.t...t...t...t.', + '....D.....D....', + 'd..D...d...D..d', + '..D...d.d...D..', + '.D...t...t...D.', + 'T..d...T...d..T', +]; + +function template(variant: Variant): string[] { + return variant === 'erudit' ? eruditBoard : standardBoard; +} + +function premiumOf(ch: string): Premium { + switch (ch) { + case 'T': + return 'TW'; + case 'D': + case '*': + return 'DW'; + case 't': + return 'TL'; + case 'd': + return 'DL'; + default: + return ''; + } +} + +/** premiumGrid returns the 15x15 premium layout for a variant (row-major). */ +export function premiumGrid(variant: Variant): Premium[][] { + return template(variant).map((line) => Array.from(line, premiumOf)); +} + +/** centre returns the first-move anchor square (row, col). */ +export function centre(variant: Variant): { row: number; col: number } { + const lines = template(variant); + for (let r = 0; r < lines.length; r++) { + const c = lines[r].search(/[*+]/); + if (c >= 0) return { row: r, col: c }; + } + return { row: 7, col: 7 }; +} + +// --- tile values (points shown on the tile face); blank scores 0 --- + +// English Latin a..z (rules.go English()). +const enValues = + 'a1 b3 c3 d2 e1 f4 g2 h4 i1 j8 k5 l1 m3 n1 o1 p3 q10 r1 s1 t1 u1 v4 w4 x8 y4 z10'; +// Russian а..я incl. ё (rules.go RussianScrabble()). +const ruValues = + 'а1 б3 в1 г3 д2 е1 ё3 ж5 з5 и1 й4 к2 л2 м2 н1 о1 п2 р1 с1 т1 у2 ф10 х5 ц5 ч5 ш8 щ10 ъ10 ы4 ь3 э8 ю8 я3'; +// Эрудит а..я incl. ё=0 (rules.go Erudit()). +const eruditValues = + 'а1 б3 в2 г3 д2 е1 ё0 ж5 з5 и1 й2 к2 л2 м2 н1 о1 п2 р2 с2 т2 у3 ф10 х5 ц10 ч5 ш10 щ10 ъ10 ы5 ь5 э10 ю10 я3'; + +// Split each "letter+value" token into its letter (all but trailing digits) and its +// integer value (the trailing digits). +function valueTable(spec: string): Map { + const m = new Map(); + for (const pair of spec.trim().split(/\s+/)) { + const match = pair.match(/^(.+?)(\d+)$/); + if (!match) continue; + m.set(match[1].toUpperCase(), Number(match[2])); + } + return m; +} + +const VALUES: Record> = { + english: valueTable(enValues), + russian: valueTable(ruValues), + erudit: valueTable(eruditValues), +}; + +/** tileValue returns the point value of a concrete letter; a blank ("?") is 0. */ +export function tileValue(variant: Variant, letter: string): number { + if (!letter || letter === '?') return 0; + return VALUES[variant]?.get(letter.toUpperCase()) ?? 0; +} + +/** alphabet lists a variant's letters in dictionary order (used by the blank chooser). */ +export function alphabet(variant: Variant): string[] { + return [...VALUES[variant].keys()]; +} diff --git a/ui/src/lib/router.svelte.ts b/ui/src/lib/router.svelte.ts new file mode 100644 index 0000000..333c7e7 --- /dev/null +++ b/ui/src/lib/router.svelte.ts @@ -0,0 +1,60 @@ +// Minimal dependency-free hash router. Hash routing survives a reload and works on +// a file:// origin (Capacitor native packaging), where there is no server to honour +// deep paths. The route is a reactive rune so screens re-render on navigation. + +export type RouteName = + | 'login' + | 'lobby' + | 'new' + | 'game' + | 'profile' + | 'settings' + | 'about' + | 'notfound'; + +export interface Route { + name: RouteName; + params: Record; +} + +function parse(hash: string): Route { + const path = (hash.replace(/^#/, '') || '/').split('?')[0]; + const seg = path.split('/').filter(Boolean); + if (seg.length === 0) return { name: 'lobby', params: {} }; + switch (seg[0]) { + case 'login': + return { name: 'login', params: {} }; + case 'new': + return { name: 'new', params: {} }; + case 'game': + return seg[1] ? { name: 'game', params: { id: seg[1] } } : { name: 'notfound', params: {} }; + case 'profile': + return { name: 'profile', params: {} }; + case 'settings': + return { name: 'settings', params: {} }; + case 'about': + return { name: 'about', params: {} }; + default: + return { name: 'notfound', params: {} }; + } +} + +export const router = $state<{ route: Route }>({ + route: parse(typeof location !== 'undefined' ? location.hash : ''), +}); + +if (typeof window !== 'undefined') { + window.addEventListener('hashchange', () => { + router.route = parse(location.hash); + }); +} + +/** navigate switches the hash route (and forces a re-parse if it is unchanged). */ +export function navigate(path: string): void { + const target = '#' + path; + if (location.hash === target) { + router.route = parse(target); + } else { + location.hash = path; + } +} diff --git a/ui/src/lib/session.ts b/ui/src/lib/session.ts new file mode 100644 index 0000000..fac640e --- /dev/null +++ b/ui/src/lib/session.ts @@ -0,0 +1,133 @@ +// Session + preferences persistence. The session token lives in memory for the app +// session and is mirrored to IndexedDB when available (so a reload does not force a +// re-login), with a localStorage fallback. Losing the store just means re-login — +// acceptable, and for a guest it simply mints a fresh guest. + +import type { Session } from './model'; +import type { ThemePref } from './theme'; +import type { Locale } from './i18n/catalog'; + +const DB_NAME = 'scrabble'; +const STORE = 'kv'; +const LS_PREFIX = 'scrabble.'; + +let dbPromise: Promise | null | undefined; + +function openDb(): Promise | null { + if (dbPromise !== undefined) return dbPromise; + if (typeof indexedDB === 'undefined') { + dbPromise = null; + return null; + } + dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => req.result.createObjectStore(STORE); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }).catch(() => { + dbPromise = null; + throw new Error('indexedDB unavailable'); + }); + return dbPromise; +} + +function lsGet(key: string): T | null { + try { + const v = localStorage.getItem(LS_PREFIX + key); + return v ? (JSON.parse(v) as T) : null; + } catch { + return null; + } +} + +function lsSet(key: string, value: unknown): void { + try { + localStorage.setItem(LS_PREFIX + key, JSON.stringify(value)); + } catch { + /* storage unavailable — stay in-memory only */ + } +} + +function lsDel(key: string): void { + try { + localStorage.removeItem(LS_PREFIX + key); + } catch { + /* ignore */ + } +} + +async function kvGet(key: string): Promise { + const db = openDb(); + if (!db) return lsGet(key); + try { + const d = await db; + return await new Promise((resolve, reject) => { + const r = d.transaction(STORE, 'readonly').objectStore(STORE).get(key); + r.onsuccess = () => resolve((r.result ?? null) as T | null); + r.onerror = () => reject(r.error); + }); + } catch { + return lsGet(key); + } +} + +async function kvSet(key: string, value: unknown): Promise { + const db = openDb(); + if (!db) return lsSet(key, value); + try { + const d = await db; + await new Promise((resolve, reject) => { + const tx = d.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).put(value, key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch { + lsSet(key, value); + } +} + +async function kvDel(key: string): Promise { + const db = openDb(); + if (!db) return lsDel(key); + try { + const d = await db; + await new Promise((resolve, reject) => { + const tx = d.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).delete(key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch { + lsDel(key); + } +} + +const SESSION_KEY = 'session'; +const PREFS_KEY = 'prefs'; + +export function loadSession(): Promise { + return kvGet(SESSION_KEY); +} + +export function saveSession(s: Session): Promise { + return kvSet(SESSION_KEY, s); +} + +export function clearSession(): Promise { + return kvDel(SESSION_KEY); +} + +export interface Prefs { + theme: ThemePref; + locale: Locale; + reduceMotion: boolean; +} + +export async function loadPrefs(): Promise> { + return (await kvGet(PREFS_KEY)) ?? {}; +} + +export function savePrefs(p: Prefs): Promise { + return kvSet(PREFS_KEY, p); +} diff --git a/ui/src/lib/theme.ts b/ui/src/lib/theme.ts new file mode 100644 index 0000000..080f6e0 --- /dev/null +++ b/ui/src/lib/theme.ts @@ -0,0 +1,47 @@ +// Theme application. The design tokens are CSS custom properties (app.css); here we +// only flip how they resolve: 'auto' follows the OS, 'light'/'dark' force a value via +// [data-theme]. A Telegram Mini App can additionally override the token values from +// WebApp.themeParams — the mapping lives here so the token system is Telegram-ready, +// while the SDK is wired in the Telegram stage. + +export type ThemePref = 'auto' | 'light' | 'dark'; + +export function applyTheme(pref: ThemePref): void { + if (typeof document === 'undefined') return; + const root = document.documentElement; + if (pref === 'auto') root.removeAttribute('data-theme'); + else root.setAttribute('data-theme', pref); +} + +export function applyReduceMotion(on: boolean): void { + if (typeof document === 'undefined') return; + document.body.classList.toggle('reduce-motion', on); +} + +/** Subset of Telegram WebApp.themeParams we map onto our tokens. */ +export interface TelegramThemeParams { + bg_color?: string; + text_color?: string; + hint_color?: string; + link_color?: string; + button_color?: string; + button_text_color?: string; + secondary_bg_color?: string; +} + +/** applyTelegramTheme overrides token values at runtime from Telegram themeParams. */ +export function applyTelegramTheme(p: TelegramThemeParams): void { + if (typeof document === 'undefined') return; + const root = document.documentElement; + const set = (value: string | undefined, name: string) => { + if (value) root.style.setProperty(name, value); + }; + set(p.bg_color, '--bg'); + set(p.bg_color, '--surface'); + set(p.secondary_bg_color, '--surface-2'); + set(p.text_color, '--text'); + set(p.hint_color, '--text-muted'); + set(p.button_color, '--accent'); + set(p.button_text_color, '--accent-text'); + set(p.link_color, '--accent'); +} diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts new file mode 100644 index 0000000..fdd9a78 --- /dev/null +++ b/ui/src/lib/transport.ts @@ -0,0 +1,36 @@ +// Placeholder for the real Connect-web + FlatBuffers transport, wired in the edge +// codegen task. Until then, selecting a non-mock mode surfaces a clear error instead +// of failing silently. The mock (lib/mock) backs `pnpm start`. + +import type { GatewayClient } from './client'; +import { GatewayError } from './client'; + +export function createTransport(_baseUrl: string): GatewayClient { + const ni = (): never => { + throw new GatewayError('unavailable', 'real transport not wired yet'); + }; + return { + setToken: () => {}, + authGuest: ni, + authEmailRequest: ni, + authEmailLogin: ni, + profileGet: ni, + gamesList: ni, + lobbyEnqueue: ni, + lobbyPoll: ni, + gameState: ni, + gameHistory: ni, + submitPlay: ni, + pass: ni, + exchange: ni, + resign: ni, + hint: ni, + evaluate: ni, + checkWord: ni, + complaint: ni, + chatPost: ni, + chatList: ni, + nudge: ni, + subscribe: ni, + }; +} diff --git a/ui/src/main.ts b/ui/src/main.ts new file mode 100644 index 0000000..dd41642 --- /dev/null +++ b/ui/src/main.ts @@ -0,0 +1,5 @@ +import { mount } from 'svelte'; +import './app.css'; +import App from './App.svelte'; + +export default mount(App, { target: document.getElementById('app')! }); diff --git a/ui/src/screens/About.svelte b/ui/src/screens/About.svelte new file mode 100644 index 0000000..a641724 --- /dev/null +++ b/ui/src/screens/About.svelte @@ -0,0 +1,22 @@ + + +
+
+

{t('app.title')}

+

{t('about.description')}

+

{t('about.version', { v: version })}

+
+ + diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte new file mode 100644 index 0000000..4d27d3f --- /dev/null +++ b/ui/src/screens/Lobby.svelte @@ -0,0 +1,234 @@ + + +
+ {#snippet menu()} + + {#if menuOpen} + + +
(menuOpen = false)}>
+ + {/if} + {/snippet} +
+ +
+
+

{t('lobby.activeGames')}

+ {#if active.length === 0} +

{t('lobby.noActive')}

+ {/if} + {#each active as g (g.id)} + + {/each} +
+ +
+

{t('lobby.finishedGames')}

+ {#if finished.length === 0} +

{t('lobby.noFinished')}

+ {/if} + {#each finished as g (g.id)} + + {/each} +
+
+ + + + diff --git a/ui/src/screens/Login.svelte b/ui/src/screens/Login.svelte new file mode 100644 index 0000000..3e71227 --- /dev/null +++ b/ui/src/screens/Login.svelte @@ -0,0 +1,127 @@ + + +
+
+

{t('app.title')}

+ + + +
{t('login.email')}
+ + {#if stage === 'choose'} + + + {:else} +

{t('login.codeSent', { email })}

+ + + {/if} +
+
+ + diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte new file mode 100644 index 0000000..74ee12a --- /dev/null +++ b/ui/src/screens/NewGame.svelte @@ -0,0 +1,123 @@ + + +
+
+ {#if searching} +
+
+

{t('new.searching')}

+ +
+ {:else} +

{t('new.subtitle')}

+
+ {#each variants as v (v.id)} + + {/each} +
+ {/if} +
+ + diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte new file mode 100644 index 0000000..568ac57 --- /dev/null +++ b/ui/src/screens/Profile.svelte @@ -0,0 +1,67 @@ + + +
+
+ {#if app.profile} +
{app.profile.displayName}
+ {#if app.profile.isGuest}{t('profile.guest')}{/if} +
+
{t('profile.language')}
+
{app.profile.preferredLanguage}
+
{t('profile.timezone')}
+
{app.profile.timeZone}
+
{t('profile.hintBalance')}
+
{app.profile.hintBalance}
+
+

{t('profile.readonly')}

+ + {/if} +
+ + diff --git a/ui/src/screens/Settings.svelte b/ui/src/screens/Settings.svelte new file mode 100644 index 0000000..2630654 --- /dev/null +++ b/ui/src/screens/Settings.svelte @@ -0,0 +1,86 @@ + + +
+
+
+

{t('settings.theme')}

+
+ {#each themes as th (th)} + + {/each} +
+
+ +
+

{t('settings.language')}

+
+ {#each locales as lc (lc)} + + {/each} +
+
+ +
+ +
+
+ + diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts new file mode 100644 index 0000000..78ed9ca --- /dev/null +++ b/ui/src/vite-env.d.ts @@ -0,0 +1,11 @@ +/// +/// + +interface ImportMetaEnv { + /** Base URL of the gateway Connect endpoint. Empty in dev (same-origin proxy). */ + readonly VITE_GATEWAY_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/ui/svelte.config.js b/ui/svelte.config.js new file mode 100644 index 0000000..461ef49 --- /dev/null +++ b/ui/svelte.config.js @@ -0,0 +1,6 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +// Plain Svelte 5 (runes) — no SvelteKit. vitePreprocess enables