From a28edf80f7115a3ed7f86a0c6a4d0d8ba1b0a561 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:31:36 +0300 Subject: [PATCH 01/50] build(deps): bump the minor-and-patch group across 1 directory with 21 updates (#3400) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 42 +- pnpm-lock.yaml | 1273 ++++++++++++++++++++++++------------------------ 2 files changed, 654 insertions(+), 661 deletions(-) diff --git a/package.json b/package.json index b04a29fd4..9953badc8 100644 --- a/package.json +++ b/package.json @@ -54,19 +54,19 @@ "knip": "knip" }, "dependencies": { - "@aws-sdk/client-s3": "3.1119.0", - "@aws-sdk/lib-storage": "3.1119.0", + "@aws-sdk/client-s3": "3.1130.0", + "@aws-sdk/lib-storage": "3.1130.0", "@date-fns/tz": "1.5.0", "@dnd-kit/core": "6.3.1", "@dnd-kit/modifiers": "9.0.0", "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", "@epic-web/cachified": "5.6.3", - "@react-router/node": "8.3.0", + "@react-router/node": "8.3.1", "@react-router/serve": "8.1.0", "@techstark/opencv-js": "5.0.0-release.1", "@tldraw/tldraw": "3.12.1", - "@zumer/snapdom": "2.24.10", + "@zumer/snapdom": "2.24.18", "chart.js": "4.5.1", "clsx": "2.1.1", "compressorjs": "1.3.0", @@ -76,33 +76,33 @@ "gray-matter": "4.0.3", "i18next": "26.3.6", "i18next-browser-languagedetector": "8.2.1", - "i18next-http-backend": "4.0.1", + "i18next-http-backend": "4.0.2", "ics": "3.12.0", - "isbot": "5.2.1", + "isbot": "5.2.2", "kysely": "0.29.0", - "lucide-react": "1.34.0", + "lucide-react": "1.44.0", "markdown-to-jsx": "9.10.2", - "mediabunny": "1.55.3", + "mediabunny": "1.56.1", "nanoid": "6.0.1", "node-cron": "4.6.0", "nprogress": "0.2.0", "openskill": "5.0.1", - "p-limit": "7.3.1", + "p-limit": "7.3.2", "picocad2-web": "2.0.3", "qrcode.react": "4.2.0", "react": "19.3.0", "react-chartjs-2": "5.3.1", "react-dom": "19.3.0", - "react-error-boundary": "6.1.3", + "react-error-boundary": "6.1.5", "react-i18next": "17.0.11", - "react-router": "8.3.0", - "remeda": "2.45.0", + "react-router": "8.3.1", + "remeda": "2.48.0", "remix-auth": "4.2.0", "remix-auth-oauth2": "3.4.1", "remix-i18next": "8.0.0", "slugify": "1.6.9", "swr": "2.5.1", - "valibot": "^1.4.2", + "valibot": "^1.5.0", "web-haptics": "0.0.6", "web-push": "3.6.7" }, @@ -110,10 +110,10 @@ "@babel/preset-typescript": "7.29.7", "@biomejs/biome": "2.5.12", "@faker-js/faker": "10.6.0", - "@napi-rs/canvas": "1.0.8", - "@playwright/test": "1.62.1", - "@react-router/dev": "8.3.0", - "@types/node": "26.4.0", + "@napi-rs/canvas": "1.0.9", + "@playwright/test": "1.63.0", + "@react-router/dev": "8.3.1", + "@types/node": "26.5.1", "@types/nprogress": "0.2.3", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", @@ -123,15 +123,15 @@ "babel-plugin-react-compiler": "19.1.0-rc.3", "cross-env": "10.1.0", "i18next-locales-sync": "2.1.1", - "knip": "6.32.3", - "magic-string": "1.2.3", + "knip": "6.35.1", + "magic-string": "1.3.1", "sharp": "^0.35.4", "sql-formatter": "15.8.2", "typescript": "7.0.2", - "vite": "8.2.2", + "vite": "8.3.0", "vite-node": "6.0.0", "vite-plugin-babel": "1.7.3", "vitest": "4.1.11", - "vitest-browser-react": "2.2.0" + "vitest-browser-react": "2.3.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af0c5ffb8..eddc755b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,11 +16,11 @@ importers: .: dependencies: '@aws-sdk/client-s3': - specifier: 3.1119.0 - version: 3.1119.0 + specifier: 3.1130.0 + version: 3.1130.0 '@aws-sdk/lib-storage': - specifier: 3.1119.0 - version: 3.1119.0(@aws-sdk/client-s3@3.1119.0) + specifier: 3.1130.0 + version: 3.1130.0(@aws-sdk/client-s3@3.1130.0) '@date-fns/tz': specifier: 1.5.0 version: 1.5.0 @@ -40,11 +40,11 @@ importers: specifier: 5.6.3 version: 5.6.3 '@react-router/node': - specifier: 8.3.0 - version: 8.3.0(react-router@8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2) + specifier: 8.3.1 + version: 8.3.1(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2) '@react-router/serve': specifier: 8.1.0 - version: 8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2) + version: 8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2) '@techstark/opencv-js': specifier: 5.0.0-release.1 version: 5.0.0-release.1(patch_hash=0e23b4243e8e2f88294c00eefdf8dfc3624f59442bd0c8d075be89a8136144f3) @@ -52,8 +52,8 @@ importers: specifier: 3.12.1 version: 3.12.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@zumer/snapdom': - specifier: 2.24.10 - version: 2.24.10 + specifier: 2.24.18 + version: 2.24.18 chart.js: specifier: 4.5.1 version: 4.5.1 @@ -82,26 +82,26 @@ importers: specifier: 8.2.1 version: 8.2.1 i18next-http-backend: - specifier: 4.0.1 - version: 4.0.1 + specifier: 4.0.2 + version: 4.0.2 ics: specifier: 3.12.0 version: 3.12.0 isbot: - specifier: 5.2.1 - version: 5.2.1 + specifier: 5.2.2 + version: 5.2.2 kysely: specifier: 0.29.0 version: 0.29.0(patch_hash=a3e94339939b1be5b70610601e96fff19ed5678aab525de24b52dfc5212c686a) lucide-react: - specifier: 1.34.0 - version: 1.34.0(react@19.3.0) + specifier: 1.44.0 + version: 1.44.0(react@19.3.0) markdown-to-jsx: specifier: 9.10.2 version: 9.10.2(react@19.3.0) mediabunny: - specifier: 1.55.3 - version: 1.55.3 + specifier: 1.56.1 + version: 1.56.1 nanoid: specifier: 6.0.1 version: 6.0.1 @@ -115,8 +115,8 @@ importers: specifier: 5.0.1 version: 5.0.1 p-limit: - specifier: 7.3.1 - version: 7.3.1 + specifier: 7.3.2 + version: 7.3.2 picocad2-web: specifier: 2.0.3 version: 2.0.3 @@ -133,17 +133,17 @@ importers: specifier: 19.3.0 version: 19.3.0(react@19.3.0) react-error-boundary: - specifier: 6.1.3 - version: 6.1.3(@types/react@19.3.0)(react@19.3.0) + specifier: 6.1.5 + version: 6.1.5(@types/react@19.3.0)(react@19.3.0) react-i18next: specifier: 17.0.11 version: 17.0.11(patch_hash=1f928881d134daf116e494299036454a3c311fe6fb81f60ef8d158f27466a269)(i18next@26.3.6(patch_hash=34c245cebb3f9c7861d2576a3a44228364cb898037f86b23a5bbf8b20f55fb1a)(typescript@7.0.2))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2) react-router: - specifier: 8.3.0 - version: 8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + specifier: 8.3.1 + version: 8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) remeda: - specifier: 2.45.0 - version: 2.45.0 + specifier: 2.48.0 + version: 2.48.0 remix-auth: specifier: 4.2.0 version: 4.2.0 @@ -152,7 +152,7 @@ importers: version: 3.4.1(remix-auth@4.2.0) remix-i18next: specifier: 8.0.0 - version: 8.0.0(i18next@26.3.6(patch_hash=34c245cebb3f9c7861d2576a3a44228364cb898037f86b23a5bbf8b20f55fb1a)(typescript@7.0.2))(react-router@8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)) + version: 8.0.0(i18next@26.3.6(patch_hash=34c245cebb3f9c7861d2576a3a44228364cb898037f86b23a5bbf8b20f55fb1a)(typescript@7.0.2))(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0)) slugify: specifier: 1.6.9 version: 1.6.9 @@ -160,8 +160,8 @@ importers: specifier: 2.5.1 version: 2.5.1(react@19.3.0) valibot: - specifier: ^1.4.2 - version: 1.4.2(typescript@7.0.2) + specifier: ^1.5.0 + version: 1.5.0(typescript@7.0.2) web-haptics: specifier: 0.0.6 version: 0.0.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -179,17 +179,17 @@ importers: specifier: 10.6.0 version: 10.6.0 '@napi-rs/canvas': - specifier: 1.0.8 - version: 1.0.8 + specifier: 1.0.9 + version: 1.0.9 '@playwright/test': - specifier: 1.62.1 - version: 1.62.1 + specifier: 1.63.0 + version: 1.63.0 '@react-router/dev': - specifier: 8.3.0 - version: 8.3.0(@react-router/serve@8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2))(react-router@8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2)(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0)) + specifier: 8.3.1 + version: 8.3.1(@react-router/serve@8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2))(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1)) '@types/node': - specifier: 26.4.0 - version: 26.4.0 + specifier: 26.5.1 + version: 26.5.1 '@types/nprogress': specifier: 0.2.3 version: 0.2.3 @@ -204,7 +204,7 @@ importers: version: 3.6.4 '@vitest/browser-playwright': specifier: 4.1.11 - version: 4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.11) + version: 4.1.11(playwright@1.63.0)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1))(vitest@4.1.11) '@vitest/ui': specifier: 4.1.11 version: 4.1.11(vitest@4.1.11) @@ -218,14 +218,14 @@ importers: specifier: 2.1.1 version: 2.1.1 knip: - specifier: 6.32.3 - version: 6.32.3 + specifier: 6.35.1 + version: 6.35.1 magic-string: - specifier: 1.2.3 - version: 1.2.3 + specifier: 1.3.1 + version: 1.3.1 sharp: specifier: ^0.35.4 - version: 0.35.4(@types/node@26.4.0) + version: 0.35.4(@types/node@26.5.1) sql-formatter: specifier: 15.8.2 version: 15.8.2 @@ -233,87 +233,87 @@ importers: specifier: 7.0.2 version: 7.0.2 vite: - specifier: 8.2.2 - version: 8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0) + specifier: 8.3.0 + version: 8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1) vite-node: specifier: 6.0.0 - version: 6.0.0(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0) + version: 6.0.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1) vite-plugin-babel: specifier: 1.7.3 - version: 1.7.3(@babel/core@7.29.7)(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0)) + version: 1.7.3(@babel/core@7.29.7)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1)) vitest: specifier: 4.1.11 - version: 4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/ui@4.1.11)(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.11(@types/node@26.5.1)(@vitest/browser-playwright@4.1.11)(@vitest/ui@4.1.11)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1)) vitest-browser-react: - specifier: 2.2.0 - version: 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@4.1.11) + specifier: 2.3.0 + version: 2.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@4.1.11) packages: - '@aws-sdk/checksums@3.1000.29': - resolution: {integrity: sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==} + '@aws-sdk/checksums@3.1001.0': + resolution: {integrity: sha512-6uTniZc87q+B5eXouGTl+7Tmc482rEeCcvxpsvREP8EfF0gvloRZ41UOA9sbSJlyy8TbqIBXb3kKfKarEArUQA==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.1119.0': - resolution: {integrity: sha512-8+UH1uBWwjbRu0ugTBhJ/cu3rL/vzf1tm9/3+xZGf1uybIO3HTF2xLJJaTKkRPvzxz8/n/w+YrwoHGZ2Q/a2Zg==} + '@aws-sdk/client-s3@3.1130.0': + resolution: {integrity: sha512-TUe0hgQi3RtuccmTgpUsRIuPk07ZtJE6s3vpOeWCM8sibLNXPVKweemNEz5gwaRYTor8MAGgjlyVZfKGuu1ZOQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.977.9': - resolution: {integrity: sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==} + '@aws-sdk/core@3.978.0': + resolution: {integrity: sha512-2yX9LUmxPklVjSGTb8dfnWRJSiFQ3TeH2nn7G1mdKHTfnabzF0+gfrS8rYfLWmZrQ8A3mEcxMJjRc51dL5KWaA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.70': - resolution: {integrity: sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==} + '@aws-sdk/credential-provider-env@3.972.71': + resolution: {integrity: sha512-JN+JHruYZw3GUZB8YGAlDk4wTDPOEAEEdEzj5nS0xodWR4smzHsN7PnK2j6IeOsDIj2aqua5DSbhXl9Gtf90FQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.72': - resolution: {integrity: sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==} + '@aws-sdk/credential-provider-http@3.972.73': + resolution: {integrity: sha512-uyYYnJOnlis8uQzaYGPd7N1JoioCoNpXgnkXYixsWJXHXgXyYi8WXJSDfofxJeWfQIGWLe2Nwyq60Uc7MZdVOg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.973.15': - resolution: {integrity: sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==} + '@aws-sdk/credential-provider-ini@3.973.16': + resolution: {integrity: sha512-i++ly+0Uxa+u3ebSSyr0S/3CFhFJDxCXT3+Zj+mW2bXenEx5bKGCdTIKFu39SgXBNhWDjex/8cXUx9MUTMCrTw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.77': - resolution: {integrity: sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==} + '@aws-sdk/credential-provider-login@3.972.78': + resolution: {integrity: sha512-eUtswnXu0+Ii9ieRK+0L7aPFV3Z/dnW2VntJzjBP9xs8s+8p5nBNuymIXtXwZ+5r5+XJP3e32nMkuZ/r0HozEA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.82': - resolution: {integrity: sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==} + '@aws-sdk/credential-provider-node@3.972.83': + resolution: {integrity: sha512-jdso7ejzfRnatxMUZK4S/U6KbaDPCvfIV4XL+IQAPFDBt5rj5Fq595euqlK8Le4lNCMFR9oUpt+1l0aMgaayOQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.70': - resolution: {integrity: sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==} + '@aws-sdk/credential-provider-process@3.972.71': + resolution: {integrity: sha512-lYmXJa4gvq4xN1lrT5NiP5vIYYKcGWAdj8y+8o6dlcateB5eF3Dn8DtmjjHKfMBrTPAMr2pebIiX/UOj8c1/UA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.973.14': - resolution: {integrity: sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==} + '@aws-sdk/credential-provider-sso@3.973.15': + resolution: {integrity: sha512-6Jhcf4v0pSFdjk1EW2kvzuEBKD+UZ2uNcHUIglKKLndD20YhvkL2kdmDOV5/j4mYuWWwe/a1FQ1aomU86/Cg5Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.76': - resolution: {integrity: sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==} + '@aws-sdk/credential-provider-web-identity@3.972.77': + resolution: {integrity: sha512-uylIQSUWpfLuH2LovxEEfwzJGM/SabLOfLMg6YXu/E8jJEKUdpdILCVCQCdFvHyu/7dLJOHPMfrSwduxO56NkQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/lib-storage@3.1119.0': - resolution: {integrity: sha512-4dxJX2pueKHtFyxdtT/IH385NpAWjbNjpbbIN6L9YK58dbae6ldyiLSom1EV5nPoOxKtWLXiqch+yo3NjXfleQ==} + '@aws-sdk/lib-storage@3.1130.0': + resolution: {integrity: sha512-e9sb+Utkn6LiYrBhLyHl7k3V/0TtIetyRgVyJ4j67rSA4zmcgcrt6YJ6qopg1k3JXVXahcUhF7Imnd62d4TfqA==} engines: {node: '>=20.0.0'} peerDependencies: - '@aws-sdk/client-s3': ^3.1119.0 + '@aws-sdk/client-s3': ^3.1130.0 - '@aws-sdk/middleware-sdk-s3@3.972.75': - resolution: {integrity: sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==} + '@aws-sdk/middleware-sdk-s3@3.972.76': + resolution: {integrity: sha512-NfnTkVUTBKTBuBgqaapFK9r3YdkKt1b2oRvgLzZq91bwNKh6ZS0S7sEcheguttREaL4iyfs/xQnqD7Z7AsWSsA==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.997.44': - resolution: {integrity: sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==} + '@aws-sdk/nested-clients@3.997.45': + resolution: {integrity: sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ==} engines: {node: '>=20.0.0'} '@aws-sdk/signature-v4-multi-region@3.996.46': resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1116.0': - resolution: {integrity: sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==} + '@aws-sdk/token-providers@3.1129.0': + resolution: {integrity: sha512-Sbl3rpzQdsG4ZK2zh0JWUYyZPKKorJlVOddA2T0DVbKJFrsW8J6wgnslxxUH04+WaBMr4A1HzJZvZX0xUvkniA==} engines: {node: '>=20.0.0'} '@aws-sdk/types@3.974.5': @@ -340,8 +340,8 @@ packages: resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.7': - resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} '@babel/helper-annotate-as-pure@7.29.7': @@ -418,8 +418,8 @@ packages: resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} engines: {node: '>=6.0.0'} hasBin: true @@ -465,16 +465,16 @@ packages: resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.7': - resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} engines: {node: '>=6.9.0'} '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} '@biomejs/biome@2.5.12': @@ -793,83 +793,83 @@ packages: '@mjackson/headers@0.10.0': resolution: {integrity: sha512-U1Eu1gF979k7ZoIBsJyD+T5l9MjtPONsZfoXfktsQHPJD0s7SokBGx+tLKDLsOY+gzVYAWS0yRFDNY8cgbQzWQ==} - '@napi-rs/canvas-android-arm64@1.0.8': - resolution: {integrity: sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw==} + '@napi-rs/canvas-android-arm64@1.0.9': + resolution: {integrity: sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA==} engines: {node: '>= 10'} cpu: [arm64] os: [android] - '@napi-rs/canvas-darwin-arm64@1.0.8': - resolution: {integrity: sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg==} + '@napi-rs/canvas-darwin-arm64@1.0.9': + resolution: {integrity: sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@napi-rs/canvas-darwin-x64@1.0.8': - resolution: {integrity: sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg==} + '@napi-rs/canvas-darwin-x64@1.0.9': + resolution: {integrity: sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@napi-rs/canvas-linux-arm-gnueabihf@1.0.8': - resolution: {integrity: sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ==} + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.9': + resolution: {integrity: sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@napi-rs/canvas-linux-arm64-gnu@1.0.8': - resolution: {integrity: sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA==} + '@napi-rs/canvas-linux-arm64-gnu@1.0.9': + resolution: {integrity: sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@napi-rs/canvas-linux-arm64-musl@1.0.8': - resolution: {integrity: sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg==} + '@napi-rs/canvas-linux-arm64-musl@1.0.9': + resolution: {integrity: sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@napi-rs/canvas-linux-riscv64-gnu@1.0.8': - resolution: {integrity: sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg==} + '@napi-rs/canvas-linux-riscv64-gnu@1.0.9': + resolution: {integrity: sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] libc: [glibc] - '@napi-rs/canvas-linux-x64-gnu@1.0.8': - resolution: {integrity: sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw==} + '@napi-rs/canvas-linux-x64-gnu@1.0.9': + resolution: {integrity: sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@napi-rs/canvas-linux-x64-musl@1.0.8': - resolution: {integrity: sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg==} + '@napi-rs/canvas-linux-x64-musl@1.0.9': + resolution: {integrity: sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@napi-rs/canvas-win32-arm64-msvc@1.0.8': - resolution: {integrity: sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ==} + '@napi-rs/canvas-win32-arm64-msvc@1.0.9': + resolution: {integrity: sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@napi-rs/canvas-win32-x64-msvc@1.0.8': - resolution: {integrity: sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ==} + '@napi-rs/canvas-win32-x64-msvc@1.0.9': + resolution: {integrity: sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@napi-rs/canvas@1.0.8': - resolution: {integrity: sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g==} + '@napi-rs/canvas@1.0.9': + resolution: {integrity: sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ==} engines: {node: '>= 10'} - '@napi-rs/wasm-runtime@1.2.3': - resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} + '@napi-rs/wasm-runtime@1.2.4': + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} peerDependencies: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 @@ -897,133 +897,133 @@ packages: resolution: {integrity: sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - '@oxc-parser/binding-android-arm-eabi@0.143.0': - resolution: {integrity: sha512-n9uozULWflPqBtdmI8lAabLqGKNgLVNN0ZH8HfgCwpKGNtzRzauB76jTiW/3YLkcA7N1zskpi9GdVnZuu1SAvg==} + '@oxc-parser/binding-android-arm-eabi@0.148.0': + resolution: {integrity: sha512-pHASv9g5pASxb7akHERZNSkrEqPhFaUix98o7d9hbTpolnnFWl7UiRrcMhCsV1+iVO4/cJwKsbKRJTFNs2tdBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxc-parser/binding-android-arm64@0.143.0': - resolution: {integrity: sha512-9BbdjHETk6O3zH/DDid9IgBtF0GlpLabNKN231uraXpRDSfY+iiZxTP5bk1Z63GBownVdhdINFIeddmMz4MzpQ==} + '@oxc-parser/binding-android-arm64@0.148.0': + resolution: {integrity: sha512-sg/6Ez0KdAygsu0POELux9wN1Po2CP93WY8eNl4DBKIGprsd4QSHBXOb471Pu9i2OCD5sLkISSb2agZEhVn2Zw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxc-parser/binding-darwin-arm64@0.143.0': - resolution: {integrity: sha512-gh+6ecoHUy4/sUcolBl/1qPXKBbYNxFY0Pk0ujgQvINTMSftJY7o4yb8gOkDJPeZeB8+a+u7xTe6umoP8N5HFA==} + '@oxc-parser/binding-darwin-arm64@0.148.0': + resolution: {integrity: sha512-yiSJmzGUvCUaJT8X3j40gVcX+ckuHQMuiOtF8DvzTs5+JtB/7XuHFPp4M+vv5u+HlBtDUd4Ks5pyHpWz8mfnkg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxc-parser/binding-darwin-x64@0.143.0': - resolution: {integrity: sha512-qd1hl2d+lXgHv/VQ/M9qm8TrMC5T4RqDBwtOnl+1D0QMjwcz+8AaB4JSg8STgeag0GP6a6L74XEGAsrTSJWNzQ==} + '@oxc-parser/binding-darwin-x64@0.148.0': + resolution: {integrity: sha512-6ZeklaamrMy4H2JmhvcJg6iip59tYILtuLaILxyAHT3l5FDxnI5ihVievAft5ZmAbqtlWHErOi1OpJK8gy1wcA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxc-parser/binding-freebsd-x64@0.143.0': - resolution: {integrity: sha512-M5XXcNa7aOqLPKTR41msfghKu2yQ4xWvCm11/gwU0JzOzHNk5sgW//rVEjJ+LO48+VDAMzXTSzurUVxIDKwozw==} + '@oxc-parser/binding-freebsd-x64@0.148.0': + resolution: {integrity: sha512-vFsPx+a/qFECPnz/H8nC6x6MDvnWscLTCo/5muojEF54ERUq1kdgbvnWo95YnkhjF9sTIcG/uDxQBh1gffaufQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxc-parser/binding-linux-arm-gnueabihf@0.143.0': - resolution: {integrity: sha512-T/GXusuOkPNQhCQCSBbcU/N8j0rAypuDBl1IyFK+lyYT594XsVz80clPC/OtbSSpBGyJxj8uYEfctxVuxVYoww==} + '@oxc-parser/binding-linux-arm-gnueabihf@0.148.0': + resolution: {integrity: sha512-eOr3M+6iGbbxNL4PSS0VtsyQ2eOUxSBh00BqO22SbolDimPSYsBuLr/LCrZBkiqW2BoabhR6V4R8jrRAay7hjg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm-musleabihf@0.143.0': - resolution: {integrity: sha512-oKu4RcBlXSqo3OC62dp6YTnQaZIurNDpCX3BnAM3+bJxt7s8J2TJKMnC0UYer1qhlRaDCg6wkTaTw+2IlsZ12w==} + '@oxc-parser/binding-linux-arm-musleabihf@0.148.0': + resolution: {integrity: sha512-58ZKDw0mQRbCNfrd2IDyV4o8T7enzGERJn41BH2tjrZVGyiKiFzcfDicuB7Zcpb/1xIOrObovr8Dja6lZi8dLw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm64-gnu@0.143.0': - resolution: {integrity: sha512-WJBbD186AZmMGaSIhlktC+rPl8L3peCTXAh88Ih9uEvK0en2mPojGyCGYiL6mHtV1RPV3JyfJW5t6n5hh0lXhA==} + '@oxc-parser/binding-linux-arm64-gnu@0.148.0': + resolution: {integrity: sha512-Fnu95O4eZ5i++GPvIzBEZ8y4ddTLR+D9paYa8JRaRk6ZK7nHQiWP5xtrhcPQsXqgat1d7sU/d5rbbI0p1FTHSQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-arm64-musl@0.143.0': - resolution: {integrity: sha512-t1AcYOwEzgceadT4v5e+vaCCb0AncCA3v5AyzfBAz/tMq11qzVccXKzNHtkWdjBsgvTKwRkaUF3QvT4kot8vcQ==} + '@oxc-parser/binding-linux-arm64-musl@0.148.0': + resolution: {integrity: sha512-3CQy/BMdx7N7H3qrcPxUL+a2CwUZodUcf6oq8iJuNZ9C6Ol1aq3mcWzsgySJ7CHFLvpX21ZDPp1r1X0QLbu/AQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-ppc64-gnu@0.143.0': - resolution: {integrity: sha512-RsnO/NoD8376LMJq8JS8TwI0ieNaFRTuNe2GVJntQg6gwZNMENZsEbknHdVwjpOmxdGLGodcwaGSbAeRr5Bgjw==} + '@oxc-parser/binding-linux-ppc64-gnu@0.148.0': + resolution: {integrity: sha512-9LkaYvfiF8hMOw900csAvkf1oxE8XlmMeGowu5BcastSSwV8mKvKRMNU7HsV+ycyj1dQD8pX5qgOw8ja6SJacg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-gnu@0.143.0': - resolution: {integrity: sha512-48fSVfR9TZi5CASZFyv0VC6z6BCoeihFsX031mAD/oSH7d9PYsPgIqza7d9mjP7Z2KTEpTFyH6SIu0Ui6R1vdg==} + '@oxc-parser/binding-linux-riscv64-gnu@0.148.0': + resolution: {integrity: sha512-2GBiM9h26dR4WJfhoMvnFMnFLf7m/kYs4UMqjvrOfQG4BV1nuTJDH22Zc2MQr3INZF7nSKYQ6xlhD3hQ7A6gug==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-musl@0.143.0': - resolution: {integrity: sha512-T8CpdD+SfE01DnIOD4HpVxu0ZJOfMJ/VhCvikKfaXAxkZ+9veyLM/D2hpi7Y2hFUyPmVQO3FNZHmYzV/WlVR4g==} + '@oxc-parser/binding-linux-riscv64-musl@0.148.0': + resolution: {integrity: sha512-uPqZexvKJmEgq4mAu36qe2xTfXZE7oyik1R7KtZ5tl8qKlq1U1fIqTFRUEBZqRGvforoTrGIpatRzcoPKO66RA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-s390x-gnu@0.143.0': - resolution: {integrity: sha512-QLdeMsCcacenPEFsfxnBUDF1y6opyz5+fmOz9bfD5Y7fiGCMupUCuB3KTPQhNwshIG1P9fPqar9MHxuBDd4bwQ==} + '@oxc-parser/binding-linux-s390x-gnu@0.148.0': + resolution: {integrity: sha512-9oUHvnTbp7ZraFsTC8PN6XhdhPSSxZumYvixWl7Smi353gEULvK6yV0sXNVrdFMHQeaDKFCi8TgDhNK7/A+Y+Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-gnu@0.143.0': - resolution: {integrity: sha512-659ujfqLy6k7cuH3sbzhd8b+ztSq+i6E2E9pG78Q0BmHjAExfGIdgc8cGgMdwAozDXeZFHkJ+LXYJdWsaGdgyw==} + '@oxc-parser/binding-linux-x64-gnu@0.148.0': + resolution: {integrity: sha512-2qhDSJwKzbSZzF7lDqqk8sr/yXsmwr3PeUa4/nazIF+zFAYz1gVPEfC34GQtGxzJUUmklaYAL63368LEfrMeyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-musl@0.143.0': - resolution: {integrity: sha512-/Mw/9j4TfZcnKphPrzOE6t4MMknXadcAAuVUlDRTF/ETWB5xOgQvOJV2Mh9We/bWxZdoxaGAdc+hy4GuYwQ2yQ==} + '@oxc-parser/binding-linux-x64-musl@0.148.0': + resolution: {integrity: sha512-qQoPDZUFV0bh9xA09XydmkjMBpgc1ukJuhMvzQ9QeVmFaHTS9W5TE5CoLmSl3QQyUP9OuHO3x/WPZTIIZPWR3Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxc-parser/binding-openharmony-arm64@0.143.0': - resolution: {integrity: sha512-8rIKWR2BFuifbIK/1XB9wTaSdtuJ25dlE7ZQYDnEwj/2xH2vHsxnvIjHT3ZjSVuLLwGGlSslIG/fbOJ8TV8rTw==} + '@oxc-parser/binding-openharmony-arm64@0.148.0': + resolution: {integrity: sha512-1UGbaQWEXUCLqAmaR5kwRDjx/R4S5LQKZkM9CHmaHkuKhriOF32aRLfS0jCRNE2yGQJLMEA1z9UucbBVqjXnDw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxc-parser/binding-win32-arm64-msvc@0.143.0': - resolution: {integrity: sha512-5U9kQYMfRRI6Zq7KDxgbIP0RMnKrfn3gLepRMgJuRkPSUALTiRCk9d/uyhb4lGDjUdzwK7mBkKqhLgzBPCmLpQ==} + '@oxc-parser/binding-win32-arm64-msvc@0.148.0': + resolution: {integrity: sha512-pWKdzRDNG2+NK4h/V6U/CYERcfYD6u28h5IB/VJVsrZaD3muvE58tUj22lieL5vLZ+XFi1GPv9YXckZbJZ9BLA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxc-parser/binding-win32-ia32-msvc@0.143.0': - resolution: {integrity: sha512-25P7AaHk4R88Yv2XH4gToDVmh0cOu+bEURQU10CRrmvgabfRArSGAP5osmwUKeSUHj0VS50upbpbRWWW/m7mHA==} + '@oxc-parser/binding-win32-ia32-msvc@0.148.0': + resolution: {integrity: sha512-i3p4x+mvwtjcE1J5HM6V7ggsbXiznExN/4MkNyOy3dfXrVV3bnkSfmZxvo6/84qCVX4ShkpNE1SKt9biIF31GQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxc-parser/binding-win32-x64-msvc@0.143.0': - resolution: {integrity: sha512-ORMh3JE1s6V7ySicdRK7vgaDQnn5o+UHg9ct989PlWHbel8O9ARrmWXM6kZjrBMtNucxNayQ8g69G0VfWzhANw==} + '@oxc-parser/binding-win32-x64-msvc@0.148.0': + resolution: {integrity: sha512-Ye6vB7VQulghWYkYkECOBYFRVEizz4XyRTUAv+t8BuyurhKU7uD0P9eowL+mKG5Mf8MSYx+DI3Cm8SKZvYG7bQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxc-project/types@0.143.0': - resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} + '@oxc-project/types@0.148.0': + resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} - '@oxc-project/types@0.147.0': - resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + '@oxc-project/types@0.150.0': + resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==} '@oxc-resolver/binding-android-arm-eabi@11.24.2': resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} @@ -1128,8 +1128,8 @@ packages: cpu: [x64] os: [win32] - '@playwright/test@1.62.1': - resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} engines: {node: '>=20'} hasBin: true @@ -1508,14 +1508,14 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - '@react-router/dev@8.3.0': - resolution: {integrity: sha512-XR+N2fEFOPjczYo2efc3/AOtosbSICCroLF/IxnZ6ErGBeBGRG6SqAso0SYoff0e18OA05qOyhJHFhXKMGIPRw==} + '@react-router/dev@8.3.1': + resolution: {integrity: sha512-yvhMiOq+LLmGPgTOYYWjzfapUN4B2/T3nfr8P81bLTiyh0YSx88OAz5y2D/KKS0boF9EoHyhzeMH4ZweMTwjJA==} engines: {node: '>=22.22.0'} hasBin: true peerDependencies: - '@react-router/serve': ^8.3.0 + '@react-router/serve': ^8.3.1 '@vitejs/plugin-rsc': ~0.5.26 - react-router: ^8.3.0 + react-router: ^8.3.1 react-server-dom-webpack: ^19.2.7 typescript: ^5.1.0 || ^6.0.0 || ^7.0.0 vite: ^7.0.0 || ^8.0.0 @@ -1553,11 +1553,11 @@ packages: typescript: optional: true - '@react-router/node@8.3.0': - resolution: {integrity: sha512-qw5ibcolE1OcwngiEw6t7LR11Hi6yWEDvj6cfvaYROX+w18JPxc0YSmsdn3Wlc9TOX+Qo8FVcxbXRdc9vojn2w==} + '@react-router/node@8.3.1': + resolution: {integrity: sha512-JQI2vPd0twfHXxRxD1QWxKkCfu5I7bxojgEZFL97EvbJLm1B6p4qeqvGc9hl9g/3ZYbieczQY6ElM3zZjiG4sA==} engines: {node: '>=22.22.0'} peerDependencies: - react-router: 8.3.0 + react-router: 8.3.1 typescript: ^5.1.0 || ^6.0.0 || ^7.0.0 peerDependenciesMeta: typescript: @@ -1576,98 +1576,101 @@ packages: '@remix-run/node-fetch-server@0.13.3': resolution: {integrity: sha512-UfjOXed/DQteaM5VyTfqTeGpHwyL2J5aoRGY6cydip4tt1ehNNeSwuXCC7AEGE0RWBs/7bgKxYkL/B/+UDe4AA==} - '@rolldown/binding-android-arm-eabi@1.2.6': - resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} + '@remix-run/node-fetch-server@0.14.1': + resolution: {integrity: sha512-pKenF5ysIN5lDCnCyvoUxDhKP0tfnGagvGbZh01xxHG6DJXSIXWVC9NpLSDGjxcxd1CKdCOKuZFleXBF3pChIg==} + + '@rolldown/binding-android-arm-eabi@1.2.9': + resolution: {integrity: sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@rolldown/binding-android-arm64@1.2.6': - resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==} + '@rolldown/binding-android-arm64@1.2.9': + resolution: {integrity: sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.6': - resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==} + '@rolldown/binding-darwin-arm64@1.2.9': + resolution: {integrity: sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.6': - resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==} + '@rolldown/binding-darwin-x64@1.2.9': + resolution: {integrity: sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.6': - resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==} + '@rolldown/binding-freebsd-x64@1.2.9': + resolution: {integrity: sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.6': - resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.9': + resolution: {integrity: sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.6': - resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==} + '@rolldown/binding-linux-arm64-gnu@1.2.9': + resolution: {integrity: sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.6': - resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==} + '@rolldown/binding-linux-arm64-musl@1.2.9': + resolution: {integrity: sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.6': - resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==} + '@rolldown/binding-linux-ppc64-gnu@1.2.9': + resolution: {integrity: sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.6': - resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==} + '@rolldown/binding-linux-s390x-gnu@1.2.9': + resolution: {integrity: sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.6': - resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==} + '@rolldown/binding-linux-x64-gnu@1.2.9': + resolution: {integrity: sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.6': - resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==} + '@rolldown/binding-linux-x64-musl@1.2.9': + resolution: {integrity: sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.6': - resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==} + '@rolldown/binding-openharmony-arm64@1.2.9': + resolution: {integrity: sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.6': - resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==} + '@rolldown/binding-win32-arm64-msvc@1.2.9': + resolution: {integrity: sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.6': - resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==} + '@rolldown/binding-win32-x64-msvc@1.2.9': + resolution: {integrity: sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1675,28 +1678,28 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@smithy/core@3.33.3': - resolution: {integrity: sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==} + '@smithy/core@3.34.1': + resolution: {integrity: sha512-dLcOUxz8YCv1RZUMKq6GbyUf95pLbrqh34bPvpCZ1+CByFF31BEAFewZjsGCnVsZTKdThNENfGyAgk2TJqVwSw==} engines: {node: '>=18.0.0'} '@smithy/credential-provider-imds@4.5.2': resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.7.2': - resolution: {integrity: sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==} + '@smithy/fetch-http-handler@5.8.0': + resolution: {integrity: sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.11.3': - resolution: {integrity: sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==} + '@smithy/node-http-handler@4.12.1': + resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==} engines: {node: '>=18.0.0'} '@smithy/signature-v4@5.7.3': resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==} engines: {node: '>=18.0.0'} - '@smithy/types@4.17.2': - resolution: {integrity: sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==} + '@smithy/types@4.18.0': + resolution: {integrity: sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==} engines: {node: '>=18.0.0'} '@standard-schema/spec@1.1.0': @@ -2645,8 +2648,8 @@ packages: '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} - '@types/node@26.4.0': - resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==} + '@types/node@26.5.1': + resolution: {integrity: sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==} '@types/nprogress@0.2.3': resolution: {integrity: sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==} @@ -2841,8 +2844,8 @@ packages: '@vitest/utils@4.1.11': resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} - '@zumer/snapdom@2.24.10': - resolution: {integrity: sha512-yK+5HvcP96aZCG8dcOuJDsOD1TACDeSTI0wlsmQkMeeGaM/JVHdBQZPS4h0Uae6bY/zx+WQCJtOKnrbB6D7NgQ==} + '@zumer/snapdom@2.24.18': + resolution: {integrity: sha512-wMw8lNY5l0pyarAv8jbe5ayOmrR2OUnK5KLtCwuhcLhFv88d2Y38Sertb+d9pn5g20Cet4eLCRXd3yFHcWiDIQ==} accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} @@ -2890,8 +2893,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.7: - resolution: {integrity: sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==} + baseline-browser-mapping@2.11.25: + resolution: {integrity: sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==} engines: {node: '>=6.0.0'} hasBin: true @@ -2912,8 +2915,8 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - browserslist@4.28.7: - resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + browserslist@4.29.0: + resolution: {integrity: sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2942,8 +2945,8 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - caniuse-lite@1.0.30001806: - resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} @@ -2993,8 +2996,8 @@ packages: compressorjs@1.3.0: resolution: {integrity: sha512-TsvzkRgDm/6mIRUdxJbrTH7kfSW3oJzOw8b1xU60fziQSosTML5TczpO6Z4H1LGF0yRmTotk6r5UNhuRxEwA1A==} - confbox@0.2.4: - resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + confbox@0.3.1: + resolution: {integrity: sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==} content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} @@ -3099,8 +3102,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.398: - resolution: {integrity: sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==} + electron-to-chromium@1.5.427: + resolution: {integrity: sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -3124,9 +3127,6 @@ packages: es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} - es-module-lexer@2.3.1: - resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} - es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -3208,8 +3208,8 @@ packages: flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} - formatly@0.3.0: - resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} + formatly@0.7.0: + resolution: {integrity: sha512-7CXJtIIA0zy/u12StsYk25qVKxvdLA2ep2sTNxK3ov0mGNIIDqIvAXDSgTnAfDJFsPfWjuz0WjfYSdpvnLA5Tg==} engines: {node: '>=18.3.0'} hasBin: true @@ -3228,11 +3228,6 @@ packages: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} - 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} @@ -3261,8 +3256,8 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - get-tsconfig@4.14.1: - resolution: {integrity: sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==} + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} gl-matrix@3.4.4: resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} @@ -3311,8 +3306,8 @@ packages: i18next-browser-languagedetector@8.2.1: resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} - i18next-http-backend@4.0.1: - resolution: {integrity: sha512-O+7MwPCJIKCu68vho8JtD1HF8QHyMPzoChIDFfYCrpaDqX6oq3RqASujnpKEdnNCEOb+kLMOHTNTcGKpj7B1BA==} + i18next-http-backend@4.0.2: + resolution: {integrity: sha512-oay62dIB2kL7+WHzoUXBjWfL3+mwijD3pQkQkIEaRLhy6kjXxUhrRenV0gInwlCASAaJaDy94+XvYizK+cAGdA==} engines: {node: '>=18'} i18next-locales-sync@2.1.1: @@ -3371,8 +3366,8 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - isbot@5.2.1: - resolution: {integrity: sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==} + isbot@5.2.2: + resolution: {integrity: sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==} engines: {node: '>=18'} isexe@2.0.0: @@ -3412,8 +3407,8 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} - knip@6.32.3: - resolution: {integrity: sha512-wOJ1Av8PwKqFO0wU7F64vzIcUjxhrieA3Tc2lmeK9C9prpxq+TeG2ewNH5tCaBVZwXNPp6lJkrr+IhZ20iqkMA==} + knip@6.35.1: + resolution: {integrity: sha512-22wnEnv4do2fvoeJsxpFCG/MBxReNxoCVBccaCl7suUJsG+F0UvxI9ycxZ9YgtLHSSjrZl5tOas0JZ/R1vJ93g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -3517,8 +3512,8 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@1.34.0: - resolution: {integrity: sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg==} + lucide-react@1.44.0: + resolution: {integrity: sha512-2egNApH4hX4j/qdCgRublh88+9u3mEhz9iSlW5ckm4kaQEqZbXbMr0l5u5JZLy8nmWRx2dbHGQkEDYz6C9aCgw==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -3529,8 +3524,8 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magic-string@1.2.3: - resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==} + magic-string@1.3.1: + resolution: {integrity: sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==} markdown-it@14.1.1: resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} @@ -3565,8 +3560,8 @@ packages: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} - mediabunny@1.55.3: - resolution: {integrity: sha512-kpBhMiJHGmerizzObAT1XLZDyImO4ZEKXaxjjfxGVkycQ0U5of/xlLepm1Izp3P+3jlaedFSRI5fJnv3Q5xV6A==} + mediabunny@1.56.1: + resolution: {integrity: sha512-DNOm0bRBmeOxJgCK/g3LxNWbxY5morTauNdlychujGmJ9L37ezVezkNoI1WtO2Xdb4WlWWJlYfIKnOEzd5phRw==} merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} @@ -3608,6 +3603,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + nanoid@6.0.1: resolution: {integrity: sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw==} engines: {node: ^22 || ^24 || >=26} @@ -3629,8 +3629,8 @@ packages: resolution: {integrity: sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==} engines: {node: '>=20'} - node-releases@2.0.51: - resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + node-releases@2.0.56: + resolution: {integrity: sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==} engines: {node: '>=18'} nprogress@0.2.0: @@ -3669,21 +3669,24 @@ packages: orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} - oxc-parser@0.143.0: - resolution: {integrity: sha512-ov0NzaDCOInknS7mP1cwKdJERt3utPW8ldjtdUXQ8Ty0GEFD08wk422vCUN0d7pST6kqtV7dxoI9w1Zi0l/9TA==} + oxc-parser@0.148.0: + resolution: {integrity: sha512-syxUKHeUll89RIABQADcI7sikYrwyssvA6gj4phSSIPezKVM8yMaLAiLLSc7fmzVvrwybfFGFbW5zme9sX87rg==} engines: {node: ^20.19.0 || >=22.12.0} oxc-resolver@11.24.2: resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} - p-limit@7.3.1: - resolution: {integrity: sha512-0trZaiG7Y7kN/Egy9a8j47t9osC0Tch4PaIWd9yGF6bvmlk7muExRvGNYb8sXBwEKMoNKsbNN9P8EefuQekE4Q==} + p-limit@7.3.2: + resolution: {integrity: sha512-Ll0w3fU24vYpXoZmjjZIee6bJQDgG0oAyo1PdmFYI8UDwJJddaHAypxIH9avUu+t+lSsAwKVsb1jDCMIIChliw==} engines: {node: '>=20'} - p-map@7.0.6: - resolution: {integrity: sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==} + p-map@7.0.8: + resolution: {integrity: sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw==} engines: {node: '>=18'} + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -3716,16 +3719,16 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} - pkg-types@2.3.1: - resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + pkg-types@2.3.3: + resolution: {integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==} - playwright-core@1.62.1: - resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} engines: {node: '>=20'} hasBin: true - playwright@1.62.1: - resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} engines: {node: '>=20'} hasBin: true @@ -3733,15 +3736,10 @@ packages: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} - postcss@8.5.26: - resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} - prettier@3.9.6: - resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} - engines: {node: '>=14'} - hasBin: true - property-expr@2.0.6: resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} @@ -3853,8 +3851,8 @@ packages: peerDependencies: react: ^19.3.0 - react-error-boundary@6.1.3: - resolution: {integrity: sha512-GnSKpCohFi2nQmJCWwP8O8wub7zexlePvpsejvQr35vS5RTouS1+utTNOmyc540yw5vyOXnSL1rBWsCQDmkyUA==} + react-error-boundary@6.1.5: + resolution: {integrity: sha512-l2nk+KhgM6e79tAiHEs3tXTdiTW4ARniKYMQN+cLY5vQFpDOkxuXKIC4uIwYuIKoMpFQ6jEbQz9wBArUmq7GWA==} peerDependencies: '@types/react': ^18.0.0 || ^19.0.0 react: ^18.0.0 || ^19.0.0 @@ -3902,8 +3900,8 @@ packages: '@types/react': optional: true - react-router@8.3.0: - resolution: {integrity: sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==} + react-router@8.3.1: + resolution: {integrity: sha512-TEOpiO2g0TJHEOJeRVv4amUFun9v1npCKszvcquNvzETUtJ8udV86ah5eFoHT7g26bsBvT6EiIhqulR8eDF++A==} engines: {node: '>=22.22.0'} peerDependencies: react: '>=19.2.7' @@ -3930,12 +3928,12 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} - readdirp@5.0.0: - resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} engines: {node: '>= 20.19.0'} - remeda@2.45.0: - resolution: {integrity: sha512-CTftZ0GJSox3CH77OhJemj20sXXUScH/kpkExD6t2LP3IG+mPKASBMFkbvWtsvzhU+54gKcgH38WWVihfr7bCA==} + remeda@2.48.0: + resolution: {integrity: sha512-+tf74WyTpQT1lHI/zJeXOkMBqb6R9gNcd3T4DpXzjs26PdwEARCPKOjLAwJaQYrvSXS+nxsXMhEZD4NS40QuSg==} engines: {node: '>=18.0.0'} remix-auth-oauth2@3.4.1: @@ -3971,8 +3969,8 @@ packages: resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} engines: {node: '>=0.12'} - rolldown@1.2.6: - resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==} + rolldown@1.2.9: + resolution: {integrity: sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -4215,8 +4213,8 @@ packages: resolution: {integrity: sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ==} engines: {node: '>=14'} - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} @@ -4226,8 +4224,8 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.3: + resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -4260,8 +4258,8 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - valibot@1.4.2: - resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} + valibot@1.5.0: + resolution: {integrity: sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ==} peerDependencies: typescript: '>=5' peerDependenciesMeta: @@ -4283,13 +4281,13 @@ packages: '@babel/core': ^7.0.0 vite: ^2.7.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - vite@8.2.2: - resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + vite@8.3.0: + resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.4.0 || ^0.5.0 + '@vitejs/devtools': ^0.7.1 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -4326,14 +4324,14 @@ packages: yaml: optional: true - vitest-browser-react@2.2.0: - resolution: {integrity: sha512-oY3KM6305kwJMa6nHo92vVtkOsih7mjEf12dLKuphaF+9ywWPEc+qanIBd394SZ6m5LadVEaG6dicvvizOzmjA==} + vitest-browser-react@2.3.0: + resolution: {integrity: sha512-S5vdGHHA93j2Z6dbmIJI1wtxT3AXPTRlmC1Meo+ekG011yNnEutMAnHrwyj8qFlGhgBYA8ENGK6+o1Q+vnFz6A==} peerDependencies: '@types/react': ^18.0.0 || ^19.0.0 '@types/react-dom': ^18.0.0 || ^19.0.0 react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - vitest: ^4.0.0 + vitest: ^4.0.0 || ^5.0.0 peerDependenciesMeta: '@types/react': optional: true @@ -4446,8 +4444,8 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yaml@2.9.0: - resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} engines: {node: '>= 14.6'} hasBin: true @@ -4466,182 +4464,182 @@ packages: yup@1.7.1: resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==} - zod@4.5.4: - resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} snapshots: - '@aws-sdk/checksums@3.1000.29': + '@aws-sdk/checksums@3.1001.0': dependencies: - '@aws-sdk/core': 3.977.9 + '@aws-sdk/core': 3.978.0 '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/client-s3@3.1119.0': + '@aws-sdk/client-s3@3.1130.0': dependencies: - '@aws-sdk/checksums': 3.1000.29 - '@aws-sdk/core': 3.977.9 - '@aws-sdk/credential-provider-node': 3.972.82 - '@aws-sdk/middleware-sdk-s3': 3.972.75 + '@aws-sdk/checksums': 3.1001.0 + '@aws-sdk/core': 3.978.0 + '@aws-sdk/credential-provider-node': 3.972.83 + '@aws-sdk/middleware-sdk-s3': 3.972.76 '@aws-sdk/signature-v4-multi-region': 3.996.46 '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/fetch-http-handler': 5.7.2 - '@smithy/node-http-handler': 4.11.3 - '@smithy/types': 4.17.2 + '@smithy/core': 3.34.1 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/core@3.977.9': + '@aws-sdk/core@3.978.0': dependencies: '@aws-sdk/types': 3.974.5 '@aws-sdk/xml-builder': 3.972.40 '@aws/lambda-invoke-store': 0.3.0 - '@smithy/core': 3.33.3 + '@smithy/core': 3.34.1 '@smithy/signature-v4': 5.7.3 - '@smithy/types': 4.17.2 + '@smithy/types': 4.18.0 bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.70': + '@aws-sdk/credential-provider-env@3.972.71': dependencies: - '@aws-sdk/core': 3.977.9 + '@aws-sdk/core': 3.978.0 '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.72': + '@aws-sdk/credential-provider-http@3.972.73': dependencies: - '@aws-sdk/core': 3.977.9 + '@aws-sdk/core': 3.978.0 '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/fetch-http-handler': 5.7.2 - '@smithy/node-http-handler': 4.11.3 - '@smithy/types': 4.17.2 + '@smithy/core': 3.34.1 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.973.15': + '@aws-sdk/credential-provider-ini@3.973.16': dependencies: - '@aws-sdk/core': 3.977.9 - '@aws-sdk/credential-provider-env': 3.972.70 - '@aws-sdk/credential-provider-http': 3.972.72 - '@aws-sdk/credential-provider-login': 3.972.77 - '@aws-sdk/credential-provider-process': 3.972.70 - '@aws-sdk/credential-provider-sso': 3.973.14 - '@aws-sdk/credential-provider-web-identity': 3.972.76 - '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/core': 3.978.0 + '@aws-sdk/credential-provider-env': 3.972.71 + '@aws-sdk/credential-provider-http': 3.972.73 + '@aws-sdk/credential-provider-login': 3.972.78 + '@aws-sdk/credential-provider-process': 3.972.71 + '@aws-sdk/credential-provider-sso': 3.973.15 + '@aws-sdk/credential-provider-web-identity': 3.972.77 + '@aws-sdk/nested-clients': 3.997.45 '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 + '@smithy/core': 3.34.1 '@smithy/credential-provider-imds': 4.5.2 - '@smithy/types': 4.17.2 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-login@3.972.77': + '@aws-sdk/credential-provider-login@3.972.78': dependencies: - '@aws-sdk/core': 3.977.9 - '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/core': 3.978.0 + '@aws-sdk/nested-clients': 3.997.45 '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-node@3.972.82': + '@aws-sdk/credential-provider-node@3.972.83': dependencies: - '@aws-sdk/credential-provider-env': 3.972.70 - '@aws-sdk/credential-provider-http': 3.972.72 - '@aws-sdk/credential-provider-ini': 3.973.15 - '@aws-sdk/credential-provider-process': 3.972.70 - '@aws-sdk/credential-provider-sso': 3.973.14 - '@aws-sdk/credential-provider-web-identity': 3.972.76 + '@aws-sdk/credential-provider-env': 3.972.71 + '@aws-sdk/credential-provider-http': 3.972.73 + '@aws-sdk/credential-provider-ini': 3.973.16 + '@aws-sdk/credential-provider-process': 3.972.71 + '@aws-sdk/credential-provider-sso': 3.973.15 + '@aws-sdk/credential-provider-web-identity': 3.972.77 '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 + '@smithy/core': 3.34.1 '@smithy/credential-provider-imds': 4.5.2 - '@smithy/types': 4.17.2 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-process@3.972.70': + '@aws-sdk/credential-provider-process@3.972.71': dependencies: - '@aws-sdk/core': 3.977.9 + '@aws-sdk/core': 3.978.0 '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.973.14': + '@aws-sdk/credential-provider-sso@3.973.15': dependencies: - '@aws-sdk/core': 3.977.9 - '@aws-sdk/nested-clients': 3.997.44 - '@aws-sdk/token-providers': 3.1116.0 + '@aws-sdk/core': 3.978.0 + '@aws-sdk/nested-clients': 3.997.45 + '@aws-sdk/token-providers': 3.1129.0 '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-web-identity@3.972.76': + '@aws-sdk/credential-provider-web-identity@3.972.77': dependencies: - '@aws-sdk/core': 3.977.9 - '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/core': 3.978.0 + '@aws-sdk/nested-clients': 3.997.45 '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/lib-storage@3.1119.0(@aws-sdk/client-s3@3.1119.0)': + '@aws-sdk/lib-storage@3.1130.0(@aws-sdk/client-s3@3.1130.0)': dependencies: - '@aws-sdk/client-s3': 3.1119.0 - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@aws-sdk/client-s3': 3.1130.0 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 buffer: 5.6.0 events: 3.3.0 stream-browserify: 3.0.0 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.75': + '@aws-sdk/middleware-sdk-s3@3.972.76': dependencies: - '@aws-sdk/core': 3.977.9 + '@aws-sdk/core': 3.978.0 '@aws-sdk/signature-v4-multi-region': 3.996.46 '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.44': + '@aws-sdk/nested-clients@3.997.45': dependencies: - '@aws-sdk/core': 3.977.9 + '@aws-sdk/core': 3.978.0 '@aws-sdk/signature-v4-multi-region': 3.996.46 '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/fetch-http-handler': 5.7.2 - '@smithy/node-http-handler': 4.11.3 - '@smithy/types': 4.17.2 + '@smithy/core': 3.34.1 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 '@aws-sdk/signature-v4-multi-region@3.996.46': dependencies: '@aws-sdk/types': 3.974.5 '@smithy/signature-v4': 5.7.3 - '@smithy/types': 4.17.2 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1116.0': + '@aws-sdk/token-providers@3.1129.0': dependencies: - '@aws-sdk/core': 3.977.9 - '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/core': 3.978.0 + '@aws-sdk/nested-clients': 3.997.45 '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 '@aws-sdk/types@3.974.5': dependencies: - '@smithy/types': 4.17.2 + '@smithy/types': 4.18.0 tslib: 2.8.1 '@aws-sdk/xml-builder@3.972.40': dependencies: - '@smithy/types': 4.17.2 + '@smithy/types': 4.18.0 tslib: 2.8.1 '@aws/lambda-invoke-store@0.3.0': {} @@ -4657,14 +4655,14 @@ snapshots: '@babel/core@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -4674,23 +4672,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.29.7': + '@babel/generator@7.29.8': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 '@babel/helper-annotate-as-pure@7.29.7': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/helper-compilation-targets@7.29.7': dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.7 + browserslist: 4.29.0 lru-cache: 5.1.1 semver: 6.3.1 @@ -4702,7 +4700,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.29.7 '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.8 semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -4711,15 +4709,15 @@ snapshots: '@babel/helper-member-expression-to-functions@7.29.7': dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -4728,13 +4726,13 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.8 transitivePeerDependencies: - supports-color '@babel/helper-optimise-call-expression@7.29.7': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/helper-plugin-utils@7.29.7': {} @@ -4743,14 +4741,14 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.8 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -4767,11 +4765,11 @@ snapshots: '@babel/helpers@7.29.7': dependencies: '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 - '@babel/parser@7.29.7': + '@babel/parser@7.29.8': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': dependencies: @@ -4820,17 +4818,17 @@ snapshots: '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 - '@babel/traverse@7.29.7': + '@babel/traverse@7.29.8': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -4840,7 +4838,7 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.7': + '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 @@ -5091,54 +5089,54 @@ snapshots: '@mjackson/headers@0.10.0': {} - '@napi-rs/canvas-android-arm64@1.0.8': + '@napi-rs/canvas-android-arm64@1.0.9': optional: true - '@napi-rs/canvas-darwin-arm64@1.0.8': + '@napi-rs/canvas-darwin-arm64@1.0.9': optional: true - '@napi-rs/canvas-darwin-x64@1.0.8': + '@napi-rs/canvas-darwin-x64@1.0.9': optional: true - '@napi-rs/canvas-linux-arm-gnueabihf@1.0.8': + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.9': optional: true - '@napi-rs/canvas-linux-arm64-gnu@1.0.8': + '@napi-rs/canvas-linux-arm64-gnu@1.0.9': optional: true - '@napi-rs/canvas-linux-arm64-musl@1.0.8': + '@napi-rs/canvas-linux-arm64-musl@1.0.9': optional: true - '@napi-rs/canvas-linux-riscv64-gnu@1.0.8': + '@napi-rs/canvas-linux-riscv64-gnu@1.0.9': optional: true - '@napi-rs/canvas-linux-x64-gnu@1.0.8': + '@napi-rs/canvas-linux-x64-gnu@1.0.9': optional: true - '@napi-rs/canvas-linux-x64-musl@1.0.8': + '@napi-rs/canvas-linux-x64-musl@1.0.9': optional: true - '@napi-rs/canvas-win32-arm64-msvc@1.0.8': + '@napi-rs/canvas-win32-arm64-msvc@1.0.9': optional: true - '@napi-rs/canvas-win32-x64-msvc@1.0.8': + '@napi-rs/canvas-win32-x64-msvc@1.0.9': optional: true - '@napi-rs/canvas@1.0.8': + '@napi-rs/canvas@1.0.9': optionalDependencies: - '@napi-rs/canvas-android-arm64': 1.0.8 - '@napi-rs/canvas-darwin-arm64': 1.0.8 - '@napi-rs/canvas-darwin-x64': 1.0.8 - '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.8 - '@napi-rs/canvas-linux-arm64-gnu': 1.0.8 - '@napi-rs/canvas-linux-arm64-musl': 1.0.8 - '@napi-rs/canvas-linux-riscv64-gnu': 1.0.8 - '@napi-rs/canvas-linux-x64-gnu': 1.0.8 - '@napi-rs/canvas-linux-x64-musl': 1.0.8 - '@napi-rs/canvas-win32-arm64-msvc': 1.0.8 - '@napi-rs/canvas-win32-x64-msvc': 1.0.8 + '@napi-rs/canvas-android-arm64': 1.0.9 + '@napi-rs/canvas-darwin-arm64': 1.0.9 + '@napi-rs/canvas-darwin-x64': 1.0.9 + '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.9 + '@napi-rs/canvas-linux-arm64-gnu': 1.0.9 + '@napi-rs/canvas-linux-arm64-musl': 1.0.9 + '@napi-rs/canvas-linux-riscv64-gnu': 1.0.9 + '@napi-rs/canvas-linux-x64-gnu': 1.0.9 + '@napi-rs/canvas-linux-x64-musl': 1.0.9 + '@napi-rs/canvas-win32-arm64-msvc': 1.0.9 + '@napi-rs/canvas-win32-x64-msvc': 1.0.9 - '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': dependencies: '@emnapi/core': 1.11.2 '@emnapi/runtime': 1.11.2 @@ -5164,66 +5162,66 @@ snapshots: dependencies: '@oslojs/encoding': 0.4.1 - '@oxc-parser/binding-android-arm-eabi@0.143.0': + '@oxc-parser/binding-android-arm-eabi@0.148.0': optional: true - '@oxc-parser/binding-android-arm64@0.143.0': + '@oxc-parser/binding-android-arm64@0.148.0': optional: true - '@oxc-parser/binding-darwin-arm64@0.143.0': + '@oxc-parser/binding-darwin-arm64@0.148.0': optional: true - '@oxc-parser/binding-darwin-x64@0.143.0': + '@oxc-parser/binding-darwin-x64@0.148.0': optional: true - '@oxc-parser/binding-freebsd-x64@0.143.0': + '@oxc-parser/binding-freebsd-x64@0.148.0': optional: true - '@oxc-parser/binding-linux-arm-gnueabihf@0.143.0': + '@oxc-parser/binding-linux-arm-gnueabihf@0.148.0': optional: true - '@oxc-parser/binding-linux-arm-musleabihf@0.143.0': + '@oxc-parser/binding-linux-arm-musleabihf@0.148.0': optional: true - '@oxc-parser/binding-linux-arm64-gnu@0.143.0': + '@oxc-parser/binding-linux-arm64-gnu@0.148.0': optional: true - '@oxc-parser/binding-linux-arm64-musl@0.143.0': + '@oxc-parser/binding-linux-arm64-musl@0.148.0': optional: true - '@oxc-parser/binding-linux-ppc64-gnu@0.143.0': + '@oxc-parser/binding-linux-ppc64-gnu@0.148.0': optional: true - '@oxc-parser/binding-linux-riscv64-gnu@0.143.0': + '@oxc-parser/binding-linux-riscv64-gnu@0.148.0': optional: true - '@oxc-parser/binding-linux-riscv64-musl@0.143.0': + '@oxc-parser/binding-linux-riscv64-musl@0.148.0': optional: true - '@oxc-parser/binding-linux-s390x-gnu@0.143.0': + '@oxc-parser/binding-linux-s390x-gnu@0.148.0': optional: true - '@oxc-parser/binding-linux-x64-gnu@0.143.0': + '@oxc-parser/binding-linux-x64-gnu@0.148.0': optional: true - '@oxc-parser/binding-linux-x64-musl@0.143.0': + '@oxc-parser/binding-linux-x64-musl@0.148.0': optional: true - '@oxc-parser/binding-openharmony-arm64@0.143.0': + '@oxc-parser/binding-openharmony-arm64@0.148.0': optional: true - '@oxc-parser/binding-win32-arm64-msvc@0.143.0': + '@oxc-parser/binding-win32-arm64-msvc@0.148.0': optional: true - '@oxc-parser/binding-win32-ia32-msvc@0.143.0': + '@oxc-parser/binding-win32-ia32-msvc@0.148.0': optional: true - '@oxc-parser/binding-win32-x64-msvc@0.143.0': + '@oxc-parser/binding-win32-x64-msvc@0.148.0': optional: true - '@oxc-project/types@0.143.0': {} + '@oxc-project/types@0.148.0': {} - '@oxc-project/types@0.147.0': {} + '@oxc-project/types@0.150.0': {} '@oxc-resolver/binding-android-arm-eabi@11.24.2': optional: true @@ -5277,7 +5275,7 @@ snapshots: dependencies: '@emnapi/core': 1.11.2 '@emnapi/runtime': 1.11.2 - '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) optional: true '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': @@ -5286,9 +5284,9 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.24.2': optional: true - '@playwright/test@1.62.1': + '@playwright/test@1.63.0': dependencies: - playwright: 1.62.1 + playwright: 1.63.0 '@polka/url@1.0.0-next.29': {} @@ -5663,74 +5661,71 @@ snapshots: '@radix-ui/rect@1.1.1': {} - '@react-router/dev@8.3.0(@react-router/serve@8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2))(react-router@8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2)(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0))': + '@react-router/dev@8.3.1(@react-router/serve@8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2))(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@babel/core': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) - '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - '@react-router/node': 8.3.0(react-router@8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2) - '@remix-run/node-fetch-server': 0.13.3 + '@babel/generator': 7.29.8 + '@babel/parser': 7.29.8 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@react-router/node': 8.3.1(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2) + '@remix-run/node-fetch-server': 0.14.1 babel-dead-code-elimination: 1.0.12 chokidar: 5.0.0 dedent: 1.7.2 - es-module-lexer: 2.3.1 + es-module-lexer: 2.3.2 exit-hook: 5.1.0 - isbot: 5.2.1 + isbot: 5.2.2 jsesc: 3.1.0 lodash: 4.18.1 - p-map: 7.0.6 + p-map: 7.0.8 pathe: 2.0.3 picocolors: 1.1.1 - pkg-types: 2.3.1 - prettier: 3.9.6 + pkg-types: 2.3.3 react-refresh: 0.18.0 - react-router: 8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react-router: 8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) semver: 7.8.5 tinyglobby: 0.2.17 - valibot: 1.4.2(typescript@7.0.2) - vite: 8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0) + valibot: 1.5.0(typescript@7.0.2) + vite: 8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1) optionalDependencies: - '@react-router/serve': 8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2) + '@react-router/serve': 8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2) typescript: 7.0.2 transitivePeerDependencies: - babel-plugin-macros - supports-color - '@react-router/express@8.1.0(express@5.2.1)(react-router@8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2)': + '@react-router/express@8.1.0(express@5.2.1)(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2)': dependencies: - '@react-router/node': 8.1.0(react-router@8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2) + '@react-router/node': 8.1.0(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2) express: 5.2.1 - react-router: 8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react-router: 8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) optionalDependencies: typescript: 7.0.2 - '@react-router/node@8.1.0(react-router@8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2)': + '@react-router/node@8.1.0(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2)': dependencies: '@remix-run/node-fetch-server': 0.13.3 - react-router: 8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react-router: 8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) optionalDependencies: typescript: 7.0.2 - '@react-router/node@8.3.0(react-router@8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2)': + '@react-router/node@8.3.1(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2)': dependencies: - '@remix-run/node-fetch-server': 0.13.3 - react-router: 8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@remix-run/node-fetch-server': 0.14.1 + react-router: 8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) optionalDependencies: typescript: 7.0.2 - '@react-router/serve@8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2)': + '@react-router/serve@8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2)': dependencies: - '@react-router/express': 8.1.0(express@5.2.1)(react-router@8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2) - '@react-router/node': 8.1.0(react-router@8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2) + '@react-router/express': 8.1.0(express@5.2.1)(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2) + '@react-router/node': 8.1.0(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2) '@remix-run/node-fetch-server': 0.13.3 compression: 1.8.1 express: 5.2.1 morgan: 1.10.1 - react-router: 8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react-router: 8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) source-map-support: 0.5.21 transitivePeerDependencies: - supports-color @@ -5740,83 +5735,85 @@ snapshots: '@remix-run/node-fetch-server@0.13.3': {} - '@rolldown/binding-android-arm-eabi@1.2.6': + '@remix-run/node-fetch-server@0.14.1': {} + + '@rolldown/binding-android-arm-eabi@1.2.9': optional: true - '@rolldown/binding-android-arm64@1.2.6': + '@rolldown/binding-android-arm64@1.2.9': optional: true - '@rolldown/binding-darwin-arm64@1.2.6': + '@rolldown/binding-darwin-arm64@1.2.9': optional: true - '@rolldown/binding-darwin-x64@1.2.6': + '@rolldown/binding-darwin-x64@1.2.9': optional: true - '@rolldown/binding-freebsd-x64@1.2.6': + '@rolldown/binding-freebsd-x64@1.2.9': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + '@rolldown/binding-linux-arm-gnueabihf@1.2.9': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.6': + '@rolldown/binding-linux-arm64-gnu@1.2.9': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.6': + '@rolldown/binding-linux-arm64-musl@1.2.9': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.6': + '@rolldown/binding-linux-ppc64-gnu@1.2.9': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.6': + '@rolldown/binding-linux-s390x-gnu@1.2.9': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.6': + '@rolldown/binding-linux-x64-gnu@1.2.9': optional: true - '@rolldown/binding-linux-x64-musl@1.2.6': + '@rolldown/binding-linux-x64-musl@1.2.9': optional: true - '@rolldown/binding-openharmony-arm64@1.2.6': + '@rolldown/binding-openharmony-arm64@1.2.9': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.6': + '@rolldown/binding-win32-arm64-msvc@1.2.9': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.6': + '@rolldown/binding-win32-x64-msvc@1.2.9': optional: true '@rolldown/pluginutils@1.0.1': {} - '@smithy/core@3.33.3': + '@smithy/core@3.34.1': dependencies: - '@smithy/types': 4.17.2 + '@smithy/types': 4.18.0 tslib: 2.8.1 '@smithy/credential-provider-imds@4.5.2': dependencies: - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@smithy/fetch-http-handler@5.7.2': + '@smithy/fetch-http-handler@5.8.0': dependencies: - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@smithy/node-http-handler@4.11.3': + '@smithy/node-http-handler@4.12.1': dependencies: - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 '@smithy/signature-v4@5.7.3': dependencies: - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@smithy/types@4.17.2': + '@smithy/types@4.18.0': dependencies: tslib: 2.8.1 @@ -6940,9 +6937,9 @@ snapshots: '@types/mdurl@2.0.0': {} - '@types/node@26.4.0': + '@types/node@26.5.1': dependencies: - undici-types: 8.3.0 + undici-types: 8.9.0 '@types/nprogress@0.2.3': {} @@ -6962,7 +6959,7 @@ snapshots: '@types/web-push@3.6.4': dependencies: - '@types/node': 26.4.0 + '@types/node': 26.5.1 '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -7031,29 +7028,29 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.3.0 - '@vitest/browser-playwright@4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.11)': + '@vitest/browser-playwright@4.1.11(playwright@1.63.0)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1))(vitest@4.1.11)': dependencies: - '@vitest/browser': 4.1.11(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.11) - '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0)) - playwright: 1.62.1 + '@vitest/browser': 4.1.11(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1))(vitest@4.1.11) + '@vitest/mocker': 4.1.11(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1)) + playwright: 1.63.0 tinyrainbow: 3.1.1 - vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/ui@4.1.11)(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@26.5.1)(@vitest/browser-playwright@4.1.11)(@vitest/ui@4.1.11)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.11(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.11)': + '@vitest/browser@4.1.11(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1))(vitest@4.1.11)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1)) '@vitest/utils': 4.1.11 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.1 - vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/ui@4.1.11)(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@26.5.1)(@vitest/browser-playwright@4.1.11)(@vitest/ui@4.1.11)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1)) ws: 8.21.3 transitivePeerDependencies: - bufferutil @@ -7070,13 +7067,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1) '@vitest/pretty-format@4.1.11': dependencies: @@ -7105,7 +7102,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/ui@4.1.11)(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@26.5.1)(@vitest/browser-playwright@4.1.11)(@vitest/ui@4.1.11)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1)) '@vitest/utils@4.1.11': dependencies: @@ -7113,7 +7110,7 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.1 - '@zumer/snapdom@2.24.10': {} + '@zumer/snapdom@2.24.18': {} accepts@2.0.0: dependencies: @@ -7156,9 +7153,9 @@ snapshots: babel-dead-code-elimination@1.0.12: dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -7168,7 +7165,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.11.7: {} + baseline-browser-mapping@2.11.25: {} basic-auth@2.0.1: dependencies: @@ -7194,13 +7191,13 @@ snapshots: bowser@2.14.1: {} - browserslist@4.28.7: + browserslist@4.29.0: dependencies: - baseline-browser-mapping: 2.11.7 - caniuse-lite: 1.0.30001806 - electron-to-chromium: 1.5.398 - node-releases: 2.0.51 - update-browserslist-db: 1.2.3(browserslist@4.28.7) + baseline-browser-mapping: 2.11.25 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.427 + node-releases: 2.0.56 + update-browserslist-db: 1.3.3(browserslist@4.29.0) buffer-equal-constant-time@1.0.1: {} @@ -7225,7 +7222,7 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - caniuse-lite@1.0.30001806: {} + caniuse-lite@1.0.30001810: {} chai@6.2.2: {} @@ -7240,7 +7237,7 @@ snapshots: chokidar@5.0.0: dependencies: - readdirp: 5.0.0 + readdirp: 5.1.1 classnames@2.5.1: {} @@ -7281,7 +7278,7 @@ snapshots: blueimp-canvas-to-blob: 3.29.0 is-blob: 2.1.0 - confbox@0.2.4: {} + confbox@0.3.1: {} content-disposition@1.1.0: {} @@ -7350,7 +7347,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.398: {} + electron-to-chromium@1.5.427: {} emoji-regex@8.0.0: {} @@ -7364,8 +7361,6 @@ snapshots: es-module-lexer@2.0.0: {} - es-module-lexer@2.3.1: {} - es-module-lexer@2.3.2: {} es-object-atoms@1.1.1: @@ -7462,9 +7457,10 @@ snapshots: flatted@3.4.4: {} - formatly@0.3.0: + formatly@0.7.0: dependencies: fd-package-json: 2.0.0 + package-manager-detector: 1.8.0 forwarded@0.2.0: {} @@ -7478,9 +7474,6 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 - fsevents@2.3.2: - optional: true - fsevents@2.3.3: optional: true @@ -7510,7 +7503,7 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 - get-tsconfig@4.14.1: + get-tsconfig@4.14.3: dependencies: resolve-pkg-maps: 1.0.0 @@ -7560,7 +7553,7 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 - i18next-http-backend@4.0.1: {} + i18next-http-backend@4.0.2: {} i18next-locales-sync@2.1.1: dependencies: @@ -7606,7 +7599,7 @@ snapshots: is-promise@4.0.0: {} - isbot@5.2.1: {} + isbot@5.2.2: {} isexe@2.0.0: {} @@ -7642,21 +7635,21 @@ snapshots: kind-of@6.0.3: {} - knip@6.32.3: + knip@6.35.1: dependencies: fdir: 6.5.0(picomatch@4.0.7) - formatly: 0.3.0 - get-tsconfig: 4.14.1 + formatly: 0.7.0 + get-tsconfig: 4.14.3 jiti: 2.7.0 - oxc-parser: 0.143.0 + oxc-parser: 0.148.0 oxc-resolver: 11.24.2 picomatch: 4.0.7 smol-toml: 1.8.0 strip-json-comments: 5.0.3 tinyglobby: 0.2.17 unbash: 4.0.11 - yaml: 2.9.0 - zod: 4.5.4 + yaml: 2.9.1 + zod: 4.6.5 kysely@0.29.0(patch_hash=a3e94339939b1be5b70610601e96fff19ed5678aab525de24b52dfc5212c686a): {} @@ -7727,7 +7720,7 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@1.34.0(react@19.3.0): + lucide-react@1.44.0(react@19.3.0): dependencies: react: 19.3.0 @@ -7737,7 +7730,7 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 - magic-string@1.2.3: + magic-string@1.3.1: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -7760,7 +7753,7 @@ snapshots: media-typer@1.1.0: {} - mediabunny@1.55.3: + mediabunny@1.56.1: dependencies: '@types/dom-mediacapture-transform': 0.1.12 '@types/dom-webcodecs': 0.1.13 @@ -7797,6 +7790,8 @@ snapshots: nanoid@3.3.18: {} + nanoid@3.3.19: {} + nanoid@6.0.1: {} nearley@2.20.1: @@ -7812,7 +7807,7 @@ snapshots: node-cron@4.6.0: {} - node-releases@2.0.51: {} + node-releases@2.0.56: {} nprogress@0.2.0: {} @@ -7848,29 +7843,29 @@ snapshots: orderedmap@2.1.1: {} - oxc-parser@0.143.0: + oxc-parser@0.148.0: dependencies: - '@oxc-project/types': 0.143.0 + '@oxc-project/types': 0.148.0 optionalDependencies: - '@oxc-parser/binding-android-arm-eabi': 0.143.0 - '@oxc-parser/binding-android-arm64': 0.143.0 - '@oxc-parser/binding-darwin-arm64': 0.143.0 - '@oxc-parser/binding-darwin-x64': 0.143.0 - '@oxc-parser/binding-freebsd-x64': 0.143.0 - '@oxc-parser/binding-linux-arm-gnueabihf': 0.143.0 - '@oxc-parser/binding-linux-arm-musleabihf': 0.143.0 - '@oxc-parser/binding-linux-arm64-gnu': 0.143.0 - '@oxc-parser/binding-linux-arm64-musl': 0.143.0 - '@oxc-parser/binding-linux-ppc64-gnu': 0.143.0 - '@oxc-parser/binding-linux-riscv64-gnu': 0.143.0 - '@oxc-parser/binding-linux-riscv64-musl': 0.143.0 - '@oxc-parser/binding-linux-s390x-gnu': 0.143.0 - '@oxc-parser/binding-linux-x64-gnu': 0.143.0 - '@oxc-parser/binding-linux-x64-musl': 0.143.0 - '@oxc-parser/binding-openharmony-arm64': 0.143.0 - '@oxc-parser/binding-win32-arm64-msvc': 0.143.0 - '@oxc-parser/binding-win32-ia32-msvc': 0.143.0 - '@oxc-parser/binding-win32-x64-msvc': 0.143.0 + '@oxc-parser/binding-android-arm-eabi': 0.148.0 + '@oxc-parser/binding-android-arm64': 0.148.0 + '@oxc-parser/binding-darwin-arm64': 0.148.0 + '@oxc-parser/binding-darwin-x64': 0.148.0 + '@oxc-parser/binding-freebsd-x64': 0.148.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.148.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.148.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.148.0 + '@oxc-parser/binding-linux-arm64-musl': 0.148.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.148.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.148.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.148.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.148.0 + '@oxc-parser/binding-linux-x64-gnu': 0.148.0 + '@oxc-parser/binding-linux-x64-musl': 0.148.0 + '@oxc-parser/binding-openharmony-arm64': 0.148.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.148.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.148.0 + '@oxc-parser/binding-win32-x64-msvc': 0.148.0 oxc-resolver@11.24.2: optionalDependencies: @@ -7894,11 +7889,13 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 - p-limit@7.3.1: + p-limit@7.3.2: dependencies: yocto-queue: 1.2.2 - p-map@7.0.6: {} + p-map@7.0.8: {} + + package-manager-detector@1.8.0: {} parseurl@1.3.3: {} @@ -7921,30 +7918,26 @@ snapshots: picomatch@4.0.7: {} - pkg-types@2.3.1: + pkg-types@2.3.3: dependencies: - confbox: 0.2.4 + confbox: 0.3.1 exsolve: 1.1.1 pathe: 2.0.3 - playwright-core@1.62.1: {} + playwright-core@1.63.0: {} - playwright@1.62.1: + playwright@1.63.0: dependencies: - playwright-core: 1.62.1 - optionalDependencies: - fsevents: 2.3.2 + playwright-core: 1.63.0 pngjs@7.0.0: {} - postcss@8.5.26: + postcss@8.5.28: dependencies: - nanoid: 3.3.18 + nanoid: 3.3.19 picocolors: 1.1.1 source-map-js: 1.2.1 - prettier@3.9.6: {} - property-expr@2.0.6: {} prosemirror-changeset@2.4.0: @@ -8098,7 +8091,7 @@ snapshots: react: 19.3.0 scheduler: 0.28.0 - react-error-boundary@6.1.3(@types/react@19.3.0)(react@19.3.0): + react-error-boundary@6.1.5(@types/react@19.3.0)(react@19.3.0): dependencies: react: 19.3.0 optionalDependencies: @@ -8136,7 +8129,7 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 - react-router@8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: cookie-es: 3.1.1 react: 19.3.0 @@ -8159,9 +8152,9 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 - readdirp@5.0.0: {} + readdirp@5.1.1: {} - remeda@2.45.0: {} + remeda@2.48.0: {} remix-auth-oauth2@3.4.1(remix-auth@4.2.0): dependencies: @@ -8172,10 +8165,10 @@ snapshots: remix-auth@4.2.0: {} - remix-i18next@8.0.0(i18next@26.3.6(patch_hash=34c245cebb3f9c7861d2576a3a44228364cb898037f86b23a5bbf8b20f55fb1a)(typescript@7.0.2))(react-router@8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)): + remix-i18next@8.0.0(i18next@26.3.6(patch_hash=34c245cebb3f9c7861d2576a3a44228364cb898037f86b23a5bbf8b20f55fb1a)(typescript@7.0.2))(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0)): dependencies: i18next: 26.3.6(patch_hash=34c245cebb3f9c7861d2576a3a44228364cb898037f86b23a5bbf8b20f55fb1a)(typescript@7.0.2) - react-router: 8.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react-router: 8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) require-directory@2.1.1: {} @@ -8190,26 +8183,26 @@ snapshots: ret@0.1.15: {} - rolldown@1.2.6: + rolldown@1.2.9: dependencies: - '@oxc-project/types': 0.147.0 + '@oxc-project/types': 0.150.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm-eabi': 1.2.6 - '@rolldown/binding-android-arm64': 1.2.6 - '@rolldown/binding-darwin-arm64': 1.2.6 - '@rolldown/binding-darwin-x64': 1.2.6 - '@rolldown/binding-freebsd-x64': 1.2.6 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.6 - '@rolldown/binding-linux-arm64-gnu': 1.2.6 - '@rolldown/binding-linux-arm64-musl': 1.2.6 - '@rolldown/binding-linux-ppc64-gnu': 1.2.6 - '@rolldown/binding-linux-s390x-gnu': 1.2.6 - '@rolldown/binding-linux-x64-gnu': 1.2.6 - '@rolldown/binding-linux-x64-musl': 1.2.6 - '@rolldown/binding-openharmony-arm64': 1.2.6 - '@rolldown/binding-win32-arm64-msvc': 1.2.6 - '@rolldown/binding-win32-x64-msvc': 1.2.6 + '@rolldown/binding-android-arm-eabi': 1.2.9 + '@rolldown/binding-android-arm64': 1.2.9 + '@rolldown/binding-darwin-arm64': 1.2.9 + '@rolldown/binding-darwin-x64': 1.2.9 + '@rolldown/binding-freebsd-x64': 1.2.9 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.9 + '@rolldown/binding-linux-arm64-gnu': 1.2.9 + '@rolldown/binding-linux-arm64-musl': 1.2.9 + '@rolldown/binding-linux-ppc64-gnu': 1.2.9 + '@rolldown/binding-linux-s390x-gnu': 1.2.9 + '@rolldown/binding-linux-x64-gnu': 1.2.9 + '@rolldown/binding-linux-x64-musl': 1.2.9 + '@rolldown/binding-openharmony-arm64': 1.2.9 + '@rolldown/binding-win32-arm64-msvc': 1.2.9 + '@rolldown/binding-win32-x64-msvc': 1.2.9 rope-sequence@1.3.4: {} @@ -8269,7 +8262,7 @@ snapshots: setprototypeof@1.2.0: {} - sharp@0.35.4(@types/node@26.4.0): + sharp@0.35.4(@types/node@26.5.1): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 @@ -8300,7 +8293,7 @@ snapshots: '@img/sharp-win32-arm64': 0.35.4 '@img/sharp-win32-ia32': 0.35.4 '@img/sharp-win32-x64': 0.35.4 - '@types/node': 26.4.0 + '@types/node': 26.5.1 shebang-command@2.0.0: dependencies: @@ -8517,15 +8510,15 @@ snapshots: unbash@4.0.11: {} - undici-types@8.3.0: {} + undici-types@8.9.0: {} universalify@2.0.1: {} unpipe@1.0.0: {} - update-browserslist-db@1.2.3(browserslist@4.28.7): + update-browserslist-db@1.3.3(browserslist@4.29.0): dependencies: - browserslist: 4.28.7 + browserslist: 4.29.0 escalade: 3.2.0 picocolors: 1.1.1 @@ -8550,19 +8543,19 @@ snapshots: util-deprecate@1.0.2: {} - valibot@1.4.2(typescript@7.0.2): + valibot@1.5.0(typescript@7.0.2): optionalDependencies: typescript: 7.0.2 vary@1.1.2: {} - vite-node@6.0.0(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0): + vite-node@6.0.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1): dependencies: cac: 7.0.0 es-module-lexer: 2.0.0 obug: 2.1.1 pathe: 2.0.3 - vite: 8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -8577,37 +8570,37 @@ snapshots: - tsx - yaml - vite-plugin-babel@1.7.3(@babel/core@7.29.7)(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0)): + vite-plugin-babel@1.7.3(@babel/core@7.29.7)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1)): dependencies: '@babel/core': 7.29.7 - vite: 8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1) - vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0): + vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 - postcss: 8.5.26 - rolldown: 1.2.6 + postcss: 8.5.28 + rolldown: 1.2.9 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.4.0 + '@types/node': 26.5.1 fsevents: 2.3.3 jiti: 2.7.0 - yaml: 2.9.0 + yaml: 2.9.1 - vitest-browser-react@2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@4.1.11): + vitest-browser-react@2.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@4.1.11): dependencies: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/ui@4.1.11)(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@26.5.1)(@vitest/browser-playwright@4.1.11)(@vitest/ui@4.1.11)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1)) optionalDependencies: '@types/react': 19.3.0 '@types/react-dom': 19.3.0(@types/react@19.3.0) - vitest@4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/ui@4.1.11)(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0)): + vitest@4.1.11(@types/node@26.5.1)(@vitest/browser-playwright@4.1.11)(@vitest/ui@4.1.11)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -8624,11 +8617,11 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.4.0 - '@vitest/browser-playwright': 4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.11) + '@types/node': 26.5.1 + '@vitest/browser-playwright': 4.1.11(playwright@1.63.0)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1))(vitest@4.1.11) '@vitest/ui': 4.1.11(vitest@4.1.11) transitivePeerDependencies: - msw @@ -8675,7 +8668,7 @@ snapshots: yallist@3.1.1: {} - yaml@2.9.0: {} + yaml@2.9.1: {} yargs-parser@21.1.1: {} @@ -8698,4 +8691,4 @@ snapshots: toposort: 2.0.2 type-fest: 2.19.0 - zod@4.5.4: {} + zod@4.6.5: {} From 4f1599a635411e64df7c5acad2cce98251e5b319 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:42:40 +0300 Subject: [PATCH 02/50] Fix E2E test --- e2e/helpers/playwright.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/e2e/helpers/playwright.ts b/e2e/helpers/playwright.ts index edd0da6dd..09afdc184 100644 --- a/e2e/helpers/playwright.ts +++ b/e2e/helpers/playwright.ts @@ -208,8 +208,9 @@ export async function selectUser({ await comboboxButton.click(); await searchInput.fill(userName); - await expect(option).toBeVisible(); - await page.keyboard.press("Enter"); + // clicking rather than pressing Enter: a result is in the DOM one commit + // before the effect that registers it and focuses it for Enter has run + await option.click(); } export async function selectTournament({ From 7a35b176b16e9a8cb6f811fcce4832fec5e037bc Mon Sep 17 00:00:00 2001 From: xi <104683822+im-xi@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:43:54 -0400 Subject: [PATCH 03/50] Update welcome page content (#3402) --- locales/en/welcome.json | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/locales/en/welcome.json b/locales/en/welcome.json index d128b0c9a..373a6c675 100644 --- a/locales/en/welcome.json +++ b/locales/en/welcome.json @@ -1,27 +1,27 @@ { "title": "Introduction to competitive Splatoon and sendou.ink", "photoCredit": "Photo by Dio", - "whatDoINeed.header": "What do I need?", - "whatDoINeed.body": "You don't need to be any specific skill level to join the community. There are people of all skill levels in the community. Typically you can expect to play mostly against people in the X Rank even in the lower level tournaments but even that is not a requirement. It can be more fun to improve in a group.\n\nDiscord account in practice will be needed as a lot of the communication within teams and tournaments happens there. You can also use a Discord account to log in to sendou.ink.\n\nFor voice chat players typically use Discord. In terms of setting it up you can search online for more information but some people might use an audio splitter that allows you to hear both in-game audio and voice chat with the same headphones. Simpler setup might be to use headphones from your phone for example and use speakers for in-game audio.\n\nYou should also invest in an ethernet cable. Stable internet makes the game more enjoyable for yourself and people who play against you.", + "whatDoINeed.header": "What do I need to play competitive Splatoon?", + "whatDoINeed.body": "You don't need to be any specific skill level to join the community, as people of all skill levels can find their own success. Typically, you can expect to mostly play against people in S+/X Rank (even in lower level tournaments that are designed for those new to competitive), but being S+/X Rank is not a requirement. A lot of people have more fun improving with a group, so give it a shot!\n\nIn practice, players will need a Discord account, as a lot of the communication within teams (through both text and voice chats) and tournaments happens there. You can also use a Discord account to log in to sendou.ink.\n\nIn terms of setting up the combination of voice chat and in-game audio, you can search online for more information, but some people might use an audio splitter or a capture card setup that allows you to hear both with the same headphones. A simpler (and cheaper) setup might be to use headphones to hear and speak to the voice chat through your phone and use a set of speakers to listen to in-game audio.\n\nYou should also invest in an ethernet cable, as a more stable internet connections make the game more enjoyable for everyone by reducing the amount of disconnects and other issues that may be caused by a wireless connection!", "motionControls.header": "Should I use motion controls?", - "motionControls.body": "In short if you are able to, yes. This is somewhat unique to Splatoon but all high and top level players practically without exception use motion controls. Stick controls differ from most other console shooters in the way that there is no aim assist. In practice using them means you are at a disadvantage.", - "weapons.header": "How to select what weapons to play?", - "weapons.body": "If you are just starting out worrying too much about what weapon is popular right now in the meta-game is not necessary. This is something that changes rapidly from patch to patch and typically only starts to matter at the very high level of play. Instead choose a weapon to get good at that you enjoy playing. Then consider complementing it with 1-2 other alternative weapons that are different from it (in kit or range for example). This makes it easier for you to fit the team composition no matter what your teammates are playing.", - "builds.header": "How to make a good build?", - "builds.body": "Min-maxing builds can be fun but it's important to realize that how well you do in matches is overwhelmingly decided by your skill rather than the exact build you are using. Only rule you really need to remember is diminishing returns meaning the first sub of any given ability is going to be the most effective and the more you are stacking, the less of an effect you will be seeing. That's why typically mixing at least a handful of different abilities is likely going to be the strongest build. That said instead of reinventing the wheel you can just check what experienced players are using via the >>builds<< listing.\n\nIf you are someone who likes to optimize everything down to the last sub then make sure to check out >>analyzer<< and >>object-damage-calculator<< to learn exactly what each ability does.", + "motionControls.body": "In short, if you are able to, yes. This is somewhat unique to Splatoon (compared to most console shooters), but almost all high and top level players use motion controls. Stick controls differ from most other console shooters in the fact that there is no aim assist in Splatoon, which causes stick controls to be disadvantageous compared to motion controls.", + "weapons.header": "How do I select what weapons to play?", + "weapons.body": "If you are just starting out, do not feel like you have to play a weapon in the metagame. The meta is something that changes over time and typically only starts to matter at very high levels of play. Instead, choose a weapon that you enjoy playing, and improve your skill using it. Then, consider complementing it with 1-2 other main weapons (most often, players will gravitate towards weapons that have similar ranges and/or playstyles when selecting their weapon pool). Having flexibility in your weapon pool will make it easier for you to fit your team's composition and take advantage of certain maps, enemy compositions, etc.", + "builds.header": "How do I make a good build?", + "builds.body": "Optimizing builds and making the most of them can be fun, but it's important to realize that how well you do in matches is overwhelmingly decided by your skill, and not the exact build you are using. If you feel stuck or want to know what experienced players are using, use the >>builds<< database.\n\nIf you are someone who likes to optimize everything down to the last sub, then make sure to check out the build >>analyzer<< and the >>object-damage-calculator<< to learn exactly what each ability does.", "mapsModes.header": "What maps/modes are played competitively?", - "mapsModes.body": "This will completely depend on the tournament. Before signing up for a tournament on sendou.ink you will be able to see the maps and modes the tournament is running. For higher level of play and some open tournaments Splat Zones only is popular. Turf War is less popular in competitive than other modes but there are also tournaments that include it. Notably Koshien in Japan which is one of the most prestigious tournaments is only Turf War.", + "mapsModes.body": "This will completely depend on the tournament. Before signing up for a tournament on sendou.ink, you will be able to see the maps and modes the tournament is running. For some open tournaments and those geared towards higher levels of play, Splat Zones only is the most popular format. Turf War as a mode is almost nonexistent in competitive (outside of Japan), but there are a few tournaments that include it (notably Koshien, which is one of the most prestigious tournaments in Japan).", "findingTeam.header": "How can I find a team?", "findingTeam.body": "Browse the posts on >>lfg<< or leave your own!", "noTeam.header": "I don't have a team yet, can I still play?", - "noTeam.body": "Team or no team solo practice is crucial for improving. Even the best of the best players have put in a significant amount of hours practicing solo ranked (X Rank). You also sometimes see people use peak X Rank power (XP) as a skill indicator. As a newer player one of your main goals should be reaching X Rank and then improving your rank. One of the pitfalls is despairing over what your teammates are doing in any given match. Do not fall for this, as this is not something you can affect and the best players can turn even the most unwinnable matches around. For divisions Tentatek can be good when starting but you will find most of the competitive players choosing the Takoroka division which is something to keep in mind.\n\nSendouQ is an extension of the in-game solo. It allows you to queue alone or with 1-3 of your friends. There is a built-in solution to form a team on the spot. When signing up solo be prepared for it to take some time. Typically you would want to for example play solo in the background and check back to how the team forming is going between games. Remember to also set your full match profile on the >>settings<< page.\n\nYou don't need a team to start playing in tournaments. Check out >>calendar<< and utilize filters to find out tournaments that are either 1vs1 format or are \"draft\" format. 1vs1 is not the typical competitive format (that would be 4vs4) but it's a good way to get your feet wet. Draft tournaments have the organizer form teams from the solo sign-ups. Even if the tournament does not have solo sign-ups as such you can use the LFG feature to indicate you are looking for a team for the event. The actual process works similarly to SendouQ team forming.\n\nGood way to improve is also to watch and study good players that play the same weapons as you! The >>vods<< feature is one way to discover them.", + "noTeam.body": "While having four players is required for most tournaments (the exception being those dedicated to 1v1, 2v2, and 3v3), there are multiple ways to get involved.\n\nSendouQ is an extension of the in-game solo queue. It allows you to queue alone, or with 1-3 of your friends. There is a built-in solution to form a team on the spot. When signing up solo, be prepared for it to take some time. Typically, a lot of players will solo in the background and check back to see how the team forming is going between games. Remember to also set your full match profile on the >>settings<< page.\n\nFor tournaments, check out the >>calendar<< and utilize filters to find tournaments. You can use the LFG tab on tournament pages you are interested in to indicate you are looking for a team for the event. That process works similarly to SendouQ team forming.", "haveTeam.header": "I have a team, what should we do?", - "haveTeam.body": "Never too early to participate in some tournaments! On the >>calendar<< you can filter by the \"Skill cap\" tag to find tournaments specifically catered to newer teams. That said don't be shy to try some open level tournaments as well. It's a good way to avoid building bad habits around strategies that won't work against higher level teams.\n\nOutside of tournaments >>sendouq<< is in fact best experienced with a pre-made team. It can give you that tournament like pressure of a \"best of\" set while you are practicing a specific map list, maybe for some of your upcoming tournaments.\n\n>>scrims<< are another great option. While map list tracking is supported here it's not really about \"win or lose\". Instead you are trying to learn in an environment where you are able to replay maps and play an extended period of time against the same opponent. Scrims can be scheduled which gives you a better chance to find a suitable opponent than just relying on finding one on the spot.", - "ranks.header": "What are ranks on sendou.ink? (Leviathan/Diamond/Platinum…)", - "ranks.body": "Play SendouQ and ranked tournaments to participate in sendou.ink competitive seasons. Once you have played at least seven sets, your rank will be calculated and shown. Then you can try to get it higher before season ends! Ranks are always strictly percentile based so for example \"Leviathan\" is always the top 5% of the players who participated in the season. View all ranks on the >>tiers<< page.", + "haveTeam.body": "Don't be afraid to participate in some tournaments early on! On the >>calendar<< you can filter by the \"Skill cap\" tag to find tournaments specifically catered to newer teams. That being said, try to incorporate some open level tournaments into your schedule as well. It's a good way to improve, as you will avoid building bad habits by being punished for them much more often by higher level teams.\n\nOutside of tournaments >>sendouq<< is, in fact, best experienced with a pre-made team. It can give you that tournament-like pressure of a \"best of\" set while you are practicing.\n\n>>scrims<< are another great option. While map list tracking is supported here, it's not really about \"win or lose\". Instead, you are trying to learn in an environment where you are able to replay maps and play an extended period of time against the same opponent. Scrims can be scheduled, which gives you a better chance to find a suitable opponent compared to just relying on finding one on the spot.", + "ranks.header": "What are ranks on sendou.ink? (Leviathan, Diamond, Platinum, etc.)", + "ranks.body": "Play SendouQ and ranked tournaments to participate in sendou.ink competitive seasons. Once you have played at least seven sets, your rank will be calculated and shown. Then you can try to climb the SendouQ ladder before the season ends! Ranks are always percentile based, so for example, the \"Leviathan\" tier is always the top 5% of the players who have participated in the season. View all ranks on the >>tiers<< page.", "divs.header": "What are \"divs\"?", - "divs.body": "Divs or divisions are related to >>luti<< (Leagues Under The Ink) which is the biggest Splatoon league. It has historically been run about once or twice a year. For historical reasons, people sometimes use them as proxy to player skill. So if someone is saying they are \"Div 2\" that would mean their skill level is higher than \"Div 4\". The actual way to get a Div is to participate in LUTI (which you should participate in because it's a very special event, not to get a div) but people also freely estimate them.", + "divs.body": "Divs or divisions are related to >>luti<< (Leagues Under The Ink), which is the biggest Splatoon league. It has historically been run once or twice a year. For historical reasons, people sometimes use them as a proxy for player skill. So if someone is saying they are \"Div 2\", that would mean they believe their skill level is higher than someone who says they are \"Div 4\". The actual way to get a division is to participate in LUTI with a team, but people also freely estimate them. Generally, newer players should aim to play in events between Divs 7-10, but players with a very high X Power can try playing at higher Divs to start.", "plusServer.header": "What is the \"Plus Server\" (+1/+2/+3)?", - "plusServer.body": "Originally a high and top level player LFG server. There is a voting happening once a season where every existing member votes on everyone in that tier as well as all the suggestions that time around. You can also get membership by achieving a high enough rank in a season. See the exact details on the >>faq<< page.", + "plusServer.body": "The Plus Server was originally a high and top level player LFG server. There is a voting stage once a season where every existing member votes on everyone in that tier, as well as all the suggestions that time around. You can also get membership by achieving a high enough rank in a season. See the exact details on the >>faq<< page.", "pills.tiers": "Tiers" } From 171661becb47aeff9f77e613c5c3d6a50e37e47e Mon Sep 17 00:00:00 2001 From: hfcRed <101019309+hfcRed@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:45:16 -0400 Subject: [PATCH 04/50] SQ Match confirmation changes (#3401) --- app/components/match-page/MatchActionTab.tsx | 10 +- app/features/chat/chat-types.ts | 2 + app/features/chat/components/Chat.tsx | 3 + .../SQMatchRepository.server.test.ts | 82 ++++++++- .../sendouq-match/SQMatchRepository.server.ts | 13 +- .../actions/q.match.$id.server.ts | 36 ++++ .../components/SendouQMatchActionTab.tsx | 160 +++++++++++++++--- app/features/sendouq-match/q-match-schemas.ts | 6 + app/hooks/useCooldown.ts | 74 ++++++++ ...-09-19-sendouq-score-confirm-safeguards.md | 10 ++ e2e/pages/sendouq/sendouq-match-page.ts | 15 +- locales/da/common.json | 1 + locales/da/q.json | 9 +- locales/de/common.json | 1 + locales/de/q.json | 9 +- locales/en/common.json | 1 + locales/en/q.json | 9 +- locales/es-ES/common.json | 1 + locales/es-ES/q.json | 9 +- locales/es-US/common.json | 1 + locales/es-US/q.json | 9 +- locales/fr-CA/common.json | 1 + locales/fr-CA/q.json | 9 +- locales/fr-EU/common.json | 1 + locales/fr-EU/q.json | 9 +- locales/he/common.json | 1 + locales/he/q.json | 9 +- locales/it/common.json | 1 + locales/it/q.json | 9 +- locales/ja/common.json | 1 + locales/ja/q.json | 9 +- locales/ko/common.json | 1 + locales/ko/q.json | 9 +- locales/nl/common.json | 1 + locales/nl/q.json | 9 +- locales/pl/common.json | 1 + locales/pl/q.json | 9 +- locales/pt-BR/common.json | 1 + locales/pt-BR/q.json | 9 +- locales/ru/common.json | 1 + locales/ru/q.json | 9 +- locales/zh/common.json | 1 + locales/zh/q.json | 9 +- 43 files changed, 530 insertions(+), 41 deletions(-) create mode 100644 app/hooks/useCooldown.ts create mode 100644 changelog/2026-09-19-sendouq-score-confirm-safeguards.md diff --git a/app/components/match-page/MatchActionTab.tsx b/app/components/match-page/MatchActionTab.tsx index a060ca14f..725e0c384 100644 --- a/app/components/match-page/MatchActionTab.tsx +++ b/app/components/match-page/MatchActionTab.tsx @@ -4,6 +4,7 @@ import type * as React from "react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useWebHaptics } from "web-haptics/react"; +import { useCooldown } from "~/hooks/useCooldown"; import { shortStageName } from "~/modules/in-game-lists/stage-ids"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import type { CommonUser } from "~/utils/kysely.server"; @@ -43,6 +44,7 @@ interface MatchActionTabProps { onSubmit?: (data: { winnerId: number; ko?: boolean }) => void; isSubmitting?: boolean; setEnding?: SetEndingData; + submitCooldownUntil?: number | null; actionButtons?: React.ReactNode; secondaryAction?: React.ReactNode; } @@ -56,6 +58,7 @@ export function MatchActionTab({ onSubmit, isSubmitting, setEnding, + submitCooldownUntil = null, actionButtons, secondaryAction, }: MatchActionTabProps) { @@ -64,8 +67,9 @@ export function MatchActionTab({ const [isKo, setIsKo] = useState(false); const [confirming, setConfirming] = useState(false); const { trigger } = useWebHaptics(); + const cooldownSecondsLeft = useCooldown(submitCooldownUntil); - const canSubmit = winnerId !== null; + const canSubmit = winnerId !== null && cooldownSecondsLeft === 0; const isOnTeam = ownTeamId != null && @@ -177,7 +181,9 @@ export function MatchActionTab({ className={styles.submit} testId="report-score-button" > - {t("common:actions.submit")} + {cooldownSecondsLeft > 0 + ? `${t("common:actions.submit")} (${cooldownSecondsLeft})` + : t("common:actions.submit")} )} diff --git a/app/features/chat/chat-types.ts b/app/features/chat/chat-types.ts index 42a7eed8d..5c172a1fe 100644 --- a/app/features/chat/chat-types.ts +++ b/app/features/chat/chat-types.ts @@ -16,6 +16,7 @@ export type SystemMessageType = | "LIKE_RECEIVED" | "SCORE_REPORTED" | "SCORE_CONFIRMED" + | "SCORE_DISPUTED" | "CANCEL_REPORTED" | "CANCEL_CONFIRMED" | "CANCEL_REFUSED" @@ -29,6 +30,7 @@ export type PersistedSystemMessageType = Extract< SystemMessageType, | "SCORE_REPORTED" | "SCORE_CONFIRMED" + | "SCORE_DISPUTED" | "CANCEL_REPORTED" | "CANCEL_CONFIRMED" | "CANCEL_REFUSED" diff --git a/app/features/chat/components/Chat.tsx b/app/features/chat/components/Chat.tsx index 4b5610675..3a9624580 100644 --- a/app/features/chat/components/Chat.tsx +++ b/app/features/chat/components/Chat.tsx @@ -68,6 +68,9 @@ export function Chat({ case "SCORE_CONFIRMED": { return t("common:chat.systemMsg.scoreConfirmed", { name }); } + case "SCORE_DISPUTED": { + return t("common:chat.systemMsg.scoreDisputed", { name }); + } case "CANCEL_REPORTED": { return t("common:chat.systemMsg.cancelReported", { name }); } diff --git a/app/features/sendouq-match/SQMatchRepository.server.test.ts b/app/features/sendouq-match/SQMatchRepository.server.test.ts index 9d045cc75..94011fbfe 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.test.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.test.ts @@ -1,5 +1,5 @@ import { add, sub } from "date-fns"; -import { beforeEach, describe, expect, test } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import * as SplatoonFaker from "~/db/seed/core/SplatoonFaker"; import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory"; import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; @@ -680,6 +680,86 @@ describe("finalizeMatch", () => { }); }); +describe("reportMapWinner confirmation", () => { + test("rejects as stale a confirmation of a set-ending report undone and re-reported since", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + const setup = await setupMatch(); + + const reportSweep = async () => { + let reportedCount = 0; + let result = await SQMatchRepository.reportMapWinner({ + matchId: setup.match.id, + winnerId: setup.alphaGroupId, + reportedByUserId: setup.alphaMembers[0].id, + reportedCount, + }); + while (result.status === "MAP_REPORTED") { + reportedCount++; + result = await SQMatchRepository.reportMapWinner({ + matchId: setup.match.id, + winnerId: setup.alphaGroupId, + reportedByUserId: setup.alphaMembers[0].id, + reportedCount, + }); + } + expect(result.status).toBe("MATCH_REPORTED"); + + return reportedCount + 1; + }; + + const decidingMapReportedAt = async () => { + const maps = await fetchMapResults(setup.match.id); + const decidingMap = maps.findLast((m) => m.winnerGroupId !== null); + invariant(decidingMap?.reportedAt, "No deciding map reported"); + + return decidingMap.reportedAt; + }; + + const reportedCount = await reportSweep(); + const originalReportedAt = await decidingMapReportedAt(); + + vi.setSystemTime(add(new Date(), { seconds: 5 })); + await SQMatchRepository.undoMatchReport({ + matchId: setup.match.id, + requestedByUserId: setup.alphaMembers[0].id, + isStaff: false, + }); + await SQMatchRepository.reportMapWinner({ + matchId: setup.match.id, + winnerId: setup.alphaGroupId, + reportedByUserId: setup.alphaMembers[0].id, + reportedCount: reportedCount - 1, + }); + const replacementReportedAt = await decidingMapReportedAt(); + expect(replacementReportedAt).not.toBe(originalReportedAt); + + const staleConfirmation = await SQMatchRepository.reportMapWinner({ + matchId: setup.match.id, + winnerId: setup.alphaGroupId, + reportedByUserId: setup.bravoMembers[0].id, + reportedCount, + confirmingReportedAt: originalReportedAt, + }); + expect(staleConfirmation.status).toBe("STALE"); + expect( + (await SQMatchRepository.findById(setup.match.id))?.isLocked, + ).toBeFalsy(); + + const confirmation = await SQMatchRepository.reportMapWinner({ + matchId: setup.match.id, + winnerId: setup.alphaGroupId, + reportedByUserId: setup.bravoMembers[0].id, + reportedCount, + confirmingReportedAt: replacementReportedAt, + }); + expect(confirmation.status).toBe("MATCH_FINALIZED"); + } finally { + vi.useRealTimers(); + } + }); +}); + describe("undoMatchReport", () => { // intended: the group stays INACTIVE after an undo so it can't re-enter the // queue while the disagreement is unresolved; the teams keep playing and diff --git a/app/features/sendouq-match/SQMatchRepository.server.ts b/app/features/sendouq-match/SQMatchRepository.server.ts index 487242339..a6db496c8 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.ts @@ -1314,12 +1314,14 @@ export async function reportMapWinner({ winnerId, reportedByUserId, reportedCount, + confirmingReportedAt, isStaffReport, }: { matchId: number; winnerId: number; reportedByUserId: number; reportedCount: number; + confirmingReportedAt?: number; isStaffReport?: boolean; }): Promise { const match = await findById(matchId); @@ -1340,7 +1342,6 @@ export async function reportMapWinner({ isDecisive: scoreAlreadyDecisive, } = SendouQMatch.score(match); - // Confirmation flow: score is already decisive (first team reported the set-ending map) if (scoreAlreadyDecisive) { return handleMatchConfirmation({ match, @@ -1348,6 +1349,7 @@ export async function reportMapWinner({ reportedByUserId, existingAlphaWins, mapsToWin, + confirmingReportedAt, isStaffReport, }); } @@ -1421,6 +1423,7 @@ async function handleMatchConfirmation({ reportedByUserId, existingAlphaWins, mapsToWin, + confirmingReportedAt, isStaffReport, }: { match: NonNullable>>; @@ -1428,6 +1431,7 @@ async function handleMatchConfirmation({ reportedByUserId: number; existingAlphaWins: number; mapsToWin: number; + confirmingReportedAt?: number; isStaffReport?: boolean; }): Promise { const members = buildMembers(match); @@ -1438,6 +1442,13 @@ async function handleMatchConfirmation({ .find((m) => m.winnerGroupId !== null); invariant(decidingMap, "No deciding map found"); + if ( + typeof confirmingReportedAt === "number" && + confirmingReportedAt !== decidingMap.reportedAt + ) { + return { status: "STALE" }; + } + const originalReporterGroupId = decidingMap.reportedByUserId ? members.find((m) => m.id === decidingMap.reportedByUserId)?.groupId : undefined; diff --git a/app/features/sendouq-match/actions/q.match.$id.server.ts b/app/features/sendouq-match/actions/q.match.$id.server.ts index 78fe46dc5..289c1d6d2 100644 --- a/app/features/sendouq-match/actions/q.match.$id.server.ts +++ b/app/features/sendouq-match/actions/q.match.$id.server.ts @@ -71,6 +71,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { winnerId: data.winnerId, reportedByUserId: user.id, reportedCount: data.reportedCount, + confirmingReportedAt: data.confirmingReportedAt, isStaffReport, }); @@ -118,6 +119,41 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { break; } + case "DISPUTE_SCORE": { + errorToastIfFalsy(!match.isLocked, "Match is already locked"); + errorToastIfFalsy( + SendouQMatch.score(match).isDecisive, + "No reported score to dispute", + ); + + const decidingMap = match.mapList + .toReversed() + .find((m) => m.winnerGroupId !== null); + const reporterSide = SendouQMatch.resolveGroupMemberOf({ + groupAlpha: match.groupAlpha, + groupBravo: match.groupBravo, + userId: decidingMap?.reportedByUserId, + }); + const disputerSide = SendouQMatch.resolveGroupMemberOf({ + groupAlpha: match.groupAlpha, + groupBravo: match.groupBravo, + userId: user.id, + }); + errorToastIfFalsy( + disputerSide !== null && disputerSide !== reporterSide, + "Only the team asked to confirm can dispute the score", + ); + + if (match.chatRoomId) { + ChatSystemMessage.sendPersisted({ + roomId: match.chatRoomId, + type: "SCORE_DISPUTED", + authorUserId: user.id, + }); + } + + break; + } case "LOOK_AGAIN": { const season = Seasons.current(); errorToastIfFalsy(season, "Season is not active"); diff --git a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx index 85b9ff00f..c75d3be28 100644 --- a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx +++ b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx @@ -1,5 +1,6 @@ import type { TFunction } from "i18next"; import { Ban, Check, Undo2, X } from "lucide-react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useFetcher } from "react-router"; import { SendouButton } from "~/components/elements/Button"; @@ -16,6 +17,7 @@ import { FormField } from "~/form/FormField"; import { SendouForm } from "~/form/SendouForm"; import type { FormObjectSchema } from "~/form/types"; import { useActionSubmit } from "~/hooks/useActionSubmit"; +import { useCooldown } from "~/hooks/useCooldown"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { resolveGroupNames, @@ -36,6 +38,10 @@ import { } from "./RejoinSections"; import styles from "./SendouQMatchActionTab.module.css"; +const CONFIRM_COOLDOWN_MS = 5_000; +const CONFIRM_LOSS_ARMED_MS = 5_000; +const MAP_CHANGED_COOLDOWN_MS = 10_000; + export function SendouQMatchActionTab({ data, currentMap, @@ -259,7 +265,7 @@ function RequeueTab({ ) : null} {isStaffOnly && awaitingConfirmation ? ( - + ) : null} ) : ( @@ -287,7 +293,9 @@ function RequeueTab({ {showTimeline ? ( ) : null} - {isOnConfirmerTeam ? : null} + {isOnConfirmerTeam ? ( + + ) : null} {isOnReporterTeam ? : null} {data.match.isCanceled ? null : ( @@ -329,10 +337,22 @@ function WeaponReportSection({ return ; } -function ScoreConfirmerSection({ data }: { data: SendouQMatchLoaderData }) { +function ScoreConfirmerSection({ + data, + viewerSide, +}: { + data: SendouQMatchLoaderData; + /** `null` for staff confirming on a team's behalf, who get a neutral button. */ + viewerSide: "ALPHA" | "BRAVO" | null; +}) { const { t } = useTranslation(["q"]); - const { submit, state } = useActionSubmit(matchSchema); - const confirmFetcherPending = state !== "idle"; + const confirmScore = useActionSubmit(matchSchema); + const disputeScore = useActionSubmit(matchSchema); + const [cooldownUntil] = useState(() => Date.now() + CONFIRM_COOLDOWN_MS); + const cooldownSecondsLeft = useCooldown(cooldownUntil); + const [lossArmedUntil, setLossArmedUntil] = useState(null); + const isLossArmed = useCooldown(lossArmedUntil) > 0; + const [hasDisputed, setHasDisputed] = useState(false); const decidingMap = [...data.match.mapList] .reverse() @@ -341,24 +361,103 @@ function ScoreConfirmerSection({ data }: { data: SendouQMatchLoaderData }) { (m) => m.winnerGroupId !== null, ).length; + const { alphaWins, bravoWins } = SendouQMatch.score(data.match); + const winnerSide = + decidingMap?.winnerGroupId === data.match.groupAlpha.id ? "ALPHA" : "BRAVO"; + const reporterSide = SendouQMatch.resolveGroupMemberOf({ + groupAlpha: data.match.groupAlpha, + groupBravo: data.match.groupBravo, + userId: decidingMap?.reportedByUserId, + }); + const groupNames = resolveGroupNames(data.match, t); + const scoreFor = (side: "ALPHA" | "BRAVO") => + side === "ALPHA" + ? `${alphaWins}-${bravoWins}` + : `${bravoWins}-${alphaWins}`; + + const outcome = + viewerSide === null ? null : viewerSide === winnerSide ? "win" : "loss"; + const ownScore = viewerSide === null ? null : scoreFor(viewerSide); + + const buttonLabel = () => { + if (outcome === "win") { + return t("q:match.confirmScore.win", { score: ownScore }); + } + if (outcome === "loss") { + return isLossArmed + ? t("q:match.confirmScore.lossAgain") + : t("q:match.confirmScore.loss", { score: ownScore }); + } + return t("q:match.confirmScore"); + }; + + const submitConfirmation = () => { + if (!decidingMap?.winnerGroupId) return; + confirmScore.submit("REPORT_SCORE", { + winnerId: decidingMap.winnerGroupId, + reportedCount, + confirmingReportedAt: decidingMap.reportedAt ?? undefined, + }); + }; + return (
- { - if (!decidingMap?.winnerGroupId) return; - submit("REPORT_SCORE", { - winnerId: decidingMap.winnerGroupId, - reportedCount, - }); - }} - > - {t("q:match.confirmScore")} - + {reporterSide ? ( +

+ {reporterSide === winnerSide + ? t("q:match.confirmScore.reportedWin", { + team: groupNames[reporterSide === "ALPHA" ? "alpha" : "bravo"], + score: scoreFor(reporterSide), + }) + : t("q:match.confirmScore.reportedLoss", { + team: groupNames[reporterSide === "ALPHA" ? "alpha" : "bravo"], + score: scoreFor(reporterSide), + })} +

+ ) : null}

- {t("q:match.confirmScore.wrongHint")} + {t("q:match.confirmScore.check")}

+ 0} + isPending={confirmScore.state !== "idle"} + onClick={() => { + if (outcome === "loss" && !isLossArmed) { + setLossArmedUntil(Date.now() + CONFIRM_LOSS_ARMED_MS); + return; + } + submitConfirmation(); + }} + testId="confirm-score-button" + > + {cooldownSecondsLeft > 0 + ? `${buttonLabel()} (${cooldownSecondsLeft})` + : buttonLabel()} + + {viewerSide === null ? null : hasDisputed ? ( +

+ {t("q:match.confirmScore.wrongSent")} +

+ ) : ( + { + setHasDisputed(true); + disputeScore.submit("DISPUTE_SCORE"); + }} + > + {t("q:match.confirmScore.wrong")} + + )}
); } @@ -404,6 +503,26 @@ function InProgressTab({ const undoReport = useActionSubmit(matchSchema); const cancelFetcher = useFetcher(); + // the reported count this viewer's own last report or undo would move the match + // to; a change to any other count came from someone else + const [expectedReportedCount, setExpectedReportedCount] = useState< + number | null + >(null); + const [mapChange, setMapChange] = useState({ + reportedCount, + cooldownUntil: null as number | null, + }); + if (mapChange.reportedCount !== reportedCount) { + const changedByOthers = expectedReportedCount !== reportedCount; + setMapChange({ + reportedCount, + cooldownUntil: changedByOthers + ? Date.now() + MAP_CHANGED_COOLDOWN_MS + : null, + }); + setExpectedReportedCount(null); + } + const isStaffOnly = ownTeamId == null; const { @@ -462,7 +581,9 @@ function InProgressTab({ withKo={false} isSubmitting={reportScore.state !== "idle"} setEnding={setEnding} + submitCooldownUntil={mapChange.cooldownUntil} onSubmit={({ winnerId }) => { + setExpectedReportedCount(reportedCount + 1); reportScore.submit("REPORT_SCORE", { winnerId, reportedCount }); }} secondaryAction={ @@ -513,6 +634,7 @@ function InProgressTab({ (m) => m.winnerGroupId !== null, ); if (mapIndex < 0) return; + setExpectedReportedCount(reportedCount - 1); undoReport.submit("UNDO_MAP_REPORT", { mapIndex }); }} > diff --git a/app/features/sendouq-match/q-match-schemas.ts b/app/features/sendouq-match/q-match-schemas.ts index 02da1221a..6a256f116 100644 --- a/app/features/sendouq-match/q-match-schemas.ts +++ b/app/features/sendouq-match/q-match-schemas.ts @@ -35,6 +35,12 @@ export const matchSchema = v.union([ _action: _action("REPORT_SCORE"), winnerId: id, reportedCount: v.pipe(coerceNumber(), v.integer(), v.minValue(0)), + confirmingReportedAt: v.optional( + v.pipe(coerceNumber(), v.integer(), v.minValue(0)), + ), + }), + v.object({ + _action: _action("DISPUTE_SCORE"), }), v.object({ _action: _action("LOOK_AGAIN"), diff --git a/app/hooks/useCooldown.ts b/app/hooks/useCooldown.ts new file mode 100644 index 000000000..f895eb0f0 --- /dev/null +++ b/app/hooks/useCooldown.ts @@ -0,0 +1,74 @@ +import * as React from "react"; + +interface CooldownStore { + subscribe: (listener: () => void) => () => void; + getSnapshot: () => number; + getServerSnapshot: () => number; +} + +const cooldownStores = new Map(); + +function secondsLeftAt(until: number, now: number) { + return Math.max(0, Math.ceil((until - now) / 1000)); +} + +function getCooldownStore(until: number): CooldownStore { + const existing = cooldownStores.get(until); + if (existing) return existing; + + const initialSecondsLeft = secondsLeftAt(until, Date.now()); + let secondsLeft = initialSecondsLeft; + const listeners = new Set<() => void>(); + let timeout: ReturnType | undefined; + + const scheduleNextTick = () => { + const now = Date.now(); + if (now >= until) return; + + const msIntoSecond = ((until - now) % 1000) + 1; + timeout = setTimeout(() => { + secondsLeft = secondsLeftAt(until, Date.now()); + for (const listener of listeners) { + listener(); + } + scheduleNextTick(); + }, msIntoSecond); + }; + + const store: CooldownStore = { + subscribe(listener) { + listeners.add(listener); + if (listeners.size === 1) { + secondsLeft = secondsLeftAt(until, Date.now()); + scheduleNextTick(); + } + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + clearTimeout(timeout); + cooldownStores.delete(until); + } + }; + }, + getSnapshot: () => secondsLeft, + getServerSnapshot: () => initialSecondsLeft, + }; + cooldownStores.set(until, store); + return store; +} + +const noopStore: CooldownStore = { + subscribe: () => () => {}, + getSnapshot: () => 0, + getServerSnapshot: () => 0, +}; + +export function useCooldown(until: number | null): number { + const store = until === null ? noopStore : getCooldownStore(until); + + return React.useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getServerSnapshot, + ); +} diff --git a/changelog/2026-09-19-sendouq-score-confirm-safeguards.md b/changelog/2026-09-19-sendouq-score-confirm-safeguards.md new file mode 100644 index 000000000..d70c72874 --- /dev/null +++ b/changelog/2026-09-19-sendouq-score-confirm-safeguards.md @@ -0,0 +1,10 @@ +--- +navItem: sendouq +type: feature +--- +SendouQ score reporting is harder to get wrong by accident + +- The confirm button is now disabled for a few seconds before it can be pressed +- Confirming a set now shows the score and the outcome for your team, and reporting a loss requires a double tap +- Added a "Score is wrong" button which tells the other team in the match chat to undo their report +- When someone else reports a map result while you have the "Action" tab open, the submit button is now disabled for ten seconds so you can't accidentally report the next map diff --git a/e2e/pages/sendouq/sendouq-match-page.ts b/e2e/pages/sendouq/sendouq-match-page.ts index 6ca9879e4..0aa6de3be 100644 --- a/e2e/pages/sendouq/sendouq-match-page.ts +++ b/e2e/pages/sendouq/sendouq-match-page.ts @@ -43,7 +43,7 @@ export class SendouQMatchPage { undoReportButton: page.getByRole("button", { name: "Undo report" }), reportWeaponsButton: page.getByTestId("expand-secondary-action-button"), undoWeaponButton: page.getByRole("button", { name: "Undo weapon" }), - confirmScoreButton: page.getByRole("button", { name: "Confirm score" }), + confirmScoreButton: page.getByTestId("confirm-score-button"), requestCancelButton: page.getByRole("button", { name: "Request cancel" }), cancelPendingText: page.getByText("Pending other team's confirmation"), cancelPrompt: page.getByText("Accept canceling the set?"), @@ -152,8 +152,19 @@ export class SendouQMatchPage { } async confirmScore() { + await expect(this.locators.confirmScoreButton).toBeEnabled({ + timeout: 10_000, + }); + await this.locators.confirmScoreButton.click(); + + const armedLossButton = this.locators.confirmScoreButton.filter({ + hasText: "Tap again", + }); + await waitForPOSTResponse(this.page, async () => { - await this.locators.confirmScoreButton.click(); + if (await armedLossButton.isVisible()) { + await armedLossButton.click(); + } }); } diff --git a/locales/da/common.json b/locales/da/common.json index d4354348e..3d8b3c0b3 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -368,6 +368,7 @@ "chat.send": "", "chat.systemMsg.scoreReported": "", "chat.systemMsg.scoreConfirmed": "", + "chat.systemMsg.scoreDisputed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", "chat.systemMsg.cancelRefused": "", diff --git a/locales/da/q.json b/locales/da/q.json index 1bc8e69bd..481743321 100644 --- a/locales/da/q.json +++ b/locales/da/q.json @@ -151,7 +151,14 @@ "match.weapon.yourWeapon": "", "match.weapon.undoWeapon": "", "match.confirmScore": "", - "match.confirmScore.wrongHint": "", + "match.confirmScore.win": "", + "match.confirmScore.loss": "", + "match.confirmScore.lossAgain": "", + "match.confirmScore.reportedWin": "", + "match.confirmScore.reportedLoss": "", + "match.confirmScore.check": "", + "match.confirmScore.wrong": "", + "match.confirmScore.wrongSent": "", "match.rematch.prompt": "", "match.rematch.resolved": "", "match.rematch.vote.yes": "", diff --git a/locales/de/common.json b/locales/de/common.json index 1116adb24..5df14944c 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -368,6 +368,7 @@ "chat.send": "", "chat.systemMsg.scoreReported": "", "chat.systemMsg.scoreConfirmed": "", + "chat.systemMsg.scoreDisputed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", "chat.systemMsg.cancelRefused": "", diff --git a/locales/de/q.json b/locales/de/q.json index 83e5e53ce..6f046c1b6 100644 --- a/locales/de/q.json +++ b/locales/de/q.json @@ -151,7 +151,14 @@ "match.weapon.yourWeapon": "", "match.weapon.undoWeapon": "", "match.confirmScore": "", - "match.confirmScore.wrongHint": "", + "match.confirmScore.win": "", + "match.confirmScore.loss": "", + "match.confirmScore.lossAgain": "", + "match.confirmScore.reportedWin": "", + "match.confirmScore.reportedLoss": "", + "match.confirmScore.check": "", + "match.confirmScore.wrong": "", + "match.confirmScore.wrongSent": "", "match.rematch.prompt": "", "match.rematch.resolved": "", "match.rematch.vote.yes": "", diff --git a/locales/en/common.json b/locales/en/common.json index 5e679c93d..bba61137b 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -368,6 +368,7 @@ "chat.send": "Send", "chat.systemMsg.scoreReported": "{{name}} reported score", "chat.systemMsg.scoreConfirmed": "{{name}} confirmed score. Match is now locked", + "chat.systemMsg.scoreDisputed": "{{name}} says the reported score is wrong.", "chat.systemMsg.cancelReported": "{{name}} requested canceling the match", "chat.systemMsg.cancelConfirmed": "{{name}} confirmed canceling the match. Match is now locked", "chat.systemMsg.cancelRefused": "{{name}} refused canceling the match", diff --git a/locales/en/q.json b/locales/en/q.json index daa9deecf..c1874c883 100644 --- a/locales/en/q.json +++ b/locales/en/q.json @@ -151,7 +151,14 @@ "match.weapon.yourWeapon": "Your weapon", "match.weapon.undoWeapon": "Undo weapon", "match.confirmScore": "Confirm score", - "match.confirmScore.wrongHint": "Wrong score? Ask the other team to undo their report and adjust it.", + "match.confirmScore.win": "Confirm {{score}} win", + "match.confirmScore.loss": "Confirm {{score}} loss", + "match.confirmScore.lossAgain": "Tap again to confirm loss", + "match.confirmScore.reportedWin": "{{team}} reported a {{score}} win", + "match.confirmScore.reportedLoss": "{{team}} reported a {{score}} loss", + "match.confirmScore.check": "Check the score above. Confirming can't be undone.", + "match.confirmScore.wrong": "Score is wrong", + "match.confirmScore.wrongSent": "The other team was informed in the match chat.", "match.rematch.prompt": "Continue queueing with the group of {{count}}?", "match.rematch.resolved": "New group of {{count}} formed", "match.rematch.vote.yes": "Yes, continue", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 35da9b70d..87c7eece4 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -368,6 +368,7 @@ "chat.send": "Enviar", "chat.systemMsg.scoreReported": "{{name}} ha reportado el resultado", "chat.systemMsg.scoreConfirmed": "{{name}} ha confirmado el resultado. La partida está cerrada", + "chat.systemMsg.scoreDisputed": "", "chat.systemMsg.cancelReported": "{{name}} pidió cancelar la partida", "chat.systemMsg.cancelConfirmed": "{{name}} confirmó cancelar la partida. La partida está cerrada", "chat.systemMsg.cancelRefused": "{{name}} ha rechazado cancelar la partida", diff --git a/locales/es-ES/q.json b/locales/es-ES/q.json index af253f7b3..13a1b1fe7 100644 --- a/locales/es-ES/q.json +++ b/locales/es-ES/q.json @@ -151,7 +151,14 @@ "match.weapon.yourWeapon": "Tu arma", "match.weapon.undoWeapon": "Deshacer arma", "match.confirmScore": "Confirmar resultado", - "match.confirmScore.wrongHint": "¿Resultado incorrecto? Pide al otro equipo que deshaga su reporte y lo ajuste.", + "match.confirmScore.win": "", + "match.confirmScore.loss": "", + "match.confirmScore.lossAgain": "", + "match.confirmScore.reportedWin": "", + "match.confirmScore.reportedLoss": "", + "match.confirmScore.check": "", + "match.confirmScore.wrong": "", + "match.confirmScore.wrongSent": "", "match.rematch.prompt": "¿Continuar en la cola con el grupo de {{count}}?", "match.rematch.resolved": "Nuevo grupo de {{count}} formado", "match.rematch.vote.yes": "Sí, continuar", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index 04eb29ff2..262f242ff 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -368,6 +368,7 @@ "chat.send": "Enviar", "chat.systemMsg.scoreReported": "{{name}} reportó el puntaje", "chat.systemMsg.scoreConfirmed": "{{name}} confirmó el puntaje. La partida está cerrada", + "chat.systemMsg.scoreDisputed": "", "chat.systemMsg.cancelReported": "{{name}} pidió cancelar la partida", "chat.systemMsg.cancelConfirmed": "{{name}} confirmó cancelar la partida. La partida está cerrada", "chat.systemMsg.cancelRefused": "{{name}} ha rechazado cancelar la partida", diff --git a/locales/es-US/q.json b/locales/es-US/q.json index 582994c50..1a64442a5 100644 --- a/locales/es-US/q.json +++ b/locales/es-US/q.json @@ -151,7 +151,14 @@ "match.weapon.yourWeapon": "Tu arma", "match.weapon.undoWeapon": "Deshacer arma", "match.confirmScore": "Confirmar resultado", - "match.confirmScore.wrongHint": "¿Resultado incorrecto? Pide al otro equipo que deshaga su reporte y lo ajuste.", + "match.confirmScore.win": "", + "match.confirmScore.loss": "", + "match.confirmScore.lossAgain": "", + "match.confirmScore.reportedWin": "", + "match.confirmScore.reportedLoss": "", + "match.confirmScore.check": "", + "match.confirmScore.wrong": "", + "match.confirmScore.wrongSent": "", "match.rematch.prompt": "¿Continuar en la cola con el grupo de {{count}}?", "match.rematch.resolved": "Nuevo grupo de {{count}} formado", "match.rematch.vote.yes": "Sí, continuar", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 52e97f395..38f6150a8 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -368,6 +368,7 @@ "chat.send": "", "chat.systemMsg.scoreReported": "", "chat.systemMsg.scoreConfirmed": "", + "chat.systemMsg.scoreDisputed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", "chat.systemMsg.cancelRefused": "", diff --git a/locales/fr-CA/q.json b/locales/fr-CA/q.json index 18161ad62..34ec52db6 100644 --- a/locales/fr-CA/q.json +++ b/locales/fr-CA/q.json @@ -151,7 +151,14 @@ "match.weapon.yourWeapon": "", "match.weapon.undoWeapon": "", "match.confirmScore": "", - "match.confirmScore.wrongHint": "", + "match.confirmScore.win": "", + "match.confirmScore.loss": "", + "match.confirmScore.lossAgain": "", + "match.confirmScore.reportedWin": "", + "match.confirmScore.reportedLoss": "", + "match.confirmScore.check": "", + "match.confirmScore.wrong": "", + "match.confirmScore.wrongSent": "", "match.rematch.prompt": "", "match.rematch.resolved": "", "match.rematch.vote.yes": "", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index e1c3b7572..1e25a3a3f 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -368,6 +368,7 @@ "chat.send": "Envoyer", "chat.systemMsg.scoreReported": "{{name}} a reporté le score", "chat.systemMsg.scoreConfirmed": "{{name}} a confirmé le score. Le match est maintenant vérrouillé", + "chat.systemMsg.scoreDisputed": "", "chat.systemMsg.cancelReported": "{{name}} a demandé d'annuler le match", "chat.systemMsg.cancelConfirmed": "{{name}} a confirmé l'anunulation du match. Le match est maintenant vérrouillé", "chat.systemMsg.cancelRefused": "", diff --git a/locales/fr-EU/q.json b/locales/fr-EU/q.json index 88ee0d00a..66ce5aca3 100644 --- a/locales/fr-EU/q.json +++ b/locales/fr-EU/q.json @@ -151,7 +151,14 @@ "match.weapon.yourWeapon": "", "match.weapon.undoWeapon": "", "match.confirmScore": "", - "match.confirmScore.wrongHint": "", + "match.confirmScore.win": "", + "match.confirmScore.loss": "", + "match.confirmScore.lossAgain": "", + "match.confirmScore.reportedWin": "", + "match.confirmScore.reportedLoss": "", + "match.confirmScore.check": "", + "match.confirmScore.wrong": "", + "match.confirmScore.wrongSent": "", "match.rematch.prompt": "", "match.rematch.resolved": "", "match.rematch.vote.yes": "", diff --git a/locales/he/common.json b/locales/he/common.json index 2ad12ecaa..39eb42d74 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -368,6 +368,7 @@ "chat.send": "", "chat.systemMsg.scoreReported": "", "chat.systemMsg.scoreConfirmed": "", + "chat.systemMsg.scoreDisputed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", "chat.systemMsg.cancelRefused": "", diff --git a/locales/he/q.json b/locales/he/q.json index 91780a985..3f368d8d7 100644 --- a/locales/he/q.json +++ b/locales/he/q.json @@ -151,7 +151,14 @@ "match.weapon.yourWeapon": "", "match.weapon.undoWeapon": "", "match.confirmScore": "", - "match.confirmScore.wrongHint": "", + "match.confirmScore.win": "", + "match.confirmScore.loss": "", + "match.confirmScore.lossAgain": "", + "match.confirmScore.reportedWin": "", + "match.confirmScore.reportedLoss": "", + "match.confirmScore.check": "", + "match.confirmScore.wrong": "", + "match.confirmScore.wrongSent": "", "match.rematch.prompt": "", "match.rematch.resolved": "", "match.rematch.vote.yes": "", diff --git a/locales/it/common.json b/locales/it/common.json index a2bd0d022..ae47f54af 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -368,6 +368,7 @@ "chat.send": "Invia", "chat.systemMsg.scoreReported": "{{name}} ha riportato il punteggio", "chat.systemMsg.scoreConfirmed": "{{name}} ha confermato il punteggio. Il match è ora bloccato", + "chat.systemMsg.scoreDisputed": "", "chat.systemMsg.cancelReported": "{{name}} ha richiesto la cancellazione del match", "chat.systemMsg.cancelConfirmed": "{{name}} ha confermato la cancellazione del match. Il match è ora bloccato", "chat.systemMsg.cancelRefused": "", diff --git a/locales/it/q.json b/locales/it/q.json index 792f5c767..3da53aa93 100644 --- a/locales/it/q.json +++ b/locales/it/q.json @@ -151,7 +151,14 @@ "match.weapon.yourWeapon": "", "match.weapon.undoWeapon": "", "match.confirmScore": "", - "match.confirmScore.wrongHint": "", + "match.confirmScore.win": "", + "match.confirmScore.loss": "", + "match.confirmScore.lossAgain": "", + "match.confirmScore.reportedWin": "", + "match.confirmScore.reportedLoss": "", + "match.confirmScore.check": "", + "match.confirmScore.wrong": "", + "match.confirmScore.wrongSent": "", "match.rematch.prompt": "", "match.rematch.resolved": "", "match.rematch.vote.yes": "", diff --git a/locales/ja/common.json b/locales/ja/common.json index 75c3d533e..609ed2329 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -368,6 +368,7 @@ "chat.send": "送る", "chat.systemMsg.scoreReported": "{{name}}がスコアを報告しました", "chat.systemMsg.scoreConfirmed": "{{name}}がスコアを確認しました。試合がロックされました", + "chat.systemMsg.scoreDisputed": "", "chat.systemMsg.cancelReported": "{{name}} が試合のキャンセルを申請しました", "chat.systemMsg.cancelConfirmed": "{{name}} が試合キャンセルを承認しました。試合がロックされました", "chat.systemMsg.cancelRefused": "{{name}} が試合キャンセルを却下しました。", diff --git a/locales/ja/q.json b/locales/ja/q.json index b0a650625..cf86f25b4 100644 --- a/locales/ja/q.json +++ b/locales/ja/q.json @@ -151,7 +151,14 @@ "match.weapon.yourWeapon": "", "match.weapon.undoWeapon": "", "match.confirmScore": "", - "match.confirmScore.wrongHint": "", + "match.confirmScore.win": "", + "match.confirmScore.loss": "", + "match.confirmScore.lossAgain": "", + "match.confirmScore.reportedWin": "", + "match.confirmScore.reportedLoss": "", + "match.confirmScore.check": "", + "match.confirmScore.wrong": "", + "match.confirmScore.wrongSent": "", "match.rematch.prompt": "", "match.rematch.resolved": "", "match.rematch.vote.yes": "", diff --git a/locales/ko/common.json b/locales/ko/common.json index 6ef939890..60514d1c6 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -368,6 +368,7 @@ "chat.send": "", "chat.systemMsg.scoreReported": "", "chat.systemMsg.scoreConfirmed": "", + "chat.systemMsg.scoreDisputed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", "chat.systemMsg.cancelRefused": "", diff --git a/locales/ko/q.json b/locales/ko/q.json index 83e5e53ce..6f046c1b6 100644 --- a/locales/ko/q.json +++ b/locales/ko/q.json @@ -151,7 +151,14 @@ "match.weapon.yourWeapon": "", "match.weapon.undoWeapon": "", "match.confirmScore": "", - "match.confirmScore.wrongHint": "", + "match.confirmScore.win": "", + "match.confirmScore.loss": "", + "match.confirmScore.lossAgain": "", + "match.confirmScore.reportedWin": "", + "match.confirmScore.reportedLoss": "", + "match.confirmScore.check": "", + "match.confirmScore.wrong": "", + "match.confirmScore.wrongSent": "", "match.rematch.prompt": "", "match.rematch.resolved": "", "match.rematch.vote.yes": "", diff --git a/locales/nl/common.json b/locales/nl/common.json index 39dd94e97..432f974ff 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -368,6 +368,7 @@ "chat.send": "", "chat.systemMsg.scoreReported": "", "chat.systemMsg.scoreConfirmed": "", + "chat.systemMsg.scoreDisputed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", "chat.systemMsg.cancelRefused": "", diff --git a/locales/nl/q.json b/locales/nl/q.json index 83e5e53ce..6f046c1b6 100644 --- a/locales/nl/q.json +++ b/locales/nl/q.json @@ -151,7 +151,14 @@ "match.weapon.yourWeapon": "", "match.weapon.undoWeapon": "", "match.confirmScore": "", - "match.confirmScore.wrongHint": "", + "match.confirmScore.win": "", + "match.confirmScore.loss": "", + "match.confirmScore.lossAgain": "", + "match.confirmScore.reportedWin": "", + "match.confirmScore.reportedLoss": "", + "match.confirmScore.check": "", + "match.confirmScore.wrong": "", + "match.confirmScore.wrongSent": "", "match.rematch.prompt": "", "match.rematch.resolved": "", "match.rematch.vote.yes": "", diff --git a/locales/pl/common.json b/locales/pl/common.json index 791ffbd45..cae63d164 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -368,6 +368,7 @@ "chat.send": "", "chat.systemMsg.scoreReported": "", "chat.systemMsg.scoreConfirmed": "", + "chat.systemMsg.scoreDisputed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", "chat.systemMsg.cancelRefused": "", diff --git a/locales/pl/q.json b/locales/pl/q.json index 83e5e53ce..6f046c1b6 100644 --- a/locales/pl/q.json +++ b/locales/pl/q.json @@ -151,7 +151,14 @@ "match.weapon.yourWeapon": "", "match.weapon.undoWeapon": "", "match.confirmScore": "", - "match.confirmScore.wrongHint": "", + "match.confirmScore.win": "", + "match.confirmScore.loss": "", + "match.confirmScore.lossAgain": "", + "match.confirmScore.reportedWin": "", + "match.confirmScore.reportedLoss": "", + "match.confirmScore.check": "", + "match.confirmScore.wrong": "", + "match.confirmScore.wrongSent": "", "match.rematch.prompt": "", "match.rematch.resolved": "", "match.rematch.vote.yes": "", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index b1adb8cf9..820f33c3e 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -368,6 +368,7 @@ "chat.send": "Enviar", "chat.systemMsg.scoreReported": "{{name}} declarou a pontuação", "chat.systemMsg.scoreConfirmed": "{{name}} confirmou a pontuação. A partida foi trancada", + "chat.systemMsg.scoreDisputed": "", "chat.systemMsg.cancelReported": "{{name}} solicitou o cancelamento da partida", "chat.systemMsg.cancelConfirmed": "{{name}} confirmou o cancelamento da partida. A partida foi trancada", "chat.systemMsg.cancelRefused": "", diff --git a/locales/pt-BR/q.json b/locales/pt-BR/q.json index d3df5d6b8..061c13cef 100644 --- a/locales/pt-BR/q.json +++ b/locales/pt-BR/q.json @@ -151,7 +151,14 @@ "match.weapon.yourWeapon": "", "match.weapon.undoWeapon": "", "match.confirmScore": "", - "match.confirmScore.wrongHint": "", + "match.confirmScore.win": "", + "match.confirmScore.loss": "", + "match.confirmScore.lossAgain": "", + "match.confirmScore.reportedWin": "", + "match.confirmScore.reportedLoss": "", + "match.confirmScore.check": "", + "match.confirmScore.wrong": "", + "match.confirmScore.wrongSent": "", "match.rematch.prompt": "", "match.rematch.resolved": "", "match.rematch.vote.yes": "", diff --git a/locales/ru/common.json b/locales/ru/common.json index 9c46cde6b..c3925a5c0 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -368,6 +368,7 @@ "chat.send": "Отправить", "chat.systemMsg.scoreReported": "{{name}} сообщил результаты", "chat.systemMsg.scoreConfirmed": "{{name}} подтвердил счёт. Матч теперь закрыт", + "chat.systemMsg.scoreDisputed": "", "chat.systemMsg.cancelReported": "{{name}} запросил отмену матча", "chat.systemMsg.cancelConfirmed": "{{name}} подтвердил отмену матча. Матч теперь закрыт", "chat.systemMsg.cancelRefused": "", diff --git a/locales/ru/q.json b/locales/ru/q.json index fdf39f4b2..865aba72d 100644 --- a/locales/ru/q.json +++ b/locales/ru/q.json @@ -151,7 +151,14 @@ "match.weapon.yourWeapon": "", "match.weapon.undoWeapon": "", "match.confirmScore": "", - "match.confirmScore.wrongHint": "", + "match.confirmScore.win": "", + "match.confirmScore.loss": "", + "match.confirmScore.lossAgain": "", + "match.confirmScore.reportedWin": "", + "match.confirmScore.reportedLoss": "", + "match.confirmScore.check": "", + "match.confirmScore.wrong": "", + "match.confirmScore.wrongSent": "", "match.rematch.prompt": "", "match.rematch.resolved": "", "match.rematch.vote.yes": "", diff --git a/locales/zh/common.json b/locales/zh/common.json index 5f5eaddd6..93a2aec72 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -368,6 +368,7 @@ "chat.send": "发送", "chat.systemMsg.scoreReported": "{{name}} 汇报比分", "chat.systemMsg.scoreConfirmed": "{{name}} 确认了比分,本次对局已锁定", + "chat.systemMsg.scoreDisputed": "", "chat.systemMsg.cancelReported": "{{name}} 申请取消对局", "chat.systemMsg.cancelConfirmed": "{{name}} 确认取消对局,本次对局已锁定", "chat.systemMsg.cancelRefused": "{{name}} 拒绝取消对局", diff --git a/locales/zh/q.json b/locales/zh/q.json index 040412d4d..f4a459753 100644 --- a/locales/zh/q.json +++ b/locales/zh/q.json @@ -151,7 +151,14 @@ "match.weapon.yourWeapon": "您的武器", "match.weapon.undoWeapon": "撤销武器选择", "match.confirmScore": "确认比分", - "match.confirmScore.wrongHint": "比分有误?请联系对方队伍撤销上报并进行修改。", + "match.confirmScore.win": "", + "match.confirmScore.loss": "", + "match.confirmScore.lossAgain": "", + "match.confirmScore.reportedWin": "", + "match.confirmScore.reportedLoss": "", + "match.confirmScore.check": "", + "match.confirmScore.wrong": "", + "match.confirmScore.wrongSent": "", "match.rematch.prompt": "要与这个由 {{count}} 人组成的小组继续排队吗?", "match.rematch.resolved": "已组成全新的 {{count}} 人小组", "match.rematch.vote.yes": "是,继续排队", From 487ed99b4c63763252e0734b0803a9da986b11a8 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:30:22 +0300 Subject: [PATCH 05/50] Fix art details & buttons hidden in some cases --- app/features/art/components/ArtGrid.tsx | 40 ++++++++++++++++--- .../2026-09-19-art-details-stay-hidden.md | 5 +++ 2 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 changelog/2026-09-19-art-details-stay-hidden.md diff --git a/app/features/art/components/ArtGrid.tsx b/app/features/art/components/ArtGrid.tsx index 5a23bb8d0..0a97ae812 100644 --- a/app/features/art/components/ArtGrid.tsx +++ b/app/features/art/components/ArtGrid.tsx @@ -84,7 +84,7 @@ export function ArtGrid({ } function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) { - const [imageSettled, setImageSettled] = React.useState(false); + const [imageSettled, imageRef] = useImageSettled(); const { formatter } = useDateTimeFormat({ year: "numeric", month: "numeric", @@ -102,8 +102,7 @@ function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) { src={art.url} loading="lazy" className={styles.dialogImg} - onLoad={() => setImageSettled(true)} - onError={() => setImageSettled(true)} + ref={imageRef} /> {art.tags || art.linkedUsers ? (
setImageSettled(true)} - onError={() => setImageSettled(true)} + ref={imageRef} className={enablePreview ? styles.thumbnail : undefined} data-testid="art-image" /> @@ -301,3 +299,33 @@ function ImagePreview({ ); } + +/** + * Whether the image has finished loading (or failed to), and the ref to give it. + * + * Native listeners rather than `onLoad`/`onError` because React drops those + * events when the image settles right after mounting, e.g. when it comes from + * the browser cache. + */ +function useImageSettled() { + const [imageSettled, setImageSettled] = React.useState(false); + + const imageRef = (image: HTMLImageElement | null) => { + if (!image) return; + if (image.complete) { + setImageSettled(true); + return; + } + + const settle = () => setImageSettled(true); + image.addEventListener("load", settle); + image.addEventListener("error", settle); + + return () => { + image.removeEventListener("load", settle); + image.removeEventListener("error", settle); + }; + }; + + return [imageSettled, imageRef] as const; +} diff --git a/changelog/2026-09-19-art-details-stay-hidden.md b/changelog/2026-09-19-art-details-stay-hidden.md new file mode 100644 index 000000000..5496af0cd --- /dev/null +++ b/changelog/2026-09-19-art-details-stay-hidden.md @@ -0,0 +1,5 @@ +--- +navItem: art +type: bug +--- +Fixed art details and buttons sometimes staying hidden after the image had loaded From 8895afc3ce3b9fa2a206455e5264235e41de60e7 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:09:18 +0300 Subject: [PATCH 06/50] New style & faster user art tab --- app/components/elements/Dialog.module.css | 10 +- app/components/elements/Dialog.tsx | 8 +- .../art/components/ArtGrid.module.css | 147 ++++++++++++--- app/features/art/components/ArtGrid.tsx | 174 ++++++++++++------ changelog/2026-09-19-art-lightbox.md | 5 + 5 files changed, 264 insertions(+), 80 deletions(-) create mode 100644 changelog/2026-09-19-art-lightbox.md diff --git a/app/components/elements/Dialog.module.css b/app/components/elements/Dialog.module.css index 37c2037fd..4f7c35854 100644 --- a/app/components/elements/Dialog.module.css +++ b/app/components/elements/Dialog.module.css @@ -18,12 +18,12 @@ margin: auto; outline: none; animation: zoom-in-95 300ms ease-out; +} - &::backdrop { - background-color: rgba(0, 0, 0, 0.25); - backdrop-filter: blur(10px); - animation: fade-in 300ms ease-out; - } +.blurredBackdrop::backdrop { + background-color: rgba(0, 0, 0, 0.25); + backdrop-filter: blur(10px); + animation: fade-in 300ms ease-out; } @keyframes fade-in { diff --git a/app/components/elements/Dialog.tsx b/app/components/elements/Dialog.tsx index bfc69b631..5f6df54a7 100644 --- a/app/components/elements/Dialog.tsx +++ b/app/components/elements/Dialog.tsx @@ -13,6 +13,8 @@ import styles from "./Dialog.module.css"; interface DialogElementProps { id?: string; className?: string; + /** The standard dialog backdrop: dimmed and blurred page behind. */ + blurredBackdrop?: boolean; isDismissable?: boolean; onClose?: () => void; "aria-label"?: string; @@ -57,6 +59,7 @@ export function SendouModal({ ref, ...rest }: DialogElementProps) { function DialogElement({ id, className, + blurredBackdrop, isDismissable, onClose, "aria-label": ariaLabel, @@ -68,7 +71,9 @@ function DialogElement({ (); + export function ArtGrid({ arts, enablePreview = false, @@ -84,68 +86,99 @@ export function ArtGrid({ } function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) { + const dialogRef = React.useRef(null); + const [infoVisible, setInfoVisible] = React.useState(true); const [imageSettled, imageRef] = useImageSettled(); + const [aspectRatio, placeholderRef] = useImageAspectRatio(); const { formatter } = useDateTimeFormat({ year: "numeric", month: "numeric", day: "numeric", }); + const dateText = formatter.format(databaseTimestampToDate(art.createdAt)); + + const handleClick = (event: React.MouseEvent) => { + const target = event.target as HTMLElement; + if (target.closest("a")) return; + if (target.closest("figure") && !deviceCanHover()) { + setInfoVisible((visible) => !visible); + return; + } + dialogRef.current?.close(); + }; + return ( - - - {art.tags || art.linkedUsers ? ( -
- {art.linkedUsers?.map((user) => ( - - {user.username} - - ))} - {art.tags?.map((tag) => ( - - #{tag.name} - - ))} -
- ) : null} - {art.description ? ( -
+
- {art.description} -
- ) : null} - } - > - Close - -
+ + +
+ + {art.description ? ( +
+ {art.description} +
+ ) : null} + {art.tags || art.linkedUsers ? ( +
+ {art.linkedUsers?.map((user) => ( + + {user.username} + + ))} + {art.tags?.map((tag) => ( + + #{tag.name} + + ))} +
+ ) : null} +
+ +
+ ); } @@ -173,6 +206,7 @@ function ImagePreview({ src={previewUrl(art.url)} loading="lazy" onClick={onClick} + onPointerEnter={enablePreview ? () => preloadImage(art.url) : undefined} ref={imageRef} className={enablePreview ? styles.thumbnail : undefined} data-testid="art-image" @@ -329,3 +363,39 @@ function useImageSettled() { return [imageSettled, imageRef] as const; } + +/** Aspect ratio of the image once it has loaded, and the ref to give it. */ +function useImageAspectRatio() { + const [aspectRatio, setAspectRatio] = React.useState(null); + + const imageRef = (image: HTMLImageElement | null) => { + if (!image) return; + + const measure = () => { + if (image.naturalWidth > 0 && image.naturalHeight > 0) { + setAspectRatio(image.naturalWidth / image.naturalHeight); + } + }; + if (image.complete) { + measure(); + return; + } + + image.addEventListener("load", measure); + return () => image.removeEventListener("load", measure); + }; + + return [aspectRatio, imageRef] as const; +} + +/** Touch devices have no hover to reveal the lightbox info with, so a tap on the image toggles it instead. */ +function deviceCanHover() { + return window.matchMedia("(hover: hover)").matches; +} + +/** Warms the browser cache so the full-size image is ready by the time the lightbox opens. */ +function preloadImage(url: string) { + if (preloadedImageUrls.has(url)) return; + preloadedImageUrls.add(url); + new Image().src = url; +} diff --git a/changelog/2026-09-19-art-lightbox.md b/changelog/2026-09-19-art-lightbox.md new file mode 100644 index 000000000..2648736a4 --- /dev/null +++ b/changelog/2026-09-19-art-lightbox.md @@ -0,0 +1,5 @@ +--- +navItem: art +type: feature +--- +User art page big art view has been redesigned and speed improved. From b91bece1db7fc131c791e8767fd5c58f395eaf93 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:21:29 +0300 Subject: [PATCH 07/50] Add S2 Joy-Con Grip to sens widget --- app/features/user-page/core/widgets/widget-form-schemas.ts | 1 + changelog/2026-09-19-switch-2-grip-sens-widget.md | 5 +++++ locales/da/forms.json | 1 + locales/da/user.json | 1 + locales/de/forms.json | 1 + locales/de/user.json | 1 + locales/en/forms.json | 3 ++- locales/en/user.json | 3 ++- locales/es-ES/forms.json | 1 + locales/es-ES/user.json | 1 + locales/es-US/forms.json | 1 + locales/es-US/user.json | 1 + locales/fr-CA/forms.json | 1 + locales/fr-CA/user.json | 1 + locales/fr-EU/forms.json | 1 + locales/fr-EU/user.json | 1 + locales/he/forms.json | 1 + locales/he/user.json | 1 + locales/it/forms.json | 1 + locales/it/user.json | 1 + locales/ja/forms.json | 1 + locales/ja/user.json | 1 + locales/ko/forms.json | 1 + locales/ko/user.json | 1 + locales/nl/forms.json | 1 + locales/nl/user.json | 1 + locales/pl/forms.json | 1 + locales/pl/user.json | 1 + locales/pt-BR/forms.json | 1 + locales/pt-BR/user.json | 1 + locales/ru/forms.json | 1 + locales/ru/user.json | 1 + locales/zh/forms.json | 1 + locales/zh/user.json | 1 + 34 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 changelog/2026-09-19-switch-2-grip-sens-widget.md diff --git a/app/features/user-page/core/widgets/widget-form-schemas.ts b/app/features/user-page/core/widgets/widget-form-schemas.ts index f0633996e..60681189c 100644 --- a/app/features/user-page/core/widgets/widget-form-schemas.ts +++ b/app/features/user-page/core/widgets/widget-form-schemas.ts @@ -108,6 +108,7 @@ const CONTROLLERS = [ "s1-pro-con", "s2-pro-con", "grip", + "s2-grip", "s1-split-joycon", "s2-split-joycon", "handheld", diff --git a/changelog/2026-09-19-switch-2-grip-sens-widget.md b/changelog/2026-09-19-switch-2-grip-sens-widget.md new file mode 100644 index 000000000..409c5a5a6 --- /dev/null +++ b/changelog/2026-09-19-switch-2-grip-sens-widget.md @@ -0,0 +1,5 @@ +--- +navItem: u +type: feature +--- +Switch 2 Joy-Con Grip can now be picked as your controller in the sens widget diff --git a/locales/da/forms.json b/locales/da/forms.json index 5f6938eda..68b46bc07 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -260,6 +260,7 @@ "options.controller.s1-pro-con": "", "options.controller.s2-pro-con": "", "options.controller.grip": "", + "options.controller.s2-grip": "", "options.controller.s1-split-joycon": "", "options.controller.s2-split-joycon": "", "options.controller.handheld": "", diff --git a/locales/da/user.json b/locales/da/user.json index 56b145dba..23f787c89 100644 --- a/locales/da/user.json +++ b/locales/da/user.json @@ -148,6 +148,7 @@ "controllers.s1-pro-con": "", "controllers.s2-pro-con": "", "controllers.grip": "", + "controllers.s2-grip": "", "controllers.s1-split-joycon": "", "controllers.s2-split-joycon": "", "controllers.handheld": "", diff --git a/locales/de/forms.json b/locales/de/forms.json index 4834f8530..3928ef2c5 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -260,6 +260,7 @@ "options.controller.s1-pro-con": "", "options.controller.s2-pro-con": "", "options.controller.grip": "", + "options.controller.s2-grip": "", "options.controller.s1-split-joycon": "", "options.controller.s2-split-joycon": "", "options.controller.handheld": "", diff --git a/locales/de/user.json b/locales/de/user.json index c657de071..55cc6fa1a 100644 --- a/locales/de/user.json +++ b/locales/de/user.json @@ -148,6 +148,7 @@ "controllers.s1-pro-con": "", "controllers.s2-pro-con": "", "controllers.grip": "", + "controllers.s2-grip": "", "controllers.s1-split-joycon": "", "controllers.s2-split-joycon": "", "controllers.handheld": "", diff --git a/locales/en/forms.json b/locales/en/forms.json index 0951cc237..fd85b652b 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -259,7 +259,8 @@ "labels.controller": "Controller", "options.controller.s1-pro-con": "Switch 1 Pro Controller", "options.controller.s2-pro-con": "Switch 2 Pro Controller", - "options.controller.grip": "Joy-Con Grip", + "options.controller.grip": "Switch 1 Joy-Con Grip", + "options.controller.s2-grip": "Switch 2 Joy-Con Grip", "options.controller.s1-split-joycon": "Switch 1 Split Joy-Con", "options.controller.s2-split-joycon": "Switch 2 Split Joy-Con", "options.controller.handheld": "Handheld", diff --git a/locales/en/user.json b/locales/en/user.json index 70121891f..236c395d8 100644 --- a/locales/en/user.json +++ b/locales/en/user.json @@ -147,7 +147,8 @@ "widget.tier-list.untitled": "Untitled Tier List", "controllers.s1-pro-con": "Switch 1 Pro Controller", "controllers.s2-pro-con": "Switch 2 Pro Controller", - "controllers.grip": "Joy-Con Grip", + "controllers.grip": "Switch 1 Joy-Con Grip", + "controllers.s2-grip": "Switch 2 Joy-Con Grip", "controllers.s1-split-joycon": "Switch 1 Split Joy-Con", "controllers.s2-split-joycon": "Switch 2 Split Joy-Con", "controllers.handheld": "Handheld", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index 0f220371e..e240d3e23 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -260,6 +260,7 @@ "options.controller.s1-pro-con": "Mando Pro de Switch 1", "options.controller.s2-pro-con": "Mando Pro de Switch 2", "options.controller.grip": "Soporte para Joy-Con", + "options.controller.s2-grip": "", "options.controller.s1-split-joycon": "", "options.controller.s2-split-joycon": "", "options.controller.handheld": "Modo portátil", diff --git a/locales/es-ES/user.json b/locales/es-ES/user.json index 1169f6881..a7cdf0655 100644 --- a/locales/es-ES/user.json +++ b/locales/es-ES/user.json @@ -149,6 +149,7 @@ "controllers.s1-pro-con": "Mando Pro de Switch 1", "controllers.s2-pro-con": "Mando Pro de Switch 2", "controllers.grip": "Soporte para Joy-Con", + "controllers.s2-grip": "", "controllers.s1-split-joycon": "", "controllers.s2-split-joycon": "", "controllers.handheld": "Modo portátil", diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index 3853d117b..fbb482be2 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -260,6 +260,7 @@ "options.controller.s1-pro-con": "Mando Pro de Switch 1", "options.controller.s2-pro-con": "Mando Pro de Switch 2", "options.controller.grip": "Soporte para Joy-Con", + "options.controller.s2-grip": "", "options.controller.s1-split-joycon": "", "options.controller.s2-split-joycon": "", "options.controller.handheld": "Modo portátil", diff --git a/locales/es-US/user.json b/locales/es-US/user.json index d7f9065c6..d9992a1b6 100644 --- a/locales/es-US/user.json +++ b/locales/es-US/user.json @@ -149,6 +149,7 @@ "controllers.s1-pro-con": "Control Pro de Switch 1", "controllers.s2-pro-con": "Control Pro de Switch 2", "controllers.grip": "Soporte para Joy-Con", + "controllers.s2-grip": "", "controllers.s1-split-joycon": "", "controllers.s2-split-joycon": "", "controllers.handheld": "Modo portátil", diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index 3f842aa1c..79a541a22 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -260,6 +260,7 @@ "options.controller.s1-pro-con": "", "options.controller.s2-pro-con": "", "options.controller.grip": "", + "options.controller.s2-grip": "", "options.controller.s1-split-joycon": "", "options.controller.s2-split-joycon": "", "options.controller.handheld": "", diff --git a/locales/fr-CA/user.json b/locales/fr-CA/user.json index aea92735e..4fe0f538d 100644 --- a/locales/fr-CA/user.json +++ b/locales/fr-CA/user.json @@ -149,6 +149,7 @@ "controllers.s1-pro-con": "", "controllers.s2-pro-con": "", "controllers.grip": "", + "controllers.s2-grip": "", "controllers.s1-split-joycon": "", "controllers.s2-split-joycon": "", "controllers.handheld": "", diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index 0158fb59a..fcbeb8888 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -260,6 +260,7 @@ "options.controller.s1-pro-con": "", "options.controller.s2-pro-con": "", "options.controller.grip": "", + "options.controller.s2-grip": "", "options.controller.s1-split-joycon": "", "options.controller.s2-split-joycon": "", "options.controller.handheld": "", diff --git a/locales/fr-EU/user.json b/locales/fr-EU/user.json index fbd986f9c..93fca72a0 100644 --- a/locales/fr-EU/user.json +++ b/locales/fr-EU/user.json @@ -149,6 +149,7 @@ "controllers.s1-pro-con": "", "controllers.s2-pro-con": "", "controllers.grip": "", + "controllers.s2-grip": "", "controllers.s1-split-joycon": "", "controllers.s2-split-joycon": "", "controllers.handheld": "", diff --git a/locales/he/forms.json b/locales/he/forms.json index 553bc4a27..a2b7fbbec 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -260,6 +260,7 @@ "options.controller.s1-pro-con": "", "options.controller.s2-pro-con": "", "options.controller.grip": "", + "options.controller.s2-grip": "", "options.controller.s1-split-joycon": "", "options.controller.s2-split-joycon": "", "options.controller.handheld": "", diff --git a/locales/he/user.json b/locales/he/user.json index 3678aa659..afeab6a77 100644 --- a/locales/he/user.json +++ b/locales/he/user.json @@ -149,6 +149,7 @@ "controllers.s1-pro-con": "", "controllers.s2-pro-con": "", "controllers.grip": "", + "controllers.s2-grip": "", "controllers.s1-split-joycon": "", "controllers.s2-split-joycon": "", "controllers.handheld": "", diff --git a/locales/it/forms.json b/locales/it/forms.json index be56040b6..dd440c1dc 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -260,6 +260,7 @@ "options.controller.s1-pro-con": "", "options.controller.s2-pro-con": "", "options.controller.grip": "", + "options.controller.s2-grip": "", "options.controller.s1-split-joycon": "", "options.controller.s2-split-joycon": "", "options.controller.handheld": "", diff --git a/locales/it/user.json b/locales/it/user.json index fd6a8fcc7..39c0391f4 100644 --- a/locales/it/user.json +++ b/locales/it/user.json @@ -149,6 +149,7 @@ "controllers.s1-pro-con": "", "controllers.s2-pro-con": "", "controllers.grip": "", + "controllers.s2-grip": "", "controllers.s1-split-joycon": "", "controllers.s2-split-joycon": "", "controllers.handheld": "", diff --git a/locales/ja/forms.json b/locales/ja/forms.json index 9ac3ee117..4c46c89f9 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -260,6 +260,7 @@ "options.controller.s1-pro-con": "", "options.controller.s2-pro-con": "", "options.controller.grip": "", + "options.controller.s2-grip": "", "options.controller.s1-split-joycon": "", "options.controller.s2-split-joycon": "", "options.controller.handheld": "", diff --git a/locales/ja/user.json b/locales/ja/user.json index dc77c3a7f..74415da32 100644 --- a/locales/ja/user.json +++ b/locales/ja/user.json @@ -146,6 +146,7 @@ "controllers.s1-pro-con": "", "controllers.s2-pro-con": "", "controllers.grip": "", + "controllers.s2-grip": "", "controllers.s1-split-joycon": "", "controllers.s2-split-joycon": "", "controllers.handheld": "", diff --git a/locales/ko/forms.json b/locales/ko/forms.json index d4f79f5a0..dfe0f85d2 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -260,6 +260,7 @@ "options.controller.s1-pro-con": "", "options.controller.s2-pro-con": "", "options.controller.grip": "", + "options.controller.s2-grip": "", "options.controller.s1-split-joycon": "", "options.controller.s2-split-joycon": "", "options.controller.handheld": "", diff --git a/locales/ko/user.json b/locales/ko/user.json index 2185b5387..e2ba02c02 100644 --- a/locales/ko/user.json +++ b/locales/ko/user.json @@ -146,6 +146,7 @@ "controllers.s1-pro-con": "", "controllers.s2-pro-con": "", "controllers.grip": "", + "controllers.s2-grip": "", "controllers.s1-split-joycon": "", "controllers.s2-split-joycon": "", "controllers.handheld": "", diff --git a/locales/nl/forms.json b/locales/nl/forms.json index 36cc8e009..28e412623 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -260,6 +260,7 @@ "options.controller.s1-pro-con": "", "options.controller.s2-pro-con": "", "options.controller.grip": "", + "options.controller.s2-grip": "", "options.controller.s1-split-joycon": "", "options.controller.s2-split-joycon": "", "options.controller.handheld": "", diff --git a/locales/nl/user.json b/locales/nl/user.json index c94510dd1..6791a56e4 100644 --- a/locales/nl/user.json +++ b/locales/nl/user.json @@ -148,6 +148,7 @@ "controllers.s1-pro-con": "", "controllers.s2-pro-con": "", "controllers.grip": "", + "controllers.s2-grip": "", "controllers.s1-split-joycon": "", "controllers.s2-split-joycon": "", "controllers.handheld": "", diff --git a/locales/pl/forms.json b/locales/pl/forms.json index ab95776fb..e4ae5670a 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -260,6 +260,7 @@ "options.controller.s1-pro-con": "", "options.controller.s2-pro-con": "", "options.controller.grip": "", + "options.controller.s2-grip": "", "options.controller.s1-split-joycon": "", "options.controller.s2-split-joycon": "", "options.controller.handheld": "", diff --git a/locales/pl/user.json b/locales/pl/user.json index 145841987..419e2514d 100644 --- a/locales/pl/user.json +++ b/locales/pl/user.json @@ -150,6 +150,7 @@ "controllers.s1-pro-con": "", "controllers.s2-pro-con": "", "controllers.grip": "", + "controllers.s2-grip": "", "controllers.s1-split-joycon": "", "controllers.s2-split-joycon": "", "controllers.handheld": "", diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index 08772baab..3bb04395e 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -260,6 +260,7 @@ "options.controller.s1-pro-con": "", "options.controller.s2-pro-con": "", "options.controller.grip": "", + "options.controller.s2-grip": "", "options.controller.s1-split-joycon": "", "options.controller.s2-split-joycon": "", "options.controller.handheld": "", diff --git a/locales/pt-BR/user.json b/locales/pt-BR/user.json index 18607d9e6..d11c02bc8 100644 --- a/locales/pt-BR/user.json +++ b/locales/pt-BR/user.json @@ -149,6 +149,7 @@ "controllers.s1-pro-con": "", "controllers.s2-pro-con": "", "controllers.grip": "", + "controllers.s2-grip": "", "controllers.s1-split-joycon": "", "controllers.s2-split-joycon": "", "controllers.handheld": "", diff --git a/locales/ru/forms.json b/locales/ru/forms.json index 1f7d6ff23..919187487 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -260,6 +260,7 @@ "options.controller.s1-pro-con": "", "options.controller.s2-pro-con": "", "options.controller.grip": "", + "options.controller.s2-grip": "", "options.controller.s1-split-joycon": "", "options.controller.s2-split-joycon": "", "options.controller.handheld": "", diff --git a/locales/ru/user.json b/locales/ru/user.json index aaa812dfc..33aa60179 100644 --- a/locales/ru/user.json +++ b/locales/ru/user.json @@ -150,6 +150,7 @@ "controllers.s1-pro-con": "", "controllers.s2-pro-con": "", "controllers.grip": "", + "controllers.s2-grip": "", "controllers.s1-split-joycon": "", "controllers.s2-split-joycon": "", "controllers.handheld": "", diff --git a/locales/zh/forms.json b/locales/zh/forms.json index 34b1631ed..cd3bacb29 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -260,6 +260,7 @@ "options.controller.s1-pro-con": "Nintendo Switch Pro 控制器", "options.controller.s2-pro-con": "Nintendo Switch 2 Pro 控制器", "options.controller.grip": "Joy-Con 握把", + "options.controller.s2-grip": "", "options.controller.s1-split-joycon": "", "options.controller.s2-split-joycon": "", "options.controller.handheld": "手提模式", diff --git a/locales/zh/user.json b/locales/zh/user.json index fd438d42f..b37ed1f42 100644 --- a/locales/zh/user.json +++ b/locales/zh/user.json @@ -146,6 +146,7 @@ "controllers.s1-pro-con": "Nintendo Switch Pro 控制器", "controllers.s2-pro-con": "Nintendo Switch 2 Pro 控制器", "controllers.grip": "Joy-Con 握把", + "controllers.s2-grip": "", "controllers.s1-split-joycon": "", "controllers.s2-split-joycon": "", "controllers.handheld": "手提模式", From c3c179e30716880a47900e7281c78c54a3472747 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:38:50 +0300 Subject: [PATCH 08/50] Add LFG post export --- app/components/LFGPostGraphic.module.css | 99 +++++++++ app/components/LFGPostGraphic.tsx | 188 ++++++++++++++++++ .../components-showcase/routes/components.tsx | 88 +++++++- .../lfg/components/LFGPost.module.css | 27 ++- app/features/lfg/components/LFGPost.tsx | 104 +++++++--- app/features/lfg/routes/lfg.module.css | 12 ++ app/features/lfg/routes/lfg.tsx | 38 ++-- changelog/2026-09-19-lfg-post-image-export.md | 5 + changelog/2026-09-19-lfg-post-layout-fixes.md | 10 + 9 files changed, 518 insertions(+), 53 deletions(-) create mode 100644 app/components/LFGPostGraphic.module.css create mode 100644 app/components/LFGPostGraphic.tsx create mode 100644 changelog/2026-09-19-lfg-post-image-export.md create mode 100644 changelog/2026-09-19-lfg-post-layout-fixes.md diff --git a/app/components/LFGPostGraphic.module.css b/app/components/LFGPostGraphic.module.css new file mode 100644 index 000000000..f3f74f0a8 --- /dev/null +++ b/app/components/LFGPostGraphic.module.css @@ -0,0 +1,99 @@ +.typeBadge { + font-size: var(--font-xs); + font-weight: var(--weight-extra); + text-transform: uppercase; + letter-spacing: 0.5px; + text-align: right; + color: var(--graphic-accent); +} + +.details { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s-3); +} + +.weapons { + display: flex; + flex-wrap: wrap; + gap: var(--s-1); + padding: var(--s-1-5); + background-color: oklch(0% 0 0 / 0.3); + border-radius: var(--radius-field); +} + +.pills { + display: flex; + flex-shrink: 0; + gap: var(--s-1-5); +} + +.pill { + padding: var(--s-1) var(--s-2); + font-size: var(--font-2xs); + font-weight: var(--weight-extra); + color: var(--graphic-text-dim); + background-color: var(--graphic-row-bg); + border: 1.5px solid var(--graphic-row-border); + border-radius: var(--radius-full); +} + +.textBox { + padding: var(--s-3); + font-size: var(--font-sm); + font-weight: var(--weight-semi); + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; + background-color: var(--graphic-row-bg); + border: 1.5px solid var(--graphic-row-border); + border-radius: var(--radius-box); +} + +.members { + /* keep in sync with MEMBER_WEAPON_SIZE */ + --member-weapon-size: 28px; + + display: flex; + flex-direction: column; + gap: var(--s-1-5); +} + +/* rows without weapons stay as tall as the ones with them */ +.member { + display: grid; + grid-template-columns: auto 1fr auto; + grid-template-rows: minmax( + calc(var(--member-weapon-size) + 2 * var(--s-1)), + auto + ); + align-items: center; + gap: var(--s-2); + padding: var(--s-1-5) var(--s-2); + background-color: var(--graphic-row-bg); + border: 1.5px solid var(--graphic-row-border); + border-radius: var(--radius-box); + + & .weapons { + padding: var(--s-1); + } +} + +.memberName { + display: flex; + align-items: center; + gap: var(--s-1-5); + min-width: 0; + font-size: var(--font-sm); + font-weight: var(--weight-extra); + line-height: 1.3; +} + +/* claims the free space like the header title, so a name the raster measures wider still fits */ +.memberUsername { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/app/components/LFGPostGraphic.tsx b/app/components/LFGPostGraphic.tsx new file mode 100644 index 000000000..aac607034 --- /dev/null +++ b/app/components/LFGPostGraphic.tsx @@ -0,0 +1,188 @@ +import { useTranslation } from "react-i18next"; +import type { Tables } from "~/db/tables"; +import { + GraphicContainer, + GraphicDateSubtitle, + GraphicFooter, + GraphicHeader, + GraphicSiteUrl, + GraphicTitle, +} from "~/features/img-export/components/Graphic"; +import type { LFGType } from "~/features/lfg/lfg-constants"; +import { lfgSearchParams } from "~/features/lfg/lfg-search-params"; +import type { UnifiedLanguageCode } from "~/modules/i18n/config"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { databaseTimestampToDate } from "~/utils/dates"; +import { LFG_PAGE, resolveAvatarUrl } from "~/utils/urls"; +import { Avatar } from "./Avatar"; +import { Flag } from "./Flag"; +import { WeaponImage } from "./Image"; +import styles from "./LFGPostGraphic.module.css"; + +const GRAPHIC_WIDTH = 480; +const WEAPON_SIZE = 40; +const MEMBER_WEAPON_SIZE = 28; + +interface LFGPostGraphicUser { + username: string; + discordId: string; + discordAvatar: Tables["User"]["discordAvatar"]; + customAvatarUrl: string | null; + country: string | null; + weaponPool: Array<{ + weaponSplId: MainWeaponId; + isFavorite: boolean | number; + isTenStar: boolean | number; + }>; +} + +export interface LFGPostGraphicPost { + id: number; + type: LFGType; + text: string; + updatedAt: number; + timezone: string; + languages: UnifiedLanguageCode[] | null; + author: LFGPostGraphicUser; + team: { + name: string; + avatarUrl: string | null; + members: Array; + } | null; +} + +/** Search param carrying the post id so the QR code lands on the post itself */ +export const lfgPostGraphicPath = (postId: number) => + lfgSearchParams.href(LFG_PAGE, { post: postId }); + +export function LFGPostGraphic({ post }: { post: LFGPostGraphicPost }) { + return ( + + +
+ {post.team ? null : } + +
+ {post.team ? : null} +
{post.text}
+ + + +
+ ); +} + +function PostHeader({ post }: { post: LFGPostGraphicPost }) { + const { t } = useTranslation(["lfg"]); + + const avatarProps = post.team + ? { + avatarUrl: post.team.avatarUrl ?? undefined, + identiconInput: post.team.name, + } + : { + avatarUrl: userAvatarUrl(post.author), + identiconInput: post.author.discordId, + }; + + return ( + + {!post.team && post.author.country ? ( + + ) : null} + {post.team?.name ?? post.author.username} + + } + subtitle={ + + } + trailing={ +
{t(`lfg:types.${post.type}`)}
+ } + alignTrailingWithTitle + /> + ); +} + +function WeaponsRow({ + weapons, + size = WEAPON_SIZE, +}: { + weapons: LFGPostGraphicUser["weaponPool"]; + size?: number; +}) { + if (weapons.length === 0) return null; + + return ( +
+ {weapons.map((weapon) => ( + + ))} +
+ ); +} + +function DetailPills({ post }: { post: LFGPostGraphicPost }) { + return ( +
+
{timezoneOffsetLabel(post.timezone)}
+ {post.languages && post.languages.length > 0 ? ( +
{languagesLabel(post.languages)}
+ ) : null} +
+ ); +} + +function TeamMembersList({ + members, +}: { + members: NonNullable["members"]; +}) { + return ( +
+ {members.map((member) => ( +
+ +
+ {member.country ? : null} + {member.username} +
+ +
+ ))} +
+ ); +} + +function userAvatarUrl(user: LFGPostGraphicUser) { + return resolveAvatarUrl({ + customAvatarUrl: user.customAvatarUrl, + discordId: user.discordId, + discordAvatar: user.discordAvatar, + size: "lg", + }); +} + +function languagesLabel(languages: UnifiedLanguageCode[]) { + return languages.join(" / ").toUpperCase(); +} + +/** e.g. "GMT+3", absolute rather than relative to the viewer since the image travels */ +function timezoneOffsetLabel(timezone: string) { + const offsetPart = new Intl.DateTimeFormat("en", { + timeZone: timezone, + timeZoneName: "shortOffset", + }) + .formatToParts(new Date()) + .find((part) => part.type === "timeZoneName"); + + return offsetPart?.value ?? timezone; +} diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx index 3501d4bdb..dfd501ed8 100644 --- a/app/features/components-showcase/routes/components.tsx +++ b/app/features/components-showcase/routes/components.tsx @@ -39,6 +39,10 @@ import { import { InfoPopover } from "~/components/InfoPopover"; import { Input } from "~/components/Input"; import { Label } from "~/components/Label"; +import { + LFGPostGraphic, + type LFGPostGraphicPost, +} from "~/components/LFGPostGraphic"; import { Main } from "~/components/Main"; import { Pagination } from "~/components/Pagination"; import { Placement } from "~/components/Placement"; @@ -96,7 +100,7 @@ import { EXAMPLE_TROPHY_MODEL } from "../example-trophy-model"; import { formFieldsShowcaseSchema } from "../form-examples-schema"; export const handle: SendouRouteHandle = { - i18n: ["user", "q", "calendar", "tournament", "schedule", "builds"], + i18n: ["user", "q", "calendar", "tournament", "schedule", "builds", "lfg"], }; export const SECTIONS = [ @@ -139,6 +143,11 @@ export const SECTIONS = [ id: "season-summary-graphic", component: SeasonSummaryGraphicSection, }, + { + title: "LFG Post Graphic", + id: "lfg-post-graphic", + component: LFGPostGraphicSection, + }, { title: "Changelog Graphic", id: "changelog-graphic", @@ -2196,6 +2205,83 @@ function SeasonSummaryGraphicSection({ id }: { id: string }) { ); } +const LFG_GRAPHIC_AUTHOR: LFGPostGraphicPost["author"] = { + username: "Sendou", + discordId: "79237403620945920", + discordAvatar: null, + customAvatarUrl: `${RESULTS_GRAPHIC_IMG_ROOT}/dBYwiLjlhVBwW-oyyNJkC-1721997877357.webp`, + country: "FI", + weaponPool: [ + { weaponSplId: 40, isFavorite: 1, isTenStar: 1 }, + { weaponSplId: 2070, isFavorite: 1, isTenStar: 0 }, + { weaponSplId: 8010, isFavorite: 0, isTenStar: 0 }, + { weaponSplId: 5030, isFavorite: 0, isTenStar: 0 }, + ], +}; + +const LFG_GRAPHIC_USER_POST: LFGPostGraphicPost = { + id: 1234, + type: "PLAYER_FOR_TEAM", + text: "Looking for a competitive team aiming for top 8 in LUTI Div 1 next season. Available most evenings EU time, can also do late-night NA scrims on weekends.\n\nMain slayer with Splattershot Jr. as a flex pick. Comfortable calling if needed. DM me on Discord or reply here!", + updatedAt: 1781974800, + timezone: "Europe/Helsinki", + languages: ["en", "de"], + author: LFG_GRAPHIC_AUTHOR, + team: null, +}; + +const LFG_GRAPHIC_TEAM_POST: LFGPostGraphicPost = { + id: 1235, + type: "TEAM_FOR_PLAYER", + text: "Besto Friendo is looking for a 4th! We're a Div 2 team playing 3-4 scrims a week plus weekend tournaments. Ideal pick-up would be a backline or a flexible support player.", + updatedAt: 1781974800, + timezone: "America/New_York", + languages: ["en"], + author: LFG_GRAPHIC_AUTHOR, + team: { + name: "Besto Friendo", + avatarUrl: `${RESULTS_GRAPHIC_IMG_ROOT}/fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp`, + members: [ + { ...LFG_GRAPHIC_AUTHOR, id: 1, username: "Yeti", country: null }, + { + ...LFG_GRAPHIC_AUTHOR, + id: 2, + username: "まるお", + country: "JP", + weaponPool: [], + }, + { + ...LFG_GRAPHIC_AUTHOR, + id: 3, + username: "Grey", + country: "FR", + weaponPool: [ + { weaponSplId: 1120, isFavorite: 0, isTenStar: 0 }, + { weaponSplId: 2010, isFavorite: 0, isTenStar: 0 }, + ], + }, + ], + }, +}; + +function LFGPostGraphicSection({ id }: { id: string }) { + return ( +
+ LFG Post Graphic + +
+ + + + + + + +
+
+ ); +} + const CHANGELOG_GRAPHIC_ENTRIES_SMALL: ChangelogGraphicEntry[] = [ { navItems: ["plans"], diff --git a/app/features/lfg/components/LFGPost.module.css b/app/features/lfg/components/LFGPost.module.css index 638741b4c..7ee46a2d9 100644 --- a/app/features/lfg/components/LFGPost.module.css +++ b/app/features/lfg/components/LFGPost.module.css @@ -32,8 +32,14 @@ color: var(--color-text); } +/* the name cap equals the basis so cells wrap instead of a long name squishing a short one's avatar */ +.teamMember { + flex: 1 1 64px; + max-width: 80px; +} + .teamMemberName { - max-width: 75px; + max-width: 64px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; @@ -44,13 +50,16 @@ .textContainer { position: relative; +} + +/* keep in sync with COLLAPSED_TEXT_HEIGHT */ +.textCollapsed { max-height: 150px; overflow: hidden; } -.textContainerExpanded { - position: relative; - margin-bottom: var(--s-6); +.textWithButtonBelow { + margin-bottom: var(--s-10); } .text { @@ -78,8 +87,8 @@ z-index: 1; } -.showAllButtonExpanded { - bottom: -35px; +.showAllButtonBelow { + bottom: -44px; } .pill { @@ -93,6 +102,8 @@ align-items: center; gap: var(--s-0-5); position: relative; + flex-shrink: 0; + white-space: nowrap; } .pillPlaceholder { @@ -103,6 +114,8 @@ .editButton { font-size: var(--font-2xs); font-weight: var(--weight-semi); + font-family: inherit; + border: 0; border-radius: var(--radius-selector); background-color: var(--color-bg-higher); padding: 0 var(--s-2); @@ -111,6 +124,8 @@ display: flex; align-items: center; gap: var(--s-1); + white-space: nowrap; + cursor: pointer; & > svg { width: 14px; diff --git a/app/features/lfg/components/LFGPost.tsx b/app/features/lfg/components/LFGPost.tsx index 431f732ce..b9d3aefdc 100644 --- a/app/features/lfg/components/LFGPost.tsx +++ b/app/features/lfg/components/LFGPost.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import { SquarePen, Trash } from "lucide-react"; +import { HardDriveDownload, SquarePen, Trash } from "lucide-react"; import React from "react"; import { useTranslation } from "react-i18next"; import { Link, useFetcher } from "react-router"; @@ -9,8 +9,13 @@ import { SendouButton } from "~/components/elements/Button"; import { Flag } from "~/components/Flag"; import { FormWithConfirm } from "~/components/FormWithConfirm"; import { WeaponImage } from "~/components/Image"; +import { + LFGPostGraphic, + lfgPostGraphicPath, +} from "~/components/LFGPostGraphic"; import { LocaleTime } from "~/components/LocaleTime"; import { NoteAvatar } from "~/components/NoteAvatar"; +import { ImageExportDialog } from "~/features/img-export/components/ImageExportDialog"; import { lfgNewPostPage } from "~/features/lfg/lfg-urls"; import { UserCard, @@ -28,6 +33,11 @@ import styles from "./LFGPost.module.css"; type Post = LFGLoaderData["posts"][number]; +/** Keep in sync with `.textCollapsed` max-height */ +const COLLAPSED_TEXT_HEIGHT = 150; +/** Server-side guess of whether the text gets clipped, corrected by measuring once mounted */ +const CLIPPED_TEXT_GUESS = { length: 300, lines: 6 }; + export function LFGPost({ post }: { post: Post }) { if (post.team) { return ; @@ -36,7 +46,6 @@ export function LFGPost({ post }: { post: Post }) { return ; } -const USER_POST_EXPANDABLE_CRITERIA = 300; function UserLFGPost({ post }: { post: Post }) { const canEdit = useHasPermission(post, "EDIT"); const canDelete = useHasPermission(post, "DELETE"); @@ -54,7 +63,7 @@ function UserLFGPost({ post }: { post: Post }) { languages={post.languages} timezone={post.timezone} canEdit={canEdit} - postId={post.id} + post={post} />
@@ -68,7 +77,6 @@ function UserLFGPost({ post }: { post: Post }) { text={post.text} isExpanded={isExpanded} setIsExpanded={setIsExpanded} - expandableCriteria={USER_POST_EXPANDABLE_CRITERIA} />
@@ -89,9 +97,9 @@ function TeamLFGPost({
-
+
-
+
{isHydrated ? ( ) : null} @@ -101,9 +109,14 @@ function TeamLFGPost({
-
+
- {canEdit ? : null} + {canEdit ? ( +
+ + +
+ ) : null}
{isExpanded ? ( @@ -123,6 +136,7 @@ function TeamLFGPost({ text={post.text} isExpanded={isExpanded} setIsExpanded={setIsExpanded} + alwaysExpandable />
@@ -166,7 +180,7 @@ function PostTeamMember({ const cardData = useUserCardData(member.id); return ( -
+
@@ -260,12 +274,12 @@ function PostPills({ timezone, languages, canEdit, - postId, + post, }: { timezone?: string | null; languages?: UnifiedLanguageCode[] | null; canEdit?: boolean; - postId: number; + post: Post; }) { const isHydrated = useHydrated(); @@ -280,7 +294,8 @@ function PostPills({ ) : null} {!isHydrated ? : null} {languages ? : null} - {canEdit ? : null} + {canEdit ? : null} + {canEdit ? : null}
); } @@ -341,6 +356,26 @@ function PostEditButton({ id }: { id: number }) { ); } +function PostImageExportDialog({ post }: { post: Post }) { + const { t } = useTranslation(["common"]); + + return ( + + + {t("common:imageExport.export")} + + } + heading={t("common:imageExport.export")} + filename={`lfg-post-${post.id}`} + qrCodePath={lfgPostGraphicPath(post.id)} + > + + + ); +} + function PostDeleteButton({ id, type }: { id: number; type: Post["type"] }) { const fetcher = useFetcher(); const { t } = useTranslation(["common", "lfg"]); @@ -369,33 +404,52 @@ function PostDeleteButton({ id, type }: { id: number; type: Post["type"] }) { function PostExpandableText({ text, - isExpanded: _isExpanded, + isExpanded, setIsExpanded, - expandableCriteria, + alwaysExpandable = false, }: { text: string; isExpanded: boolean; setIsExpanded: (isExpanded: boolean) => void; - expandableCriteria?: number; + /** Keeps the button even when the text fits, for expanding content outside the text (team roster) */ + alwaysExpandable?: boolean; }) { const { t } = useTranslation(["common"]); - const isExpandable = !expandableCriteria || text.length > expandableCriteria; + const [isClipped, setIsClipped] = React.useState( + () => + text.length > CLIPPED_TEXT_GUESS.length || + text.split("\n").length > CLIPPED_TEXT_GUESS.lines, + ); - const isExpanded = !isExpandable ? true : _isExpanded; + const measureText = (element: HTMLDivElement | null) => { + if (!element) return; + + const observer = new ResizeObserver(() => { + setIsClipped(element.offsetHeight > COLLAPSED_TEXT_HEIGHT); + }); + observer.observe(element); + + return () => observer.disconnect(); + }; + + const showButton = isClipped || alwaysExpandable; + const isTextCollapsed = isClipped && !isExpanded; return (
-
{text}
- {isExpandable ? ( +
+ {text} +
+ {showButton ? ( setIsExpanded(!isExpanded)} - className={clsx([styles.showAllButton], { - [styles.showAllButtonExpanded]: isExpanded, + className={clsx(styles.showAllButton, { + [styles.showAllButtonBelow]: !isTextCollapsed, })} variant="outlined" size="small" @@ -405,7 +459,7 @@ function PostExpandableText({ : t("common:actions.showMore")} ) : null} - {!isExpanded ?
: null} + {isTextCollapsed ?
: null}
); } diff --git a/app/features/lfg/routes/lfg.module.css b/app/features/lfg/routes/lfg.module.css index 0ef45816d..4a9afa8ab 100644 --- a/app/features/lfg/routes/lfg.module.css +++ b/app/features/lfg/routes/lfg.module.css @@ -1,3 +1,15 @@ +/* the gap is what surrounds an expiry alert; a post not led by one doubles it */ +.posts { + display: flex; + flex-direction: column; + gap: var(--s-6); +} + .post { + margin-block-start: var(--s-6); scroll-margin-top: 6rem; } + +.expiryAlert + .post { + margin-block-start: 0; +} diff --git a/app/features/lfg/routes/lfg.tsx b/app/features/lfg/routes/lfg.tsx index 6c1e29214..21cf1297f 100644 --- a/app/features/lfg/routes/lfg.tsx +++ b/app/features/lfg/routes/lfg.tsx @@ -1,5 +1,5 @@ -import clsx from "clsx"; import { add, sub } from "date-fns"; +import * as React from "react"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; import { useLoaderData } from "react-router"; @@ -72,12 +72,14 @@ export default function LFGPage() { return (
- +
+ + {data.posts.length > 0 ? : null} +
{data.posts.length > 0 ? ( - <> - - {data.pagesCount > 1 ? : null} - + data.pagesCount > 1 ? ( + + ) : null ) : ( {t("lfg:noPosts")} )} @@ -101,20 +103,14 @@ function PostsList({ posts }: { posts: LFGLoaderData["posts"] }) { return true; }; - return ( - <> - {posts.map((post) => ( -
- {showExpiryAlert(post) ? : null} - -
- ))} - - ); + return posts.map((post) => ( + + {showExpiryAlert(post) ? : null} +
+ +
+
+ )); } function Filters() { @@ -352,7 +348,7 @@ function PostExpiryAlert({ postId }: { postId: number }) { const { t } = useTranslation(["common", "lfg"]); return ( - +
{t("lfg:expiring")}{" "} Date: Sat, 19 Sep 2026 18:48:17 +0300 Subject: [PATCH 09/50] Comp image export --- .../components/DamageComboBar.tsx | 9 +- .../comp-analyzer/routes/comp-analyzer.tsx | 121 +++++++- .../components-showcase/routes/components.tsx | 45 ++- .../components/CompGraphic.module.css | 225 ++++++++++++++ .../img-export/components/CompGraphic.tsx | 279 ++++++++++++++++++ .../2026-09-19-comp-analyzer-image-export.md | 5 + locales/da/analyzer.json | 3 +- locales/da/common.json | 1 + locales/de/analyzer.json | 3 +- locales/de/common.json | 1 + locales/en/analyzer.json | 3 +- locales/en/common.json | 1 + locales/es-ES/analyzer.json | 3 +- locales/es-ES/common.json | 1 + locales/es-US/analyzer.json | 3 +- locales/es-US/common.json | 1 + locales/fr-CA/analyzer.json | 3 +- locales/fr-CA/common.json | 1 + locales/fr-EU/analyzer.json | 3 +- locales/fr-EU/common.json | 1 + locales/he/analyzer.json | 3 +- locales/he/common.json | 1 + locales/it/analyzer.json | 3 +- locales/it/common.json | 1 + locales/ja/analyzer.json | 3 +- locales/ja/common.json | 1 + locales/ko/analyzer.json | 3 +- locales/ko/common.json | 1 + locales/nl/analyzer.json | 3 +- locales/nl/common.json | 1 + locales/pl/analyzer.json | 3 +- locales/pl/common.json | 1 + locales/pt-BR/analyzer.json | 3 +- locales/pt-BR/common.json | 1 + locales/ru/analyzer.json | 3 +- locales/ru/common.json | 1 + locales/zh/analyzer.json | 3 +- locales/zh/common.json | 1 + 38 files changed, 727 insertions(+), 21 deletions(-) create mode 100644 app/features/img-export/components/CompGraphic.module.css create mode 100644 app/features/img-export/components/CompGraphic.tsx create mode 100644 changelog/2026-09-19-comp-analyzer-image-export.md diff --git a/app/features/comp-analyzer/components/DamageComboBar.tsx b/app/features/comp-analyzer/components/DamageComboBar.tsx index 4afd51853..098508f30 100644 --- a/app/features/comp-analyzer/components/DamageComboBar.tsx +++ b/app/features/comp-analyzer/components/DamageComboBar.tsx @@ -260,11 +260,16 @@ function FilteredItem({ filterKey, onRestore }: FilteredItemProps) { interface DamageComboListProps { weaponIds: MainWeaponId[]; + excludedKeys: ExcludedDamageKey[]; + onExcludedKeysChange: (keys: ExcludedDamageKey[]) => void; } -export function DamageComboList({ weaponIds }: DamageComboListProps) { +export function DamageComboList({ + weaponIds, + excludedKeys, + onExcludedKeysChange: setExcludedKeys, +}: DamageComboListProps) { const { t } = useTranslation(["analyzer"]); - const [excludedKeys, setExcludedKeys] = useState([]); const [initialTargetResAp, commitTargetResAp] = useTargetResAp(); const [initialTargetSubDefenseAp, commitTargetSubDefenseAp] = useTargetSubDefenseAp(); diff --git a/app/features/comp-analyzer/routes/comp-analyzer.tsx b/app/features/comp-analyzer/routes/comp-analyzer.tsx index 1c1776792..2b08eda31 100644 --- a/app/features/comp-analyzer/routes/comp-analyzer.tsx +++ b/app/features/comp-analyzer/routes/comp-analyzer.tsx @@ -1,19 +1,34 @@ +import { HardDriveDownload } from "lucide-react"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import type { MetaFunction, ShouldRevalidateFunction } from "react-router"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouSwitch } from "~/components/elements/Switch"; import { Main } from "~/components/Main"; import { Placeholder } from "~/components/Placeholder"; +import { CompGraphic } from "~/features/img-export/components/CompGraphic"; +import { ImageExportDialog } from "~/features/img-export/components/ImageExportDialog"; import { useHydrated } from "~/hooks/useHydrated"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { COMP_ANALYZER_URL, navIconUrl } from "~/utils/urls"; import { metaTags, ogPageImage } from "../../../utils/remix"; import { MAX_WEAPONS } from "../comp-analyzer-constants"; -import { useCategorization, useSelectedWeapons } from "../comp-analyzer-hooks"; +import { + useCategorization, + useSelectedWeapons, + useSingleWeaponCombos, + useTargetSubDefenseAp, +} from "../comp-analyzer-hooks"; import { DamageComboList } from "../components/DamageComboBar"; import { RangeVisualization } from "../components/RangeVisualization"; import { SelectedWeapons } from "../components/SelectedWeapons"; import { WeaponCategories } from "../components/WeaponCategories"; import { WeaponGrid } from "../components/WeaponGrid"; +import { + calculateDamageCombos, + type ExcludedDamageKey, +} from "../core/damage-combinations"; export const meta: MetaFunction = (args) => { return metaTags({ @@ -53,6 +68,9 @@ function CompAnalyzerPage() { const [isGridCollapsed, setIsGridCollapsed] = useState( selectedWeaponIds.length >= MAX_WEAPONS, ); + const [excludedDamageKeys, setExcludedDamageKeys] = useState< + ExcludedDamageKey[] + >([]); const handleWeaponClick = (weaponId: MainWeaponId) => { if (selectedWeaponIds.length >= MAX_WEAPONS) { @@ -81,6 +99,14 @@ function CompAnalyzerPage() { onRemove={handleRemoveWeapon} onReorder={setSelectedWeaponIds} /> + {selectedWeaponIds.length >= MAX_WEAPONS ? ( +
+ +
+ ) : null} setIsGridCollapsed(!isGridCollapsed)} /> - +
); } + +interface CompExportProps { + weaponIds: MainWeaponId[]; + excludedDamageKeys: ExcludedDamageKey[]; +} + +function CompExportDialog({ weaponIds, excludedDamageKeys }: CompExportProps) { + const { t } = useTranslation(["common", "analyzer"]); + const [title, setTitle] = useState(""); + const [showRanges, setShowRanges] = useState(true); + const [showCombos, setShowCombos] = useState(true); + + return ( + } + > + {t("common:imageExport.export")} + + } + heading={t("common:imageExport.export")} + filename="comp" + settings={ + <> + setTitle(e.target.value)} + placeholder={t("common:imageExport.title")} + aria-label={t("common:imageExport.title")} + /> + + {t("analyzer:comp.weaponRanges")} + + + {t("analyzer:comp.damageCombos")} + + + } + > + + + ); +} + +/** Combos are only computed once the dialog opens (its content is lazy) */ +function CompGraphicWithCombos({ + weaponIds, + excludedDamageKeys, + title, + showRanges, + showCombos, +}: CompExportProps & { + title: string; + showRanges: boolean; + showCombos: boolean; +}) { + const [singleWeaponCombos] = useSingleWeaponCombos(); + const [targetSubDefenseAp] = useTargetSubDefenseAp(); + + const combos = calculateDamageCombos( + weaponIds, + excludedDamageKeys, + targetSubDefenseAp, + undefined, + weaponIds.length === 1 || singleWeaponCombos, + ); + + return ( + + ); +} diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx index dfd501ed8..da2eed9ee 100644 --- a/app/features/components-showcase/routes/components.tsx +++ b/app/features/components-showcase/routes/components.tsx @@ -63,8 +63,10 @@ import { ChangelogGraphic, type ChangelogGraphicEntry, } from "~/features/changelog/components/ChangelogGraphic"; +import { calculateDamageCombos } from "~/features/comp-analyzer/core/damage-combinations"; import { useGlobalStatus } from "~/features/global-status/GlobalStatusProvider"; import type { GlobalStatus } from "~/features/global-status/global-status-types"; +import { CompGraphic } from "~/features/img-export/components/CompGraphic"; import { SeasonSummaryGraphic, type SeasonSummaryGraphicActivity, @@ -100,7 +102,16 @@ import { EXAMPLE_TROPHY_MODEL } from "../example-trophy-model"; import { formFieldsShowcaseSchema } from "../form-examples-schema"; export const handle: SendouRouteHandle = { - i18n: ["user", "q", "calendar", "tournament", "schedule", "builds", "lfg"], + i18n: [ + "user", + "q", + "calendar", + "tournament", + "schedule", + "builds", + "lfg", + "analyzer", + ], }; export const SECTIONS = [ @@ -148,6 +159,11 @@ export const SECTIONS = [ id: "lfg-post-graphic", component: LFGPostGraphicSection, }, + { + title: "Comp Graphic", + id: "comp-graphic", + component: CompGraphicSection, + }, { title: "Changelog Graphic", id: "changelog-graphic", @@ -2282,6 +2298,33 @@ function LFGPostGraphicSection({ id }: { id: string }) { ); } +const COMP_GRAPHIC_WEAPON_IDS: MainWeaponId[] = [40, 220, 2070, 5010]; + +function CompGraphicSection({ id }: { id: string }) { + return ( +
+ Comp Graphic + +
+ + + + + + + +
+
+ ); +} + const CHANGELOG_GRAPHIC_ENTRIES_SMALL: ChangelogGraphicEntry[] = [ { navItems: ["plans"], diff --git a/app/features/img-export/components/CompGraphic.module.css b/app/features/img-export/components/CompGraphic.module.css new file mode 100644 index 000000000..8da7edc98 --- /dev/null +++ b/app/features/img-export/components/CompGraphic.module.css @@ -0,0 +1,225 @@ +.slot { + &[data-slot="0"] { + --slot-color: #f5d742; + } + + &[data-slot="1"] { + --slot-color: #f5b8d0; + } + + &[data-slot="2"] { + --slot-color: #90e8a8; + } + + &[data-slot="3"] { + --slot-color: #8cd4f5; + } +} + +.subtitle { + font-size: var(--font-xs); + font-weight: var(--weight-semi); + color: var(--graphic-text-dim); +} + +.weaponsList { + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.weaponRow { + position: relative; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: var(--s-3); + padding: var(--s-2) var(--s-3) var(--s-2) var(--s-5); + background-color: var(--graphic-row-bg); + border: 1.5px solid var(--graphic-row-border); + border-radius: var(--radius-box); + + /* a pill inside the box instead of a left border so the color is not cut by the rounded corners */ + &::before { + content: ""; + position: absolute; + inset-block: var(--s-1-5); + left: var(--s-1-5); + width: 4px; + background-color: var(--slot-color); + border-radius: var(--radius-full); + } +} + +.weaponBadge { + display: flex; + padding: var(--s-0-5); + background-color: oklch(0% 0 0 / 0.3); + border-radius: 50%; +} + +.weaponInfo { + min-width: 0; + overflow: hidden; +} + +.weaponName { + overflow: hidden; + font-size: var(--font-sm); + font-weight: var(--weight-extra); + white-space: nowrap; + text-overflow: ellipsis; +} + +.weaponRoles { + display: flex; + align-items: center; + gap: var(--s-1-5); + margin-top: var(--s-0-5); + font-size: var(--font-xs); + font-weight: var(--weight-semi); + color: var(--graphic-text-dim); +} + +.weaponRoleSeparator { + opacity: 0.5; +} + +.kit { + display: flex; + gap: var(--s-1-5); + padding: var(--s-1-5); + background-color: oklch(0% 0 0 / 0.3); + border-radius: var(--radius-field); +} + +.rangeChart { + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.rangeRow { + display: grid; + grid-template-columns: auto 1fr 3rem; + align-items: center; + gap: var(--s-2); +} + +.rangeTrack { + position: relative; + height: 14px; + overflow: hidden; + background-color: var(--graphic-row-bg); + border: 1.5px solid var(--graphic-row-border); + border-radius: var(--radius-full); +} + +.rangeBar { + position: absolute; + inset-block: 0; + left: 0; + background-color: var(--slot-color); + border-radius: var(--radius-full); + + /* the blast radius continues the bar, so its end stays square */ + &.rangeBarWithBlast { + border-start-end-radius: 0; + border-end-end-radius: 0; + } +} + +.rangeBlast { + position: absolute; + inset-block: 0; + background-color: color-mix(in oklch, var(--slot-color) 35%, transparent); + border-radius: 0 var(--radius-full) var(--radius-full) 0; +} + +.rangeValue { + font-size: var(--font-sm); + font-weight: var(--weight-extra); + font-variant-numeric: tabular-nums; + text-align: right; +} + +.combosList { + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.comboRow { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: var(--s-3); +} + +.comboBar { + position: relative; + display: flex; + overflow: hidden; + background-color: var(--graphic-row-bg); + border: 1.5px solid var(--graphic-row-border); + border-radius: var(--radius-field); +} + +.comboSegment { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + gap: var(--s-1); + /* the widths are damage shares, but a segment never gets narrower than its content */ + min-width: fit-content; + height: 32px; + padding: 0 var(--s-2); + color: oklch(20% 0 0); + background-color: var(--slot-color); +} + +.segmentDamage { + font-size: var(--font-xs); + font-weight: var(--weight-extra); + font-variant-numeric: tabular-nums; +} + +.segmentMultiplier { + margin-left: var(--s-0-5); + font-size: var(--font-2xs); + font-weight: var(--weight-semi); + opacity: 0.8; +} + +.lethalLine { + position: absolute; + inset-block: 0; + z-index: 1; + width: 0; + border-left: 2px dashed oklch(20% 0 0 / 0.6); +} + +.comboTotal { + display: flex; + flex-direction: column; + align-items: flex-end; + min-width: 3.5rem; + line-height: 1.15; +} + +.comboDamage { + font-size: var(--font-md); + font-weight: var(--weight-extra); + font-variant-numeric: tabular-nums; +} + +.comboDamageLethal { + color: var(--graphic-win); +} + +.comboHits { + font-size: var(--font-2xs); + font-weight: var(--weight-semi); + color: var(--graphic-text-dim); +} diff --git a/app/features/img-export/components/CompGraphic.tsx b/app/features/img-export/components/CompGraphic.tsx new file mode 100644 index 000000000..0f0d6fc4a --- /dev/null +++ b/app/features/img-export/components/CompGraphic.tsx @@ -0,0 +1,279 @@ +import clsx from "clsx"; +import { useTranslation } from "react-i18next"; +import { + Image, + SpecialWeaponImage, + SubWeaponImage, + WeaponImage, +} from "~/components/Image"; +import { mainWeaponParams } from "~/features/build-analyzer/core/utils"; +import { + LETHAL_DAMAGE, + SPECIAL_WEAPON_CATEGORIES, + SUB_WEAPON_CATEGORIES, +} from "~/features/comp-analyzer/comp-analyzer-constants"; +import type { + DamageCombo, + DamageSegment, +} from "~/features/comp-analyzer/comp-analyzer-types"; +import { getWeaponsWithRange } from "~/features/comp-analyzer/core/weapon-range"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { COMP_ANALYZER_URL, navIconUrl } from "~/utils/urls"; +import styles from "./CompGraphic.module.css"; +import { + GraphicContainer, + GraphicFooter, + GraphicHeader, + GraphicSectionDivider, + GraphicSiteUrl, + GraphicTitle, +} from "./Graphic"; + +const COMP_GRAPHIC_WIDTH = 600; +const MAX_COMBOS = 5; +/** Same scale as the analyzer's trajectory chart */ +const RANGE_CHART_MAX = 32; + +export function CompGraphic({ + weaponIds, + combos, + title, + showRanges = true, + showCombos = true, +}: { + weaponIds: MainWeaponId[]; + /** Without a title the whole header is left out */ + title?: string; + /** Damage combos in the order the analyzer shows them, only the top ones get rendered */ + combos: DamageCombo[]; + showRanges?: boolean; + showCombos?: boolean; +}) { + const { t } = useTranslation(["analyzer"]); + + const weaponsWithRange = getWeaponsWithRange(weaponIds); + const topCombos = combos.slice(0, MAX_COMBOS); + + return ( + + {title ? ( + + } + titleRow={{title}} + subtitle={ +
+ {t("analyzer:comp.graphicSubtitle")} +
+ } + /> + ) : null} +
+ {weaponIds.map((weaponId, slot) => ( + + ))} +
+ {showRanges && weaponsWithRange.length > 0 ? ( + <> + + {t("analyzer:comp.weaponRanges")} + + ({ + ...weapon, + slot: weaponIds.indexOf(weapon.weaponId), + }))} + /> + + ) : null} + {showCombos && topCombos.length > 0 ? ( + <> + + {t("analyzer:comp.damageCombos")} + +
+ {topCombos.map((combo, index) => ( + + ))} +
+ + ) : null} + + + +
+ ); +} + +function WeaponRow({ + weaponId, + slot, +}: { + weaponId: MainWeaponId; + slot: number; +}) { + const { t } = useTranslation(["weapons", "analyzer"]); + + const params = mainWeaponParams(weaponId); + const subCategory = SUB_WEAPON_CATEGORIES[params.subWeaponId]; + const specialCategory = SPECIAL_WEAPON_CATEGORIES[params.specialWeaponId]; + + return ( +
+
+ +
+
+
{t(`weapons:MAIN_${weaponId}`)}
+
+ {t(`analyzer:comp.subCategory.${subCategory}`)} + · + {t(`analyzer:comp.specialCategory.${specialCategory}`)} +
+
+
+ + +
+
+ ); +} + +function RangeChart({ + weapons, +}: { + weapons: Array<{ + weaponId: MainWeaponId; + range: number; + blastRadius?: number; + slot: number; + }>; +}) { + return ( +
+ {weapons.map((weapon) => ( +
+ +
+
+ {weapon.blastRadius ? ( +
+ ) : null} +
+
{weapon.range.toFixed(1)}
+
+ ))} +
+ ); +} + +function ComboRow({ combo }: { combo: DamageCombo }) { + const { t } = useTranslation(["analyzer"]); + + const barTotal = Math.max(combo.totalDamage, LETHAL_DAMAGE); + const lethalPosition = (LETHAL_DAMAGE / combo.totalDamage) * 100; + + return ( +
+
+ {combo.segments.map((segment, index) => ( + + ))} + {lethalPosition < 100 ? ( +
+ ) : null} +
+
+ = LETHAL_DAMAGE, + })} + > + {combo.totalDamage.toFixed(1)} + + + {t("analyzer:comp.hits", { count: combo.hitCount })} + +
+
+ ); +} + +function ComboSegment({ + segment, + barTotal, +}: { + segment: DamageSegment; + barTotal: number; +}) { + const width = ((segment.damageValue * segment.count) / barTotal) * 100; + + return ( +
+ + + {segment.damageValue} + {segment.count > 1 ? ( + ×{segment.count} + ) : null} + +
+ ); +} + +function SegmentIcon({ segment }: { segment: DamageSegment }) { + const params = mainWeaponParams(segment.weaponId); + + if (segment.isSubWeapon) { + return ; + } + + if (segment.isSpecialWeapon) { + return ( + + ); + } + + return ( + + ); +} + +function rangePercentage(range: number) { + return `${Math.min(range / RANGE_CHART_MAX, 1) * 100}%`; +} diff --git a/changelog/2026-09-19-comp-analyzer-image-export.md b/changelog/2026-09-19-comp-analyzer-image-export.md new file mode 100644 index 000000000..efcf8fb03 --- /dev/null +++ b/changelog/2026-09-19-comp-analyzer-image-export.md @@ -0,0 +1,5 @@ +--- +navItem: comp-analyzer +type: feature +--- +Export your team composition from the comp analyzer as an image to share diff --git a/locales/da/analyzer.json b/locales/da/analyzer.json index 9990c658e..ac183de84 100644 --- a/locales/da/analyzer.json +++ b/locales/da/analyzer.json @@ -218,5 +218,6 @@ "comp.specialCategory.TEAM_BUFF": "", "comp.removeAll": "", "comp.addAll": "", - "comp.singleWeaponCombos": "" + "comp.singleWeaponCombos": "", + "comp.graphicSubtitle": "" } diff --git a/locales/da/common.json b/locales/da/common.json index 3d8b3c0b3..25b40ab51 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -184,6 +184,7 @@ "imageExport.theme.lightCustom": "", "imageExport.theme.darkCustom": "", "imageExport.qrCode": "", + "imageExport.title": "", "imageExport.buildTitle": "", "imageExport.abilityPoints": "", "imageExport.abilityChunks": "", diff --git a/locales/de/analyzer.json b/locales/de/analyzer.json index d95075530..b62d5b6eb 100644 --- a/locales/de/analyzer.json +++ b/locales/de/analyzer.json @@ -218,5 +218,6 @@ "comp.specialCategory.TEAM_BUFF": "", "comp.removeAll": "", "comp.addAll": "", - "comp.singleWeaponCombos": "" + "comp.singleWeaponCombos": "", + "comp.graphicSubtitle": "" } diff --git a/locales/de/common.json b/locales/de/common.json index 5df14944c..560fb7633 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -184,6 +184,7 @@ "imageExport.theme.lightCustom": "", "imageExport.theme.darkCustom": "", "imageExport.qrCode": "", + "imageExport.title": "", "imageExport.buildTitle": "", "imageExport.abilityPoints": "", "imageExport.abilityChunks": "", diff --git a/locales/en/analyzer.json b/locales/en/analyzer.json index 047ec7be0..25cc78dd3 100644 --- a/locales/en/analyzer.json +++ b/locales/en/analyzer.json @@ -218,5 +218,6 @@ "comp.specialCategory.TEAM_BUFF": "Team Buff", "comp.removeAll": "Remove all", "comp.addAll": "Add all", - "comp.singleWeaponCombos": "Single weapon combos" + "comp.singleWeaponCombos": "Single weapon combos", + "comp.graphicSubtitle": "Composition Analyzer" } diff --git a/locales/en/common.json b/locales/en/common.json index bba61137b..3725ae932 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -184,6 +184,7 @@ "imageExport.theme.lightCustom": "Light (custom)", "imageExport.theme.darkCustom": "Dark (custom)", "imageExport.qrCode": "QR code", + "imageExport.title": "Title", "imageExport.buildTitle": "Build title", "imageExport.abilityPoints": "Ability points", "imageExport.abilityChunks": "Ability chunks", diff --git a/locales/es-ES/analyzer.json b/locales/es-ES/analyzer.json index 290aa9585..c59003008 100644 --- a/locales/es-ES/analyzer.json +++ b/locales/es-ES/analyzer.json @@ -222,5 +222,6 @@ "comp.specialCategory.TEAM_BUFF": "Mejora de equipo", "comp.removeAll": "Eliminar todos", "comp.addAll": "Añadir todos", - "comp.singleWeaponCombos": "Combos de un arma" + "comp.singleWeaponCombos": "Combos de un arma", + "comp.graphicSubtitle": "" } diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 87c7eece4..310a93927 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -184,6 +184,7 @@ "imageExport.theme.lightCustom": "Claro (personalizado)", "imageExport.theme.darkCustom": "Oscuro (personalizado)", "imageExport.qrCode": "Código QR", + "imageExport.title": "", "imageExport.buildTitle": "Título de la build", "imageExport.abilityPoints": "Puntos de potenciador", "imageExport.abilityChunks": "Fragmentos", diff --git a/locales/es-US/analyzer.json b/locales/es-US/analyzer.json index 689bcf85b..996ac0c53 100644 --- a/locales/es-US/analyzer.json +++ b/locales/es-US/analyzer.json @@ -222,5 +222,6 @@ "comp.specialCategory.TEAM_BUFF": "Mejora de equipo", "comp.removeAll": "Eliminar todos", "comp.addAll": "Añadir todos", - "comp.singleWeaponCombos": "Combos de un arma" + "comp.singleWeaponCombos": "Combos de un arma", + "comp.graphicSubtitle": "" } diff --git a/locales/es-US/common.json b/locales/es-US/common.json index 262f242ff..b3321022a 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -184,6 +184,7 @@ "imageExport.theme.lightCustom": "Claro (personalizado)", "imageExport.theme.darkCustom": "Oscuro (personalizado)", "imageExport.qrCode": "Código QR", + "imageExport.title": "", "imageExport.buildTitle": "Título de la build", "imageExport.abilityPoints": "Puntos de potenciador", "imageExport.abilityChunks": "Fragmentos", diff --git a/locales/fr-CA/analyzer.json b/locales/fr-CA/analyzer.json index 105d04b89..12388804b 100644 --- a/locales/fr-CA/analyzer.json +++ b/locales/fr-CA/analyzer.json @@ -222,5 +222,6 @@ "comp.specialCategory.TEAM_BUFF": "", "comp.removeAll": "", "comp.addAll": "", - "comp.singleWeaponCombos": "" + "comp.singleWeaponCombos": "", + "comp.graphicSubtitle": "" } diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 38f6150a8..1d760c4a3 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -184,6 +184,7 @@ "imageExport.theme.lightCustom": "", "imageExport.theme.darkCustom": "", "imageExport.qrCode": "", + "imageExport.title": "", "imageExport.buildTitle": "", "imageExport.abilityPoints": "", "imageExport.abilityChunks": "", diff --git a/locales/fr-EU/analyzer.json b/locales/fr-EU/analyzer.json index 07649ea07..01c689472 100644 --- a/locales/fr-EU/analyzer.json +++ b/locales/fr-EU/analyzer.json @@ -222,5 +222,6 @@ "comp.specialCategory.TEAM_BUFF": "", "comp.removeAll": "", "comp.addAll": "", - "comp.singleWeaponCombos": "" + "comp.singleWeaponCombos": "", + "comp.graphicSubtitle": "" } diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index 1e25a3a3f..1f8d8b680 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -184,6 +184,7 @@ "imageExport.theme.lightCustom": "", "imageExport.theme.darkCustom": "", "imageExport.qrCode": "", + "imageExport.title": "", "imageExport.buildTitle": "", "imageExport.abilityPoints": "", "imageExport.abilityChunks": "", diff --git a/locales/he/analyzer.json b/locales/he/analyzer.json index f2dbbb8fb..f5d919788 100644 --- a/locales/he/analyzer.json +++ b/locales/he/analyzer.json @@ -222,5 +222,6 @@ "comp.specialCategory.TEAM_BUFF": "חיזוק הקבוצה", "comp.removeAll": "הסר הכל", "comp.addAll": "הוסף הכל", - "comp.singleWeaponCombos": "" + "comp.singleWeaponCombos": "", + "comp.graphicSubtitle": "" } diff --git a/locales/he/common.json b/locales/he/common.json index 39eb42d74..49edd753b 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -184,6 +184,7 @@ "imageExport.theme.lightCustom": "", "imageExport.theme.darkCustom": "", "imageExport.qrCode": "", + "imageExport.title": "", "imageExport.buildTitle": "", "imageExport.abilityPoints": "", "imageExport.abilityChunks": "", diff --git a/locales/it/analyzer.json b/locales/it/analyzer.json index 0e25040cf..647ecedaf 100644 --- a/locales/it/analyzer.json +++ b/locales/it/analyzer.json @@ -222,5 +222,6 @@ "comp.specialCategory.TEAM_BUFF": "", "comp.removeAll": "", "comp.addAll": "", - "comp.singleWeaponCombos": "" + "comp.singleWeaponCombos": "", + "comp.graphicSubtitle": "" } diff --git a/locales/it/common.json b/locales/it/common.json index ae47f54af..54e2e3ce8 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -184,6 +184,7 @@ "imageExport.theme.lightCustom": "", "imageExport.theme.darkCustom": "", "imageExport.qrCode": "", + "imageExport.title": "", "imageExport.buildTitle": "", "imageExport.abilityPoints": "", "imageExport.abilityChunks": "", diff --git a/locales/ja/analyzer.json b/locales/ja/analyzer.json index fed434635..a8ab48f47 100644 --- a/locales/ja/analyzer.json +++ b/locales/ja/analyzer.json @@ -210,5 +210,6 @@ "comp.specialCategory.TEAM_BUFF": "", "comp.removeAll": "", "comp.addAll": "", - "comp.singleWeaponCombos": "" + "comp.singleWeaponCombos": "", + "comp.graphicSubtitle": "" } diff --git a/locales/ja/common.json b/locales/ja/common.json index 609ed2329..46a3474f7 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -184,6 +184,7 @@ "imageExport.theme.lightCustom": "", "imageExport.theme.darkCustom": "", "imageExport.qrCode": "", + "imageExport.title": "", "imageExport.buildTitle": "", "imageExport.abilityPoints": "", "imageExport.abilityChunks": "", diff --git a/locales/ko/analyzer.json b/locales/ko/analyzer.json index da90a1851..6ccc2aa71 100644 --- a/locales/ko/analyzer.json +++ b/locales/ko/analyzer.json @@ -210,5 +210,6 @@ "comp.specialCategory.TEAM_BUFF": "", "comp.removeAll": "", "comp.addAll": "", - "comp.singleWeaponCombos": "" + "comp.singleWeaponCombos": "", + "comp.graphicSubtitle": "" } diff --git a/locales/ko/common.json b/locales/ko/common.json index 60514d1c6..a2e2289e2 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -184,6 +184,7 @@ "imageExport.theme.lightCustom": "", "imageExport.theme.darkCustom": "", "imageExport.qrCode": "", + "imageExport.title": "", "imageExport.buildTitle": "", "imageExport.abilityPoints": "", "imageExport.abilityChunks": "", diff --git a/locales/nl/analyzer.json b/locales/nl/analyzer.json index 712a992d6..559986a49 100644 --- a/locales/nl/analyzer.json +++ b/locales/nl/analyzer.json @@ -218,5 +218,6 @@ "comp.specialCategory.TEAM_BUFF": "", "comp.removeAll": "", "comp.addAll": "", - "comp.singleWeaponCombos": "" + "comp.singleWeaponCombos": "", + "comp.graphicSubtitle": "" } diff --git a/locales/nl/common.json b/locales/nl/common.json index 432f974ff..714f11d27 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -184,6 +184,7 @@ "imageExport.theme.lightCustom": "", "imageExport.theme.darkCustom": "", "imageExport.qrCode": "", + "imageExport.title": "", "imageExport.buildTitle": "", "imageExport.abilityPoints": "", "imageExport.abilityChunks": "", diff --git a/locales/pl/analyzer.json b/locales/pl/analyzer.json index 4700cb7b5..b9adfd20f 100644 --- a/locales/pl/analyzer.json +++ b/locales/pl/analyzer.json @@ -226,5 +226,6 @@ "comp.specialCategory.TEAM_BUFF": "", "comp.removeAll": "", "comp.addAll": "", - "comp.singleWeaponCombos": "" + "comp.singleWeaponCombos": "", + "comp.graphicSubtitle": "" } diff --git a/locales/pl/common.json b/locales/pl/common.json index cae63d164..d69c02eb1 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -184,6 +184,7 @@ "imageExport.theme.lightCustom": "", "imageExport.theme.darkCustom": "", "imageExport.qrCode": "", + "imageExport.title": "", "imageExport.buildTitle": "", "imageExport.abilityPoints": "", "imageExport.abilityChunks": "", diff --git a/locales/pt-BR/analyzer.json b/locales/pt-BR/analyzer.json index 3e60c1bc4..fb72dce13 100644 --- a/locales/pt-BR/analyzer.json +++ b/locales/pt-BR/analyzer.json @@ -222,5 +222,6 @@ "comp.specialCategory.TEAM_BUFF": "", "comp.removeAll": "", "comp.addAll": "", - "comp.singleWeaponCombos": "" + "comp.singleWeaponCombos": "", + "comp.graphicSubtitle": "" } diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index 820f33c3e..244903da1 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -184,6 +184,7 @@ "imageExport.theme.lightCustom": "", "imageExport.theme.darkCustom": "", "imageExport.qrCode": "", + "imageExport.title": "", "imageExport.buildTitle": "", "imageExport.abilityPoints": "", "imageExport.abilityChunks": "", diff --git a/locales/ru/analyzer.json b/locales/ru/analyzer.json index 2cca82f04..ebf3a53e4 100644 --- a/locales/ru/analyzer.json +++ b/locales/ru/analyzer.json @@ -226,5 +226,6 @@ "comp.specialCategory.TEAM_BUFF": "", "comp.removeAll": "", "comp.addAll": "", - "comp.singleWeaponCombos": "" + "comp.singleWeaponCombos": "", + "comp.graphicSubtitle": "" } diff --git a/locales/ru/common.json b/locales/ru/common.json index c3925a5c0..cb8d048bd 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -184,6 +184,7 @@ "imageExport.theme.lightCustom": "", "imageExport.theme.darkCustom": "", "imageExport.qrCode": "", + "imageExport.title": "", "imageExport.buildTitle": "", "imageExport.abilityPoints": "", "imageExport.abilityChunks": "", diff --git a/locales/zh/analyzer.json b/locales/zh/analyzer.json index adbc36396..a29764961 100644 --- a/locales/zh/analyzer.json +++ b/locales/zh/analyzer.json @@ -213,5 +213,6 @@ "comp.specialCategory.TEAM_BUFF": "团队增益", "comp.removeAll": "移除所有", "comp.addAll": "添加所有", - "comp.singleWeaponCombos": "" + "comp.singleWeaponCombos": "", + "comp.graphicSubtitle": "" } diff --git a/locales/zh/common.json b/locales/zh/common.json index 93a2aec72..3f056ea26 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -184,6 +184,7 @@ "imageExport.theme.lightCustom": "", "imageExport.theme.darkCustom": "", "imageExport.qrCode": "", + "imageExport.title": "", "imageExport.buildTitle": "", "imageExport.abilityPoints": "", "imageExport.abilityChunks": "", From e60671ead0950ef541cfe5c7c023b92402b5fef6 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:52:57 +0300 Subject: [PATCH 10/50] Fix E2E test --- e2e/helpers/playwright.ts | 29 +++++++++++++++++++++++++++++ e2e/sendouq.spec.ts | 9 ++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/e2e/helpers/playwright.ts b/e2e/helpers/playwright.ts index 09afdc184..6ae95a8b6 100644 --- a/e2e/helpers/playwright.ts +++ b/e2e/helpers/playwright.ts @@ -29,6 +29,7 @@ interface RouterProbe { declare global { interface Window { __routerProbe?: RouterProbe; + __scrollYAtPress?: number; } } @@ -503,6 +504,34 @@ export async function waitForDropToSettle(page: Page) { await page.waitForTimeout(2 * DND_KIT_CLICK_SUPPRESSION_MS); } +/** + * The scroll position the page was at when it was last pressed, for asserting that + * an action did not move the viewer. Playwright scrolls a click target into view + * itself, and a retried click force-scrolls it again, so a reading taken before the + * press can be stale by the time the press lands. + */ +export async function trackScrollYAtPress(page: Page) { + await page.evaluate(() => { + window.__scrollYAtPress = undefined; + document.addEventListener( + "pointerdown", + () => { + window.__scrollYAtPress = window.scrollY; + }, + { capture: true }, + ); + }); + + return async () => { + const scrollY = await page.evaluate(() => window.__scrollYAtPress); + if (typeof scrollY !== "number") { + throw new Error("The page was never pressed"); + } + + return scrollY; + }; +} + /** Asserts the page rendered rather than the error boundary catching something. */ export async function expectNoErrorPage(page: Page) { await expect(page.getByTestId("error-page")).toHaveCount(0); diff --git a/e2e/sendouq.spec.ts b/e2e/sendouq.spec.ts index 3d6cde298..249bb6efc 100644 --- a/e2e/sendouq.spec.ts +++ b/e2e/sendouq.spec.ts @@ -19,6 +19,7 @@ import { isNotVisible, runRoutine, test, + trackScrollYAtPress, } from "./helpers/playwright"; import { NotificationPopover } from "./pages/layout/notification-popover"; import { SendouQLookingPage } from "./pages/sendouq/sendouq-looking-page"; @@ -237,13 +238,15 @@ test.describe("SendouQ", () => { const lastCard = looking.groupCard(otherUsers.length - 1); await lastCard.root.scrollIntoViewIfNeeded(); - const scrollBefore = await page.evaluate(() => window.scrollY); - expect(scrollBefore).toBeGreaterThan(0); + expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0); + const scrollYAtPress = await trackScrollYAtPress(page); await lastCard.pressSuggest(); await expect(looking.groupCard(0).trail).toBeVisible(); - expect(await page.evaluate(() => window.scrollY)).toBe(scrollBefore); + expect(await page.evaluate(() => window.scrollY)).toBe( + await scrollYAtPress(), + ); }); }); From 5bc7cb9a7445fe59f2ca89b8ab989a20bcdd12a2 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:38:17 +0300 Subject: [PATCH 11/50] Chat initial load to loader to avoid layout shift --- app/components/MobileNav.tsx | 4 +- app/components/layout/ChatSidebar.tsx | 5 +- app/components/layout/LazyChatSidebar.tsx | 18 - app/components/layout/index.tsx | 6 +- app/features/chat/ChatProvider.tsx | 125 ++++--- .../chat/RouteChatRooms.server.test.ts | 84 +++++ app/features/chat/RouteChatRooms.server.ts | 50 +++ app/features/chat/chat-client.test.ts | 163 +++++++-- app/features/chat/chat-client.ts | 182 +++++++---- app/features/chat/chat-room-list.server.ts | 20 +- app/features/chat/chat-types.ts | 11 +- .../chat/components/Chat.browser.test.tsx | 97 +++++- app/features/chat/components/Chat.module.css | 8 +- app/features/chat/components/Chat.tsx | 309 +++++++++--------- app/features/chat/routes/api.chat.rooms.ts | 19 +- .../events/events-hooks.browser.test.tsx | 20 +- app/features/events/events-hooks.ts | 11 +- .../scrims/loaders/scrims.$id.server.ts | 13 +- .../loaders/q.match.$id.server.test.ts | 17 +- .../loaders/q.match.$id.server.ts | 75 +++-- .../sendouq/loaders/q.looking.server.ts | 13 +- .../loaders/to.$id.matches.$mid.server.ts | 13 +- app/form/SendouForm.tsx | 14 +- app/root.tsx | 7 +- changelog/2026-09-20-chat-loads-with-page.md | 4 + docs/dev/architecture.md | 8 +- 26 files changed, 899 insertions(+), 397 deletions(-) delete mode 100644 app/components/layout/LazyChatSidebar.tsx create mode 100644 app/features/chat/RouteChatRooms.server.test.ts create mode 100644 app/features/chat/RouteChatRooms.server.ts create mode 100644 changelog/2026-09-20-chat-loads-with-page.md diff --git a/app/components/MobileNav.tsx b/app/components/MobileNav.tsx index 715d80654..9df7d6f0d 100644 --- a/app/components/MobileNav.tsx +++ b/app/components/MobileNav.tsx @@ -39,7 +39,7 @@ import { EventsList } from "./EventsList"; import { LinkButton } from "./elements/Button"; import { isOwnToggle } from "./elements/Popover"; import { Image } from "./Image"; -import { LazyChatSidebar } from "./layout/LazyChatSidebar"; +import { ChatSidebar } from "./layout/ChatSidebar"; import { LogInButtonContainer } from "./layout/LogInButtonContainer"; import { NotificationContent, @@ -649,7 +649,7 @@ function ChatPanel({ >
{isOpen ? ( - panelRef.current?.hidePopover()} /> + panelRef.current?.hidePopover()} /> ) : null}
diff --git a/app/components/layout/ChatSidebar.tsx b/app/components/layout/ChatSidebar.tsx index e63fe026c..f80712b94 100644 --- a/app/components/layout/ChatSidebar.tsx +++ b/app/components/layout/ChatSidebar.tsx @@ -158,8 +158,9 @@ function RoomList({ onClose }: { onClose?: () => void }) { const byRecency = (a: ChatRoomListItem, b: ChatRoomListItem) => (b.latestMessageAt ?? 0) - (a.latestMessageAt ?? 0) || b.id - a.id; + // the context's copy of the room over the route's: it carries the live unread count const routeRooms = useCurrentRouteChatRooms().flatMap((entry) => { - const room = chatContext.roomForId(entry.roomId); + const room = chatContext.roomForId(entry.room.id); return room ? [{ ...entry, room }] : []; }); @@ -363,7 +364,7 @@ function SingleChatView({ const chatContext = useChatContext()!; const roomDisplay = useRoomDisplay(); const routeLabel = useCurrentRouteChatRooms().find( - (entry) => entry.roomId === room?.id, + (entry) => entry.room.id === room?.id, )?.label; const otherRoomsUnreadCount = chatContext.rooms diff --git a/app/components/layout/LazyChatSidebar.tsx b/app/components/layout/LazyChatSidebar.tsx deleted file mode 100644 index 34c5f8590..000000000 --- a/app/components/layout/LazyChatSidebar.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import * as React from "react"; -import { Placeholder } from "../Placeholder"; - -const ChatSidebar = React.lazy(() => - import("./ChatSidebar").then((module) => ({ default: module.ChatSidebar })), -); - -export function preloadChatSidebar() { - void import("./ChatSidebar"); -} - -export function LazyChatSidebar({ onClose }: { onClose?: () => void }) { - return ( - }> - - - ); -} diff --git a/app/components/layout/index.tsx b/app/components/layout/index.tsx index 387ed49b8..b9fd55cea 100644 --- a/app/components/layout/index.tsx +++ b/app/components/layout/index.tsx @@ -49,9 +49,9 @@ import { MobileNav } from "../MobileNav"; import { NotificationDot } from "../NotificationDot"; import { ListLink, SideNav, SideNavFooter, SideNavHeader } from "../SideNav"; import { StreamListItems } from "../StreamListItems"; +import { ChatSidebar } from "./ChatSidebar"; import { Footer } from "./Footer"; import styles from "./index.module.css"; -import { LazyChatSidebar } from "./LazyChatSidebar"; import { LogInButtonContainer } from "./LogInButtonContainer"; import { authErrorSearchParams } from "./layout-search-params"; import { NotificationPopover, useNotifications } from "./NotificationPopover"; @@ -451,7 +451,7 @@ export function Layout({ aria-label={t("common:chat.sidebar.title")} onClose={() => setChatSidebarModalOpenAndSync(false)} > - + ) : null}
- setChatSidebarOpen(false)} /> + setChatSidebarOpen(false)} />
) : null} {typeof authError === "string" ? ( diff --git a/app/features/chat/ChatProvider.tsx b/app/features/chat/ChatProvider.tsx index c17a65b1e..65b6fcd03 100644 --- a/app/features/chat/ChatProvider.tsx +++ b/app/features/chat/ChatProvider.tsx @@ -1,15 +1,19 @@ import * as React from "react"; import { useLocation, useMatches } from "react-router"; -import { preloadChatSidebar } from "~/components/layout/LazyChatSidebar"; import { eventsClient } from "~/features/events/events-client"; import { useEventStreamCatchUp, useEventsConnection, } from "~/features/events/events-hooks"; import { chatRoomChannel } from "~/features/events/events-types"; +import { useHydrated } from "~/hooks/useHydrated"; import { useLayoutSize } from "~/hooks/useLayoutSize"; import type { LoggedInUser } from "~/root"; -import { type ChatSnapshot, chatClient } from "./chat-client"; +import { + type ChatSnapshot, + chatClient, + snapshotFromLoaderData, +} from "./chat-client"; import { useServerRevalidationEvents } from "./chat-hooks"; import type { ChatRoomListItem, @@ -18,6 +22,8 @@ import type { } from "./chat-types"; const EMPTY_MESSAGES: ClientChatMessage[] = []; +const EMPTY_ROOM_LIST: ChatRoomListItem[] = []; +const EMPTY_ROUTE_ROOMS: RouteChatRoom[] = []; const SERVER_SNAPSHOT: ChatSnapshot = { roomsLoaded: false, @@ -60,33 +66,61 @@ export function useChatContext(): ChatContextValue | null { export function ChatProvider({ user, + roomList, children, }: { user?: LoggedInUser | null; + /** The user's rooms as the root loader served them. */ + roomList?: ChatRoomListItem[]; children: React.ReactNode; }) { if (!user) { return <>{children}; } - return {children}; + return ( + + {children} + + ); } function ChatProviderInner({ user, + roomList, children, }: { user: LoggedInUser; + roomList: ChatRoomListItem[]; children: React.ReactNode; }) { useEventsConnection(true); useServerRevalidationEvents(user.id); - const snapshot = React.useSyncExternalStore( + const hydrated = useHydrated(); + const routeRooms = useCurrentRouteChatRooms(); + + const storeSnapshot = React.useSyncExternalStore( chatClient.subscribe, chatClient.getSnapshot, getServerSnapshot, ); + // the loader data stands in until the live client holds it, so the page + // arrives with its chat the way it will stay; memoized to keep the effects + // depending on it off the render loop + const loaderSnapshot = React.useMemo( + () => snapshotFromLoaderData(roomList, routeRooms), + [roomList, routeRooms], + ); + const snapshot = storeSnapshot.roomsLoaded ? storeSnapshot : loaderSnapshot; + + React.useEffect(() => { + chatClient.applyRoomList(roomList); + }, [roomList]); + + React.useEffect(() => { + chatClient.applyRouteRooms(routeRooms); + }, [routeRooms]); React.useEffect(() => { chatClient.start(user.id); @@ -113,25 +147,28 @@ function ChatProviderInner({ useEventStreamCatchUp({ enabled: true, onCatchUp: () => chatClient.catchUp(), + // the loader data is at most as old as the navigation that fetched it + heldSince: performance.timeOrigin, }); - const [chatOpen, setChatOpenState] = React.useState(false); - const [activeRoomIds, setActiveRoomIds] = React.useState([]); - - // the sidebar chunk is fetched as soon as there is something to open it for, - // so that opening chat never waits on a download - const hasRoomToOpen = - snapshot.rooms.length > 0 || activeRoomIds.length > 0 || chatOpen; - React.useEffect(() => { - if (!hasRoomToOpen) return; - - preloadChatSidebar(); - }, [hasRoomToOpen]); + const autoOpenRoomIdsKey = routeRooms + .filter((room) => room.autoOpen) + .map((room) => room.room.id) + .join(","); + const [chatOpenState, setChatOpenState] = React.useState(false); + const [activeRoomIds, setActiveRoomIds] = React.useState(() => + roomIdsFromKey(autoOpenRoomIdsKey), + ); + // the server renders a route's rooms open as the desktop layout has them + // (smaller layouts hide the rail); the route sync settles it once the + // layout is known + const chatOpen = + chatOpenState || (!hydrated && autoOpenRoomIdsKey.length > 0); // messages arriving to a room on screen are read immediately instead of counting unread React.useEffect(() => { - chatClient.setViewedRoomIds(chatOpen ? activeRoomIds : []); - }, [chatOpen, activeRoomIds]); + chatClient.setViewedRoomIds(chatOpenState ? activeRoomIds : []); + }, [chatOpenState, activeRoomIds]); const rooms = snapshot.rooms; @@ -172,9 +209,12 @@ function ChatProviderInner({ useChatRouteSync({ userId: user.id, + hydrated, roomsLoaded: snapshot.roomsLoaded, rooms, observedRoomIds: snapshot.observedRoomIds, + routeRooms, + autoOpenRoomIdsKey, setActiveRoomIds, setChatOpenState, }); @@ -221,27 +261,30 @@ function ChatProviderInner({ function useChatRouteSync({ userId, + hydrated, roomsLoaded, rooms, observedRoomIds, + routeRooms, + autoOpenRoomIdsKey, setActiveRoomIds, setChatOpenState, }: { userId: number; + hydrated: boolean; roomsLoaded: boolean; rooms: ChatRoomListItem[]; observedRoomIds: ReadonlySet; + routeRooms: RouteChatRoom[]; + autoOpenRoomIdsKey: string; setActiveRoomIds: React.Dispatch>; setChatOpenState: (open: boolean) => void; }) { - const routeRooms = useCurrentRouteChatRooms(); // keys rather than the arrays themselves: a route revalidation hands over // equal-but-new loader data that must not re-run the effects - const routeRoomIdsKey = routeRooms.map((room) => room.roomId).join(","); - const autoOpenRoomIdsKey = routeRooms - .filter((room) => room.autoOpen) - .map((room) => room.roomId) - .join(","); + const routeRoomIdsKey = routeRooms.map((room) => room.room.id).join(","); + const latestRouteRoomsRef = React.useRef(routeRooms); + latestRouteRoomsRef.current = routeRooms; const { pathname } = useLocation(); const layoutSize = useLayoutSize(); const previousRouteRoomIdsKeyRef = React.useRef(null); @@ -261,7 +304,9 @@ function useChatRouteSync({ }, [routeRoomIdsKey]); React.useEffect(() => { - if (!roomsLoaded) return; + // the hydration render's layout size is the server's guess, so a room + // opening on arrival waits for the real one + if (!roomsLoaded || !hydrated) return; // route sync opens its own rooms directly: going through the context's // `setChatOpen` would read the previous render's empty `activeRoomIds` and @@ -291,16 +336,15 @@ function useChatRouteSync({ return kept.length === openRoomIds.length ? openRoomIds : kept; }); - // the loader can know about a just-created room before the room list - // does; an observer's room is never in the list at all, so its info is - // fetched separately as an observed room - for (const roomId of roomIdsFromKey(routeRoomIdsKey)) { - if (rooms.some((room) => room.id === roomId)) continue; - - if (autoOpenRoomIds.includes(roomId)) { - void chatClient.refreshRooms(); - } - chatClient.ensureRoomKnown(roomId); + // the loader can know about a just-created room of the user's own before + // the room list does: refetched so it gets listed rather than merely observed + const unlistedOwnRoom = latestRouteRoomsRef.current.find( + (entry) => + entry.room.participantUserIds.includes(userId) && + !rooms.some((room) => room.id === entry.room.id), + ); + if (unlistedOwnRoom) { + void chatClient.refreshRooms(); } } @@ -308,8 +352,12 @@ function useChatRouteSync({ if (!routeRoomIdsChanged) return; setActiveRoomIds(autoOpenRoomIds); - for (const roomId of autoOpenRoomIds) { - chatClient.ensureMessagesLoaded(roomId); + // a room opening on arrival brings its history along; one that did not + // (an older loader response) is fetched + for (const entry of latestRouteRoomsRef.current) { + if (entry.autoOpen && entry.messages === null) { + chatClient.ensureMessagesLoaded(entry.room.id); + } } if (layoutSize === "desktop") { openChatForRooms(autoOpenRoomIds); @@ -333,6 +381,7 @@ function useChatRouteSync({ openChatForRooms([matchedRoom.id]); } }, [ + hydrated, roomsLoaded, routeRoomIdsKey, autoOpenRoomIdsKey, @@ -363,7 +412,7 @@ export function useCurrentRouteChatRooms(): RouteChatRoom[] { } } - return []; + return EMPTY_ROUTE_ROOMS; } function roomIdsFromKey(key: string) { diff --git a/app/features/chat/RouteChatRooms.server.test.ts b/app/features/chat/RouteChatRooms.server.test.ts new file mode 100644 index 000000000..ae5fad56e --- /dev/null +++ b/app/features/chat/RouteChatRooms.server.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import * as ChatMessageFactory from "~/db/seed/factories/ChatMessageFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import * as RouteChatRooms from "./RouteChatRooms.server"; +import { setupSqMatch } from "./tests/fixtures"; + +const users = UserFactory.pool(); + +const outsiderId = () => users.id(10); + +beforeEach(async () => { + await users.create(10); +}); + +describe("RouteChatRooms.resolve", () => { + test("hands a participant the room as listed along with its history", async () => { + const { match, alphaUserIds } = await setupSqMatch(users); + await ChatMessageFactory.create({ + roomId: match.chatRoomId!, + authorUserId: alphaUserIds[1], + contents: "gg", + }); + + const rooms = await RouteChatRooms.resolve({ id: alphaUserIds[0] }, [ + { roomId: match.chatRoomId!, autoOpen: true }, + ]); + + expect(rooms).toHaveLength(1); + expect(rooms[0].autoOpen).toBe(true); + expect(rooms[0].room).toMatchObject({ + id: match.chatRoomId, + type: "SQ_MATCH", + canPost: true, + unreadCount: 1, + }); + expect(rooms[0].messages?.map((message) => message.contents)).toEqual([ + "gg", + ]); + }); + + test("leaves the history of a room only listed to be fetched on open", async () => { + const { match, alphaUserIds } = await setupSqMatch(users); + + const rooms = await RouteChatRooms.resolve({ id: alphaUserIds[0] }, [ + { roomId: match.chatRoomId!, autoOpen: false, label: "Match" }, + ]); + + expect(rooms).toEqual([ + expect.objectContaining({ + autoOpen: false, + label: "Match", + messages: null, + }), + ]); + }); + + test("drops a room the user may not view", async () => { + const { match } = await setupSqMatch(users); + + const rooms = await RouteChatRooms.resolve({ id: outsiderId() }, [ + { roomId: match.chatRoomId!, autoOpen: true }, + ]); + + expect(rooms).toEqual([]); + }); + + test("drops a room that resolves to nothing", async () => { + const rooms = await RouteChatRooms.resolve({ id: outsiderId() }, [ + { roomId: 999_999, autoOpen: true }, + ]); + + expect(rooms).toEqual([]); + }); + + test("resolves nothing for a logged out viewer", async () => { + const { match } = await setupSqMatch(users); + + const rooms = await RouteChatRooms.resolve(undefined, [ + { roomId: match.chatRoomId!, autoOpen: true }, + ]); + + expect(rooms).toEqual([]); + }); +}); diff --git a/app/features/chat/RouteChatRooms.server.ts b/app/features/chat/RouteChatRooms.server.ts new file mode 100644 index 000000000..fb2cc6b94 --- /dev/null +++ b/app/features/chat/RouteChatRooms.server.ts @@ -0,0 +1,50 @@ +import { hasPermission } from "~/modules/permissions/utils"; +import { logger } from "~/utils/logger"; +import * as ChatRepository from "./ChatRepository.server"; +import * as ChatRoomResolver from "./ChatRoomResolver.server"; +import { roomListItem } from "./chat-room-list.server"; +import type { RouteChatRoom, RouteChatRoomInput } from "./chat-types"; + +/** + * Resolves the rooms a route surfaces into what its page arrives with: each room as the sidebar + * lists it and, for the rooms opening on arrival, their latest messages. A room the user may not + * view is left out, the loader's own access logic being the one that decides who gets it. + */ +export async function resolve( + user: { id: number } | undefined, + inputs: RouteChatRoomInput[], +): Promise { + if (!user || inputs.length === 0) return []; + + const rooms = await ChatRoomResolver.resolveAll( + inputs.map((input) => input.roomId), + ); + const viewableRooms = rooms.filter((room) => + hasPermission(room, "VIEW", user), + ); + const stats = await ChatRepository.findMessageStatsByRoomIds( + user.id, + viewableRooms.map((room) => room.roomId), + ); + const statsByRoomId = new Map(stats.map((row) => [row.roomId, row])); + const roomsById = new Map(viewableRooms.map((room) => [room.roomId, room])); + + const result: RouteChatRoom[] = []; + for (const { roomId, ...input } of inputs) { + const room = roomsById.get(roomId); + if (!room) { + logger.warn(`Route surfaced chat room ${roomId} the user can not view`); + continue; + } + + result.push({ + ...input, + room: roomListItem(room, statsByRoomId.get(roomId), user), + messages: input.autoOpen + ? await ChatRepository.findAllMessagesByRoomId(roomId) + : null, + }); + } + + return result; +} diff --git a/app/features/chat/chat-client.test.ts b/app/features/chat/chat-client.test.ts index 05a3f104a..c5e878115 100644 --- a/app/features/chat/chat-client.test.ts +++ b/app/features/chat/chat-client.test.ts @@ -1,10 +1,15 @@ import { describe, expect, test, vi } from "vitest"; import type { ServerEvent } from "~/features/events/events-types"; -import { type ChatClient, createChatClient } from "./chat-client"; +import { + type ChatClient, + createChatClient, + snapshotFromLoaderData, +} from "./chat-client"; import type { ChatMessageAuthor, ChatMessageWithAuthor, ChatRoomListItem, + RouteChatRoom, } from "./chat-types"; const READ_DEBOUNCE_MS = 20; @@ -56,6 +61,14 @@ function message( }; } +/** A route's room as its loader serves it, with the history an opening room brings along. */ +function routeRoom( + listItem: ChatRoomListItem, + messages: ChatMessageWithAuthor[] | null = null, +): RouteChatRoom { + return { autoOpen: true, room: listItem, messages }; +} + function createHarness({ rooms = [room()], messages = [] as ChatMessageWithAuthor[], @@ -512,17 +525,15 @@ describe("createChatClient", () => { expect(harness.fetchRooms).toHaveBeenCalledTimes(5); }); - test("ensureRoomKnown fetches an observed room outside the user's list", async () => { + test("a route room outside the user's list is held as observed without a fetch", async () => { const observed = room({ id: 50, url: "/to/2/matches/2" }); - const harness = createHarness({ observedRoom: observed }); + const harness = createHarness(); const client = await startedClient(harness); - client.ensureRoomKnown(50); - await flush(); - client.ensureRoomKnown(50); - await flush(); + client.applyRouteRooms([routeRoom(observed)]); + client.applyRouteRooms([routeRoom(observed)]); - expect(harness.fetchRoom).toHaveBeenCalledTimes(1); + expect(harness.fetchRoom).not.toHaveBeenCalled(); expect(client.getSnapshot().roomsById.get(50)).toMatchObject({ id: 50 }); expect(client.getSnapshot().rooms).toHaveLength(1); expect([...client.getSnapshot().observedRoomIds]).toEqual([50]); @@ -530,32 +541,30 @@ describe("createChatClient", () => { test("an observed room starts with no unread of its own", async () => { const observed = room({ id: 50, unreadCount: 12 }); - const harness = createHarness({ observedRoom: observed }); + const harness = createHarness(); const client = await startedClient(harness); - client.ensureRoomKnown(50); - await flush(); + client.applyRouteRooms([routeRoom(observed)]); expect(client.getSnapshot().roomsById.get(50)?.unreadCount).toBe(0); expect(client.getSnapshot().totalUnreadCount).toBe(0); }); - test("ensureRoomKnown is a no-op for a room already in the user's list", async () => { - const harness = createHarness(); + test("a route room already in the user's list stays listed with the list's unread", async () => { + const harness = createHarness({ rooms: [room({ id: 1, unreadCount: 4 })] }); const client = await startedClient(harness); - client.ensureRoomKnown(1); - await flush(); + client.applyRouteRooms([routeRoom(room({ id: 1, unreadCount: 0 }))]); - expect(harness.fetchRoom).not.toHaveBeenCalled(); + expect(client.getSnapshot().observedRoomIds.size).toBe(0); + expect(client.getSnapshot().totalUnreadCount).toBe(4); }); test("a message to an observed room appends without counting unread or refetching the list", async () => { const observed = room({ id: 50 }); - const harness = createHarness({ observedRoom: observed }); + const harness = createHarness(); const client = await startedClient(harness); - client.ensureRoomKnown(50); - await flush(); + client.applyRouteRooms([routeRoom(observed)]); client.ensureMessagesLoaded(50); await flush(); @@ -575,12 +584,10 @@ describe("createChatClient", () => { test("a rooms refetch keeps the held history of an observed room", async () => { const observed = room({ id: 50 }); const harness = createHarness({ - observedRoom: observed, messages: [message({ id: 1, roomId: 50 })], }); const client = await startedClient(harness); - client.ensureRoomKnown(50); - await flush(); + client.applyRouteRooms([routeRoom(observed)]); client.ensureMessagesLoaded(50); await flush(); @@ -593,12 +600,10 @@ describe("createChatClient", () => { test("reopening an observed room refetches its history", async () => { const observed = room({ id: 50 }); const harness = createHarness({ - observedRoom: observed, messages: [message({ id: 1, roomId: 50 })], }); const client = await startedClient(harness); - client.ensureRoomKnown(50); - await flush(); + client.applyRouteRooms([routeRoom(observed)]); client.ensureMessagesLoaded(50); await flush(); @@ -618,10 +623,9 @@ describe("createChatClient", () => { test("an observed room the user's list later carries is superseded by the list version", async () => { const observed = room({ id: 50 }); - const harness = createHarness({ observedRoom: observed }); + const harness = createHarness(); const client = await startedClient(harness); - client.ensureRoomKnown(50); - await flush(); + client.applyRouteRooms([routeRoom(observed)]); expect(client.getSnapshot().rooms).toHaveLength(1); harness.fetchRooms.mockResolvedValue({ @@ -635,6 +639,88 @@ describe("createChatClient", () => { expect(client.getSnapshot().observedRoomIds.size).toBe(0); }); + test("start does not fetch a room list a loader already supplied", async () => { + const harness = createHarness(); + harness.client.applyRoomList([room({ id: 1, unreadCount: 2 })]); + + const client = await startedClient(harness); + + expect(harness.fetchRooms).not.toHaveBeenCalled(); + expect(client.getSnapshot().roomsLoaded).toBe(true); + expect(client.getSnapshot().totalUnreadCount).toBe(2); + }); + + test("a route room's history arrives without a fetch and keeps what was pushed since", async () => { + const harness = createHarness(); + const client = await startedClient(harness); + + client.applyRouteRooms([ + routeRoom(room(), [message({ id: 1 }), message({ id: 2 })]), + ]); + harness.emit({ + kind: "chatMessage", + roomId: 1, + message: message({ id: 3 }), + }); + // a revalidation's snapshot taken before the push + client.applyRouteRooms([ + routeRoom(room(), [message({ id: 1 }), message({ id: 2 })]), + ]); + client.ensureMessagesLoaded(1); + await flush(); + + expect(harness.fetchMessages).not.toHaveBeenCalled(); + expect( + client + .getSnapshot() + .messagesByRoomId.get(1) + ?.map((each) => each.id), + ).toEqual([1, 2, 3]); + }); + + test("a route room's empty history counts as loaded", async () => { + const harness = createHarness(); + const client = await startedClient(harness); + + client.applyRouteRooms([routeRoom(room(), [])]); + client.ensureMessagesLoaded(1); + await flush(); + + expect(harness.fetchMessages).not.toHaveBeenCalled(); + expect(client.getSnapshot().messagesByRoomId.get(1)).toEqual([]); + }); + + test("a history snapshot saying nothing new leaves the held one untouched", async () => { + const harness = createHarness(); + const client = await startedClient(harness); + client.applyRouteRooms([routeRoom(room(), [message({ id: 1 })])]); + const held = client.getSnapshot().messagesByRoomId.get(1); + + client.applyRouteRooms([routeRoom(room(), [message({ id: 1 })])]); + + expect(client.getSnapshot().messagesByRoomId.get(1)).toBe(held); + }); + + test("a pending send survives a route history snapshot", async () => { + const harness = createHarness(); + const client = await startedClient(harness); + client.applyRouteRooms([routeRoom(room(), [])]); + client.send(1, { + publicId: "pending-1", + contents: "hi", + author: author(1), + }); + + client.applyRouteRooms([routeRoom(room(), [message({ id: 1 })])]); + + expect( + client + .getSnapshot() + .messagesByRoomId.get(1) + ?.map((each) => each.publicId), + ).toEqual(["public-1", "pending-1"]); + }); + test("a roomsChanged event refetches the room list", async () => { const harness = createHarness(); await startedClient(harness); @@ -779,3 +865,24 @@ describe("createChatClient", () => { ).toEqual([3, 5]); }); }); + +describe("snapshotFromLoaderData", () => { + test("holds the loader data the way the live client will", () => { + const snapshot = snapshotFromLoaderData( + [room({ id: 1, unreadCount: 2 })], + [ + routeRoom(room({ id: 1, unreadCount: 0 }), [message({ id: 1 })]), + routeRoom(room({ id: 50, unreadCount: 7 }), [ + message({ id: 2, roomId: 50 }), + ]), + ], + ); + + expect(snapshot.roomsLoaded).toBe(true); + expect(snapshot.rooms.map((each) => each.id)).toEqual([1]); + expect(snapshot.totalUnreadCount).toBe(2); + expect([...snapshot.observedRoomIds]).toEqual([50]); + expect(snapshot.messagesByRoomId.get(1)).toHaveLength(1); + expect(snapshot.messagesByRoomId.get(50)).toHaveLength(1); + }); +}); diff --git a/app/features/chat/chat-client.ts b/app/features/chat/chat-client.ts index 6d594d47c..514e2f0a7 100644 --- a/app/features/chat/chat-client.ts +++ b/app/features/chat/chat-client.ts @@ -15,6 +15,7 @@ import type { ChatMessageWithAuthor, ChatRoomListItem, ClientChatMessage, + RouteChatRoom, } from "./chat-types"; const READ_DEBOUNCE_MS = 1_500; @@ -57,16 +58,18 @@ export interface ChatSnapshot { } export interface ChatClient { - /** Starts listening to server events and fetches the room list. */ + /** Starts listening to server events, fetching the room list unless a loader's was applied already. */ start: (ownUserId: number) => void; + /** Takes a loader-served room list as the room list, the way a `GET /api/chat/rooms` response is. */ + applyRoomList: (rooms: ChatRoomListItem[]) => void; + /** Takes in a route's rooms: one not in the user's list is held as observed, and a history that came along is merged into the room's held one. */ + applyRouteRooms: (routeRooms: RouteChatRoom[]) => void; /** Stops event handling and resets all held data. */ stop: () => void; getSnapshot: () => ChatSnapshot; /** Subscribes to snapshot changes, for `useSyncExternalStore`. Returns an unsubscribe function. */ subscribe: (listener: () => void) => () => void; refreshRooms: () => Promise; - /** Fetches a room's info as an observed room when the user's own room list does not carry it (observer access via a route's `chatRooms`). */ - ensureRoomKnown: (roomId: number) => void; /** Fetches the room's history unless it is already loaded or loading. An observed room's held history is refetched instead of trusted: messages only reach an observer while the route surfacing the room keeps its subscription. */ ensureMessagesLoaded: (roomId: number) => void; /** Reconnect catch-up: refetches the room list and every loaded history. */ @@ -263,46 +266,7 @@ export function createChatClient(deps: ChatClientDeps): ChatClient { const data = await deps.fetchRooms(); if (!data) return; - const next = new Map(); - for (const room of data.rooms) { - // a room that arrived in the list is no longer unknown; a later - // recreation under the same owner may need a refetch again - refetchedUnknownRoomIds.delete(room.id); - - // a message that arrived while the fetch was in flight is missing - // from its snapshot: the held room is the newer one - const known = roomsById.get(room.id); - const outrunByPush = - known !== undefined && - (known.latestMessageId ?? 0) > (room.latestMessageId ?? 0); - const newer = outrunByPush ? known : room; - - // a locally-read room stays read even when the server response - // raced the debounced read POST - const readUpTo = locallyReadByRoomId.get(room.id) ?? 0; - const locallyRead = - newer.latestMessageId !== null && readUpTo >= newer.latestMessageId; - - next.set(room.id, { - ...room, - latestMessageId: newer.latestMessageId, - latestMessageAt: newer.latestMessageAt, - unreadCount: locallyRead ? 0 : newer.unreadCount, - observed: false, - }); - } - - // the list version wins over a held observed copy - for (const [roomId, room] of roomsById) { - if (room.observed && !next.has(roomId)) { - next.set(roomId, room); - } - } - - replaceRooms(next); - pruneLostRooms(); - roomsLoaded = true; - notify(); + mergeRoomList(data.rooms); } catch (error) { logger.error("Fetching chat rooms failed", error); } finally { @@ -317,6 +281,75 @@ export function createChatClient(deps: ChatClientDeps): ChatClient { return roomsRefreshInflight; }; + /** Takes a room list snapshot (fetched or loader-served) as the user's own rooms, keeping what the snapshot predates. */ + const mergeRoomList = (rooms: ChatRoomListItem[]) => { + const next = new Map(); + for (const room of rooms) { + // a room that arrived in the list is no longer unknown; a later + // recreation under the same owner may need a refetch again + refetchedUnknownRoomIds.delete(room.id); + + // a message that arrived while the snapshot was on its way is missing + // from it: the held room is the newer one + const known = roomsById.get(room.id); + const outrunByPush = + known !== undefined && + (known.latestMessageId ?? 0) > (room.latestMessageId ?? 0); + const newer = outrunByPush ? known : room; + + // a locally-read room stays read even when the server response + // raced the debounced read POST + const readUpTo = locallyReadByRoomId.get(room.id) ?? 0; + const locallyRead = + newer.latestMessageId !== null && readUpTo >= newer.latestMessageId; + + next.set(room.id, { + ...room, + latestMessageId: newer.latestMessageId, + latestMessageAt: newer.latestMessageAt, + unreadCount: locallyRead ? 0 : newer.unreadCount, + observed: false, + }); + } + + // the list version wins over a held observed copy + for (const [roomId, room] of roomsById) { + if (room.observed && !next.has(roomId)) { + next.set(roomId, room); + } + } + + replaceRooms(next); + pruneLostRooms(); + roomsLoaded = true; + notify(); + }; + + /** Folds a history snapshot into the held one, keeping everything the snapshot predates: optimistic sends, and messages pushed over SSE. */ + const mergeHistory = (roomId: number, messages: ChatMessageWithAuthor[]) => { + const fetchedPublicIds = new Set( + messages.map((message) => message.publicId), + ); + const held = messagesByRoomId.get(roomId) ?? []; + const missedBySnapshot = held.filter( + (message) => !fetchedPublicIds.has(message.publicId), + ); + const merged = sortedMessages([...messages, ...missedBySnapshot]); + + // a snapshot that says nothing new (a page revalidation) must not churn the view + const unchanged = + messagesByRoomId.has(roomId) && + merged.length === held.length && + merged.every( + (message, index) => + message.id === held[index].id && + message.publicId === held[index].publicId, + ); + if (unchanged) return; + + setMessages(roomId, merged); + }; + /** A held history whose room is no longer known belongs to a room the user lost access to (e.g. left the group); drop the local copy. */ const pruneLostRooms = () => { const lostRoomIds = [...messagesByRoomId.keys()].filter( @@ -379,15 +412,7 @@ export function createChatClient(deps: ChatClientDeps): ChatClient { return; } - // keep everything appended while the fetch was in flight that its - // snapshot predates: optimistic sends, and messages pushed over SSE - const fetchedPublicIds = new Set( - data.messages.map((message) => message.publicId), - ); - const missedByFetch = (messagesByRoomId.get(roomId) ?? []).filter( - (message) => !fetchedPublicIds.has(message.publicId), - ); - setMessages(roomId, sortedMessages([...data.messages, ...missedByFetch])); + mergeHistory(roomId, data.messages); notify(); if (viewedRoomIds.has(roomId)) { @@ -414,7 +439,30 @@ export function createChatClient(deps: ChatClientDeps): ChatClient { ownUserId = userId; removeEventListener = deps.addServerEventListener(handleEvent); - void refreshRooms(); + if (!roomsLoaded) { + void refreshRooms(); + } + }, + applyRoomList: mergeRoomList, + applyRouteRooms: (routeRooms) => { + for (const { room, messages } of routeRooms) { + if (!roomsById.has(room.id)) { + // a room outside the user's own list is only observed: it never + // accrues unread, so it must not start out with the server's count + // of everything said in it before the observer showed up + replaceRooms( + new Map(roomsById).set(room.id, { + ...room, + unreadCount: 0, + observed: true, + }), + ); + } + if (messages) { + mergeHistory(room.id, messages); + } + } + notify(); }, stop: () => { removeEventListener?.(); @@ -450,10 +498,6 @@ export function createChatClient(deps: ChatClientDeps): ChatClient { return () => listeners.delete(listener); }, refreshRooms, - ensureRoomKnown: (roomId) => { - if (roomsById.has(roomId)) return; - void loadObservedRoom(roomId); - }, ensureMessagesLoaded: (roomId) => { const canHaveMissedMessages = roomById(roomId)?.observed ?? false; if (messagesByRoomId.has(roomId) && !canHaveMissedMessages) return; @@ -553,6 +597,30 @@ function sortedMessages(messages: ClientChatMessage[]): ClientChatMessage[] { return [...persisted, ...pending]; } +const NEVER_RESOLVING = () => new Promise(() => {}); + +/** Deps of a client that never reaches the network, for a snapshot made from loader data alone. */ +const OFFLINE_DEPS: ChatClientDeps = { + fetchRooms: NEVER_RESOLVING, + fetchRoom: NEVER_RESOLVING, + fetchMessages: NEVER_RESOLVING, + postMessage: NEVER_RESOLVING, + postRead: NEVER_RESOLVING, + onSendFailed: () => {}, + addServerEventListener: () => () => {}, +}; + +/** What a page renders with before the live client has taken the loader data over: held exactly as `applyRoomList` and `applyRouteRooms` hold it. */ +export function snapshotFromLoaderData( + roomList: ChatRoomListItem[], + routeRooms: RouteChatRoom[], +): ChatSnapshot { + const client = createChatClient(OFFLINE_DEPS); + client.applyRoomList(roomList); + client.applyRouteRooms(routeRooms); + return client.getSnapshot(); +} + const fetchJson = async (url: string): Promise => { const response = await fetch(url); if (!response.ok) { diff --git a/app/features/chat/chat-room-list.server.ts b/app/features/chat/chat-room-list.server.ts index 68e9ae85b..986ed2776 100644 --- a/app/features/chat/chat-room-list.server.ts +++ b/app/features/chat/chat-room-list.server.ts @@ -1,12 +1,28 @@ import { hasPermission } from "~/modules/permissions/utils"; -import type * as ChatRepository from "./ChatRepository.server"; -import type * as ChatRoomResolver from "./ChatRoomResolver.server"; +import * as ChatRepository from "./ChatRepository.server"; +import * as ChatRoomResolver from "./ChatRoomResolver.server"; import type { ChatRoomListItem } from "./chat-types"; type MessageStats = Awaited< ReturnType >[number]; +/** The user's open rooms as the chat sidebar lists them, with unread counts; the root loader's first snapshot and the `GET /api/chat/rooms` refetch alike. */ +export async function resolveRoomList(user: { + id: number; +}): Promise { + const rooms = await ChatRoomResolver.findAllByUserId(user.id); + const messageStats = await ChatRepository.findMessageStatsByRoomIds( + user.id, + rooms.map((room) => room.roomId), + ); + const statsByRoomId = new Map(messageStats.map((row) => [row.roomId, row])); + + return rooms.map((room) => + roomListItem(room, statsByRoomId.get(room.roomId), user), + ); +} + /** Shapes a resolved room into the list item the chat client consumes. */ export function roomListItem( room: ChatRoomResolver.ResolvedRoom, diff --git a/app/features/chat/chat-types.ts b/app/features/chat/chat-types.ts index 5c172a1fe..9f20ffde9 100644 --- a/app/features/chat/chat-types.ts +++ b/app/features/chat/chat-types.ts @@ -92,8 +92,8 @@ export interface ChatRoomListItem { latestMessageAt: number | null; } -/** A room the current route surfaces to the viewer, from its loader's `chatRooms`. */ -export interface RouteChatRoom { +/** A room a route asks to surface to the viewer, resolved into a `RouteChatRoom` by `RouteChatRooms.resolve`. */ +export interface RouteChatRoomInput { roomId: number; /** Whether the room opens for the viewer on arrival, rather than only being listed in the sidebar (staff reading a private group chat). */ autoOpen: boolean; @@ -101,4 +101,11 @@ export interface RouteChatRoom { label?: string; } +/** A room the current route surfaces to the viewer, from its loader's `chatRooms`, arriving with everything the chat opens with. */ +export interface RouteChatRoom extends Omit { + room: ChatRoomListItem; + /** Latest messages oldest first; `null` for a room only listed, whose history is fetched when opened. */ + messages: ChatMessageWithAuthor[] | null; +} + export type RevalidateScope = "MATCH_RESULTS"; diff --git a/app/features/chat/components/Chat.browser.test.tsx b/app/features/chat/components/Chat.browser.test.tsx index ea70a87bb..443e05f35 100644 --- a/app/features/chat/components/Chat.browser.test.tsx +++ b/app/features/chat/components/Chat.browser.test.tsx @@ -1,14 +1,59 @@ import * as React from "react"; import { createMemoryRouter, RouterProvider } from "react-router"; -import { describe, expect, test, vi } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { userEvent } from "vitest/browser"; import { render } from "vitest-browser-react"; +import type { EventsReadyState } from "~/features/events/events-client"; import type { ChatMessageAuthor, ClientChatMessage } from "../chat-types"; import { Chat } from "./Chat"; +const CONNECTION_STATUS_GRACE_MS = 1_500; + vi.mock("~/features/auth/core/user", () => ({ useUser: () => null, })); +// the composer only sends over a live event stream, which the tests have none of +const readyStateStore = vi.hoisted(() => { + let current = "CONNECTED"; + const listeners = new Set<() => void>(); + + return { + get: () => current, + set: (next: string) => { + current = next; + for (const listener of listeners) listener(); + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +}); + +vi.mock("~/features/events/events-hooks", async (importOriginal) => { + const react = await import("react"); + + return { + ...(await importOriginal< + typeof import("~/features/events/events-hooks") + >()), + useEventsReadyState: () => + react.useSyncExternalStore( + readyStateStore.subscribe, + readyStateStore.get, + ), + }; +}); + +const setReadyState = (next: EventsReadyState) => readyStateStore.set(next); + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +afterEach(() => { + setReadyState("CONNECTED"); +}); + const ALICE: ChatMessageAuthor = { id: 1, username: "Alice", @@ -156,6 +201,32 @@ describe("Chat", () => { expect(screen.getByRole("textbox").elements()).toHaveLength(0); }); + test("sends the draft on enter and clears the composer", async () => { + const onSend = vi.fn(); + const screen = await renderChat([createMessage()], { onSend }); + + const composer = screen.getByPlaceholder("Press enter to send"); + await composer.fill("hello there"); + await userEvent.keyboard("{Enter}"); + + expect(onSend).toHaveBeenCalledWith({ + publicId: expect.any(String), + contents: "hello there", + }); + await expect.element(composer).toHaveValue(""); + }); + + test("a blank draft is not sent", async () => { + const onSend = vi.fn(); + const screen = await renderChat([createMessage()], { onSend }); + + const composer = screen.getByPlaceholder("Press enter to send"); + await composer.fill(" "); + await userEvent.keyboard("{Enter}"); + + expect(onSend).not.toHaveBeenCalled(); + }); + test("renders a splatnet room link with its QR code", async () => { const url = "https://s.nintendo.com/av5ja/lobby"; const screen = await renderChat([ @@ -284,4 +355,28 @@ describe("Chat", () => { await new Promise((resolve) => setTimeout(resolve, 300)); expect(element.scrollTop).toBe(readingPosition); }); + + test("says the stream is down only once it has been down for the grace period", async () => { + const screen = await renderChat([createMessage()]); + + setReadyState("CLOSED"); + await wait(CONNECTION_STATUS_GRACE_MS / 2); + expect(screen.getByText("Disconnected").elements()).toHaveLength(0); + + await wait(CONNECTION_STATUS_GRACE_MS); + await expect.element(screen.getByText("Disconnected")).toBeInTheDocument(); + }); + + test("never says the stream is down when it reconnects inside the grace period", async () => { + const screen = await renderChat([createMessage()]); + + setReadyState("CONNECTING"); + await wait(CONNECTION_STATUS_GRACE_MS / 2); + expect(screen.getByText("Connecting...").elements()).toHaveLength(0); + + setReadyState("CONNECTED"); + await wait(CONNECTION_STATUS_GRACE_MS * 2); + expect(screen.getByText("Connecting...").elements()).toHaveLength(0); + expect(screen.getByText("Disconnected").elements()).toHaveLength(0); + }); }); diff --git a/app/features/chat/components/Chat.module.css b/app/features/chat/components/Chat.module.css index 3965986f1..5e97da44d 100644 --- a/app/features/chat/components/Chat.module.css +++ b/app/features/chat/components/Chat.module.css @@ -139,13 +139,7 @@ align-items: center; gap: var(--s-1); - /* an empty composer is a no-op, so its "required" error is only noise */ - & [id$="-error"] { - display: none; - } - - /* the contents field wrapper takes the row's free space */ - & > div:first-child { + & > input { flex: 1; min-width: 0; } diff --git a/app/features/chat/components/Chat.tsx b/app/features/chat/components/Chat.tsx index 3a9624580..a8fbd4217 100644 --- a/app/features/chat/components/Chat.tsx +++ b/app/features/chat/components/Chat.tsx @@ -3,21 +3,18 @@ import { sub } from "date-fns"; import { SendHorizontal } from "lucide-react"; import { QRCodeSVG } from "qrcode.react"; import * as React from "react"; +import { browser } from "react-dom"; import { useTranslation } from "react-i18next"; -import { useLocation } from "react-router"; +import * as v from "valibot"; import { useEventsReadyState } from "~/features/events/events-hooks"; -import { - type FormRenderProps, - SendouForm, - useFormValue, -} from "~/form/SendouForm"; +import { useDebounce } from "~/hooks/useDebounce"; import { useVirtualizer } from "~/modules/virtualizer/react"; import { databaseTimestampToDate } from "~/utils/dates"; import { shortNanoid } from "~/utils/id"; import { Avatar } from "../../../components/Avatar"; import { SendouButton } from "../../../components/elements/Button"; -import { SubmitButton } from "../../../components/SubmitButton"; import { useDateTimeFormat } from "../../../hooks/intl/useDateTimeFormat"; +import { MESSAGE_MAX_LENGTH } from "../chat-constants"; import { useChatAutoScroll } from "../chat-hooks"; import { findRoomLinks } from "../chat-message-links"; import { sendChatMessageSchema } from "../chat-schemas"; @@ -26,6 +23,8 @@ import styles from "./Chat.module.css"; const MESSAGE_GAP = 8; const ESTIMATED_MESSAGE_HEIGHT = 44; +/** How long the stream may be down before the composer says so, so a connect right after page load never flashes it. */ +const CONNECTION_STATUS_GRACE_MS = 1_500; export interface ChatProps { messages: ClientChatMessage[]; @@ -50,6 +49,55 @@ export function Chat({ disabled, readOnly, }: ChatProps) { + const { t } = useTranslation(["common"]); + + return ( +
+
+ + } + > + + + {readOnly ? ( + // only observers ever see this, so it stays English +
Read-only
+ ) : disabled ? ( +
+ {t("common:chat.expired")} +
+ ) : ( + + )} +
+
+ ); +} + +function MessageLog({ + messages, + labelByUserId, + className, +}: Pick & { className?: string }) { + // the server can't open the pane scrolled to its end, so it stays empty + // (the fallback holding its place) until the browser renders it + React.use(browser("the chat log opens scrolled to its end")); + const { t } = useTranslation(["common"]); const messagesContainerRef = React.useRef(null); @@ -112,160 +160,127 @@ export function Chat({ }); return ( -
-
+ <> +
-
- {virtualizer.items.map(({ index, start }) => { - const msg = messages[index]; - const systemMessage = systemMessageText(msg); + {virtualizer.items.map(({ index, start }) => { + const msg = messages[index]; + const systemMessage = systemMessageText(msg); - return ( -
- {systemMessage ? ( - - ) : ( - - )} -
- ); - })} -
+ return ( +
+ {systemMessage ? ( + + ) : ( + + )} +
+ ); + })}
- {unseenMessagesInTheRoom ? ( - - {t("common:chat.newMessages")} - - ) : null} - {readOnly ? ( - // only observers ever see this, so it stays English -
Read-only
- ) : disabled ? ( -
- {t("common:chat.expired")} -
- ) : ( - - )}
-
+ {unseenMessagesInTheRoom ? ( + + {t("common:chat.newMessages")} + + ) : null} + ); } +/** A plain form on purpose: the chat client POSTs the message itself, so sending never touches the router or revalidates the page's loaders. */ function Composer({ onSend }: { onSend: ChatProps["onSend"] }) { - const { t } = useTranslation(["common"]); - const { pathname } = useLocation(); + const { t } = useTranslation(["common", "forms"]); const readyState = useEventsReadyState(); - const [publicId, setPublicId] = React.useState(() => shortNanoid()); - const [hasSent, setHasSent] = React.useState(false); - - // a send's autofocus must not carry over to an unrelated page - React.useEffect(() => { - setHasSent(false); - }, [pathname]); + const [contents, setContents] = React.useState(""); + const inputRef = React.useRef(null); + const [connectionStatusShown, setConnectionStatusShown] = + React.useState(false); + useDebounce( + () => setConnectionStatusShown(readyState !== "CONNECTED"), + CONNECTION_STATUS_GRACE_MS, + [readyState], + ); const sendingDisabled = readyState !== "CONNECTED"; + const showConnectionStatus = sendingDisabled && connectionStatusShown; + const isEmpty = contents.trim().length === 0; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (sendingDisabled || isEmpty) return; + + const parsed = v.safeParse(sendChatMessageSchema, { + publicId: shortNanoid(), + contents, + }); + if (!parsed.success) return; + + onSend(parsed.output); + setContents(""); + inputRef.current?.focus(); + }; return ( - { - onSend(values); - setPublicId(shortNanoid()); - setHasSent(true); - }} - > - {({ FormField }) => ( - <> - {readyState !== "CONNECTED" ? ( -
- {t( - readyState === "CONNECTING" - ? "common:chat.connecting" - : "common:chat.disconnected", - )} -
- ) : null} - - - )} -
- ); -} - -function ComposerRow({ - FormField, - sendingDisabled, - hasSent, -}: { - FormField: FormRenderProps["FormField"]; - sendingDisabled: boolean; - hasSent: boolean; -}) { - const { t } = useTranslation(["common"]); - const contents = useFormValue("contents"); - const isEmpty = typeof contents !== "string" || contents.trim().length === 0; - - return ( -
- - } - testId="chat-submit-button" - /> -
+ + {showConnectionStatus ? ( +
+ {t( + readyState === "CONNECTING" + ? "common:chat.connecting" + : "common:chat.disconnected", + )} +
+ ) : null} +
+ setContents(event.target.value)} + placeholder={t("forms:placeholders.chatMessage")} + maxLength={MESSAGE_MAX_LENGTH} + disabled={sendingDisabled} + /> + } + data-testid="chat-submit-button" + /> +
+ ); } diff --git a/app/features/chat/routes/api.chat.rooms.ts b/app/features/chat/routes/api.chat.rooms.ts index ac205f538..d9b32f664 100644 --- a/app/features/chat/routes/api.chat.rooms.ts +++ b/app/features/chat/routes/api.chat.rooms.ts @@ -1,23 +1,10 @@ import { requireUser } from "~/features/auth/core/user.server"; -import * as ChatRepository from "../ChatRepository.server"; -import * as ChatRoomResolver from "../ChatRoomResolver.server"; -import { roomListItem } from "../chat-room-list.server"; +import { resolveRoomList } from "../chat-room-list.server"; import type { ChatRoomListItem } from "../chat-types"; -/** The user's open chat rooms with unread counts; fetched after mount and refetched on `chatMessage` / `roomsChanged` events, never riding a page loader. */ +/** The user's open chat rooms with unread counts, refetched on `chatMessage` / `roomsChanged` events and reconnects; the root loader serves the first snapshot. */ export const loader = async (): Promise<{ rooms: ChatRoomListItem[] }> => { const user = requireUser(); - const rooms = await ChatRoomResolver.findAllByUserId(user.id); - const messageStats = await ChatRepository.findMessageStatsByRoomIds( - user.id, - rooms.map((room) => room.roomId), - ); - const statsByRoomId = new Map(messageStats.map((row) => [row.roomId, row])); - - return { - rooms: rooms.map((room) => - roomListItem(room, statsByRoomId.get(room.roomId), user), - ), - }; + return { rooms: await resolveRoomList(user) }; }; diff --git a/app/features/events/events-hooks.browser.test.tsx b/app/features/events/events-hooks.browser.test.tsx index a87535775..63c6106a6 100644 --- a/app/features/events/events-hooks.browser.test.tsx +++ b/app/features/events/events-hooks.browser.test.tsx @@ -155,13 +155,20 @@ const LATE_FIRST_CONNECT_MS = 2_000; let catchUps = 0; let triggerCatchUp: () => void = () => {}; -function CatchUpHarness({ enabled }: { enabled: boolean }) { +function CatchUpHarness({ + enabled, + heldSince, +}: { + enabled: boolean; + heldSince?: number; +}) { useEventsConnection(true); triggerCatchUp = useEventStreamCatchUp({ enabled, onCatchUp: () => { catchUps++; }, + heldSince, }); return null; @@ -245,6 +252,17 @@ describe("useEventStreamCatchUp", () => { expect(catchUps).toBe(1); }); + test("catches up on a first connect landing long after the data held was current", async () => { + await render( + , + ); + await advanceTimers(); + await helloArrives(); + await advanceTimers(CATCH_UP_MAX_JITTER_MS); + + expect(catchUps).toBe(1); + }); + test("does not catch up on the first connect after being enabled", async () => { const screen = await mountConnecting(false); diff --git a/app/features/events/events-hooks.ts b/app/features/events/events-hooks.ts index 2ab38d2a2..a103fc94b 100644 --- a/app/features/events/events-hooks.ts +++ b/app/features/events/events-hooks.ts @@ -69,9 +69,12 @@ export function useEventsTopic(topic: string, enabled = true) { export function useEventStreamCatchUp({ enabled, onCatchUp, + heldSince, }: { enabled: boolean; onCatchUp: () => void; + /** When the data the caller holds was current (client clock), for data that predates listening: a first connect landing long after it catches up. Defaults to when listening started. */ + heldSince?: number; }) { const connected = useEventsConnected(); const latestOnCatchUp = React.useRef(onCatchUp); @@ -97,7 +100,7 @@ export function useEventStreamCatchUp({ [], ); - useCatchUpOnConnect(enabled, connected, catchUp); + useCatchUpOnConnect(enabled, connected, catchUp, heldSince); React.useEffect(() => { if (!enabled) return; @@ -123,6 +126,7 @@ function useCatchUpOnConnect( enabled: boolean, connected: boolean, onConnect: () => void, + heldSince?: number, ) { const hasConnectedRef = React.useRef(false); const listeningSinceRef = React.useRef(null); @@ -141,13 +145,14 @@ function useCatchUpOnConnect( hasConnectedRef.current = true; if ( isFirstConnect && - Date.now() - listeningSinceRef.current < LATE_FIRST_CONNECT_MS + Date.now() - (heldSince ?? listeningSinceRef.current) < + LATE_FIRST_CONNECT_MS ) { return; } onConnect(); - }, [enabled, connected, onConnect]); + }, [enabled, connected, onConnect, heldSince]); } const returnListeners = new Set<() => void>(); diff --git a/app/features/scrims/loaders/scrims.$id.server.ts b/app/features/scrims/loaders/scrims.$id.server.ts index 62aeb8fd9..4fb1f3996 100644 --- a/app/features/scrims/loaders/scrims.$id.server.ts +++ b/app/features/scrims/loaders/scrims.$id.server.ts @@ -1,5 +1,5 @@ import type { LoaderFunctionArgs } from "react-router"; -import type { RouteChatRoom } from "~/features/chat/chat-types"; +import * as RouteChatRooms from "~/features/chat/RouteChatRooms.server"; import { resolveNotifications } from "~/features/notifications/core/resolve.server"; import * as UserCardRepository from "~/features/user-card/UserCardRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; @@ -54,10 +54,13 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { })), post, // staff observers chat alongside the participants - chatRooms: (post.chatRoomId !== null && - (participantIds.includes(user.id) || user.roles.includes("STAFF")) - ? [{ roomId: post.chatRoomId, autoOpen: true }] - : []) satisfies RouteChatRoom[], + chatRooms: await RouteChatRooms.resolve( + user, + post.chatRoomId !== null && + (participantIds.includes(user.id) || user.roles.includes("STAFF")) + ? [{ roomId: post.chatRoomId, autoOpen: true }] + : [], + ), anyUserPrefersNoScreen, mapByMap, }; diff --git a/app/features/sendouq-match/loaders/q.match.$id.server.test.ts b/app/features/sendouq-match/loaders/q.match.$id.server.test.ts index 0dfff2bdd..c96676663 100644 --- a/app/features/sendouq-match/loaders/q.match.$id.server.test.ts +++ b/app/features/sendouq-match/loaders/q.match.$id.server.test.ts @@ -39,12 +39,19 @@ describe("q match loader", () => { const loadAs = (userId: number, matchId: number) => matchLoader({ user: userId, params: { id: String(matchId) } }); + const surfacedRooms = (data: Awaited>) => + data.chatRooms.map(({ room, autoOpen, label }) => ({ + roomId: room.id, + autoOpen, + label, + })); + test("surfaces both group chats read-only to staff outside the match", async () => { const match = await createMatch(); const data = await loadAs(staffId(), match.id); - expect(data.chatRooms).toEqual([ + expect(surfacedRooms(data)).toEqual([ { roomId: match.chatRoomId, autoOpen: true }, { roomId: await groupChatRoomId(match.alphaGroup.id), @@ -57,6 +64,12 @@ describe("q match loader", () => { label: "Group Bravo", }, ]); + // only the room opening on arrival brings its history along + expect(data.chatRooms.map((entry) => entry.messages !== null)).toEqual([ + true, + false, + false, + ]); }); test("gives a participant the match chat and their own group chat only", async () => { @@ -64,7 +77,7 @@ describe("q match loader", () => { const data = await loadAs(alphaUserIds()[0], match.id); - expect(data.chatRooms).toEqual([ + expect(surfacedRooms(data)).toEqual([ { roomId: match.chatRoomId, autoOpen: true }, { roomId: await groupChatRoomId(match.alphaGroup.id), diff --git a/app/features/sendouq-match/loaders/q.match.$id.server.ts b/app/features/sendouq-match/loaders/q.match.$id.server.ts index fa6eb11f4..4f740f094 100644 --- a/app/features/sendouq-match/loaders/q.match.$id.server.ts +++ b/app/features/sendouq-match/loaders/q.match.$id.server.ts @@ -1,6 +1,7 @@ import type { LoaderFunctionArgs } from "react-router"; import { getUser } from "~/features/auth/core/user.server"; -import type { RouteChatRoom } from "~/features/chat/chat-types"; +import type { RouteChatRoomInput } from "~/features/chat/chat-types"; +import * as RouteChatRooms from "~/features/chat/RouteChatRooms.server"; import * as Seasons from "~/features/mmr/core/Seasons"; import { resolveNotifications } from "~/features/notifications/core/resolve.server"; import * as ScannerIngestRepository from "~/features/scanner-ingest/ScannerIngestRepository.server"; @@ -60,42 +61,44 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { reportedWeapons, ingestedScoreboards, isOffSeason: Seasons.current() === null, - chatRooms: ((): RouteChatRoom[] => { - if (!user) return []; - - if (isParticipant) { - const ownGroup = matchUnmapped.groupAlpha.members.some( - (member) => member.id === user.id, - ) - ? match.groupAlpha - : match.groupBravo; - - return [match.chatRoomId, ownGroup.chatRoomId] - .filter((id): id is number => typeof id === "number") - .map((roomId) => ({ roomId, autoOpen: true })); - } - - if (!isStaff) return []; - - return [ - // staff observers chat alongside the participants in the match room - { roomId: matchUnmapped.chatRoomId, autoOpen: true }, - // the group chats stay private team spaces: staff only ever reads them - { - roomId: matchUnmapped.groupAlpha.chatRoomId, - autoOpen: false, - label: "Group Alpha", - }, - { - roomId: matchUnmapped.groupBravo.chatRoomId, - autoOpen: false, - label: "Group Bravo", - }, - ].filter( - (room): room is RouteChatRoom => typeof room.roomId === "number", - ); - })(), + chatRooms: await RouteChatRooms.resolve(user, routeChatRoomInputs()), }; + + function routeChatRoomInputs(): RouteChatRoomInput[] { + if (!user) return []; + + if (isParticipant) { + const ownGroup = matchUnmapped.groupAlpha.members.some( + (member) => member.id === user.id, + ) + ? match.groupAlpha + : match.groupBravo; + + return [match.chatRoomId, ownGroup.chatRoomId] + .filter((id): id is number => typeof id === "number") + .map((roomId) => ({ roomId, autoOpen: true })); + } + + if (!isStaff) return []; + + return [ + // staff observers chat alongside the participants in the match room + { roomId: matchUnmapped.chatRoomId, autoOpen: true }, + // the group chats stay private team spaces: staff only ever reads them + { + roomId: matchUnmapped.groupAlpha.chatRoomId, + autoOpen: false, + label: "Group Alpha", + }, + { + roomId: matchUnmapped.groupBravo.chatRoomId, + autoOpen: false, + label: "Group Bravo", + }, + ].filter( + (room): room is RouteChatRoomInput => typeof room.roomId === "number", + ); + } }; export type SendouQMatchLoaderData = SerializeFrom; diff --git a/app/features/sendouq/loaders/q.looking.server.ts b/app/features/sendouq/loaders/q.looking.server.ts index 021cae2a3..8b58caa59 100644 --- a/app/features/sendouq/loaders/q.looking.server.ts +++ b/app/features/sendouq/loaders/q.looking.server.ts @@ -1,7 +1,7 @@ import type { LoaderFunctionArgs } from "react-router"; import * as R from "remeda"; import { requireUser } from "~/features/auth/core/user.server"; -import type { RouteChatRoom } from "~/features/chat/chat-types"; +import * as RouteChatRooms from "~/features/chat/RouteChatRooms.server"; import { resolveNotifications } from "~/features/notifications/core/resolve.server"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; import { cachedStreams } from "~/features/sendouq-streams/core/streams.server"; @@ -70,10 +70,11 @@ export const loader = async ({ url }: LoaderFunctionArgs) => { : [], lastUpdated: Date.now(), streamsCount: (await cachedStreams()).length, - chatRooms: (ownGroup && - ownGroup.members.length > 1 && - ownGroup.chatRoomId !== null - ? [{ roomId: ownGroup.chatRoomId, autoOpen: true }] - : []) satisfies RouteChatRoom[], + chatRooms: await RouteChatRooms.resolve( + user, + ownGroup && ownGroup.members.length > 1 && ownGroup.chatRoomId !== null + ? [{ roomId: ownGroup.chatRoomId, autoOpen: true }] + : [], + ), }; }; diff --git a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts index d4dc92e68..02085867f 100644 --- a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts @@ -1,6 +1,6 @@ import cachified from "@epic-web/cachified"; import type { LoaderFunctionArgs } from "react-router"; -import type { RouteChatRoom } from "~/features/chat/chat-types"; +import * as RouteChatRooms from "~/features/chat/RouteChatRooms.server"; import * as ScannerIngestRepository from "~/features/scanner-ingest/ScannerIngestRepository.server"; import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; @@ -209,10 +209,13 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { endedEarly, noScreen, // observers (TO/streamer/site staff) chat alongside the participants - chatRooms: (match.chatRoomId && - (isParticipant || isSiteStaff || tournament.isOrganizerOrStreamer(user)) - ? [{ roomId: match.chatRoomId, autoOpen: true }] - : []) satisfies RouteChatRoom[], + chatRooms: await RouteChatRooms.resolve( + user, + match.chatRoomId && + (isParticipant || isSiteStaff || tournament.isOrganizerOrStreamer(user)) + ? [{ roomId: match.chatRoomId, autoOpen: true }] + : [], + ), canJoin, // the views can't derive these themselves, the layout ships no bracket match data bracketContext: { diff --git a/app/form/SendouForm.tsx b/app/form/SendouForm.tsx index ec2fbc3e6..ca276a246 100644 --- a/app/form/SendouForm.tsx +++ b/app/form/SendouForm.tsx @@ -108,10 +108,6 @@ type BaseFormProps = { ) => boolean; /** Called once after the action returns without field errors. */ onSuccess?: () => void; - /** For forms that render their own submit control inside `children`. */ - hideSubmitButton?: boolean; - /** When false, navigating away with unsaved edits is not blocked (e.g. a chat draft). */ - guardUnsavedChanges?: boolean; }; /** @@ -187,8 +183,6 @@ function SendouFormInner({ secondarySubmit, hideSubmitButtonWhen, onSuccess, - hideSubmitButton = false, - guardUnsavedChanges = true, }: SendouFormProps) { const { t } = useTranslation(["forms"]); const fetcher = useFetcher<{ fieldErrors?: Record }>(); @@ -264,11 +258,7 @@ function SendouFormInner({ const hasUnsavedChangesRef = React.useRef<() => boolean>(() => false); hasUnsavedChangesRef.current = () => - guardUnsavedChanges && - mode === "submit" && - !readOnly && - store.dirty && - fetcher.state === "idle"; + mode === "submit" && !readOnly && store.dirty && fetcher.state === "idle"; useUnsavedChangesChecker(hasUnsavedChangesRef); const previousFetcherStateRef = React.useRef(fetcher.state); @@ -325,7 +315,7 @@ function SendouFormInner({ <> {title ?

{title}

: null} {resolvedChildren} - {mode !== "submit" || readOnly || hideSubmitButton ? null : ( + {mode !== "submit" || readOnly ? null : ( boolean) | undefined diff --git a/app/root.tsx b/app/root.tsx index 71fa3633c..87400f69a 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -38,6 +38,7 @@ import { Layout, NPROGRESS_ANCHOR_ID } from "./components/layout"; import { getUser } from "./features/auth/core/user.server"; import { userMiddleware } from "./features/auth/core/user-middleware.server"; import { ChatProvider } from "./features/chat/ChatProvider"; +import { resolveRoomList } from "./features/chat/chat-room-list.server"; import { isMatchResultsScopedRevalidation } from "./features/chat/revalidation-scope"; import { GlobalStatusProvider } from "./features/global-status/GlobalStatusProvider"; import { getSidenavSession } from "./features/layout/core/sidenav-session.server"; @@ -146,6 +147,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { return data( { locale, + chatRoomList: user ? await resolveRoomList(user) : [], i18nPreloadUrls: localePreloadUrls(locale), theme: themeSession.getTheme(), sidenavCollapsed: sidenavSession.getCollapsed(), @@ -266,7 +268,10 @@ function Document({ - + diff --git a/changelog/2026-09-20-chat-loads-with-page.md b/changelog/2026-09-20-chat-loads-with-page.md new file mode 100644 index 000000000..7f7191414 --- /dev/null +++ b/changelog/2026-09-20-chat-loads-with-page.md @@ -0,0 +1,4 @@ +--- +type: feature +--- +Chat opens together with the page instead of loading in after it, so pages with a chat no longer shift around when it appears diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index cda2acf39..9572b8d79 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -223,15 +223,17 @@ There is a single `ChatProvider` mounted near the root, glue over the framework- Two things drive which rooms the client cares about: -1) **The user's own rooms.** `GET /api/chat/rooms` returns every room the user participates in, resolved from the owning entity (SendouQ group/match, tournament match/team, scrim). These show up in the chat list regardless of which page the user is on. -2) **Route-exposed `chatRooms`.** A loader can expose `chatRooms: RouteChatRoom[]` in its returned data. The provider reads this out of `useMatches()` and surfaces those rooms for the duration of the route being active — used for rooms the user is viewing but is not a participant of (e.g. a tournament match chat viewed by a TO). An `autoOpen` room is opened for the viewer, the rest are only listed in the chat sidebar (e.g. the private group chats of a SendouQ match, which staff may read but not post in). +1) **The user's own rooms.** Every room the user participates in, resolved from the owning entity (SendouQ group/match, tournament match/team, scrim). The root loader serves the list with the page (`chatRoomList`) and `GET /api/chat/rooms` refetches it on events and reconnects. These show up in the chat list regardless of which page the user is on. +2) **Route-exposed `chatRooms`.** A loader can expose `chatRooms: RouteChatRoom[]` in its returned data, built with `RouteChatRooms.resolve()` so the page arrives with each room as the sidebar lists it and, for rooms opening on arrival, its latest messages (no fetch after mount, no content shift). The provider reads this out of `useMatches()` and surfaces those rooms for the duration of the route being active — used for rooms the user is viewing but is not a participant of (e.g. a tournament match chat viewed by a TO). An `autoOpen` room is opened for the viewer, the rest are only listed in the chat sidebar (e.g. the private group chats of a SendouQ match, which staff may read but not post in). The loader decides who gets which rooms; the helper drops any the user may not view. Example loader: ```ts return { // ...other loader data - chatRooms: [{ roomId: match.chatRoomId, autoOpen: true }], + chatRooms: await RouteChatRooms.resolve(user, [ + { roomId: match.chatRoomId, autoOpen: true }, + ]), }; ``` From 43778b8582fefbc56189367331d56c7f9374fe67 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 07:33:48 +0300 Subject: [PATCH 12/50] Exclude placeholder teams and league teams from global status --- .../core/global-status.server.test.ts | 30 +++++++++++++++++++ .../tournament/TournamentRepository.server.ts | 6 ++++ 2 files changed, 36 insertions(+) diff --git a/app/features/global-status/core/global-status.server.test.ts b/app/features/global-status/core/global-status.server.test.ts index 71680db15..6ce448161 100644 --- a/app/features/global-status/core/global-status.server.test.ts +++ b/app/features/global-status/core/global-status.server.test.ts @@ -5,6 +5,7 @@ import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory"; import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; import * as SQReadyCheckFactory from "~/db/seed/factories/SQReadyCheckFactory"; import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; +import * as TournamentLFGTeamFactory from "~/db/seed/factories/TournamentLFGTeamFactory"; import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import { refreshSendouQInstance } from "~/features/sendouq/core/SendouQ.server"; @@ -204,6 +205,35 @@ describe("resolveGlobalStatus", () => { expect(await resolveGlobalStatus(users.id(1))).toBeNull(); }); + test("resolves nothing for a solo player looking for a team", async () => { + const { id: tournamentId } = await TournamentFactory.create({ + authorId: users.id(8), + startTimes: [dateToDatabaseTimestamp(addMinutes(new Date(), 30))], + }); + await TournamentLFGTeamFactory.create({ + tournamentId, + userId: users.id(1), + }); + + expect(await resolveGlobalStatus(users.id(1))).toBeNull(); + }); + + test("resolves nothing for a league, which has no check-in", async () => { + const { id: tournamentId } = await TournamentFactory.create( + { + authorId: users.id(8), + startTimes: [dateToDatabaseTimestamp(addMinutes(new Date(), 30))], + }, + { isLeague: true }, + ); + await TournamentTeamFactory.create({ + tournamentId, + memberUserIds: userIds([1, 2, 3, 4]), + }); + + expect(await resolveGlobalStatus(users.id(1))).toBeNull(); + }); + test("resolves nothing while check-in has yet to open", async () => { const { id: tournamentId } = await TournamentFactory.create({ authorId: users.id(8), diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 4001b6395..0f18db6a8 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -988,11 +988,17 @@ export function findPendingCheckInsStartingBetween({ .where("CalendarEvent.hidden", "=", 0) .where("Tournament.isFinalized", "=", 0) .where("TournamentTeam.droppedOut", "=", 0) + .where("TournamentTeam.isPlaceholder", "=", 0) .where( sql`json_extract("Tournament"."settings", '$.isTest')`, "is not", 1, ) + .where( + sql`json_extract("Tournament"."settings", '$.isLeague')`, + "is not", + 1, + ) .where( sql`json_extract("Tournament"."settings", '$.isDraft')`, "is not", From 592bb33cdf5f63583efbdfe993c79fc47b73dc09 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 08:02:40 +0300 Subject: [PATCH 13/50] Fix SQ weapon etc. cache not purged on match profile change --- app/features/sendouq/core/likes.server.ts | 12 +-- .../settings/actions/settings.server.test.ts | 76 +++++++++++++++++++ .../settings/actions/settings.server.ts | 31 +++++++- ...endouq-match-profile-visible-right-away.md | 5 ++ 4 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 app/features/settings/actions/settings.server.test.ts create mode 100644 changelog/2026-09-20-sendouq-match-profile-visible-right-away.md diff --git a/app/features/sendouq/core/likes.server.ts b/app/features/sendouq/core/likes.server.ts index 392d9ee56..dca24be6c 100644 --- a/app/features/sendouq/core/likes.server.ts +++ b/app/features/sendouq/core/likes.server.ts @@ -8,19 +8,19 @@ import { } from "../q-constants"; import { refreshSendouQInstance, SendouQ } from "./SendouQ.server"; -/** Cancels every pending challenge involving the user's active full group. Partial groups merge rather than start a match on accept, so their preferences are not locked in yet. */ +/** Cancels every pending challenge involving the user's active full group, returning whether any were, which also means the instance was refreshed. Partial groups merge rather than start a match on accept, so their preferences are not locked in yet. */ export async function cancelActiveGroupLikes(userId: number) { const ownGroup = SendouQ.findOwnGroup(userId); - if (!ownGroup) return; - if (ownGroup.status !== "ACTIVE" || ownGroup.matchId) return; - if (ownGroup.members.length !== FULL_GROUP_SIZE) return; + if (!ownGroup) return false; + if (ownGroup.status !== "ACTIVE" || ownGroup.matchId) return false; + if (ownGroup.members.length !== FULL_GROUP_SIZE) return false; const likes = await SQGroupRepository.findAllLikesByGroupId(ownGroup.id); const affectedGroupIds = R.unique([ ...likes.given.map((like) => like.groupId), ...likes.received.map((like) => like.groupId), ]); - if (affectedGroupIds.length === 0) return; + if (affectedGroupIds.length === 0) return false; await SQGroupRepository.deleteAllLikesByGroupId(ownGroup.id); @@ -40,4 +40,6 @@ export async function cancelActiveGroupLikes(userId: number) { ) ?? [], ), ); + + return true; } diff --git a/app/features/settings/actions/settings.server.test.ts b/app/features/settings/actions/settings.server.test.ts new file mode 100644 index 000000000..fe9f7ff3a --- /dev/null +++ b/app/features/settings/actions/settings.server.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server"; +import { + refreshSendouQInstance, + SendouQ, +} from "~/features/sendouq/core/SendouQ.server"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { wrappedAction } from "~/utils/Test"; +import type { settingsActionSchema } from "../settings-schemas.server"; +import { action } from "./settings.server"; + +vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ + send: vi.fn(), + notifyStatusChanged: vi.fn(), + notifyNotificationsChanged: vi.fn(), +})); + +const OLD_WEAPON_ID: MainWeaponId = 0; +const NEW_WEAPON_ID: MainWeaponId = 40; + +const settingsAction = wrappedAction({ + action, + isJsonSubmission: true, +}); + +const users = UserFactory.pool(); + +const actorId = () => users.id(1); + +const updateWeaponPool = (weaponId: MainWeaponId) => + settingsAction( + { + _action: "UPDATE_MATCH_PROFILE", + mapModePreferences: { modes: [], pool: [] }, + weaponPool: [{ id: weaponId, isFavorite: false }], + vc: "NO", + languages: [], + noScreen: false, + }, + { user: actorId() }, + ); + +const ownWeaponsInQueue = () => + SendouQ.findOwnGroup(actorId()) + ?.members.find((member) => member.id === actorId()) + ?.weapons?.map((weapon) => weapon.weaponSplId); + +describe("settings action: UPDATE_MATCH_PROFILE", () => { + beforeEach(async () => { + await users.create(1, null, { + matchProfile: { weaponPool: [{ id: OLD_WEAPON_ID, isFavorite: false }] }, + }); + }); + + test("a new weapon pool is shown to the queue right away", async () => { + await SQGroupFactory.create({ memberUserIds: [actorId()] }); + await refreshSendouQInstance(); + + await updateWeaponPool(NEW_WEAPON_ID); + + expect(ownWeaponsInQueue()).toEqual([NEW_WEAPON_ID]); + }); + + test("updates the weapon pool of a user who is not queueing", async () => { + await updateWeaponPool(NEW_WEAPON_ID); + + const weaponPool = await MatchProfileRepository.findWeaponPoolByUserId( + actorId(), + ); + expect(weaponPool.map((weapon) => weapon.weaponSplId)).toEqual([ + NEW_WEAPON_ID, + ]); + }); +}); diff --git a/app/features/settings/actions/settings.server.ts b/app/features/settings/actions/settings.server.ts index 55e7834c1..051542e81 100644 --- a/app/features/settings/actions/settings.server.ts +++ b/app/features/settings/actions/settings.server.ts @@ -1,7 +1,16 @@ import type { ActionFunctionArgs } from "react-router"; import { requireUser } from "~/features/auth/core/user.server"; +import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server"; import { cancelActiveGroupLikes } from "~/features/sendouq/core/likes.server"; +import { + refreshSendouQInstance, + SendouQ, +} from "~/features/sendouq/core/SendouQ.server"; +import { + SENDOUQ_LOOKING_CHANNEL, + sqGroupChannel, +} from "~/features/sendouq/q-constants"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { parseFormData } from "~/form/parse.server"; import { isSupporter } from "~/modules/permissions/utils"; @@ -78,8 +87,13 @@ export const action = async ({ request }: ActionFunctionArgs) => { }); // challenges are based on the preferences shown at the time, so changing them undoes pending ones - if (mapModePreferencesChanged || noScreenChanged) { - await cancelActiveGroupLikes(user.id); + const likesCancelled = + mapModePreferencesChanged || noScreenChanged + ? await cancelActiveGroupLikes(user.id) + : false; + + if (!likesCancelled) { + await showMatchProfileToOthers(user.id); } break; } @@ -90,3 +104,16 @@ export const action = async ({ request }: ActionFunctionArgs) => { return null; }; + +/** SendouQ pages serve group members from the in-memory instance, so a match profile change stays invisible to everyone else till it is rebuilt. */ +async function showMatchProfileToOthers(userId: number) { + const ownGroup = SendouQ.findOwnGroup(userId); + if (!ownGroup) return; + + await refreshSendouQInstance(); + + ChatSystemMessage.send([ + { channel: sqGroupChannel(ownGroup.id) }, + { channel: SENDOUQ_LOOKING_CHANNEL }, + ]); +} diff --git a/changelog/2026-09-20-sendouq-match-profile-visible-right-away.md b/changelog/2026-09-20-sendouq-match-profile-visible-right-away.md new file mode 100644 index 000000000..3247266a0 --- /dev/null +++ b/changelog/2026-09-20-sendouq-match-profile-visible-right-away.md @@ -0,0 +1,5 @@ +--- +navItem: sendouq +type: bug +--- +Changes to your SendouQ info (weapon pool, languages, voice chat) now show up to other players right away instead of after a delay From 13e8791048ce0d8fdd4977103221251f434fce2c Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 08:32:14 +0300 Subject: [PATCH 14/50] Fix toast closing nuking search params --- app/root.tsx | 15 ++++++-- app/utils/remix.server.test.ts | 36 +++++++++++++++++++ app/utils/remix.server.ts | 8 +++-- app/utils/request-context.server.ts | 9 +++-- .../2026-09-20-toasts-keep-search-params.md | 4 +++ 5 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 changelog/2026-09-20-toasts-keep-search-params.md diff --git a/app/root.tsx b/app/root.tsx index 87400f69a..7d789a380 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -297,6 +297,7 @@ function useTriggerToasts() { const error = searchParams.get("__error"); const success = searchParams.get("__success"); + const searchWithoutToastParams = searchParamsWithoutToastParams(searchParams); // layout effect: the restore has to land after (a child) reset the scroll, before paint useIsomorphicLayoutEffect(() => { @@ -324,14 +325,24 @@ function useTriggerToasts() { } navigate( - { search: "" }, + { search: searchWithoutToastParams }, { replace: true, preventScrollReset: true, defaultShouldRevalidate: false, }, ); - }, [error, success, navigate, scrollBeforeToast]); + }, [error, success, searchWithoutToastParams, navigate, scrollBeforeToast]); +} + +function searchParamsWithoutToastParams(searchParams: URLSearchParams) { + const rest = new URLSearchParams(searchParams); + rest.delete("__error"); + rest.delete("__success"); + + const asString = rest.toString(); + + return asString ? `?${asString}` : ""; } /** Latest scroll position and the page it was scrolled on, to undo the scroll reset of a toast's redirect. */ diff --git a/app/utils/remix.server.test.ts b/app/utils/remix.server.test.ts index 175f0f558..81fd1a6f9 100644 --- a/app/utils/remix.server.test.ts +++ b/app/utils/remix.server.test.ts @@ -1,9 +1,12 @@ import { describe, expect, test } from "vitest"; import { + errorToastRedirect, paginate, safeReturnTo, + successToast, successToastWithRedirect, } from "./remix.server"; +import { runWithRequestContext } from "./request-context.server"; const buildUrl = (url: string) => new URL(url); @@ -182,3 +185,36 @@ describe("successToastWithRedirect()", () => { ); }); }); + +describe("errorToastRedirect()", () => { + const redirectLocationOf = (url: string, message: string) => + runWithRequestContext({ url: new URL(url) }, () => + errorToastRedirect(message).headers.get("Location"), + ); + + test("keeps the search params of the current request", () => { + expect( + redirectLocationOf("https://sendou.ink/q/match/1?tab=action", "No"), + ).toBe("/q/match/1?tab=action&__error=No"); + }); + + test("replaces a toast param already on the current request", () => { + expect( + redirectLocationOf( + "https://sendou.ink/q/match/1?tab=action&__success=Reported", + "No", + ), + ).toBe("/q/match/1?tab=action&__error=No"); + }); +}); + +describe("successToast()", () => { + test("keeps the search params of the current request", () => { + const location = runWithRequestContext( + { url: new URL("https://sendou.ink/to/1/brackets?bracket=1") }, + () => successToast("Saved").headers.get("Location"), + ); + + expect(location).toBe("/to/1/brackets?bracket=1&__success=Saved"); + }); +}); diff --git a/app/utils/remix.server.ts b/app/utils/remix.server.ts index 9feac9587..3dc74025d 100644 --- a/app/utils/remix.server.ts +++ b/app/utils/remix.server.ts @@ -7,7 +7,7 @@ import { ServerConfig } from "~/config.server"; import type { Ok, Result } from "~/utils/result"; import type { AnySchema, AnySyncSchema } from "~/utils/schema"; import { logger } from "./logger"; -import { currentRequestPathname } from "./request-context.server"; +import { currentRequestPath } from "./request-context.server"; export function notFoundIfNullish(value: T | null | undefined): T { if (value === null || value === undefined) { @@ -152,7 +152,7 @@ export function canAccessLohiEndpoint(request: Request) { export function errorToastRedirect(message: string) { return redirect( - urlWithToastParam(currentRequestPathname() ?? "", "__error", message), + urlWithToastParam(currentRequestPath() ?? "", "__error", message), ); } @@ -182,7 +182,7 @@ export function errorToast(message: string) { export function successToast(message: string) { return redirect( - urlWithToastParam(currentRequestPathname() ?? "", "__success", message), + urlWithToastParam(currentRequestPath() ?? "", "__success", message), ); } @@ -205,6 +205,8 @@ function urlWithToastParam( const [pathname, search] = splitOnce(pathnameAndSearch, "?"); const searchParams = new URLSearchParams(search); + searchParams.delete("__error"); + searchParams.delete("__success"); searchParams.set(param, message); return `${pathname}?${searchParams}${hash ? `#${hash}` : ""}`; diff --git a/app/utils/request-context.server.ts b/app/utils/request-context.server.ts index 4b68b73e9..24e57468f 100644 --- a/app/utils/request-context.server.ts +++ b/app/utils/request-context.server.ts @@ -17,7 +17,10 @@ export function runWithRequestContext( return requestContextAsyncLocalStorage.run(context, fn); } -/** Pathname of the current request, `undefined` outside a request context. */ -export function currentRequestPathname(): string | undefined { - return requestContextAsyncLocalStorage.getStore()?.url.pathname; +/** Pathname and search params of the current request, `undefined` outside a request context. */ +export function currentRequestPath(): string | undefined { + const url = requestContextAsyncLocalStorage.getStore()?.url; + if (!url) return undefined; + + return `${url.pathname}${url.search}`; } diff --git a/changelog/2026-09-20-toasts-keep-search-params.md b/changelog/2026-09-20-toasts-keep-search-params.md new file mode 100644 index 000000000..6387f0387 --- /dev/null +++ b/changelog/2026-09-20-toasts-keep-search-params.md @@ -0,0 +1,4 @@ +--- +type: bug +--- +Error and success messages no longer throw you back to the default tab or reset your filters From bf8b7292d1dac743703a4ca1f1892a13d79c68db Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 08:43:36 +0300 Subject: [PATCH 15/50] Add bracket check-in info back --- .../components/BracketCheckIn.tsx | 14 +++++++++----- .../tournament-bracket/core/Tournament.test.ts | 7 ++++--- app/features/tournament-bracket/core/Tournament.ts | 2 +- .../tournament-bracket/routes/to.$id.brackets.tsx | 4 +++- locales/da/tournament.json | 3 +++ locales/de/tournament.json | 3 +++ locales/en/tournament.json | 3 +++ locales/es-ES/tournament.json | 3 +++ locales/es-US/tournament.json | 3 +++ locales/fr-CA/tournament.json | 3 +++ locales/fr-EU/tournament.json | 3 +++ locales/he/tournament.json | 3 +++ locales/it/tournament.json | 3 +++ locales/ja/tournament.json | 3 +++ locales/ko/tournament.json | 3 +++ locales/nl/tournament.json | 3 +++ locales/pl/tournament.json | 3 +++ locales/pt-BR/tournament.json | 3 +++ locales/ru/tournament.json | 3 +++ locales/zh/tournament.json | 3 +++ 20 files changed, 65 insertions(+), 10 deletions(-) diff --git a/app/features/tournament-bracket/components/BracketCheckIn.tsx b/app/features/tournament-bracket/components/BracketCheckIn.tsx index 53aea2597..af25260d7 100644 --- a/app/features/tournament-bracket/components/BracketCheckIn.tsx +++ b/app/features/tournament-bracket/components/BracketCheckIn.tsx @@ -1,4 +1,5 @@ import { sub } from "date-fns"; +import { useTranslation } from "react-i18next"; import { ActionButton } from "~/components/ActionButton"; import { LocaleTimeRange } from "~/components/LocaleTimeRange"; import { useUser } from "~/features/auth/core/user"; @@ -8,6 +9,7 @@ import styles from "./BracketCheckIn.module.css"; /** Prompts the viewer's team to check in to a follow-up bracket; other progress states live in the header status indicator. */ export function BracketCheckIn({ bracketIdx }: { bracketIdx: number }) { + const { t } = useTranslation(["tournament"]); const tournament = useTournament(); const user = useUser(); @@ -15,8 +17,8 @@ export function BracketCheckIn({ bracketIdx }: { bracketIdx: number }) { if (!bracket) return null; return ( -
- {bracket.name} check-in +
+ {t("tournament:bracket.checkIn.title", { bracket: bracket.name })} {tournament.canCheckInToBracket(bracket.idx, user) ? ( - Check-in + {t("tournament:pre.checkIn.button")} ) : bracket.startTime && bracket.startTime > new Date() ? ( - open{" "} + {t("tournament:bracket.checkIn.opens")}{" "} ) : bracket.startTime && bracket.startTime < new Date() ? ( - over + + {t("tournament:bracket.checkIn.over")} + ) : null}
); diff --git a/app/features/tournament-bracket/core/Tournament.test.ts b/app/features/tournament-bracket/core/Tournament.test.ts index efce77e2b..59ea8ada4 100644 --- a/app/features/tournament-bracket/core/Tournament.test.ts +++ b/app/features/tournament-bracket/core/Tournament.test.ts @@ -642,9 +642,10 @@ describe("teamMemberOfProgressStatus with a follow-up bracket check-in", () => { }); test("waits for the bracket while its check-in has yet to open", () => { - expect(progressStatusWithFollowUpIn(3 * 60)?.type).toBe( - "WAITING_FOR_BRACKET", - ); + expect(progressStatusWithFollowUpIn(3 * 60)).toEqual({ + type: "WAITING_FOR_BRACKET", + bracketIdx: 1, + }); }); }); diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index 68027fbb7..d75eb16e1 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -1249,7 +1249,7 @@ export class Tournament { // a follow-up bracket's check-in only opens an hour before it starts return this.canCheckInToBracket(bracketIdx, user) ? ({ type: "CHECKIN", bracketIdx } as const) - : ({ type: "WAITING_FOR_BRACKET" } as const); + : ({ type: "WAITING_FOR_BRACKET", bracketIdx } as const); } for (const bracket of startedBrackets) { diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index 59fd12d8d..a80bac424 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -117,8 +117,10 @@ function TournamentBracketsView() { !tournament.ctx.isFinalized, ); + // "WAITING_FOR_BRACKET" so that the team is also told ahead of time when the check-in opens const bracketCheckInIdx = - data.teamProgressStatus?.type === "CHECKIN" && + (data.teamProgressStatus?.type === "CHECKIN" || + data.teamProgressStatus?.type === "WAITING_FOR_BRACKET") && typeof data.teamProgressStatus.bracketIdx === "number" ? data.teamProgressStatus.bracketIdx : null; diff --git a/locales/da/tournament.json b/locales/da/tournament.json index 9d74271b0..0808ecfe6 100644 --- a/locales/da/tournament.json +++ b/locales/da/tournament.json @@ -175,6 +175,9 @@ "bracket.sources.placements": "", "bracket.sources.eliminated": "", "bracket.sources.earlyAdvancers": "", + "bracket.checkIn.title": "", + "bracket.checkIn.opens": "", + "bracket.checkIn.over": "", "match.pass": "Spring over", "match.pool": "Pulje", "match.score": "{{scoreOne}}-{{scoreTwo}} (Best ud af {{bestOf}})", diff --git a/locales/de/tournament.json b/locales/de/tournament.json index 9bcab21f2..6a5a652ca 100644 --- a/locales/de/tournament.json +++ b/locales/de/tournament.json @@ -175,6 +175,9 @@ "bracket.sources.placements": "", "bracket.sources.eliminated": "", "bracket.sources.earlyAdvancers": "", + "bracket.checkIn.title": "", + "bracket.checkIn.opens": "", + "bracket.checkIn.over": "", "match.pass": "Pass", "match.pool": "Pool", "match.score": "{{scoreOne}}-{{scoreTwo}} (Best of {{bestOf}})", diff --git a/locales/en/tournament.json b/locales/en/tournament.json index 0f19721c9..709d9865b 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -175,6 +175,9 @@ "bracket.sources.placements": "{{bracket}} (placements {{placements}})", "bracket.sources.eliminated": "{{bracket}} (eliminated in the first {{count}} rounds)", "bracket.sources.earlyAdvancers": "{{bracket}} (teams that win {{count}} sets)", + "bracket.checkIn.title": "{{bracket}} check-in", + "bracket.checkIn.opens": "opens", + "bracket.checkIn.over": "over", "match.pass": "Pass", "match.pool": "Pool", "match.score": "{{scoreOne}}-{{scoreTwo}} (Best of {{bestOf}})", diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json index 954161288..ec9c7932b 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -176,6 +176,9 @@ "bracket.sources.placements": "{{bracket}} (posiciones {{placements}})", "bracket.sources.eliminated": "{{bracket}} (eliminados en las primeras {{count}} rondas)", "bracket.sources.earlyAdvancers": "{{bracket}} (equipos que ganan {{count}} sets)", + "bracket.checkIn.title": "", + "bracket.checkIn.opens": "", + "bracket.checkIn.over": "", "match.pass": "Contraseña", "match.pool": "Grupo", "match.score": "{{scoreOne}}-{{scoreTwo}} (Mejor de {{bestOf}})", diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json index f9511cbb3..3796fbfa1 100644 --- a/locales/es-US/tournament.json +++ b/locales/es-US/tournament.json @@ -176,6 +176,9 @@ "bracket.sources.placements": "{{bracket}} (posiciones {{placements}})", "bracket.sources.eliminated": "{{bracket}} (eliminados en las primeras {{count}} rondas)", "bracket.sources.earlyAdvancers": "{{bracket}} (equipos que ganan {{count}} sets)", + "bracket.checkIn.title": "", + "bracket.checkIn.opens": "", + "bracket.checkIn.over": "", "match.pass": "Contraseña", "match.pool": "Canal", "match.score": "{{scoreOne}}-{{scoreTwo}} (Mejor de {{bestOf}})", diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json index da308f57a..ca6713a0b 100644 --- a/locales/fr-CA/tournament.json +++ b/locales/fr-CA/tournament.json @@ -176,6 +176,9 @@ "bracket.sources.placements": "", "bracket.sources.eliminated": "", "bracket.sources.earlyAdvancers": "", + "bracket.checkIn.title": "", + "bracket.checkIn.opens": "", + "bracket.checkIn.over": "", "match.pass": "MdP", "match.pool": "Canal}", "match.score": "{{scoreOne}}-{{scoreTwo}} (Meilleur de {{bestOf}})", diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json index 1de906c60..067c4c04f 100644 --- a/locales/fr-EU/tournament.json +++ b/locales/fr-EU/tournament.json @@ -176,6 +176,9 @@ "bracket.sources.placements": "", "bracket.sources.eliminated": "", "bracket.sources.earlyAdvancers": "", + "bracket.checkIn.title": "", + "bracket.checkIn.opens": "", + "bracket.checkIn.over": "", "match.pass": "MdP", "match.pool": "Canal}", "match.score": "{{scoreOne}}-{{scoreTwo}} (Meilleur de {{bestOf}})", diff --git a/locales/he/tournament.json b/locales/he/tournament.json index be8816b57..d75ed43a7 100644 --- a/locales/he/tournament.json +++ b/locales/he/tournament.json @@ -176,6 +176,9 @@ "bracket.sources.placements": "", "bracket.sources.eliminated": "", "bracket.sources.earlyAdvancers": "", + "bracket.checkIn.title": "", + "bracket.checkIn.opens": "", + "bracket.checkIn.over": "", "match.pass": "סיסמה", "match.pool": "פול", "match.score": "{{scoreOne}}-{{scoreTwo}} (הטוב מ-{{bestOf}})", diff --git a/locales/it/tournament.json b/locales/it/tournament.json index c3ec94aa8..345b74364 100644 --- a/locales/it/tournament.json +++ b/locales/it/tournament.json @@ -176,6 +176,9 @@ "bracket.sources.placements": "", "bracket.sources.eliminated": "", "bracket.sources.earlyAdvancers": "", + "bracket.checkIn.title": "", + "bracket.checkIn.opens": "", + "bracket.checkIn.over": "", "match.pass": "Pass", "match.pool": "Pool", "match.score": "{{scoreOne}}-{{scoreTwo}} (Al meglio di {{bestOf}})", diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json index 8c413a605..908b12c22 100644 --- a/locales/ja/tournament.json +++ b/locales/ja/tournament.json @@ -173,6 +173,9 @@ "bracket.sources.placements": "", "bracket.sources.eliminated": "", "bracket.sources.earlyAdvancers": "", + "bracket.checkIn.title": "", + "bracket.checkIn.opens": "", + "bracket.checkIn.over": "", "match.pass": "通過", "match.pool": "プール", "match.score": "{{scoreOne}}-{{scoreTwo}} (Best of {{bestOf}})", diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json index 0e40afa35..1513d6fdf 100644 --- a/locales/ko/tournament.json +++ b/locales/ko/tournament.json @@ -173,6 +173,9 @@ "bracket.sources.placements": "", "bracket.sources.eliminated": "", "bracket.sources.earlyAdvancers": "", + "bracket.checkIn.title": "", + "bracket.checkIn.opens": "", + "bracket.checkIn.over": "", "match.pass": "", "match.pool": "", "match.score": "", diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json index 27a82041a..b5b0f9192 100644 --- a/locales/nl/tournament.json +++ b/locales/nl/tournament.json @@ -175,6 +175,9 @@ "bracket.sources.placements": "", "bracket.sources.eliminated": "", "bracket.sources.earlyAdvancers": "", + "bracket.checkIn.title": "", + "bracket.checkIn.opens": "", + "bracket.checkIn.over": "", "match.pass": "", "match.pool": "", "match.score": "", diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json index ab5713160..9bd27f782 100644 --- a/locales/pl/tournament.json +++ b/locales/pl/tournament.json @@ -177,6 +177,9 @@ "bracket.sources.placements": "", "bracket.sources.eliminated": "", "bracket.sources.earlyAdvancers": "", + "bracket.checkIn.title": "", + "bracket.checkIn.opens": "", + "bracket.checkIn.over": "", "match.pass": "", "match.pool": "", "match.score": "", diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json index 3c51a9750..0873dd448 100644 --- a/locales/pt-BR/tournament.json +++ b/locales/pt-BR/tournament.json @@ -176,6 +176,9 @@ "bracket.sources.placements": "", "bracket.sources.eliminated": "", "bracket.sources.earlyAdvancers": "", + "bracket.checkIn.title": "", + "bracket.checkIn.opens": "", + "bracket.checkIn.over": "", "match.pass": "Senha", "match.pool": "Pool", "match.score": "{{scoreOne}}-{{scoreTwo}} (Melhor de {{bestOf}})", diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json index a0f7fc3b0..1af6e3b48 100644 --- a/locales/ru/tournament.json +++ b/locales/ru/tournament.json @@ -177,6 +177,9 @@ "bracket.sources.placements": "", "bracket.sources.eliminated": "", "bracket.sources.earlyAdvancers": "", + "bracket.checkIn.title": "", + "bracket.checkIn.opens": "", + "bracket.checkIn.over": "", "match.pass": "Пароль", "match.pool": "Канал", "match.score": "{{scoreOne}}-{{scoreTwo}} (Лучшее из {{bestOf}})", diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index 9a9f77e7b..ff20e860c 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -174,6 +174,9 @@ "bracket.sources.placements": "", "bracket.sources.eliminated": "", "bracket.sources.earlyAdvancers": "", + "bracket.checkIn.title": "", + "bracket.checkIn.opens": "", + "bracket.checkIn.over": "", "match.pass": "密码", "match.pool": "频道", "match.score": "{{scoreOne}}-{{scoreTwo}} (Bo{{bestOf}})", From 61e8a8313ceae1f09fa6f0082c24d38a91b2e984 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 08:56:38 +0300 Subject: [PATCH 16/50] Show fallback UI when art fails to load --- .../art/components/ArtGrid.module.css | 28 +++++++++ app/features/art/components/ArtGrid.tsx | 57 +++++++++++++------ locales/da/art.json | 3 +- locales/de/art.json | 3 +- locales/en/art.json | 3 +- locales/es-ES/art.json | 3 +- locales/es-US/art.json | 3 +- locales/fr-CA/art.json | 3 +- locales/fr-EU/art.json | 3 +- locales/he/art.json | 3 +- locales/it/art.json | 3 +- locales/ja/art.json | 3 +- locales/ko/art.json | 3 +- locales/nl/art.json | 3 +- locales/pl/art.json | 3 +- locales/pt-BR/art.json | 3 +- locales/ru/art.json | 3 +- locales/zh/art.json | 3 +- 18 files changed, 100 insertions(+), 33 deletions(-) diff --git a/app/features/art/components/ArtGrid.module.css b/app/features/art/components/ArtGrid.module.css index f09aabdb3..42321780f 100644 --- a/app/features/art/components/ArtGrid.module.css +++ b/app/features/art/components/ArtGrid.module.css @@ -56,6 +56,34 @@ ); } +/* nothing to look at, so the details take over the figure instead of floating over the image */ +.lightboxFigureFailed { + display: flex; + flex-direction: column; + width: min(var(--lightbox-max-width), 22rem); + background-color: rgba(0, 0, 0, 0.6); + + .lightboxInfo { + position: static; + opacity: 1; + padding-block-start: var(--s-3); + background: none; + } +} + +.lightboxFailed { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--s-2); + min-height: 10rem; + padding: var(--s-4); + color: rgba(255, 255, 255, 0.7); + font-size: var(--font-sm); + text-align: center; +} + .lightboxPlaceholder { display: block; width: 100%; diff --git a/app/features/art/components/ArtGrid.tsx b/app/features/art/components/ArtGrid.tsx index 5d7b8bb7c..b1480acc9 100644 --- a/app/features/art/components/ArtGrid.tsx +++ b/app/features/art/components/ArtGrid.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import { SquarePen, Trash, Unlink } from "lucide-react"; +import { ImageOff, SquarePen, Trash, Unlink } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; @@ -90,12 +90,15 @@ function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) { const [infoVisible, setInfoVisible] = React.useState(true); const [imageSettled, imageRef] = useImageSettled(); const [aspectRatio, placeholderRef] = useImageAspectRatio(); + const { t } = useTranslation(["art"]); const { formatter } = useDateTimeFormat({ year: "numeric", month: "numeric", day: "numeric", }); + const imageFailed = aspectRatio === "FAILED"; + const dateText = formatter.format(databaseTimestampToDate(art.createdAt)); const handleClick = (event: React.MouseEvent) => { @@ -120,10 +123,11 @@ function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
void; art: ListedArt }) { className={styles.lightboxPlaceholder} ref={placeholderRef} /> - + {imageFailed ? ( +
+ + {t("art:imageFailed")} +
+ ) : ( + + )}
(null); + const [aspectRatio, setAspectRatio] = React.useState< + number | "FAILED" | null + >(null); const imageRef = (image: HTMLImageElement | null) => { if (!image) return; const measure = () => { - if (image.naturalWidth > 0 && image.naturalHeight > 0) { - setAspectRatio(image.naturalWidth / image.naturalHeight); - } + setAspectRatio( + image.naturalWidth > 0 && image.naturalHeight > 0 + ? image.naturalWidth / image.naturalHeight + : "FAILED", + ); }; if (image.complete) { measure(); @@ -382,7 +400,12 @@ function useImageAspectRatio() { } image.addEventListener("load", measure); - return () => image.removeEventListener("load", measure); + image.addEventListener("error", measure); + + return () => { + image.removeEventListener("load", measure); + image.removeEventListener("error", measure); + }; }; return [aspectRatio, imageRef] as const; diff --git a/locales/da/art.json b/locales/da/art.json index 11d7851b0..b5349fc3d 100644 --- a/locales/da/art.json +++ b/locales/da/art.json @@ -22,5 +22,6 @@ "forms.tags.maxReached": "Maks antal etiketter opnået", "delete.title": "", "unlink.title": "", - "noArtForTag": "" + "noArtForTag": "", + "imageFailed": "" } diff --git a/locales/de/art.json b/locales/de/art.json index baf5e611f..852565d8a 100644 --- a/locales/de/art.json +++ b/locales/de/art.json @@ -22,5 +22,6 @@ "forms.tags.maxReached": "", "delete.title": "", "unlink.title": "", - "noArtForTag": "" + "noArtForTag": "", + "imageFailed": "" } diff --git a/locales/en/art.json b/locales/en/art.json index 3b12a358a..655b8117d 100644 --- a/locales/en/art.json +++ b/locales/en/art.json @@ -22,5 +22,6 @@ "forms.tags.maxReached": "Max tags reached", "delete.title": "Are you sure you want to delete the art?", "unlink.title": "Are you sure you want to remove this art from your profile (only {{username}} can add it back)?", - "noArtForTag": "No uploaded art found for #{{tag}}" + "noArtForTag": "No uploaded art found for #{{tag}}", + "imageFailed": "Image could not be loaded" } diff --git a/locales/es-ES/art.json b/locales/es-ES/art.json index 7efc1e9a9..a83b4598f 100644 --- a/locales/es-ES/art.json +++ b/locales/es-ES/art.json @@ -23,5 +23,6 @@ "forms.tags.maxReached": "Límite máximo de etiquetas", "delete.title": "¿Seguro que quieres eliminar este arte?", "unlink.title": "¿Seguro que quieres desvincular este arte de tu perfil? (solo {{username}} puede volver a añadirlo)", - "noArtForTag": "No se ha encontrado arte subido para #{{tag}}" + "noArtForTag": "No se ha encontrado arte subido para #{{tag}}", + "imageFailed": "" } diff --git a/locales/es-US/art.json b/locales/es-US/art.json index 8b760f074..f1756fdb5 100644 --- a/locales/es-US/art.json +++ b/locales/es-US/art.json @@ -23,5 +23,6 @@ "forms.tags.maxReached": "Límite máximo de etiquetas", "delete.title": "¿Seguro que quieres eliminar este arte?", "unlink.title": "¿Seguro que quieres desvincular este arte de tu perfil? (solo {{username}} puede volver a añadirlo)", - "noArtForTag": "No se ha encontrado arte subido para #{{tag}}" + "noArtForTag": "No se ha encontrado arte subido para #{{tag}}", + "imageFailed": "" } diff --git a/locales/fr-CA/art.json b/locales/fr-CA/art.json index 1d5f7a4cf..9f38265a1 100644 --- a/locales/fr-CA/art.json +++ b/locales/fr-CA/art.json @@ -23,5 +23,6 @@ "forms.tags.maxReached": "Limite de tags atteinte", "delete.title": "", "unlink.title": "", - "noArtForTag": "" + "noArtForTag": "", + "imageFailed": "" } diff --git a/locales/fr-EU/art.json b/locales/fr-EU/art.json index 2208b153d..10328c217 100644 --- a/locales/fr-EU/art.json +++ b/locales/fr-EU/art.json @@ -23,5 +23,6 @@ "forms.tags.maxReached": "Limite de tags atteinte", "delete.title": "Êtes vous sûre de vouloir supprimer cette œuvre?", "unlink.title": "Êtes vous sûre de vouloir supprimer cette œuvre de votre profil? (Seulement {{username}} pourra le remettre)", - "noArtForTag": "" + "noArtForTag": "", + "imageFailed": "" } diff --git a/locales/he/art.json b/locales/he/art.json index 0f5e17ab3..355a72d8e 100644 --- a/locales/he/art.json +++ b/locales/he/art.json @@ -23,5 +23,6 @@ "forms.tags.maxReached": "הגעת לכמות תגים מקסימלית", "delete.title": "האם אתה בטוח שאתה רוצה למחוק את הציור?", "unlink.title": "האם אתה בטוח שאתה רוצה להסיר את הציור הזה מהפרופיל שלך (רק {{username}} יוכל להוסיף אותו חזרה)?", - "noArtForTag": "" + "noArtForTag": "", + "imageFailed": "" } diff --git a/locales/it/art.json b/locales/it/art.json index 3adb73671..bab969f02 100644 --- a/locales/it/art.json +++ b/locales/it/art.json @@ -23,5 +23,6 @@ "forms.tags.maxReached": "Numero massimo di tag raggiunto.", "delete.title": "", "unlink.title": "", - "noArtForTag": "" + "noArtForTag": "", + "imageFailed": "" } diff --git a/locales/ja/art.json b/locales/ja/art.json index 6b5cd67c6..707c457b4 100644 --- a/locales/ja/art.json +++ b/locales/ja/art.json @@ -20,5 +20,6 @@ "forms.tags.maxReached": "タグの最大数に到達しました", "delete.title": "本当にこの作品を削除しますか?", "unlink.title": "本当にこの作品をプロフィールから削除しますか?{{username}}しか再び追加することはできません。", - "noArtForTag": "#{{tag}}の作品が見つかりませんでした。" + "noArtForTag": "#{{tag}}の作品が見つかりませんでした。", + "imageFailed": "" } diff --git a/locales/ko/art.json b/locales/ko/art.json index 0136021ce..654f7c885 100644 --- a/locales/ko/art.json +++ b/locales/ko/art.json @@ -20,5 +20,6 @@ "forms.tags.maxReached": "", "delete.title": "", "unlink.title": "", - "noArtForTag": "" + "noArtForTag": "", + "imageFailed": "" } diff --git a/locales/nl/art.json b/locales/nl/art.json index baf5e611f..852565d8a 100644 --- a/locales/nl/art.json +++ b/locales/nl/art.json @@ -22,5 +22,6 @@ "forms.tags.maxReached": "", "delete.title": "", "unlink.title": "", - "noArtForTag": "" + "noArtForTag": "", + "imageFailed": "" } diff --git a/locales/pl/art.json b/locales/pl/art.json index 86b280742..f801affdc 100644 --- a/locales/pl/art.json +++ b/locales/pl/art.json @@ -24,5 +24,6 @@ "forms.tags.maxReached": "", "delete.title": "", "unlink.title": "", - "noArtForTag": "" + "noArtForTag": "", + "imageFailed": "" } diff --git a/locales/pt-BR/art.json b/locales/pt-BR/art.json index 21533fb0a..303786be2 100644 --- a/locales/pt-BR/art.json +++ b/locales/pt-BR/art.json @@ -23,5 +23,6 @@ "forms.tags.maxReached": "Quantidade máxima de marcações atingida", "delete.title": "", "unlink.title": "", - "noArtForTag": "" + "noArtForTag": "", + "imageFailed": "" } diff --git a/locales/ru/art.json b/locales/ru/art.json index f0fc40155..08a0d70a6 100644 --- a/locales/ru/art.json +++ b/locales/ru/art.json @@ -24,5 +24,6 @@ "forms.tags.maxReached": "Максимальное кол-во тегов достигнуто", "delete.title": "Вы точно хотите удалить этот арт?", "unlink.title": "Вы точно хотите удалить этот арт с вашего профиля (только {{username}} может добавить его обратно)?", - "noArtForTag": "" + "noArtForTag": "", + "imageFailed": "" } diff --git a/locales/zh/art.json b/locales/zh/art.json index 4a0145b04..af36e7d61 100644 --- a/locales/zh/art.json +++ b/locales/zh/art.json @@ -21,5 +21,6 @@ "forms.tags.maxReached": "已达到最大标签数", "delete.title": "您确定要删除这件作品吗?", "unlink.title": "您确定要将这件作品从个人资料中移除吗(只有 {{username}} 可以重新添加)?", - "noArtForTag": "未能找到包含 #{{tag}} 的作品。" + "noArtForTag": "未能找到包含 #{{tag}} 的作品。", + "imageFailed": "" } From 52a6f7fd119f28ad78e62465a08ea6324cb1d62a Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:58:22 +0300 Subject: [PATCH 17/50] Fix link to art not opening it if it was not on the first page --- app/features/art/components/ArtGrid.tsx | 15 +++++++++++++-- app/hooks/usePagination.ts | 4 +++- .../2026-09-20-art-share-link-later-pages.md | 5 +++++ 3 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 changelog/2026-09-20-art-share-link-later-pages.md diff --git a/app/features/art/components/ArtGrid.tsx b/app/features/art/components/ArtGrid.tsx index b1480acc9..401e2b6bf 100644 --- a/app/features/art/components/ArtGrid.tsx +++ b/app/features/art/components/ArtGrid.tsx @@ -35,6 +35,7 @@ export function ArtGrid({ enablePreview?: boolean; showUploadDate?: boolean; }) { + const [bigArtId, setBigArtId] = useSearchParam(artGridSearchParams, "big"); const { itemsToDisplay, everythingVisible, @@ -46,13 +47,13 @@ export function ArtGrid({ } = usePagination({ items: arts, pageSize: ART_PER_PAGE, + initialPage: pageOfArt(arts, bigArtId), }); - const [bigArtId, setBigArtId] = useSearchParam(artGridSearchParams, "big"); const isHydrated = useHydrated(); if (!isHydrated) return null; - const bigArt = itemsToDisplay.find((art) => art.id === bigArtId); + const bigArt = arts.find((art) => art.id === bigArtId); return ( <> @@ -411,6 +412,16 @@ function useImageAspectRatio() { return [aspectRatio, imageRef] as const; } +/** Page the art is on, so that a shared `?big=` link renders the page containing it. */ +function pageOfArt(arts: ListedArt[], artId: number | null) { + if (typeof artId !== "number") return 1; + + const index = arts.findIndex((art) => art.id === artId); + if (index === -1) return 1; + + return Math.floor(index / ART_PER_PAGE) + 1; +} + /** Touch devices have no hover to reveal the lightbox info with, so a tap on the image toggles it instead. */ function deviceCanHover() { return window.matchMedia("(hover: hover)").matches; diff --git a/app/hooks/usePagination.ts b/app/hooks/usePagination.ts index a4ce7fa1a..71574d0db 100644 --- a/app/hooks/usePagination.ts +++ b/app/hooks/usePagination.ts @@ -3,13 +3,15 @@ import * as React from "react"; export function usePagination({ items, pageSize, + initialPage = 1, scrollToTop = true, }: { items: T[]; pageSize: number; + initialPage?: number; scrollToTop?: boolean; }) { - const [currentPage, setCurrentPage] = React.useState(1); + const [currentPage, setCurrentPage] = React.useState(initialPage); const pagesCount = Math.ceil(items.length / pageSize); const itemsToDisplay = React.useMemo( diff --git a/changelog/2026-09-20-art-share-link-later-pages.md b/changelog/2026-09-20-art-share-link-later-pages.md new file mode 100644 index 000000000..bb0bbb4e5 --- /dev/null +++ b/changelog/2026-09-20-art-share-link-later-pages.md @@ -0,0 +1,5 @@ +--- +navItem: art +type: bug +--- +Fixed link to a specific art not opening it if the art was not on the first page From 442d5845a2fba11eb9b94c5c18f4ece4ec66d5b0 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:42:28 +0300 Subject: [PATCH 18/50] Show text if in trusted group someone rejoined queue --- .../components/RejoinSections.tsx | 21 ++++++- .../components/SendouQMatchActionTab.tsx | 3 + .../loaders/q.match.$id.server.test.ts | 61 +++++++++++++++++-- .../loaders/q.match.$id.server.ts | 17 +++++- .../2026-09-20-sendouq-look-again-blocked.md | 5 ++ locales/da/q.json | 1 + locales/de/q.json | 1 + locales/en/q.json | 1 + locales/es-ES/q.json | 1 + locales/es-US/q.json | 1 + locales/fr-CA/q.json | 1 + locales/fr-EU/q.json | 1 + locales/he/q.json | 1 + locales/it/q.json | 1 + locales/ja/q.json | 1 + locales/ko/q.json | 1 + locales/nl/q.json | 1 + locales/pl/q.json | 1 + locales/pt-BR/q.json | 1 + locales/ru/q.json | 1 + locales/zh/q.json | 1 + 21 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 changelog/2026-09-20-sendouq-look-again-blocked.md diff --git a/app/features/sendouq-match/components/RejoinSections.tsx b/app/features/sendouq-match/components/RejoinSections.tsx index a641d6032..1978a1564 100644 --- a/app/features/sendouq-match/components/RejoinSections.tsx +++ b/app/features/sendouq-match/components/RejoinSections.tsx @@ -58,9 +58,11 @@ export function MatchmadeRejoinSection({ export function TrustedRejoinSection({ viewerGroup, hasJoinedNewGroup, + someGroupMemberHasJoinedNewGroup, }: { viewerGroup: NonNullable; hasJoinedNewGroup: boolean; + someGroupMemberHasJoinedNewGroup: boolean; }) { const { t } = useTranslation(["q"]); const lookAgain = useActionSubmit(matchSchema); @@ -77,6 +79,15 @@ export function TrustedRejoinSection({ ); } + // the whole group has to be free for it, so offering it here could only fail + if (someGroupMemberHasJoinedNewGroup) { + return ( + + ); + } + return (
; +} + +function RejoinQueueSection({ explanation }: { explanation: string }) { + const { t } = useTranslation(["q"]); const rejoinQueue = useActionSubmit(frontPageSchema, { action: SENDOUQ_PAGE, }); return (
-

- {t("q:match.rematch.declined")} -

+

{explanation}

) : null} {isOnReporterTeam ?
: null} diff --git a/app/features/sendouq-match/loaders/q.match.$id.server.test.ts b/app/features/sendouq-match/loaders/q.match.$id.server.test.ts index c96676663..4e94ca9a0 100644 --- a/app/features/sendouq-match/loaders/q.match.$id.server.test.ts +++ b/app/features/sendouq-match/loaders/q.match.$id.server.test.ts @@ -1,7 +1,9 @@ import { beforeEach, describe, expect, test } from "vitest"; +import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory"; import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import { db } from "~/db/sql"; +import { refreshSendouQInstance } from "~/features/sendouq/core/SendouQ.server"; import type { SerializeFrom } from "~/utils/remix"; import { wrappedLoader } from "~/utils/Test"; import { loader } from "./q.match.$id.server"; @@ -21,11 +23,14 @@ describe("q match loader", () => { await users.create(10); }); - const createMatch = () => - SQMatchFactory.create({ - alphaUserIds: alphaUserIds(), - bravoUserIds: bravoUserIds(), - }); + const createMatch = (options: { isConcluded?: boolean } = {}) => + SQMatchFactory.create( + { + alphaUserIds: alphaUserIds(), + bravoUserIds: bravoUserIds(), + }, + options, + ); const groupChatRoomId = async (groupId: number) => ( @@ -93,4 +98,50 @@ describe("q match loader", () => { expect(data.chatRooms).toEqual([]); }); + + describe("requeueing with the same group", () => { + const queueElsewhere = async (userId: number) => { + await SQGroupFactory.create({ memberUserIds: [userId] }); + await refreshSendouQInstance(); + }; + + test("offers the requeue while every member is free of other groups", async () => { + const match = await createMatch({ isConcluded: true }); + await refreshSendouQInstance(); + + const data = await loadAs(alphaUserIds()[0], match.id); + + expect(data.hasJoinedNewGroup).toBe(false); + expect(data.someGroupMemberHasJoinedNewGroup).toBe(false); + }); + + test("points the member who queued elsewhere to their new group", async () => { + const match = await createMatch({ isConcluded: true }); + await queueElsewhere(alphaUserIds()[1]); + + const data = await loadAs(alphaUserIds()[1], match.id); + + expect(data.hasJoinedNewGroup).toBe(true); + }); + + test("blocks the requeue for the rest of the group too", async () => { + const match = await createMatch({ isConcluded: true }); + await queueElsewhere(alphaUserIds()[1]); + + const data = await loadAs(alphaUserIds()[0], match.id); + + expect(data.hasJoinedNewGroup).toBe(false); + expect(data.someGroupMemberHasJoinedNewGroup).toBe(true); + }); + + test("leaves the other group's requeue alone", async () => { + const match = await createMatch({ isConcluded: true }); + await queueElsewhere(alphaUserIds()[1]); + + const data = await loadAs(bravoUserIds()[0], match.id); + + expect(data.hasJoinedNewGroup).toBe(false); + expect(data.someGroupMemberHasJoinedNewGroup).toBe(false); + }); + }); }); diff --git a/app/features/sendouq-match/loaders/q.match.$id.server.ts b/app/features/sendouq-match/loaders/q.match.$id.server.ts index 4f740f094..cc9e42d58 100644 --- a/app/features/sendouq-match/loaders/q.match.$id.server.ts +++ b/app/features/sendouq-match/loaders/q.match.$id.server.ts @@ -46,12 +46,23 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { const match = SendouQ.mapMatch(matchUnmapped, user); - const currentGroup = user ? SendouQ.findOwnGroup(user.id) : undefined; + const viewerGroup = user + ? [matchUnmapped.groupAlpha, matchUnmapped.groupBravo].find((group) => + group.members.some((member) => member.id === user.id), + ) + : undefined; + + const joinedNewGroup = (userId: number) => { + const currentGroup = SendouQ.findOwnGroup(userId); + return Boolean(currentGroup && currentGroup.matchId !== matchId); + }; return { // e.g. the group already requeued, so the viewer has nothing left to requeue with - hasJoinedNewGroup: Boolean( - currentGroup && currentGroup.matchId !== matchId, + hasJoinedNewGroup: Boolean(user && joinedNewGroup(user.id)), + // requeueing with the same group needs every member of it free of other groups + someGroupMemberHasJoinedNewGroup: Boolean( + viewerGroup?.members.some((member) => joinedNewGroup(member.id)), ), ...(await UserCardRepository.findAllByUserIds({ userIds: matchUsers, diff --git a/changelog/2026-09-20-sendouq-look-again-blocked.md b/changelog/2026-09-20-sendouq-look-again-blocked.md new file mode 100644 index 000000000..046e5e32c --- /dev/null +++ b/changelog/2026-09-20-sendouq-look-again-blocked.md @@ -0,0 +1,5 @@ +--- +navItem: sendouq +type: bug +--- +"Look again with same group" is no longer offered when someone from your group has already joined another group. Instead you are told so and can rejoin the queue right away diff --git a/locales/da/q.json b/locales/da/q.json index 481743321..88cac963a 100644 --- a/locales/da/q.json +++ b/locales/da/q.json @@ -165,6 +165,7 @@ "match.rematch.vote.no": "", "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", + "match.rematch.memberJoinedNewGroup": "", "match.rematch.fizzled": "", "match.rematch.rejoinQueue": "", "match.rematch.backToQueue": "", diff --git a/locales/de/q.json b/locales/de/q.json index 6f046c1b6..82a29e692 100644 --- a/locales/de/q.json +++ b/locales/de/q.json @@ -165,6 +165,7 @@ "match.rematch.vote.no": "", "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", + "match.rematch.memberJoinedNewGroup": "", "match.rematch.fizzled": "", "match.rematch.rejoinQueue": "", "match.rematch.backToQueue": "", diff --git a/locales/en/q.json b/locales/en/q.json index c1874c883..44265a46c 100644 --- a/locales/en/q.json +++ b/locales/en/q.json @@ -165,6 +165,7 @@ "match.rematch.vote.no": "No, I'm done", "match.rematch.vote.noConfirm": "Vote no? You can't change your vote afterwards.", "match.rematch.declined": "You are not continuing with this group", + "match.rematch.memberJoinedNewGroup": "A member of your group already joined another group", "match.rematch.fizzled": "Nobody wanted to continue", "match.rematch.rejoinQueue": "Rejoin queue", "match.rematch.backToQueue": "Back to queue", diff --git a/locales/es-ES/q.json b/locales/es-ES/q.json index 13a1b1fe7..46a5c1a7f 100644 --- a/locales/es-ES/q.json +++ b/locales/es-ES/q.json @@ -165,6 +165,7 @@ "match.rematch.vote.no": "No, he terminado", "match.rematch.vote.noConfirm": "¿Votar no? No podrás cambiar tu voto después.", "match.rematch.declined": "", + "match.rematch.memberJoinedNewGroup": "", "match.rematch.fizzled": "Nadie quiso continuar", "match.rematch.rejoinQueue": "Volver a la cola", "match.rematch.backToQueue": "Volver a la cola", diff --git a/locales/es-US/q.json b/locales/es-US/q.json index 1a64442a5..4473de20b 100644 --- a/locales/es-US/q.json +++ b/locales/es-US/q.json @@ -165,6 +165,7 @@ "match.rematch.vote.no": "No, he terminado", "match.rematch.vote.noConfirm": "¿Votar no? No podrás cambiar tu voto después.", "match.rematch.declined": "", + "match.rematch.memberJoinedNewGroup": "", "match.rematch.fizzled": "Nadie quiso continuar", "match.rematch.rejoinQueue": "Volver a la cola", "match.rematch.backToQueue": "Volver a la cola", diff --git a/locales/fr-CA/q.json b/locales/fr-CA/q.json index 34ec52db6..65300be73 100644 --- a/locales/fr-CA/q.json +++ b/locales/fr-CA/q.json @@ -165,6 +165,7 @@ "match.rematch.vote.no": "", "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", + "match.rematch.memberJoinedNewGroup": "", "match.rematch.fizzled": "", "match.rematch.rejoinQueue": "", "match.rematch.backToQueue": "", diff --git a/locales/fr-EU/q.json b/locales/fr-EU/q.json index 66ce5aca3..7a7830f4d 100644 --- a/locales/fr-EU/q.json +++ b/locales/fr-EU/q.json @@ -165,6 +165,7 @@ "match.rematch.vote.no": "", "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", + "match.rematch.memberJoinedNewGroup": "", "match.rematch.fizzled": "", "match.rematch.rejoinQueue": "", "match.rematch.backToQueue": "", diff --git a/locales/he/q.json b/locales/he/q.json index 3f368d8d7..412896c8b 100644 --- a/locales/he/q.json +++ b/locales/he/q.json @@ -165,6 +165,7 @@ "match.rematch.vote.no": "", "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", + "match.rematch.memberJoinedNewGroup": "", "match.rematch.fizzled": "", "match.rematch.rejoinQueue": "", "match.rematch.backToQueue": "", diff --git a/locales/it/q.json b/locales/it/q.json index 3da53aa93..0d3bd8add 100644 --- a/locales/it/q.json +++ b/locales/it/q.json @@ -165,6 +165,7 @@ "match.rematch.vote.no": "", "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", + "match.rematch.memberJoinedNewGroup": "", "match.rematch.fizzled": "", "match.rematch.rejoinQueue": "", "match.rematch.backToQueue": "", diff --git a/locales/ja/q.json b/locales/ja/q.json index cf86f25b4..d4905ab24 100644 --- a/locales/ja/q.json +++ b/locales/ja/q.json @@ -165,6 +165,7 @@ "match.rematch.vote.no": "", "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", + "match.rematch.memberJoinedNewGroup": "", "match.rematch.fizzled": "", "match.rematch.rejoinQueue": "", "match.rematch.backToQueue": "", diff --git a/locales/ko/q.json b/locales/ko/q.json index 6f046c1b6..82a29e692 100644 --- a/locales/ko/q.json +++ b/locales/ko/q.json @@ -165,6 +165,7 @@ "match.rematch.vote.no": "", "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", + "match.rematch.memberJoinedNewGroup": "", "match.rematch.fizzled": "", "match.rematch.rejoinQueue": "", "match.rematch.backToQueue": "", diff --git a/locales/nl/q.json b/locales/nl/q.json index 6f046c1b6..82a29e692 100644 --- a/locales/nl/q.json +++ b/locales/nl/q.json @@ -165,6 +165,7 @@ "match.rematch.vote.no": "", "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", + "match.rematch.memberJoinedNewGroup": "", "match.rematch.fizzled": "", "match.rematch.rejoinQueue": "", "match.rematch.backToQueue": "", diff --git a/locales/pl/q.json b/locales/pl/q.json index 6f046c1b6..82a29e692 100644 --- a/locales/pl/q.json +++ b/locales/pl/q.json @@ -165,6 +165,7 @@ "match.rematch.vote.no": "", "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", + "match.rematch.memberJoinedNewGroup": "", "match.rematch.fizzled": "", "match.rematch.rejoinQueue": "", "match.rematch.backToQueue": "", diff --git a/locales/pt-BR/q.json b/locales/pt-BR/q.json index 061c13cef..557492df3 100644 --- a/locales/pt-BR/q.json +++ b/locales/pt-BR/q.json @@ -165,6 +165,7 @@ "match.rematch.vote.no": "", "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", + "match.rematch.memberJoinedNewGroup": "", "match.rematch.fizzled": "", "match.rematch.rejoinQueue": "", "match.rematch.backToQueue": "", diff --git a/locales/ru/q.json b/locales/ru/q.json index 865aba72d..09351e25d 100644 --- a/locales/ru/q.json +++ b/locales/ru/q.json @@ -165,6 +165,7 @@ "match.rematch.vote.no": "", "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", + "match.rematch.memberJoinedNewGroup": "", "match.rematch.fizzled": "", "match.rematch.rejoinQueue": "", "match.rematch.backToQueue": "", diff --git a/locales/zh/q.json b/locales/zh/q.json index f4a459753..ee864a049 100644 --- a/locales/zh/q.json +++ b/locales/zh/q.json @@ -165,6 +165,7 @@ "match.rematch.vote.no": "不,到此为止", "match.rematch.vote.noConfirm": "确认投反对票吗?投票后将无法更改。", "match.rematch.declined": "", + "match.rematch.memberJoinedNewGroup": "", "match.rematch.fizzled": "无人想要继续排队", "match.rematch.rejoinQueue": "重新加入队列", "match.rematch.backToQueue": "返回队列", From b98f700a21f9ffa0444cf1c74c276291efb5e9eb Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:55:37 +0300 Subject: [PATCH 19/50] Prevent double click to confirm result --- .../components/SendouQMatchActionTab.tsx | 25 +++++++++++++++---- e2e/pages/sendouq/sendouq-match-page.ts | 16 +++++++++++- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx index 3677e7bf8..a1cac40f2 100644 --- a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx +++ b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx @@ -40,6 +40,8 @@ import styles from "./SendouQMatchActionTab.module.css"; const CONFIRM_COOLDOWN_MS = 5_000; const CONFIRM_LOSS_ARMED_MS = 5_000; +/** Guards against an accidental double-click satisfying the loss "tap again". */ +const CONFIRM_LOSS_MIN_GAP_MS = 300; const MAP_CHANGED_COOLDOWN_MS = 10_000; export function SendouQMatchActionTab({ @@ -353,8 +355,13 @@ function ScoreConfirmerSection({ const disputeScore = useActionSubmit(matchSchema); const [cooldownUntil] = useState(() => Date.now() + CONFIRM_COOLDOWN_MS); const cooldownSecondsLeft = useCooldown(cooldownUntil); - const [lossArmedUntil, setLossArmedUntil] = useState(null); - const isLossArmed = useCooldown(lossArmedUntil) > 0; + const [lossArmedAt, setLossArmedAt] = useState(null); + const isLossArmed = + useCooldown( + typeof lossArmedAt === "number" + ? lossArmedAt + CONFIRM_LOSS_ARMED_MS + : null, + ) > 0; const [hasDisputed, setHasDisputed] = useState(false); const decidingMap = [...data.match.mapList] @@ -432,9 +439,17 @@ function ScoreConfirmerSection({ isDisabled={cooldownSecondsLeft > 0} isPending={confirmScore.state !== "idle"} onClick={() => { - if (outcome === "loss" && !isLossArmed) { - setLossArmedUntil(Date.now() + CONFIRM_LOSS_ARMED_MS); - return; + if (outcome === "loss") { + if (!isLossArmed) { + setLossArmedAt(Date.now()); + return; + } + if ( + typeof lossArmedAt === "number" && + Date.now() - lossArmedAt < CONFIRM_LOSS_MIN_GAP_MS + ) { + return; + } } submitConfirmation(); }} diff --git a/e2e/pages/sendouq/sendouq-match-page.ts b/e2e/pages/sendouq/sendouq-match-page.ts index 0aa6de3be..9f25d1886 100644 --- a/e2e/pages/sendouq/sendouq-match-page.ts +++ b/e2e/pages/sendouq/sendouq-match-page.ts @@ -22,6 +22,9 @@ type Tab = "action" | "result" | "rosters"; const MAPS_TO_WIN = Math.ceil(SENDOUQ_BEST_OF / 2); +/** The loss confirm ignores taps for `CONFIRM_LOSS_MIN_GAP_MS` after arming. */ +const LOSS_CONFIRM_MIN_GAP_MS = 400; + const TEAM_NAMES: Record = { ALPHA: "Group Alpha", BRAVO: "Group Bravo", @@ -161,8 +164,13 @@ export class SendouQMatchPage { hasText: "Tap again", }); + const isArmed = await armedLossButton.isVisible(); + if (isArmed) { + await waitOutLossConfirmMinGap(this.page); + } + await waitForPOSTResponse(this.page, async () => { - if (await armedLossButton.isVisible()) { + if (isArmed) { await armedLossButton.click(); } }); @@ -241,3 +249,9 @@ export class SendouQMatchPage { }); } } + +/** Waits out the gap the loss confirm keeps between arming and accepting the second tap. */ +export async function waitOutLossConfirmMinGap(page: Page) { + // biome-ignore lint/nursery/noPlaywrightWaitForTimeout: the min gap after arming has no observable end + await page.waitForTimeout(LOSS_CONFIRM_MIN_GAP_MS); +} From a3a44aacf4e3a55c5b3b7002eb519615ed68bda1 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:27:25 +0300 Subject: [PATCH 20/50] Tournament team-picked map lists and check-ins fixes --- .../calendar/CalendarRepository.server.ts | 51 +++++++++++++++---- .../actions/calendar.new.server.test.ts | 37 ++++++++++++++ app/features/calendar/routes/calendar.new.tsx | 17 ++++--- .../tournament/routes/to.$id.register.tsx | 7 ++- locales/da/tournament.json | 1 + locales/de/tournament.json | 1 + locales/en/forms.json | 2 +- locales/en/tournament.json | 1 + locales/es-ES/tournament.json | 1 + locales/es-US/tournament.json | 1 + locales/fr-CA/tournament.json | 1 + locales/fr-EU/tournament.json | 1 + locales/he/tournament.json | 1 + locales/it/tournament.json | 1 + locales/ja/tournament.json | 1 + locales/ko/tournament.json | 1 + locales/nl/tournament.json | 1 + locales/pl/tournament.json | 1 + locales/pt-BR/tournament.json | 1 + locales/ru/tournament.json | 1 + locales/zh/tournament.json | 1 + 21 files changed, 113 insertions(+), 17 deletions(-) diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index b18bfe004..8aa12442b 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -815,15 +815,7 @@ async function updateTournamentTables( // the teams' picks were made against the old settings, so they pick again if (changedMapPickingStyle || changedMapPool) { - await trx - .deleteFrom("MapPoolMap") - .where("tournamentTeamId", "in", (eb) => - eb - .selectFrom("TournamentTeam") - .select("id") - .where("tournamentId", "=", tournamentId), - ) - .execute(); + await resetTeamMapPicks({ tournamentId, args }, trx); } if ( @@ -841,6 +833,47 @@ async function updateTournamentTables( } } +/** + * Deletes every team's map picks. Teams that had picked are also checked out when picks are still + * a check-in requirement, as otherwise they would enter the bracket without a pool. + */ +async function resetTeamMapPicks( + { + tournamentId, + args, + }: { tournamentId: number; args: Pick }, + trx: Transaction, +) { + // before the picks go, as the teams to check out are the ones that have them + if (args.mapPickingStyle !== "TO") { + await trx + .deleteFrom("TournamentTeamCheckIn") + .where("bracketIdx", "is", null) + .where("tournamentTeamId", "in", (eb) => + eb + .selectFrom("MapPoolMap") + .innerJoin( + "TournamentTeam", + "TournamentTeam.id", + "MapPoolMap.tournamentTeamId", + ) + .select("TournamentTeam.id") + .where("TournamentTeam.tournamentId", "=", tournamentId), + ) + .execute(); + } + + await trx + .deleteFrom("MapPoolMap") + .where("tournamentTeamId", "in", (eb) => + eb + .selectFrom("TournamentTeam") + .select("id") + .where("tournamentId", "=", tournamentId), + ) + .execute(); +} + function teamPickSettings( args: Pick, ) { diff --git a/app/features/calendar/actions/calendar.new.server.test.ts b/app/features/calendar/actions/calendar.new.server.test.ts index 97a226b9f..9187f60ea 100644 --- a/app/features/calendar/actions/calendar.new.server.test.ts +++ b/app/features/calendar/actions/calendar.new.server.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, test } from "vitest"; import * as BadgeFactory from "~/db/seed/factories/BadgeFactory"; import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory"; +import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; @@ -21,6 +22,8 @@ const editAction = wrappedAction({ const users = UserFactory.pool(); const badgeManagingAuthorId = () => users.id(1); +const teamPickAuthorId = () => users.id(5); +const teamPickTeamMemberIds = () => users.ids(4); describe("calendar new action: editing an event with badge prizes", () => { let orgAdminId: number; @@ -168,6 +171,7 @@ describe("calendar new action: bracket URL", () => { describe("calendar new action: team picked tournament", () => { beforeEach(async () => { await UserFactory.createRegular(null, { roles: ["TOURNAMENT_ORGANIZER"] }); + await users.create(5); }); test("saves the team pick settings and the custom pool", async () => { @@ -211,4 +215,37 @@ describe("calendar new action: team picked tournament", () => { customPool.serialized, ); }); + + test("changing the pick settings resets the teams' picks and checks them out", async () => { + const tournament = await TournamentFactory.create({ + authorId: teamPickAuthorId(), + mapPickingStyle: "AUTO", + teamPick: { modes: [{ mode: "SZ", count: 2 }], pool: "ALL" }, + }); + await TournamentTeamFactory.create( + { + tournamentId: tournament.id, + memberUserIds: teamPickTeamMemberIds(), + mapPool: new MapPool({ ...MapPool.EMPTY.parsed, SZ: [1, 2] }), + }, + { isCheckedIn: true }, + ); + + const res = await editAction( + calendarNewFormValues({ + eventToEditId: tournament.eventId, + bracketUrl: "https://sendou.ink", + mapPickingStyle: "AUTO", + teamPickModes: ["SZ"], + teamPickCounts: [{ mode: "SZ", count: 3 }], + teamPickPool: "ALL", + }), + { user: teamPickAuthorId() }, + ); + expect(res.fieldErrors).toBeUndefined(); + + const edited = await tournamentFromDB(tournament.id); + expect(edited.ctx.teams[0].hasMapPool).toBe(0); + expect(edited.ctx.teams[0].checkIns).toEqual([]); + }); }); diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index 02f99875e..4e97dd3fa 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -601,13 +601,18 @@ function TeamPickCountInputs({ value={count} onChange={(e) => onChange( - pickedModes.map((m) => ({ - mode: m, - count: + // an emptied input drops the entry so the mode falls back to the default count + pickedModes.flatMap((m) => { + const newCount = m === mode - ? Number(e.target.value) - : (value.find((c) => c.mode === m)?.count ?? 1), - })), + ? Number.parseInt(e.target.value, 10) + : value.find((c) => c.mode === m)?.count; + + return typeof newCount === "number" && + !Number.isNaN(newCount) + ? [{ mode: m, count: newCount }] + : []; + }), mode, ) } diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index 00004b7ff..2e8942b4c 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -118,7 +118,12 @@ export default function TournamentRegisterPage() { return (
- {isRegularMemberOfATeam ? ( + {tournament.hasStarted && teamMemberOf ? ( +
+ {t("tournament:pre.startedNoEdit")} + +
+ ) : isRegularMemberOfATeam ? (
{t("tournament:pre.captainOnlyEdit")}
diff --git a/locales/da/tournament.json b/locales/da/tournament.json index 0808ecfe6..c41aa9ba4 100644 --- a/locales/da/tournament.json +++ b/locales/da/tournament.json @@ -37,6 +37,7 @@ "pre.friendCode.needed": "", "pre.logIn": "Log in for at tilmelde dig", "pre.captainOnlyEdit": "", + "pre.startedNoEdit": "", "pre.leave.button": "", "pre.leave.confirm": "", "pre.leave.cant.organizerAdded": "", diff --git a/locales/de/tournament.json b/locales/de/tournament.json index 6a5a652ca..c6876e51e 100644 --- a/locales/de/tournament.json +++ b/locales/de/tournament.json @@ -37,6 +37,7 @@ "pre.friendCode.needed": "", "pre.logIn": "Logge dich zum Registrieren ein", "pre.captainOnlyEdit": "", + "pre.startedNoEdit": "", "pre.leave.button": "", "pre.leave.confirm": "", "pre.leave.cant.organizerAdded": "", diff --git a/locales/en/forms.json b/locales/en/forms.json index fd85b652b..952febfbe 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -362,7 +362,7 @@ "options.teamPickPool.SENDOUQ": "SendouQ legal maps", "options.teamPickPool.ALL": "All maps", "options.teamPickPool.CUSTOM": "Custom", - "bottomTexts.teamPickReset": "Changing the map settings resets the maps every team has already picked", + "bottomTexts.teamPickReset": "Changing the map settings resets the maps every team has already picked and checks those teams out", "bottomTexts.regClosesAt": "All times relative to the reported tournament start time e.g. \"30 minutes\" means \"30 minutes before event start time\". After registration closes only TO's can make changes to team rosters.", "bottomTexts.maxTeamSize": "Maximum number of players that can be registered per team. Doesn't apply to tournament organizers. Default is 6.", "bottomTexts.avatarValidation": "Note that for non-patrons there is a validation process before avatar is shown.", diff --git a/locales/en/tournament.json b/locales/en/tournament.json index 709d9865b..4731821fa 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -37,6 +37,7 @@ "pre.friendCode.needed": "To play tournaments on sendou.ink you'll need to register your friend code.", "pre.logIn": "Log in to register", "pre.captainOnlyEdit": "You are in a team for this event. Only the team captain can edit the registration.", + "pre.startedNoEdit": "The tournament has started. Registration can no longer be edited.", "pre.leave.button": "Leave the team", "pre.leave.confirm": "Leave \"{{teamName}}\"?", "pre.leave.cant.organizerAdded": "You were added to the team by the organizer. Contact the TO to leave the team.", diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json index ec9c7932b..af225a554 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -37,6 +37,7 @@ "pre.friendCode.needed": "", "pre.logIn": "Inicia sesión para inscribirte", "pre.captainOnlyEdit": "Estás en un equipo para este evento. Solo el capitán del equipo puede editar la inscripción.", + "pre.startedNoEdit": "", "pre.leave.button": "", "pre.leave.confirm": "", "pre.leave.cant.organizerAdded": "", diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json index 3796fbfa1..900ffc8ed 100644 --- a/locales/es-US/tournament.json +++ b/locales/es-US/tournament.json @@ -37,6 +37,7 @@ "pre.friendCode.needed": "", "pre.logIn": "Inicia sesión para inscribirte", "pre.captainOnlyEdit": "Estás en un equipo para este evento. Solo el capitán del equipo puede editar la inscripción.", + "pre.startedNoEdit": "", "pre.leave.button": "", "pre.leave.confirm": "", "pre.leave.cant.organizerAdded": "", diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json index ca6713a0b..ff69377ec 100644 --- a/locales/fr-CA/tournament.json +++ b/locales/fr-CA/tournament.json @@ -37,6 +37,7 @@ "pre.friendCode.needed": "", "pre.logIn": "Connectez-vous pour vous inscrire", "pre.captainOnlyEdit": "", + "pre.startedNoEdit": "", "pre.leave.button": "", "pre.leave.confirm": "", "pre.leave.cant.organizerAdded": "", diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json index 067c4c04f..a580500c5 100644 --- a/locales/fr-EU/tournament.json +++ b/locales/fr-EU/tournament.json @@ -37,6 +37,7 @@ "pre.friendCode.needed": "", "pre.logIn": "Connectez-vous pour vous inscrire", "pre.captainOnlyEdit": "", + "pre.startedNoEdit": "", "pre.leave.button": "", "pre.leave.confirm": "", "pre.leave.cant.organizerAdded": "", diff --git a/locales/he/tournament.json b/locales/he/tournament.json index d75ed43a7..10efa0241 100644 --- a/locales/he/tournament.json +++ b/locales/he/tournament.json @@ -37,6 +37,7 @@ "pre.friendCode.needed": "", "pre.logIn": "התחבר כדי להירשם", "pre.captainOnlyEdit": "", + "pre.startedNoEdit": "", "pre.leave.button": "", "pre.leave.confirm": "", "pre.leave.cant.organizerAdded": "", diff --git a/locales/it/tournament.json b/locales/it/tournament.json index 345b74364..dd4129d29 100644 --- a/locales/it/tournament.json +++ b/locales/it/tournament.json @@ -37,6 +37,7 @@ "pre.friendCode.needed": "", "pre.logIn": "Accedi per iscriverti", "pre.captainOnlyEdit": "", + "pre.startedNoEdit": "", "pre.leave.button": "", "pre.leave.confirm": "", "pre.leave.cant.organizerAdded": "", diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json index 908b12c22..a5a33ba6c 100644 --- a/locales/ja/tournament.json +++ b/locales/ja/tournament.json @@ -37,6 +37,7 @@ "pre.friendCode.needed": "", "pre.logIn": "ログインして登録する", "pre.captainOnlyEdit": "", + "pre.startedNoEdit": "", "pre.leave.button": "", "pre.leave.confirm": "", "pre.leave.cant.organizerAdded": "", diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json index 1513d6fdf..7fca89573 100644 --- a/locales/ko/tournament.json +++ b/locales/ko/tournament.json @@ -37,6 +37,7 @@ "pre.friendCode.needed": "", "pre.logIn": "", "pre.captainOnlyEdit": "", + "pre.startedNoEdit": "", "pre.leave.button": "", "pre.leave.confirm": "", "pre.leave.cant.organizerAdded": "", diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json index b5b0f9192..68d7f0cf6 100644 --- a/locales/nl/tournament.json +++ b/locales/nl/tournament.json @@ -37,6 +37,7 @@ "pre.friendCode.needed": "", "pre.logIn": "", "pre.captainOnlyEdit": "", + "pre.startedNoEdit": "", "pre.leave.button": "", "pre.leave.confirm": "", "pre.leave.cant.organizerAdded": "", diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json index 9bd27f782..39f020161 100644 --- a/locales/pl/tournament.json +++ b/locales/pl/tournament.json @@ -37,6 +37,7 @@ "pre.friendCode.needed": "", "pre.logIn": "", "pre.captainOnlyEdit": "", + "pre.startedNoEdit": "", "pre.leave.button": "", "pre.leave.confirm": "", "pre.leave.cant.organizerAdded": "", diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json index 0873dd448..56c494378 100644 --- a/locales/pt-BR/tournament.json +++ b/locales/pt-BR/tournament.json @@ -37,6 +37,7 @@ "pre.friendCode.needed": "", "pre.logIn": "Faça o login para registrar", "pre.captainOnlyEdit": "", + "pre.startedNoEdit": "", "pre.leave.button": "", "pre.leave.confirm": "", "pre.leave.cant.organizerAdded": "", diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json index 1af6e3b48..8a18f52c2 100644 --- a/locales/ru/tournament.json +++ b/locales/ru/tournament.json @@ -37,6 +37,7 @@ "pre.friendCode.needed": "", "pre.logIn": "Войдите, чтобы зарегистрироваться", "pre.captainOnlyEdit": "", + "pre.startedNoEdit": "", "pre.leave.button": "", "pre.leave.confirm": "", "pre.leave.cant.organizerAdded": "", diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index ff20e860c..6375c0b51 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -37,6 +37,7 @@ "pre.friendCode.needed": "", "pre.logIn": "登录以报名", "pre.captainOnlyEdit": "您已加入此赛事的某支队伍。只有队长可以修改报名信息。", + "pre.startedNoEdit": "", "pre.leave.button": "", "pre.leave.confirm": "", "pre.leave.cant.organizerAdded": "", From d782e698bc17c856d50ce9700087bf9b094cd8db Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:54:46 +0300 Subject: [PATCH 21/50] Art & tier list maker tweaks --- .../art/components/ArtGrid.module.css | 12 +++++ app/features/art/components/ArtGrid.tsx | 19 ++++++-- .../comp-analyzer/routes/comp-analyzer.tsx | 44 +++++++++++++------ .../tier-list-maker-utils.test.ts | 5 ++- .../tier-list-maker/tier-list-maker-utils.ts | 26 ++++++++--- locales/da/analyzer.json | 3 +- locales/da/art.json | 3 +- locales/de/analyzer.json | 3 +- locales/de/art.json | 3 +- locales/en/analyzer.json | 3 +- locales/en/art.json | 3 +- locales/es-ES/analyzer.json | 3 +- locales/es-ES/art.json | 3 +- locales/es-US/analyzer.json | 3 +- locales/es-US/art.json | 3 +- locales/fr-CA/analyzer.json | 3 +- locales/fr-CA/art.json | 3 +- locales/fr-EU/analyzer.json | 3 +- locales/fr-EU/art.json | 3 +- locales/he/analyzer.json | 3 +- locales/he/art.json | 3 +- locales/it/analyzer.json | 3 +- locales/it/art.json | 3 +- locales/ja/analyzer.json | 3 +- locales/ja/art.json | 3 +- locales/ko/analyzer.json | 3 +- locales/ko/art.json | 3 +- locales/nl/analyzer.json | 3 +- locales/nl/art.json | 3 +- locales/pl/analyzer.json | 3 +- locales/pl/art.json | 3 +- locales/pt-BR/analyzer.json | 3 +- locales/pt-BR/art.json | 3 +- locales/ru/analyzer.json | 3 +- locales/ru/art.json | 3 +- locales/zh/analyzer.json | 3 +- locales/zh/art.json | 3 +- 37 files changed, 146 insertions(+), 56 deletions(-) diff --git a/app/features/art/components/ArtGrid.module.css b/app/features/art/components/ArtGrid.module.css index 42321780f..0393bf380 100644 --- a/app/features/art/components/ArtGrid.module.css +++ b/app/features/art/components/ArtGrid.module.css @@ -1,3 +1,15 @@ +.thumbnailButton { + all: unset; + display: block; + cursor: pointer; + + /* thumbnails have square corners, a rounded ring would leave the art's corners outside it */ + &:focus-visible { + outline: var(--focus-ring); + outline-offset: 2px; + } +} + .thumbnail { cursor: pointer; transition: all 0.2s ease; diff --git a/app/features/art/components/ArtGrid.tsx b/app/features/art/components/ArtGrid.tsx index 401e2b6bf..6bfad46b9 100644 --- a/app/features/art/components/ArtGrid.tsx +++ b/app/features/art/components/ArtGrid.tsx @@ -211,20 +211,31 @@ function ImagePreview({ const { t } = useTranslation(["common", "art"]); const formatDistanceToNow = useFormatDistanceToNow(); - const img = ( - // biome-ignore lint/a11y/noStaticElementInteractions: Biome v2 migration + const image = ( preloadImage(art.url) : undefined} ref={imageRef} className={enablePreview ? styles.thumbnail : undefined} data-testid="art-image" /> ); + const img = onClick ? ( + + ) : ( + image + ); + if (!art.author && canEdit) { return (
diff --git a/app/features/comp-analyzer/routes/comp-analyzer.tsx b/app/features/comp-analyzer/routes/comp-analyzer.tsx index 2b08eda31..d4486cd2a 100644 --- a/app/features/comp-analyzer/routes/comp-analyzer.tsx +++ b/app/features/comp-analyzer/routes/comp-analyzer.tsx @@ -2,7 +2,11 @@ import { HardDriveDownload } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import type { MetaFunction, ShouldRevalidateFunction } from "react-router"; -import { SendouButton } from "~/components/elements/Button"; +import { + SendouButton, + type SendouButtonProps, +} from "~/components/elements/Button"; +import { SendouPopover } from "~/components/elements/Popover"; import { SendouSwitch } from "~/components/elements/Switch"; import { Main } from "~/components/Main"; import { Placeholder } from "~/components/Placeholder"; @@ -63,6 +67,7 @@ export default function CompAnalyzerShell() { } function CompAnalyzerPage() { + const { t } = useTranslation(["common", "analyzer"]); const [selectedWeaponIds, setSelectedWeaponIds] = useSelectedWeapons(); const [categorization, setCategorization] = useCategorization(); const [isGridCollapsed, setIsGridCollapsed] = useState( @@ -99,14 +104,18 @@ function CompAnalyzerPage() { onRemove={handleRemoveWeapon} onReorder={setSelectedWeaponIds} /> - {selectedWeaponIds.length >= MAX_WEAPONS ? ( -
+
+ {selectedWeaponIds.length >= MAX_WEAPONS ? ( -
- ) : null} + ) : ( + }> + {t("analyzer:comp.exportHint", { max: MAX_WEAPONS })} + + )} +
} - > - {t("common:imageExport.export")} - - } + trigger={} heading={t("common:imageExport.export")} filename="comp" settings={ @@ -212,3 +213,18 @@ function CompGraphicWithCombos({ /> ); } + +function ExportButton(props: SendouButtonProps) { + const { t } = useTranslation(["common"]); + + return ( + } + > + {t("common:imageExport.export")} + + ); +} diff --git a/app/features/tier-list-maker/tier-list-maker-utils.test.ts b/app/features/tier-list-maker/tier-list-maker-utils.test.ts index 0ffe7fa1d..74b4d0758 100644 --- a/app/features/tier-list-maker/tier-list-maker-utils.test.ts +++ b/app/features/tier-list-maker/tier-list-maker-utils.test.ts @@ -203,7 +203,10 @@ describe("isLightColor", () => { { hex: "#8b0000", expected: false, why: "dark red" }, { hex: "#4169e1", expected: false, why: "royal blue" }, { hex: "#000000", expected: false, why: "black" }, - { hex: "#fff", expected: false, why: "unsupported short form" }, + { hex: "#fff", expected: true, why: "short form white" }, + { hex: "#000", expected: false, why: "short form black" }, + { hex: "#ffd", expected: true, why: "short form light yellow" }, + { hex: "not-a-color", expected: false, why: "garbage" }, ])("$why -> $expected", ({ hex, expected }) => { expect(isLightColor(hex)).toBe(expected); }); diff --git a/app/features/tier-list-maker/tier-list-maker-utils.ts b/app/features/tier-list-maker/tier-list-maker-utils.ts index 887bdb55a..9d2d1f391 100644 --- a/app/features/tier-list-maker/tier-list-maker-utils.ts +++ b/app/features/tier-list-maker/tier-list-maker-utils.ts @@ -87,20 +87,36 @@ export function tierNameFontSize(name: string) { const LIGHT_COLOR_LUMINANCE_THRESHOLD = 0.5; -/** Whether dark text/icons read better on the given `#rrggbb` color than light ones. */ +/** Whether dark text/icons read better on the given `#rgb` or `#rrggbb` color than light ones. */ export function isLightColor(hex: string) { - const channels = hex.replace("#", "").match(/.{2}/g); - if (!channels || channels.length < 3) return false; + const channels = hexChannels(hex); + if (!channels) return false; - const [r, g, b] = channels.map((channel) => Number.parseInt(channel, 16)); + const [r, g, b] = channels; const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; return luminance > LIGHT_COLOR_LUMINANCE_THRESHOLD; } -/** Text color that stays readable on a tier's `#rrggbb` background color. */ +/** Text color that stays readable on a tier's `#rgb` or `#rrggbb` background color. */ export function tierTextColor(hex: string) { return isLightColor(hex) ? "var(--color-text-on-light)" : "var(--color-text-on-dark)"; } + +function hexChannels(hex: string) { + const digits = hex.replace("#", ""); + const channels = + digits.length === 3 + ? digits.split("").map((digit) => `${digit}${digit}`) + : digits.match(/.{2}/g); + + if (!channels || channels.length < 3) return null; + + const parsed = channels + .slice(0, 3) + .map((channel) => Number.parseInt(channel, 16)); + + return parsed.some(Number.isNaN) ? null : parsed; +} diff --git a/locales/da/analyzer.json b/locales/da/analyzer.json index ac183de84..9553b48c2 100644 --- a/locales/da/analyzer.json +++ b/locales/da/analyzer.json @@ -219,5 +219,6 @@ "comp.removeAll": "", "comp.addAll": "", "comp.singleWeaponCombos": "", - "comp.graphicSubtitle": "" + "comp.graphicSubtitle": "", + "comp.exportHint": "" } diff --git a/locales/da/art.json b/locales/da/art.json index b5349fc3d..d2bc3b231 100644 --- a/locales/da/art.json +++ b/locales/da/art.json @@ -23,5 +23,6 @@ "delete.title": "", "unlink.title": "", "noArtForTag": "", - "imageFailed": "" + "imageFailed": "", + "openImage": "" } diff --git a/locales/de/analyzer.json b/locales/de/analyzer.json index b62d5b6eb..b02045949 100644 --- a/locales/de/analyzer.json +++ b/locales/de/analyzer.json @@ -219,5 +219,6 @@ "comp.removeAll": "", "comp.addAll": "", "comp.singleWeaponCombos": "", - "comp.graphicSubtitle": "" + "comp.graphicSubtitle": "", + "comp.exportHint": "" } diff --git a/locales/de/art.json b/locales/de/art.json index 852565d8a..4d3d4b4d7 100644 --- a/locales/de/art.json +++ b/locales/de/art.json @@ -23,5 +23,6 @@ "delete.title": "", "unlink.title": "", "noArtForTag": "", - "imageFailed": "" + "imageFailed": "", + "openImage": "" } diff --git a/locales/en/analyzer.json b/locales/en/analyzer.json index 25cc78dd3..faf96e554 100644 --- a/locales/en/analyzer.json +++ b/locales/en/analyzer.json @@ -219,5 +219,6 @@ "comp.removeAll": "Remove all", "comp.addAll": "Add all", "comp.singleWeaponCombos": "Single weapon combos", - "comp.graphicSubtitle": "Composition Analyzer" + "comp.graphicSubtitle": "Composition Analyzer", + "comp.exportHint": "Pick {{max}} weapons to export an image" } diff --git a/locales/en/art.json b/locales/en/art.json index 655b8117d..6c931915a 100644 --- a/locales/en/art.json +++ b/locales/en/art.json @@ -23,5 +23,6 @@ "delete.title": "Are you sure you want to delete the art?", "unlink.title": "Are you sure you want to remove this art from your profile (only {{username}} can add it back)?", "noArtForTag": "No uploaded art found for #{{tag}}", - "imageFailed": "Image could not be loaded" + "imageFailed": "Image could not be loaded", + "openImage": "Open image" } diff --git a/locales/es-ES/analyzer.json b/locales/es-ES/analyzer.json index c59003008..10803581d 100644 --- a/locales/es-ES/analyzer.json +++ b/locales/es-ES/analyzer.json @@ -223,5 +223,6 @@ "comp.removeAll": "Eliminar todos", "comp.addAll": "Añadir todos", "comp.singleWeaponCombos": "Combos de un arma", - "comp.graphicSubtitle": "" + "comp.graphicSubtitle": "", + "comp.exportHint": "" } diff --git a/locales/es-ES/art.json b/locales/es-ES/art.json index a83b4598f..e335fbc27 100644 --- a/locales/es-ES/art.json +++ b/locales/es-ES/art.json @@ -24,5 +24,6 @@ "delete.title": "¿Seguro que quieres eliminar este arte?", "unlink.title": "¿Seguro que quieres desvincular este arte de tu perfil? (solo {{username}} puede volver a añadirlo)", "noArtForTag": "No se ha encontrado arte subido para #{{tag}}", - "imageFailed": "" + "imageFailed": "", + "openImage": "" } diff --git a/locales/es-US/analyzer.json b/locales/es-US/analyzer.json index 996ac0c53..36e3ca987 100644 --- a/locales/es-US/analyzer.json +++ b/locales/es-US/analyzer.json @@ -223,5 +223,6 @@ "comp.removeAll": "Eliminar todos", "comp.addAll": "Añadir todos", "comp.singleWeaponCombos": "Combos de un arma", - "comp.graphicSubtitle": "" + "comp.graphicSubtitle": "", + "comp.exportHint": "" } diff --git a/locales/es-US/art.json b/locales/es-US/art.json index f1756fdb5..56d215d0c 100644 --- a/locales/es-US/art.json +++ b/locales/es-US/art.json @@ -24,5 +24,6 @@ "delete.title": "¿Seguro que quieres eliminar este arte?", "unlink.title": "¿Seguro que quieres desvincular este arte de tu perfil? (solo {{username}} puede volver a añadirlo)", "noArtForTag": "No se ha encontrado arte subido para #{{tag}}", - "imageFailed": "" + "imageFailed": "", + "openImage": "" } diff --git a/locales/fr-CA/analyzer.json b/locales/fr-CA/analyzer.json index 12388804b..4268422ae 100644 --- a/locales/fr-CA/analyzer.json +++ b/locales/fr-CA/analyzer.json @@ -223,5 +223,6 @@ "comp.removeAll": "", "comp.addAll": "", "comp.singleWeaponCombos": "", - "comp.graphicSubtitle": "" + "comp.graphicSubtitle": "", + "comp.exportHint": "" } diff --git a/locales/fr-CA/art.json b/locales/fr-CA/art.json index 9f38265a1..543ad1b29 100644 --- a/locales/fr-CA/art.json +++ b/locales/fr-CA/art.json @@ -24,5 +24,6 @@ "delete.title": "", "unlink.title": "", "noArtForTag": "", - "imageFailed": "" + "imageFailed": "", + "openImage": "" } diff --git a/locales/fr-EU/analyzer.json b/locales/fr-EU/analyzer.json index 01c689472..bb5486f82 100644 --- a/locales/fr-EU/analyzer.json +++ b/locales/fr-EU/analyzer.json @@ -223,5 +223,6 @@ "comp.removeAll": "", "comp.addAll": "", "comp.singleWeaponCombos": "", - "comp.graphicSubtitle": "" + "comp.graphicSubtitle": "", + "comp.exportHint": "" } diff --git a/locales/fr-EU/art.json b/locales/fr-EU/art.json index 10328c217..8a73b5492 100644 --- a/locales/fr-EU/art.json +++ b/locales/fr-EU/art.json @@ -24,5 +24,6 @@ "delete.title": "Êtes vous sûre de vouloir supprimer cette œuvre?", "unlink.title": "Êtes vous sûre de vouloir supprimer cette œuvre de votre profil? (Seulement {{username}} pourra le remettre)", "noArtForTag": "", - "imageFailed": "" + "imageFailed": "", + "openImage": "" } diff --git a/locales/he/analyzer.json b/locales/he/analyzer.json index f5d919788..a6e86984f 100644 --- a/locales/he/analyzer.json +++ b/locales/he/analyzer.json @@ -223,5 +223,6 @@ "comp.removeAll": "הסר הכל", "comp.addAll": "הוסף הכל", "comp.singleWeaponCombos": "", - "comp.graphicSubtitle": "" + "comp.graphicSubtitle": "", + "comp.exportHint": "" } diff --git a/locales/he/art.json b/locales/he/art.json index 355a72d8e..294c25582 100644 --- a/locales/he/art.json +++ b/locales/he/art.json @@ -24,5 +24,6 @@ "delete.title": "האם אתה בטוח שאתה רוצה למחוק את הציור?", "unlink.title": "האם אתה בטוח שאתה רוצה להסיר את הציור הזה מהפרופיל שלך (רק {{username}} יוכל להוסיף אותו חזרה)?", "noArtForTag": "", - "imageFailed": "" + "imageFailed": "", + "openImage": "" } diff --git a/locales/it/analyzer.json b/locales/it/analyzer.json index 647ecedaf..4b8ec69e5 100644 --- a/locales/it/analyzer.json +++ b/locales/it/analyzer.json @@ -223,5 +223,6 @@ "comp.removeAll": "", "comp.addAll": "", "comp.singleWeaponCombos": "", - "comp.graphicSubtitle": "" + "comp.graphicSubtitle": "", + "comp.exportHint": "" } diff --git a/locales/it/art.json b/locales/it/art.json index bab969f02..13878e8f8 100644 --- a/locales/it/art.json +++ b/locales/it/art.json @@ -24,5 +24,6 @@ "delete.title": "", "unlink.title": "", "noArtForTag": "", - "imageFailed": "" + "imageFailed": "", + "openImage": "" } diff --git a/locales/ja/analyzer.json b/locales/ja/analyzer.json index a8ab48f47..ed6fd3491 100644 --- a/locales/ja/analyzer.json +++ b/locales/ja/analyzer.json @@ -211,5 +211,6 @@ "comp.removeAll": "", "comp.addAll": "", "comp.singleWeaponCombos": "", - "comp.graphicSubtitle": "" + "comp.graphicSubtitle": "", + "comp.exportHint": "" } diff --git a/locales/ja/art.json b/locales/ja/art.json index 707c457b4..d28910974 100644 --- a/locales/ja/art.json +++ b/locales/ja/art.json @@ -21,5 +21,6 @@ "delete.title": "本当にこの作品を削除しますか?", "unlink.title": "本当にこの作品をプロフィールから削除しますか?{{username}}しか再び追加することはできません。", "noArtForTag": "#{{tag}}の作品が見つかりませんでした。", - "imageFailed": "" + "imageFailed": "", + "openImage": "" } diff --git a/locales/ko/analyzer.json b/locales/ko/analyzer.json index 6ccc2aa71..5b2523043 100644 --- a/locales/ko/analyzer.json +++ b/locales/ko/analyzer.json @@ -211,5 +211,6 @@ "comp.removeAll": "", "comp.addAll": "", "comp.singleWeaponCombos": "", - "comp.graphicSubtitle": "" + "comp.graphicSubtitle": "", + "comp.exportHint": "" } diff --git a/locales/ko/art.json b/locales/ko/art.json index 654f7c885..fcaf0c8c3 100644 --- a/locales/ko/art.json +++ b/locales/ko/art.json @@ -21,5 +21,6 @@ "delete.title": "", "unlink.title": "", "noArtForTag": "", - "imageFailed": "" + "imageFailed": "", + "openImage": "" } diff --git a/locales/nl/analyzer.json b/locales/nl/analyzer.json index 559986a49..bbe72b670 100644 --- a/locales/nl/analyzer.json +++ b/locales/nl/analyzer.json @@ -219,5 +219,6 @@ "comp.removeAll": "", "comp.addAll": "", "comp.singleWeaponCombos": "", - "comp.graphicSubtitle": "" + "comp.graphicSubtitle": "", + "comp.exportHint": "" } diff --git a/locales/nl/art.json b/locales/nl/art.json index 852565d8a..4d3d4b4d7 100644 --- a/locales/nl/art.json +++ b/locales/nl/art.json @@ -23,5 +23,6 @@ "delete.title": "", "unlink.title": "", "noArtForTag": "", - "imageFailed": "" + "imageFailed": "", + "openImage": "" } diff --git a/locales/pl/analyzer.json b/locales/pl/analyzer.json index b9adfd20f..631a26eb4 100644 --- a/locales/pl/analyzer.json +++ b/locales/pl/analyzer.json @@ -227,5 +227,6 @@ "comp.removeAll": "", "comp.addAll": "", "comp.singleWeaponCombos": "", - "comp.graphicSubtitle": "" + "comp.graphicSubtitle": "", + "comp.exportHint": "" } diff --git a/locales/pl/art.json b/locales/pl/art.json index f801affdc..9929c2269 100644 --- a/locales/pl/art.json +++ b/locales/pl/art.json @@ -25,5 +25,6 @@ "delete.title": "", "unlink.title": "", "noArtForTag": "", - "imageFailed": "" + "imageFailed": "", + "openImage": "" } diff --git a/locales/pt-BR/analyzer.json b/locales/pt-BR/analyzer.json index fb72dce13..f5d7a26de 100644 --- a/locales/pt-BR/analyzer.json +++ b/locales/pt-BR/analyzer.json @@ -223,5 +223,6 @@ "comp.removeAll": "", "comp.addAll": "", "comp.singleWeaponCombos": "", - "comp.graphicSubtitle": "" + "comp.graphicSubtitle": "", + "comp.exportHint": "" } diff --git a/locales/pt-BR/art.json b/locales/pt-BR/art.json index 303786be2..4fd889008 100644 --- a/locales/pt-BR/art.json +++ b/locales/pt-BR/art.json @@ -24,5 +24,6 @@ "delete.title": "", "unlink.title": "", "noArtForTag": "", - "imageFailed": "" + "imageFailed": "", + "openImage": "" } diff --git a/locales/ru/analyzer.json b/locales/ru/analyzer.json index ebf3a53e4..b4e534cbf 100644 --- a/locales/ru/analyzer.json +++ b/locales/ru/analyzer.json @@ -227,5 +227,6 @@ "comp.removeAll": "", "comp.addAll": "", "comp.singleWeaponCombos": "", - "comp.graphicSubtitle": "" + "comp.graphicSubtitle": "", + "comp.exportHint": "" } diff --git a/locales/ru/art.json b/locales/ru/art.json index 08a0d70a6..a78245334 100644 --- a/locales/ru/art.json +++ b/locales/ru/art.json @@ -25,5 +25,6 @@ "delete.title": "Вы точно хотите удалить этот арт?", "unlink.title": "Вы точно хотите удалить этот арт с вашего профиля (только {{username}} может добавить его обратно)?", "noArtForTag": "", - "imageFailed": "" + "imageFailed": "", + "openImage": "" } diff --git a/locales/zh/analyzer.json b/locales/zh/analyzer.json index a29764961..91ef92042 100644 --- a/locales/zh/analyzer.json +++ b/locales/zh/analyzer.json @@ -214,5 +214,6 @@ "comp.removeAll": "移除所有", "comp.addAll": "添加所有", "comp.singleWeaponCombos": "", - "comp.graphicSubtitle": "" + "comp.graphicSubtitle": "", + "comp.exportHint": "" } diff --git a/locales/zh/art.json b/locales/zh/art.json index af36e7d61..fb1cf8c78 100644 --- a/locales/zh/art.json +++ b/locales/zh/art.json @@ -22,5 +22,6 @@ "delete.title": "您确定要删除这件作品吗?", "unlink.title": "您确定要将这件作品从个人资料中移除吗(只有 {{username}} 可以重新添加)?", "noArtForTag": "未能找到包含 #{{tag}} 的作品。", - "imageFailed": "" + "imageFailed": "", + "openImage": "" } From 5906cdeb77c35e92cfaba46401fcea5b9b418fda Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:07:43 +0300 Subject: [PATCH 22/50] Dialog scroll lock --- .../elements/Dialog.browser.test.tsx | 48 +++++++++++ app/components/elements/Dialog.tsx | 85 ++++++++++++++++++- changelog/2026-09-20-dialog-scroll-lock.md | 4 + e2e/pages/sendouq/sendouq-match-page.ts | 2 +- 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 changelog/2026-09-20-dialog-scroll-lock.md diff --git a/app/components/elements/Dialog.browser.test.tsx b/app/components/elements/Dialog.browser.test.tsx index 1d073b907..10b0d737c 100644 --- a/app/components/elements/Dialog.browser.test.tsx +++ b/app/components/elements/Dialog.browser.test.tsx @@ -120,6 +120,54 @@ describe("SendouDialog", () => { expect(openDialog().open).toBe(true); }); + test("locks page scrolling while open without changing the page width", async () => { + const tall = document.createElement("div"); + tall.style.height = "300vh"; + document.body.appendChild(tall); + cleanupFns.push(() => tall.remove()); + const root = document.documentElement; + const widthBefore = root.clientWidth; + + const screen = await render( + withRouter( + Open} + showCloseButton + > + Content + , + ), + ); + + await screen.getByRole("button", { name: "Open" }).click(); + await expect.element(screen.getByText("Content")).toBeVisible(); + await vi.waitFor(() => expect(root.style.overflow).toBe("hidden")); + expect(root.clientWidth).toBe(widthBefore); + + await screen.getByRole("button", { name: "Close" }).click(); + await vi.waitFor(() => expect(root.style.overflow).toBe("")); + expect(root.style.scrollbarGutter).toBe(""); + expect(root.clientWidth).toBe(widthBefore); + }); + + test("releases the scroll lock when an open dialog unmounts", async () => { + const screen = await render( + withRouter( + {}}> + Content + , + ), + ); + await expect.element(screen.getByText("Content")).toBeVisible(); + await vi.waitFor(() => + expect(document.documentElement.style.overflow).toBe("hidden"), + ); + + await screen.unmount(); + expect(document.documentElement.style.overflow).toBe(""); + }); + test("focuses the dialog itself instead of the close button on open", async () => { await render( withRouter( diff --git a/app/components/elements/Dialog.tsx b/app/components/elements/Dialog.tsx index 5f6df54a7..3bbdd53c3 100644 --- a/app/components/elements/Dialog.tsx +++ b/app/components/elements/Dialog.tsx @@ -67,9 +67,19 @@ function DialogElement({ children, ref, }: DialogElementProps) { + const dialogRef = React.useRef(null); + useScrollLockWhileOpen(dialogRef); + return ( { + dialogRef.current = dialog; + if (typeof ref === "function") { + ref(dialog); + } else if (ref) { + ref.current = dialog; + } + }} id={id} className={clsx(className, { [styles.blurredBackdrop]: blurredBackdrop, @@ -100,6 +110,79 @@ function closeOnBackdropClick(event: React.MouseEvent) { } } +/** + * Locks page scrolling for as long as the dialog is open. Open state is read + * from the DOM (`toggle` events plus the initial `open`) rather than React + * state, so a dialog opened before hydration or closed natively is covered. + */ +function useScrollLockWhileOpen( + dialogRef: React.RefObject, +) { + React.useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + + let release: (() => void) | undefined; + const syncLock = (isOpen: boolean) => { + if (isOpen) { + release ??= lockPageScroll(); + } else { + release?.(); + release = undefined; + } + }; + const onToggle = (event: Event) => { + syncLock((event as ToggleEvent).newState === "open"); + }; + + dialog.addEventListener("toggle", onToggle); + syncLock(dialog.open); + return () => { + dialog.removeEventListener("toggle", onToggle); + syncLock(false); + }; + }, [dialogRef]); +} + +let pageScrollLocks = 0; +let restorePageScroll: (() => void) | undefined; + +/** + * Hides the root scrollbar without the page reflowing into its space: when a + * classic scrollbar was taking up width, `scrollbar-gutter: stable` keeps that + * width reserved. Overlay scrollbars take no width so nothing is reserved. + * Reference counted so nested dialogs release the lock only once all close. + */ +function lockPageScroll() { + if (pageScrollLocks++ === 0) { + const root = document.documentElement; + const scrollbarTakesWidth = window.innerWidth > root.clientWidth; + const previous = { + overflow: root.style.overflow, + scrollbarGutter: root.style.scrollbarGutter, + }; + + root.style.overflow = "hidden"; + if (scrollbarTakesWidth) { + root.style.scrollbarGutter = "stable"; + } + restorePageScroll = () => { + root.style.overflow = previous.overflow; + root.style.scrollbarGutter = previous.scrollbarGutter; + }; + } + + let released = false; + return () => { + if (released) return; + released = true; + if (--pageScrollLocks === 0) { + restorePageScroll?.(); + restorePageScroll = undefined; + } + }; +} + /** Invoker commands open and close the dialog natively; this guards the JS fallback for browsers without them. */ function supportsInvokerCommands() { return "commandForElement" in HTMLButtonElement.prototype; diff --git a/changelog/2026-09-20-dialog-scroll-lock.md b/changelog/2026-09-20-dialog-scroll-lock.md new file mode 100644 index 000000000..e50102c9a --- /dev/null +++ b/changelog/2026-09-20-dialog-scroll-lock.md @@ -0,0 +1,4 @@ +--- +type: bug +--- +The page behind an open dialog no longer scrolls, and the layout stays put when the scrollbar is hidden diff --git a/e2e/pages/sendouq/sendouq-match-page.ts b/e2e/pages/sendouq/sendouq-match-page.ts index 9f25d1886..58b764cdc 100644 --- a/e2e/pages/sendouq/sendouq-match-page.ts +++ b/e2e/pages/sendouq/sendouq-match-page.ts @@ -251,7 +251,7 @@ export class SendouQMatchPage { } /** Waits out the gap the loss confirm keeps between arming and accepting the second tap. */ -export async function waitOutLossConfirmMinGap(page: Page) { +async function waitOutLossConfirmMinGap(page: Page) { // biome-ignore lint/nursery/noPlaywrightWaitForTimeout: the min gap after arming has no observable end await page.waitForTimeout(LOSS_CONFIRM_MIN_GAP_MS); } From 75f089fe8b1994ddbbd27c1519ec304ae32f3268 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:38:31 +0300 Subject: [PATCH 23/50] Allow starring association members --- app/db/seed/factories/AssociationFactory.ts | 20 ++- app/db/tables.ts | 3 +- .../AssociationRepository.server.ts | 74 ++++++++-- .../actions/associations.server.ts | 36 ++++- .../associations/associations-schemas.ts | 14 ++ app/features/associations/core/Association.ts | 13 ++ .../loaders/associations.server.ts | 2 +- .../associations/routes/associations.test.ts | 122 ++++++++++++++++ .../associations/routes/associations.tsx | 130 +++++++++++++----- changelog/2026-09-20-association-managers.md | 5 + e2e/associations.spec.ts | 36 +++++ e2e/pages/associations/associations-page.ts | 8 ++ locales/da/scrims.json | 5 + locales/de/scrims.json | 5 + locales/en/scrims.json | 5 + locales/es-ES/scrims.json | 5 + locales/es-US/scrims.json | 5 + locales/fr-CA/scrims.json | 5 + locales/fr-EU/scrims.json | 5 + locales/he/scrims.json | 5 + locales/it/scrims.json | 5 + locales/ja/scrims.json | 5 + locales/ko/scrims.json | 5 + locales/nl/scrims.json | 5 + locales/pl/scrims.json | 5 + locales/pt-BR/scrims.json | 5 + locales/ru/scrims.json | 5 + locales/zh/scrims.json | 5 + 28 files changed, 490 insertions(+), 53 deletions(-) create mode 100644 app/features/associations/routes/associations.test.ts create mode 100644 changelog/2026-09-20-association-managers.md diff --git a/app/db/seed/factories/AssociationFactory.ts b/app/db/seed/factories/AssociationFactory.ts index e1911a66e..baa958916 100644 --- a/app/db/seed/factories/AssociationFactory.ts +++ b/app/db/seed/factories/AssociationFactory.ts @@ -5,6 +5,8 @@ import { faker } from "../core/faker"; type Options = { /** Members besides the owner, who is an admin of the association regardless. */ memberUserIds?: number[]; + /** Members who can also share the association's invite link. */ + managerUserIds?: number[]; }; /** Creates associations. `userId` is the owner, added as its admin by the repository. */ @@ -13,12 +15,26 @@ export const { create } = defineFactory({ name: faker.company.name(), }), insert: AssociationRepository.insert, - applyOptions: async (association, { memberUserIds }: Options) => { - for (const userId of memberUserIds ?? []) { + applyOptions: async ( + association, + { memberUserIds, managerUserIds }: Options, + ) => { + for (const userId of [ + ...(memberUserIds ?? []), + ...(managerUserIds ?? []), + ]) { await AssociationRepository.insertMember({ associationId: association.id, userId, }); } + + for (const userId of managerUserIds ?? []) { + await AssociationRepository.updateMemberRole({ + associationId: association.id, + userId, + role: "MANAGER", + }); + } }, }); diff --git a/app/db/tables.ts b/app/db/tables.ts index 35b95d3b2..588959f8d 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -1269,7 +1269,8 @@ export interface Association { export interface AssociationMember { userId: number; associationId: number; - role: "MEMBER" | "ADMIN"; + /** MANAGER can also share the invite link, ADMIN (one per association) can also manage the members */ + role: "MEMBER" | "MANAGER" | "ADMIN"; } export interface Notification { diff --git a/app/features/associations/AssociationRepository.server.ts b/app/features/associations/AssociationRepository.server.ts index 90605eafa..f61618fff 100644 --- a/app/features/associations/AssociationRepository.server.ts +++ b/app/features/associations/AssociationRepository.server.ts @@ -1,5 +1,5 @@ import { db } from "~/db/sql"; -import type { TablesInsertable } from "~/db/tables"; +import type { Tables, TablesInsertable } from "~/db/tables"; import type { AssociationVirtualIdentifier } from "~/features/associations/associations-constants"; import { ASSOCIATION } from "~/features/associations/associations-constants"; import * as FriendRepository from "~/features/friends/FriendRepository.server"; @@ -88,14 +88,25 @@ async function findBy( .where("Association.id", "=", args.associationId) .execute(); - return associations.map((a) => ({ - ...a, - permissions: { - MANAGE: (a.members ?? []) - .filter((member) => member.role === "ADMIN") - .map((user) => user.id), - }, - })); + return associations.map((a) => { + const members = a.members ?? []; + const adminIds = members + .filter((member) => member.role === "ADMIN") + .map((user) => user.id); + + return { + ...a, + permissions: { + MANAGE: adminIds, + SHARE_INVITE_LINK: [ + ...adminIds, + ...members + .filter((member) => member.role === "MANAGER") + .map((user) => user.id), + ], + }, + }; + }); } const DEFAULT_VIRTUAL_ASSOCIATIONS: Array = [ @@ -191,6 +202,23 @@ export function insertMember({ .execute(); } +export function updateMemberRole({ + associationId, + userId, + role, +}: { + associationId: number; + userId: number; + role: Tables["AssociationMember"]["role"]; +}) { + return db + .updateTable("AssociationMember") + .set({ role }) + .where("associationId", "=", associationId) + .where("userId", "=", userId) + .execute(); +} + export function deleteMember({ associationId, userId, @@ -205,6 +233,34 @@ export function deleteMember({ .execute(); } +/** Removes the member and, when they were the admin, promotes `newAdminUserId` in their place. */ +export function handleMemberLeaving({ + associationId, + userId, + newAdminUserId, +}: { + associationId: number; + userId: number; + newAdminUserId?: number; +}) { + return db.transaction().execute(async (trx) => { + await trx + .deleteFrom("AssociationMember") + .where("associationId", "=", associationId) + .where("userId", "=", userId) + .execute(); + + if (typeof newAdminUserId === "number") { + await trx + .updateTable("AssociationMember") + .set({ role: "ADMIN" }) + .where("associationId", "=", associationId) + .where("userId", "=", newAdminUserId) + .execute(); + } + }); +} + export function deleteById(associationId: number) { return db.deleteFrom("Association").where("id", "=", associationId).execute(); } diff --git a/app/features/associations/actions/associations.server.ts b/app/features/associations/actions/associations.server.ts index 2d444943e..5dd0264d6 100644 --- a/app/features/associations/actions/associations.server.ts +++ b/app/features/associations/actions/associations.server.ts @@ -1,6 +1,7 @@ import type { ActionFunctionArgs } from "react-router"; import { ASSOCIATION } from "~/features/associations/associations-constants"; import { associationsPageActionSchema } from "~/features/associations/associations-schemas"; +import * as Association from "~/features/associations/core/Association"; import { requireUser } from "~/features/auth/core/user.server"; import { requirePermission } from "~/modules/permissions/guards.server"; import { @@ -35,6 +36,27 @@ export const action = async ({ request }: ActionFunctionArgs) => { break; } + case "ADD_MANAGER": + case "REMOVE_MANAGER": { + const association = await validateHasManagePermissions( + data.associationId, + ); + + errorToastIfFalsy( + association.members!.some( + (member) => member.id === data.userId && member.role !== "ADMIN", + ), + "Not a member of the association", + ); + + await AssociationRepository.updateMemberRole({ + associationId: data.associationId, + userId: data.userId, + role: data._action === "ADD_MANAGER" ? "MANAGER" : "MEMBER", + }); + + break; + } case "DELETE_ASSOCIATION": { await validateHasManagePermissions(data.associationId); @@ -89,14 +111,20 @@ export const action = async ({ request }: ActionFunctionArgs) => { }), ); + const isAdmin = association.permissions.MANAGE.includes(user.id); + const newAdmin = isAdmin + ? Association.resolveNewAdmin(association.members!) + : null; + errorToastIfFalsy( - !association.permissions.MANAGE.includes(user.id), - "You cannot leave an association you manage", + !isAdmin || newAdmin, + "You cannot leave an association you manage without a starred member to take over", ); - await AssociationRepository.deleteMember({ + await AssociationRepository.handleMemberLeaving({ userId: user.id, associationId: data.associationId, + newAdminUserId: newAdmin?.id, }); return successToast("Left association"); @@ -115,4 +143,6 @@ async function validateHasManagePermissions(associationId: number) { ); requirePermission(association, "MANAGE"); + + return association; } diff --git a/app/features/associations/associations-schemas.ts b/app/features/associations/associations-schemas.ts index cba8a65c5..452ef501b 100644 --- a/app/features/associations/associations-schemas.ts +++ b/app/features/associations/associations-schemas.ts @@ -16,6 +16,18 @@ const removeMemberSchema = v.object({ userId: id, }); +const addManagerSchema = v.object({ + _action: _action("ADD_MANAGER"), + associationId: id, + userId: id, +}); + +const removeManagerSchema = v.object({ + _action: _action("REMOVE_MANAGER"), + associationId: id, + userId: id, +}); + const deleteAssociationSchema = v.object({ _action: _action("DELETE_ASSOCIATION"), associationId: id, @@ -38,6 +50,8 @@ const leaveAssociationSchema = v.object({ export const associationsPageActionSchema = v.union([ removeMemberSchema, + addManagerSchema, + removeManagerSchema, deleteAssociationSchema, refreshInviteCodeSchema, joinAssociationSchema, diff --git a/app/features/associations/core/Association.ts b/app/features/associations/core/Association.ts index 16a9686a7..58f518635 100644 --- a/app/features/associations/core/Association.ts +++ b/app/features/associations/core/Association.ts @@ -60,3 +60,16 @@ export function isPublic(args: Omit) { visibility: args.visibility, }); } + +/** + * Who becomes the admin after the current one leaves. `null` if no manager can take over, + * in which case the admin can't leave. Of several managers the one with the lowest user id + * (the oldest account) is picked, same as team ownership passing on. + */ +export function resolveNewAdmin( + members: Array, +) { + const managers = members.filter((member) => member.role === "MANAGER"); + + return managers.sort((a, b) => a.id - b.id).at(0) ?? null; +} diff --git a/app/features/associations/loaders/associations.server.ts b/app/features/associations/loaders/associations.server.ts index b939443c3..ff276850f 100644 --- a/app/features/associations/loaders/associations.server.ts +++ b/app/features/associations/loaders/associations.server.ts @@ -18,7 +18,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const associationsWithInviteCodes = await Promise.all( associations.map(async (association) => ({ ...association, - inviteCode: association.permissions.MANAGE.includes(user.id) + inviteCode: association.permissions.SHARE_INVITE_LINK.includes(user.id) ? await AssociationRepository.findInviteCodeById(association.id) : undefined, })), diff --git a/app/features/associations/routes/associations.test.ts b/app/features/associations/routes/associations.test.ts new file mode 100644 index 000000000..915c0c603 --- /dev/null +++ b/app/features/associations/routes/associations.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import * as AssociationFactory from "~/db/seed/factories/AssociationFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import type { SerializeFrom } from "~/utils/remix"; +import { + assertResponseErrored, + wrappedAction, + wrappedLoader, +} from "~/utils/Test"; +import { action } from "../actions/associations.server"; +import type { associationsPageActionSchema } from "../associations-schemas"; +import { loader } from "../loaders/associations.server"; + +const associationsAction = wrappedAction({ + action, +}); + +const associationsLoader = wrappedLoader>({ + loader, +}); + +const users = UserFactory.pool(); +const adminId = () => users.id(1); +const memberId = () => users.id(2); +const otherMemberId = () => users.id(3); + +describe("Associations page action", () => { + beforeEach(async () => { + await users.create(3); + }); + + test("starred member can share the invite link", async () => { + const association = await AssociationFactory.create( + { userId: adminId() }, + { memberUserIds: [memberId()] }, + ); + + const before = await associationsLoader({ user: memberId() }); + expect(before.associations[0]!.inviteCode).toBeUndefined(); + + await associationsAction( + { + _action: "ADD_MANAGER", + associationId: association.id, + userId: memberId(), + }, + { user: adminId() }, + ); + + const after = await associationsLoader({ user: memberId() }); + expect(after.associations[0]!.inviteCode).toBeTruthy(); + }); + + test("unstarring takes the invite link away again", async () => { + const association = await AssociationFactory.create( + { userId: adminId() }, + { managerUserIds: [memberId()] }, + ); + + await associationsAction( + { + _action: "REMOVE_MANAGER", + associationId: association.id, + userId: memberId(), + }, + { user: adminId() }, + ); + + const { associations } = await associationsLoader({ user: memberId() }); + expect(associations[0]!.inviteCode).toBeUndefined(); + }); + + test("member can't star another member", async () => { + const association = await AssociationFactory.create( + { userId: adminId() }, + { memberUserIds: [memberId(), otherMemberId()] }, + ); + + await expect( + associationsAction( + { + _action: "ADD_MANAGER", + associationId: association.id, + userId: otherMemberId(), + }, + { user: memberId() }, + ), + ).rejects.toThrow(); + }); + + test("admin can't leave without a starred member", async () => { + const association = await AssociationFactory.create( + { userId: adminId() }, + { memberUserIds: [memberId()] }, + ); + + const response = await associationsAction( + { _action: "LEAVE_ASSOCIATION", associationId: association.id }, + { user: adminId() }, + ); + + assertResponseErrored(response, "starred member to take over"); + }); + + test("oldest starred member becomes the admin when the admin leaves", async () => { + const association = await AssociationFactory.create( + { userId: adminId() }, + { managerUserIds: [otherMemberId(), memberId()] }, + ); + + await associationsAction( + { _action: "LEAVE_ASSOCIATION", associationId: association.id }, + { user: adminId() }, + ); + + const { associations } = await associationsLoader({ user: memberId() }); + expect(associations[0]!.permissions.MANAGE).toEqual([memberId()]); + expect(associations[0]!.members!.map((member) => member.id)).not.toContain( + adminId(), + ); + }); +}); diff --git a/app/features/associations/routes/associations.tsx b/app/features/associations/routes/associations.tsx index f034dd565..83615d8de 100644 --- a/app/features/associations/routes/associations.tsx +++ b/app/features/associations/routes/associations.tsx @@ -1,4 +1,4 @@ -import { Check, Clipboard, Trash } from "lucide-react"; +import { Check, Clipboard, Star, Trash } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { type MetaFunction, Outlet, useLoaderData } from "react-router"; @@ -10,6 +10,7 @@ import { Main } from "~/components/Main"; import { UserLink } from "~/components/UserLink"; import { action } from "~/features/associations/actions/associations.server"; import { associationsPage } from "~/features/associations/associations-urls"; +import * as Association from "~/features/associations/core/Association"; import { type AssociationsLoaderData, loader, @@ -45,7 +46,7 @@ export default function AssociationsPage() {
{data.associations.map((association) => ( - + ))} ); @@ -89,7 +90,7 @@ function JoinForm() { ); } -function Association({ +function AssociationSection({ association, }: { association: AssociationsLoaderData["associations"][number]; @@ -97,6 +98,9 @@ function Association({ const { t } = useTranslation(["common", "scrims"]); const user = useUser(); const canManage = useHasPermission(association, "MANAGE"); + const newAdmin = canManage + ? Association.resolveNewAdmin(association.members ?? []) + : null; return (
@@ -129,11 +133,18 @@ function Association({ ?.username, })}
- {!canManage ? ( + {!canManage || newAdmin ? ( ))}
+ {canManage ? ( +
+ {t("scrims:associations.manager.explanation")} +
+ ) : null} {association.inviteCode ? ( ) : null} @@ -174,9 +191,11 @@ function Association({ function AssociationInviteCodeActions({ associationId, inviteCode, + canResetLink, }: { associationId: number; inviteCode: string; + canResetLink: boolean; }) { const { t } = useTranslation(["common", "scrims"]); const { copyToClipboard, copySuccess } = useCopyToClipboard(); @@ -197,15 +216,17 @@ function AssociationInviteCodeActions({ aria-label="Copy to clipboard" />
- - {t("scrims:associations.shareLink.reset")} - + {canResetLink ? ( + + {t("scrims:associations.shareLink.reset")} + + ) : null}
); } @@ -225,28 +246,63 @@ function AssociationMember({ return (
- - {showControls ? ( - - } - className="small-text" - variant="minimal-destructive" - size="small" - type="submit" +
+ + {!showControls && member.role === "MANAGER" ? ( + - + ) : null} +
+ {showControls ? ( +
+ + } + aria-label={t( + member.role === "MANAGER" + ? "scrims:associations.manager.remove" + : "scrims:associations.manager.add", + { username: member.username }, + )} + /> + + } + className="small-text" + variant="minimal-destructive" + size="small" + type="submit" + /> + +
) : null}
); diff --git a/changelog/2026-09-20-association-managers.md b/changelog/2026-09-20-association-managers.md new file mode 100644 index 000000000..7b1eadbba --- /dev/null +++ b/changelog/2026-09-20-association-managers.md @@ -0,0 +1,5 @@ +--- +navItem: associations +type: feature +--- +Association admins can star members to let them share the invite link, and hand the association over by leaving diff --git a/e2e/associations.spec.ts b/e2e/associations.spec.ts index 7a26dd642..53ae13b23 100644 --- a/e2e/associations.spec.ts +++ b/e2e/associations.spec.ts @@ -45,6 +45,42 @@ test.describe("Associations", () => { await expect(associations.locators.deleteButtons).toHaveCount(1); }); + test("stars a member, who then takes over when the admin leaves", async ({ + page, + factories, + }) => { + await factories.AssociationFactory.create( + { userId: ADMIN_ID }, + { memberUserIds: [NZAP_TEST_ID] }, + ); + + await impersonate(page, ADMIN_ID); + + const associations = new AssociationsPage(page); + await associations.goto(); + + await isNotVisible(associations.locators.leaveButton); + + await associations.toggleManager("N-ZAP"); + + await expect(associations.locators.leaveButton).toBeVisible(); + + await impersonate(page, NZAP_TEST_ID); + await associations.goto(); + + await expect(associations.locators.inviteLinkInputs).toHaveCount(1); + await isNotVisible(associations.locators.resetLinkButton); + + await impersonate(page, ADMIN_ID); + await associations.goto(); + await associations.leave(); + + await impersonate(page, NZAP_TEST_ID); + await associations.goto(); + + await expect(associations.locators.resetLinkButton).toBeVisible(); + }); + test("joins and leaves an association", async ({ page, factories }) => { await factories.AssociationFactory.create({ userId: ADMIN_ID }); diff --git a/e2e/pages/associations/associations-page.ts b/e2e/pages/associations/associations-page.ts index 912d2a7c7..0f639725d 100644 --- a/e2e/pages/associations/associations-page.ts +++ b/e2e/pages/associations/associations-page.ts @@ -17,6 +17,7 @@ export class AssociationsPage { deleteButtons: page.getByTestId("delete-association"), leaveButton: page.getByTestId("leave-team-button"), inviteLinkInputs: page.getByLabel("Share link to add members"), + resetLinkButton: page.getByText("Reset link"), }; } @@ -52,6 +53,13 @@ export class AssociationsPage { await modalClickConfirmButton(this.page); } + /** The star that lets the member share the invite link, or takes that away. */ + async toggleManager(username: string) { + await this.page + .getByRole("button", { name: username, exact: false }) + .click(); + } + async leave() { await this.locators.leaveButton.click(); await modalClickConfirmButton(this.page); diff --git a/locales/da/scrims.json b/locales/da/scrims.json index 3636ccaa0..fca64413d 100644 --- a/locales/da/scrims.json +++ b/locales/da/scrims.json @@ -52,9 +52,14 @@ "associations.delete.title": "", "associations.admin": "", "associations.leave.title": "", + "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "", "associations.shareLink.title": "", "associations.shareLink.reset": "", + "associations.manager.label": "", + "associations.manager.explanation": "", + "associations.manager.add": "", + "associations.manager.remove": "", "associations.removeMember.title": "", "associations.forms.title": "", "banner.canceled.header": "", diff --git a/locales/de/scrims.json b/locales/de/scrims.json index 5177bb8e4..d00d2fc95 100644 --- a/locales/de/scrims.json +++ b/locales/de/scrims.json @@ -52,9 +52,14 @@ "associations.delete.title": "Assoziation {{name}} löschen?", "associations.admin": "Admin: {{username}}", "associations.leave.title": "Assoziation {{name}} verlassen?", + "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "Verlassen", "associations.shareLink.title": "Link teilen, um Mitglieder hinzuzufügen", "associations.shareLink.reset": "Link zurücksetzen", + "associations.manager.label": "", + "associations.manager.explanation": "", + "associations.manager.add": "", + "associations.manager.remove": "", "associations.removeMember.title": "{{username}} aus der Assoziation entfernen?", "associations.forms.title": "Neue Assoziation erstellen", "banner.canceled.header": "", diff --git a/locales/en/scrims.json b/locales/en/scrims.json index dfbf3a850..cf3cfbef4 100644 --- a/locales/en/scrims.json +++ b/locales/en/scrims.json @@ -52,9 +52,14 @@ "associations.delete.title": "Delete {{name}} association?", "associations.admin": "Admin: {{username}}", "associations.leave.title": "Leave {{name}} association?", + "associations.leave.titleWithNewAdmin": "Leave {{name}} association? {{username}} will become the new admin", "associations.leave.action": "Leave", "associations.shareLink.title": "Share link to add members", "associations.shareLink.reset": "Reset link", + "associations.manager.label": "Can share the invite link", + "associations.manager.explanation": "Starred members can share the invite link. With at least one starred member you can leave the association, and the longest-standing starred member will become its new admin.", + "associations.manager.add": "Let {{username}} share the invite link", + "associations.manager.remove": "Stop {{username}} from sharing the invite link", "associations.removeMember.title": "Remove {{username}} from the association?", "associations.forms.title": "Creating a new association", "banner.canceled.header": "Canceled by {{user}}", diff --git a/locales/es-ES/scrims.json b/locales/es-ES/scrims.json index 652f2c798..8f9767ab0 100644 --- a/locales/es-ES/scrims.json +++ b/locales/es-ES/scrims.json @@ -52,9 +52,14 @@ "associations.delete.title": "¿Eliminar la asociación {{name}}?", "associations.admin": "Administrador: {{username}}", "associations.leave.title": "¿Salir de la asociación {{name}}?", + "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "Salir", "associations.shareLink.title": "Compartir enlace para añadir miembros", "associations.shareLink.reset": "Restablecer enlace", + "associations.manager.label": "", + "associations.manager.explanation": "", + "associations.manager.add": "", + "associations.manager.remove": "", "associations.removeMember.title": "¿Eliminar a {{username}} de la asociación?", "associations.forms.title": "Creando una nueva asociación", "banner.canceled.header": "Cancelado por {{user}}", diff --git a/locales/es-US/scrims.json b/locales/es-US/scrims.json index 652f2c798..8f9767ab0 100644 --- a/locales/es-US/scrims.json +++ b/locales/es-US/scrims.json @@ -52,9 +52,14 @@ "associations.delete.title": "¿Eliminar la asociación {{name}}?", "associations.admin": "Administrador: {{username}}", "associations.leave.title": "¿Salir de la asociación {{name}}?", + "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "Salir", "associations.shareLink.title": "Compartir enlace para añadir miembros", "associations.shareLink.reset": "Restablecer enlace", + "associations.manager.label": "", + "associations.manager.explanation": "", + "associations.manager.add": "", + "associations.manager.remove": "", "associations.removeMember.title": "¿Eliminar a {{username}} de la asociación?", "associations.forms.title": "Creando una nueva asociación", "banner.canceled.header": "Cancelado por {{user}}", diff --git a/locales/fr-CA/scrims.json b/locales/fr-CA/scrims.json index 3636ccaa0..fca64413d 100644 --- a/locales/fr-CA/scrims.json +++ b/locales/fr-CA/scrims.json @@ -52,9 +52,14 @@ "associations.delete.title": "", "associations.admin": "", "associations.leave.title": "", + "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "", "associations.shareLink.title": "", "associations.shareLink.reset": "", + "associations.manager.label": "", + "associations.manager.explanation": "", + "associations.manager.add": "", + "associations.manager.remove": "", "associations.removeMember.title": "", "associations.forms.title": "", "banner.canceled.header": "", diff --git a/locales/fr-EU/scrims.json b/locales/fr-EU/scrims.json index cc5dc281b..58fea6d1a 100644 --- a/locales/fr-EU/scrims.json +++ b/locales/fr-EU/scrims.json @@ -52,9 +52,14 @@ "associations.delete.title": "Supprimer l'association {{name}} ?", "associations.admin": "Admin: {{username}}", "associations.leave.title": "Quitter l'association {{name}} ?", + "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "Quitter", "associations.shareLink.title": "Partager le lien pour ajouter des membres", "associations.shareLink.reset": "Réinitialiser le lien", + "associations.manager.label": "", + "associations.manager.explanation": "", + "associations.manager.add": "", + "associations.manager.remove": "", "associations.removeMember.title": "Enlever {{username}} de l'association?", "associations.forms.title": "Créer une nouvelle association", "banner.canceled.header": "", diff --git a/locales/he/scrims.json b/locales/he/scrims.json index 3636ccaa0..fca64413d 100644 --- a/locales/he/scrims.json +++ b/locales/he/scrims.json @@ -52,9 +52,14 @@ "associations.delete.title": "", "associations.admin": "", "associations.leave.title": "", + "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "", "associations.shareLink.title": "", "associations.shareLink.reset": "", + "associations.manager.label": "", + "associations.manager.explanation": "", + "associations.manager.add": "", + "associations.manager.remove": "", "associations.removeMember.title": "", "associations.forms.title": "", "banner.canceled.header": "", diff --git a/locales/it/scrims.json b/locales/it/scrims.json index 3636ccaa0..fca64413d 100644 --- a/locales/it/scrims.json +++ b/locales/it/scrims.json @@ -52,9 +52,14 @@ "associations.delete.title": "", "associations.admin": "", "associations.leave.title": "", + "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "", "associations.shareLink.title": "", "associations.shareLink.reset": "", + "associations.manager.label": "", + "associations.manager.explanation": "", + "associations.manager.add": "", + "associations.manager.remove": "", "associations.removeMember.title": "", "associations.forms.title": "", "banner.canceled.header": "", diff --git a/locales/ja/scrims.json b/locales/ja/scrims.json index 3636ccaa0..fca64413d 100644 --- a/locales/ja/scrims.json +++ b/locales/ja/scrims.json @@ -52,9 +52,14 @@ "associations.delete.title": "", "associations.admin": "", "associations.leave.title": "", + "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "", "associations.shareLink.title": "", "associations.shareLink.reset": "", + "associations.manager.label": "", + "associations.manager.explanation": "", + "associations.manager.add": "", + "associations.manager.remove": "", "associations.removeMember.title": "", "associations.forms.title": "", "banner.canceled.header": "", diff --git a/locales/ko/scrims.json b/locales/ko/scrims.json index 3636ccaa0..fca64413d 100644 --- a/locales/ko/scrims.json +++ b/locales/ko/scrims.json @@ -52,9 +52,14 @@ "associations.delete.title": "", "associations.admin": "", "associations.leave.title": "", + "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "", "associations.shareLink.title": "", "associations.shareLink.reset": "", + "associations.manager.label": "", + "associations.manager.explanation": "", + "associations.manager.add": "", + "associations.manager.remove": "", "associations.removeMember.title": "", "associations.forms.title": "", "banner.canceled.header": "", diff --git a/locales/nl/scrims.json b/locales/nl/scrims.json index 3636ccaa0..fca64413d 100644 --- a/locales/nl/scrims.json +++ b/locales/nl/scrims.json @@ -52,9 +52,14 @@ "associations.delete.title": "", "associations.admin": "", "associations.leave.title": "", + "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "", "associations.shareLink.title": "", "associations.shareLink.reset": "", + "associations.manager.label": "", + "associations.manager.explanation": "", + "associations.manager.add": "", + "associations.manager.remove": "", "associations.removeMember.title": "", "associations.forms.title": "", "banner.canceled.header": "", diff --git a/locales/pl/scrims.json b/locales/pl/scrims.json index 3636ccaa0..fca64413d 100644 --- a/locales/pl/scrims.json +++ b/locales/pl/scrims.json @@ -52,9 +52,14 @@ "associations.delete.title": "", "associations.admin": "", "associations.leave.title": "", + "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "", "associations.shareLink.title": "", "associations.shareLink.reset": "", + "associations.manager.label": "", + "associations.manager.explanation": "", + "associations.manager.add": "", + "associations.manager.remove": "", "associations.removeMember.title": "", "associations.forms.title": "", "banner.canceled.header": "", diff --git a/locales/pt-BR/scrims.json b/locales/pt-BR/scrims.json index 3636ccaa0..fca64413d 100644 --- a/locales/pt-BR/scrims.json +++ b/locales/pt-BR/scrims.json @@ -52,9 +52,14 @@ "associations.delete.title": "", "associations.admin": "", "associations.leave.title": "", + "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "", "associations.shareLink.title": "", "associations.shareLink.reset": "", + "associations.manager.label": "", + "associations.manager.explanation": "", + "associations.manager.add": "", + "associations.manager.remove": "", "associations.removeMember.title": "", "associations.forms.title": "", "banner.canceled.header": "", diff --git a/locales/ru/scrims.json b/locales/ru/scrims.json index 3636ccaa0..fca64413d 100644 --- a/locales/ru/scrims.json +++ b/locales/ru/scrims.json @@ -52,9 +52,14 @@ "associations.delete.title": "", "associations.admin": "", "associations.leave.title": "", + "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "", "associations.shareLink.title": "", "associations.shareLink.reset": "", + "associations.manager.label": "", + "associations.manager.explanation": "", + "associations.manager.add": "", + "associations.manager.remove": "", "associations.removeMember.title": "", "associations.forms.title": "", "banner.canceled.header": "", diff --git a/locales/zh/scrims.json b/locales/zh/scrims.json index 3ecb413f7..fed6f4d1a 100644 --- a/locales/zh/scrims.json +++ b/locales/zh/scrims.json @@ -52,9 +52,14 @@ "associations.delete.title": "删除群组 {{name}} ?", "associations.admin": "管理员: {{username}}", "associations.leave.title": "退出群组 {{name}} ?", + "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "退出", "associations.shareLink.title": "分享链接以邀请成员", "associations.shareLink.reset": "重置链接", + "associations.manager.label": "", + "associations.manager.explanation": "", + "associations.manager.add": "", + "associations.manager.remove": "", "associations.removeMember.title": "将 {{username}} 从群组中移除?", "associations.forms.title": "创建新群组", "banner.canceled.header": "由 {{user}} 取消", From 9f1a41a1dca48f6a2b24d26746504c3102b5ebc7 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:49:45 +0300 Subject: [PATCH 24/50] Rework elim bracket data model in preparation for groups support --- app/db/tables-json.ts | 13 +- app/db/tables.ts | 9 +- .../bracket-test/routes/bracket-test.tsx | 22 +- .../BracketRepository.server.ts | 2 + .../actions/to.$id.brackets.server.test.ts | 5 +- .../actions/to.$id.brackets.server.ts | 8 +- .../Bracket/Bracket.browser.test.tsx | 36 ++- .../components/BracketMapListDialog.tsx | 100 ++++---- .../tournament-bracket/core/Bracket.test.ts | 60 +++-- .../core/Bracket/DoubleEliminationBracket.ts | 119 +++++----- .../core/Bracket/RoundRobinBracket.ts | 11 +- .../core/Bracket/SingleEliminationBracket.ts | 98 ++++---- .../core/Bracket/SwissBracket.ts | 11 +- .../core/PreparedMaps.test.ts | 44 ++-- .../tournament-bracket/core/PreparedMaps.ts | 50 ++-- .../core/engine/create/builder.ts | 78 +++--- .../engine/create/double-elimination.test.ts | 26 +- .../core/engine/create/double-elimination.ts | 12 +- .../core/engine/create/index.ts | 2 +- .../engine/create/single-elimination.test.ts | 3 +- .../core/engine/create/single-elimination.ts | 10 +- .../core/engine/create/swiss.ts | 1 + .../core/engine/propagation/helpers.ts | 32 +-- .../core/engine/propagation/reset-result.ts | 6 +- .../core/engine/propagation/store.ts | 46 ++-- .../core/engine/propagation/traversal.ts | 144 +++++------- .../tournament-bracket/core/engine/status.ts | 11 +- .../tournament-bracket/core/engine/types.ts | 9 +- .../tournament-bracket/core/rounds.ts | 26 +- .../tournament-bracket/core/tests/mocks-li.ts | 222 ++++++++++-------- .../core/tests/mocks-sos.ts | 33 +++ .../core/tests/mocks-zones-weekly.ts | 4 + .../tournament-bracket/core/tests/mocks.ts | 163 +++++++++---- .../tournament-bracket/core/toMapList.test.ts | 27 ++- .../tournament-bracket/core/toMapList.ts | 64 ++--- .../tournament-bracket-schemas.ts | 7 +- .../TournamentMatchRepository.server.ts | 6 +- .../tournament/core/Standings.test.ts | 6 +- .../tournament/core/sets.server.test.ts | 4 +- app/features/tournament/core/sets.server.ts | 25 +- .../loaders/to.$id.teams.$tid.server.ts | 5 +- .../tournament/tournament-constants.ts | 9 + ...20260920110044-tournament-round-section.ts | 179 ++++++++++++++ 43 files changed, 1046 insertions(+), 702 deletions(-) create mode 100644 migrations/20260920110044-tournament-round-section.ts diff --git a/app/db/tables-json.ts b/app/db/tables-json.ts index 5e5a4a5b3..c9c726feb 100644 --- a/app/db/tables-json.ts +++ b/app/db/tables-json.ts @@ -4,7 +4,10 @@ import type { DBBoolean } from "~/db/tables"; import type { CalendarFilters } from "~/features/calendar/calendar-types"; import type { ScrimFilters } from "~/features/scrims/scrims-types"; import type { CustomThemeVar } from "~/features/theme/theme-constants"; -import type { TeamPickPool } from "~/features/tournament/tournament-constants"; +import type { + TeamPickPool, + TournamentRoundSection, +} from "~/features/tournament/tournament-constants"; import type * as PickBan from "~/features/tournament-bracket/core/PickBan"; import type * as Progression from "~/features/tournament-bracket/core/Progression"; import type { @@ -133,7 +136,13 @@ export interface SeedingSnapshot { export interface PreparedMaps { authorId: number; createdAt: number; - maps: Array; + /** Keyed by the local round ids of the bracket preview the maps were picked against; groups share one map list per round number. */ + maps: Array< + TournamentRoundMaps & { + roundId: number; + section: TournamentRoundSection | null; + } + >; eliminationTeamCount?: number; } diff --git a/app/db/tables.ts b/app/db/tables.ts index 588959f8d..e61110516 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -50,6 +50,7 @@ import type { TOURNAMENT_STAGE_TYPES, TournamentAuditLogType, TournamentMapPickingStyle, + TournamentRoundSection, TournamentStaffRole, } from "~/features/tournament/tournament-constants"; import type { @@ -680,8 +681,9 @@ export interface TournamentBadgeOwner { } /** - * Groups rounds together. In round-robin and swiss a group is a pool; in elimination it is a bracket - * (single: the bracket + optional consolation final, double: upper, lower + optional grand final). + * A set of participants of a stage that play among themselves: a pool of a round robin or swiss stage, + * or an elimination bracket whose rounds {@link TournamentRound.section} splits into winners, losers + * and finals. Every elimination stage has one group for now. */ export interface TournamentGroup { id: GeneratedAlways; @@ -752,8 +754,11 @@ export interface TournamentResult { export interface TournamentRound { groupId: number; id: GeneratedAlways; + /** Restarts from 1 per group, and in an elimination group per {@link TournamentRound.section}. */ number: number; stageId: number; + /** Part of the elimination group the round belongs to. `null` in round robin and swiss. */ + section: TournamentRoundSection | null; maps: JSONColumnType; /** Datetime the round is played by default (leagues). Null = no default play time, the round is played whenever. */ defaultPlayTime: number | null; diff --git a/app/features/bracket-test/routes/bracket-test.tsx b/app/features/bracket-test/routes/bracket-test.tsx index b388dedad..b0e3e442e 100644 --- a/app/features/bracket-test/routes/bracket-test.tsx +++ b/app/features/bracket-test/routes/bracket-test.tsx @@ -39,7 +39,11 @@ export default function BracketTestLayout() { const { totalRounds, wbRounds, lbRounds } = countRounds(data, isDoubleElim); if (isDoubleElim) { - simulateCompletedRoundsByGroup(data, completedWbRounds, completedLbRounds); + simulateCompletedRoundsBySection( + data, + completedWbRounds, + completedLbRounds, + ); } else { simulateCompletedRounds(data, Math.min(completedRounds, totalRounds)); } @@ -213,29 +217,23 @@ function countRounds(data: BracketData, isDoubleElim: boolean) { if (!isDoubleElim) return { totalRounds, wbRounds: 0, lbRounds: 0 }; - const wbGroupId = data.group.find((g) => g.number === 1)?.id; - const lbGroupId = data.group.find((g) => g.number === 2)?.id; - - const wbRounds = data.round.filter((r) => r.groupId === wbGroupId).length; - const lbRounds = data.round.filter((r) => r.groupId === lbGroupId).length; + const wbRounds = data.round.filter((r) => r.section === "winners").length; + const lbRounds = data.round.filter((r) => r.section === "losers").length; return { totalRounds, wbRounds, lbRounds }; } -function simulateCompletedRoundsByGroup( +function simulateCompletedRoundsBySection( data: BracketData, wbCompleted: number, lbCompleted: number, ) { - const wbGroupId = data.group.find((g) => g.number === 1)?.id; - const lbGroupId = data.group.find((g) => g.number === 2)?.id; - const completedRoundIds = new Set(); for (const round of data.round) { - if (round.groupId === wbGroupId && round.number <= wbCompleted) { + if (round.section === "winners" && round.number <= wbCompleted) { completedRoundIds.add(round.id); } - if (round.groupId === lbGroupId && round.number <= lbCompleted) { + if (round.section === "losers" && round.number <= lbCompleted) { completedRoundIds.add(round.id); } } diff --git a/app/features/tournament-bracket/BracketRepository.server.ts b/app/features/tournament-bracket/BracketRepository.server.ts index fd73d283c..97d33f12f 100644 --- a/app/features/tournament-bracket/BracketRepository.server.ts +++ b/app/features/tournament-bracket/BracketRepository.server.ts @@ -77,6 +77,7 @@ export async function findByTournamentId( "TournamentRound.id", "TournamentRound.stageId", "TournamentRound.groupId", + "TournamentRound.section", "TournamentRound.number", "TournamentRound.maps", "TournamentRound.defaultPlayTime", @@ -198,6 +199,7 @@ export function insertBracket(args: { args.bracket.round.map((round) => ({ stageId: stage.id, groupId: groupIdMapping.get(round.groupId)!, + section: round.section, number: round.number, maps: JSON.stringify(round.maps), })), diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.test.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.test.ts index a01177c07..0bf1fd112 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.server.test.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.test.ts @@ -4,6 +4,7 @@ import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactor import * as UserFactory from "~/db/seed/factories/UserFactory"; import type { TournamentSettings } from "~/db/tables-json"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import type { RoundData } from "~/features/tournament-bracket/core/engine/types"; import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; import type { bracketSchema } from "~/features/tournament-bracket/tournament-bracket-schemas"; import type { ModeShort } from "~/modules/in-game-lists/types"; @@ -340,12 +341,12 @@ async function previewRounds(tournamentId: number) { } function teamPickedRoundMaps( - round: { id: number; groupId: number }, + round: { id: number; section: RoundData["section"] }, modes: ModeShort[], ) { return { roundId: round.id, - groupId: round.groupId, + section: round.section, count: 3 as const, type: "BEST_OF" as const, list: null, diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts index 5af2e2fa3..ce6fdac61 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts @@ -426,18 +426,18 @@ function adjustLinkedRounds({ }): Omit["maps"] { if (thirdPlaceMatchLinked) { const finalsMaps = maps - .filter((m) => m.groupId === 0) + .filter((m) => m.section === "winners") .sort((a, b) => b.roundId - a.roundId)[0]; invariant(finalsMaps, "Missing finals maps"); return [ - ...maps.filter((m) => m.groupId === 0), - { ...finalsMaps, groupId: 1, roundId: finalsMaps.roundId + 1 }, + ...maps.filter((m) => m.section === "winners"), + { ...finalsMaps, section: "finals", roundId: finalsMaps.roundId + 1 }, ]; } invariant( - maps.some((m) => m.groupId === 1), + maps.some((m) => m.section === "finals"), "Missing 3rd place match maps", ); diff --git a/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx b/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx index dfa7f1791..ffdade370 100644 --- a/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx +++ b/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx @@ -131,6 +131,7 @@ function createSingleEliminationData(): BracketData { groupId: 1, number: 1, stageId: 1, + section: "winners", maps: { count: 3, type: "BEST_OF", pickBan: null }, }, { @@ -138,6 +139,7 @@ function createSingleEliminationData(): BracketData { groupId: 1, number: 2, stageId: 1, + section: "winners", maps: { count: 3, type: "BEST_OF", pickBan: null }, }, { @@ -145,6 +147,7 @@ function createSingleEliminationData(): BracketData { groupId: 1, number: 3, stageId: 1, + section: "winners", maps: { count: 5, type: "BEST_OF", pickBan: null }, }, ], @@ -241,6 +244,7 @@ function createByeHeavySingleEliminationData(): BracketData { groupId: 1, number: 1, stageId: 1, + section: "winners", maps: { count: 3, type: "BEST_OF", pickBan: null }, }, { @@ -248,6 +252,7 @@ function createByeHeavySingleEliminationData(): BracketData { groupId: 1, number: 2, stageId: 1, + section: "winners", maps: { count: 3, type: "BEST_OF", pickBan: null }, }, { @@ -255,6 +260,7 @@ function createByeHeavySingleEliminationData(): BracketData { groupId: 1, number: 3, stageId: 1, + section: "winners", maps: { count: 5, type: "BEST_OF", pickBan: null }, }, ], @@ -347,16 +353,14 @@ function createDoubleEliminationData(): BracketData { settings: {}, }, ], - group: [ - { id: 1, number: 1, stageId: 1 }, - { id: 2, number: 2, stageId: 1 }, - ], + group: [{ id: 1, number: 1, stageId: 1 }], round: [ { id: 1, groupId: 1, number: 1, stageId: 1, + section: "winners", maps: { count: 3, type: "BEST_OF", pickBan: null }, }, { @@ -364,20 +368,23 @@ function createDoubleEliminationData(): BracketData { groupId: 1, number: 2, stageId: 1, + section: "winners", maps: { count: 5, type: "BEST_OF", pickBan: null }, }, { id: 3, - groupId: 2, + groupId: 1, number: 1, stageId: 1, + section: "losers", maps: { count: 3, type: "BEST_OF", pickBan: null }, }, { id: 4, - groupId: 2, + groupId: 1, number: 2, stageId: 1, + section: "losers", maps: { count: 5, type: "BEST_OF", pickBan: null }, }, ], @@ -416,7 +423,7 @@ function createDoubleEliminationData(): BracketData { id: 4, number: 1, stageId: 1, - groupId: 2, + groupId: 1, roundId: 3, opponent1: { id: 4, score: 1 }, opponent2: { id: 3, score: 2 }, @@ -426,7 +433,7 @@ function createDoubleEliminationData(): BracketData { id: 5, number: 1, stageId: 1, - groupId: 2, + groupId: 1, roundId: 4, opponent1: { id: 3 }, opponent2: { id: null }, @@ -457,6 +464,7 @@ function createRoundRobinData(): BracketData { groupId: 1, number: 1, stageId: 1, + section: null, maps: { count: 3, type: "BEST_OF", pickBan: null }, }, { @@ -464,6 +472,7 @@ function createRoundRobinData(): BracketData { groupId: 1, number: 2, stageId: 1, + section: null, maps: { count: 3, type: "BEST_OF", pickBan: null }, }, { @@ -471,6 +480,7 @@ function createRoundRobinData(): BracketData { groupId: 1, number: 3, stageId: 1, + section: null, maps: { count: 3, type: "BEST_OF", pickBan: null }, }, { @@ -478,6 +488,7 @@ function createRoundRobinData(): BracketData { groupId: 2, number: 1, stageId: 1, + section: null, maps: { count: 3, type: "BEST_OF", pickBan: null }, }, { @@ -485,6 +496,7 @@ function createRoundRobinData(): BracketData { groupId: 2, number: 2, stageId: 1, + section: null, maps: { count: 3, type: "BEST_OF", pickBan: null }, }, { @@ -492,6 +504,7 @@ function createRoundRobinData(): BracketData { groupId: 2, number: 3, stageId: 1, + section: null, maps: { count: 3, type: "BEST_OF", pickBan: null }, }, ], @@ -578,6 +591,7 @@ function createSwissData(): BracketData { groupId: 1, number: 1, stageId: 1, + section: null, maps: { count: 3, type: "BEST_OF", pickBan: null }, }, { @@ -585,6 +599,7 @@ function createSwissData(): BracketData { groupId: 1, number: 2, stageId: 1, + section: null, maps: { count: 3, type: "BEST_OF", pickBan: null }, }, { @@ -592,6 +607,7 @@ function createSwissData(): BracketData { groupId: 1, number: 3, stageId: 1, + section: null, maps: { count: 3, type: "BEST_OF", pickBan: null }, }, ], @@ -682,6 +698,7 @@ function createLargeSingleEliminationData(options?: { groupId: 1, number: 1, stageId: 1, + section: "winners", maps: { count: 3, type: "BEST_OF", pickBan: null }, }, { @@ -689,6 +706,7 @@ function createLargeSingleEliminationData(options?: { groupId: 1, number: 2, stageId: 1, + section: "winners", maps: { count: 3, type: "BEST_OF", pickBan: null }, }, { @@ -696,6 +714,7 @@ function createLargeSingleEliminationData(options?: { groupId: 1, number: 3, stageId: 1, + section: "winners", maps: { count: 3, type: "BEST_OF", pickBan: null }, }, { @@ -703,6 +722,7 @@ function createLargeSingleEliminationData(options?: { groupId: 1, number: 4, stageId: 1, + section: "winners", maps: { count: 5, type: "BEST_OF", pickBan: null }, }, ], diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.tsx b/app/features/tournament-bracket/components/BracketMapListDialog.tsx index 53367d6f9..dcd9613b8 100644 --- a/app/features/tournament-bracket/components/BracketMapListDialog.tsx +++ b/app/features/tournament-bracket/components/BracketMapListDialog.tsx @@ -28,7 +28,10 @@ import { calendarEditPage } from "~/features/calendar/calendar-urls"; import { useTournamentPreparedMaps } from "~/features/tournament/routes/to.$id"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import { useTournament } from "~/features/tournament/tournament-context"; -import type { BracketData } from "~/features/tournament-bracket/core/engine/types"; +import type { + BracketData, + RoundData, +} from "~/features/tournament-bracket/core/engine/types"; import * as PickBan from "~/features/tournament-bracket/core/PickBan"; import { modesShort } from "~/modules/in-game-lists/modes"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; @@ -45,6 +48,7 @@ import type { Tournament } from "../core/Tournament"; import { type BracketMapCounts, generateTournamentRoundMaplist, + roundSetKey, type TournamentRoundMapList, } from "../core/toMapList"; import { bracketSchema } from "../tournament-bracket-schemas"; @@ -127,9 +131,11 @@ export function BracketMapListDialog({ // infer default from whether finals and third place match have different maps const finalsMaps = preparedMaps.maps - .filter((map) => map.groupId === 0) + .filter((map) => map.section === "winners") .sort((a, b) => b.roundId - a.roundId)[0]; - const thirdPlaceMaps = preparedMaps.maps.find((map) => map.groupId === 1); + const thirdPlaceMaps = preparedMaps.maps.find( + (map) => map.section === "finals", + ); if (!finalsMaps?.list || !thirdPlaceMaps?.list) { logger.error( @@ -223,14 +229,14 @@ export function BracketMapListDialog({ const singleElimRounds = getRounds({ type: "single", bracketData }); const hasThirdPlaceMatch = singleElimRounds.some( - (round) => round.groupId === 1, + (round) => round.section === "finals", ); if (!thirdPlaceMatchLinked || !hasThirdPlaceMatch) return singleElimRounds; return singleElimRounds - .filter((round) => round.groupId !== 1) + .filter((round) => round.section !== "finals") .map((round) => round.name === "Finals" ? { @@ -247,13 +253,13 @@ export function BracketMapListDialog({ const mapCountsWithGlobalCount = (newCount: number) => { const newMap = new Map(defaultRoundBestOfs); - for (const [groupId, value] of newMap.entries()) { - const newGroupMap: typeof value = new Map(value); + for (const [roundSet, value] of newMap.entries()) { + const newRoundSetMap: typeof value = new Map(value); for (const [roundNumber, roundValue] of value.entries()) { - newGroupMap.set(roundNumber, { ...roundValue, count: newCount }); + newRoundSetMap.set(roundNumber, { ...roundValue, count: newCount }); } - newMap.set(groupId, newGroupMap); + newMap.set(roundSet, newRoundSetMap); } return newMap; @@ -276,9 +282,9 @@ export function BracketMapListDialog({ }; const validateNoDecreasingCount = () => { - for (const groupCounts of mapCounts.values()) { + for (const roundSetCounts of mapCounts.values()) { let roundPreviousValue = 0; - for (const [, roundValue] of Array.from(groupCounts.entries()).sort( + for (const [, roundValue] of Array.from(roundSetCounts.entries()).sort( (a, b) => a[0] - b[0], )) { if (roundPreviousValue > roundValue.count) { @@ -289,10 +295,17 @@ export function BracketMapListDialog({ } } - // grands need at least as many maps as winners final (different groups) + // grands need at least as many maps as winners final (different sections) if (bracket.type === "double_elimination") { - const grandsCounts = Array.from(mapCounts.get(2)?.values() ?? []); - const winnersCounts = Array.from(mapCounts.get(0)?.values() ?? []); + const sectionCounts = (section: RoundData["section"]) => + rounds + .filter((round) => round.section === section) + .flatMap( + (round) => + mapCounts.get(roundSetKey(round))?.get(round.number) ?? [], + ); + const grandsCounts = sectionCounts("finals"); + const winnersCounts = sectionCounts("winners"); const maxWinnersCount = Math.max(...winnersCounts.map((c) => c.count)); if (grandsCounts.some(({ count }) => count < maxWinnersCount)) { @@ -357,7 +370,7 @@ export function BracketMapListDialog({ Array.from(maps.entries()).map(([key, value]) => ({ ...value, roundId: key, - groupId: rounds.find((r) => r.id === key)?.groupId, + section: rounds.find((r) => r.id === key)?.section ?? null, type: countType, customFlow: value.pickBan === "CUSTOM" ? customFlow : undefined, })), @@ -615,22 +628,22 @@ export function BracketMapListDialog({ "Expected round to be defined", ); - const groupInfo = newMapCounts.get( - bracketRound.groupId, + const roundSetInfo = newMapCounts.get( + roundSetKey(bracketRound), ); invariant( - groupInfo, - "Expected group info to be defined", + roundSetInfo, + "Expected round set info to be defined", + ); + const oldMapInfo = roundSetInfo.get( + bracketRound.number, ); - const oldMapInfo = newMapCounts - .get(bracketRound.groupId) - ?.get(bracketRound.number); invariant( oldMapInfo, "Expected map info to be defined", ); - groupInfo.set(bracketRound.number, { + roundSetInfo.set(bracketRound.number, { ...oldMapInfo, count: newCount, }); @@ -759,30 +772,27 @@ function inferMapCounts({ tournamentRoundMapList: TournamentRoundMapList; }) { const result: BracketMapCounts = new Map(); + const defaultBestOfs = bracket.defaultRoundBestOfs(data); - for (const [groupId, value] of bracket.defaultRoundBestOfs(data).entries()) { - for (const roundNumber of value.keys()) { - const roundId = data.round.find( - (round) => round.groupId === groupId && round.number === roundNumber, - )?.id; - invariant(typeof roundId === "number", "Expected roundId to be defined"); + for (const round of data.round) { + const key = roundSetKey(round); + if (!defaultBestOfs.get(key)?.has(round.number)) continue; - const count = tournamentRoundMapList.get(roundId)?.count; + const count = tournamentRoundMapList.get(round.id)?.count; - // skip rounds in RR and Swiss that don't have maps (only one group has maps) - if (typeof count !== "number") { - continue; - } - - result.set( - groupId, - new Map(result.get(groupId)).set(roundNumber, { - count, - // "best of" / "play all" is per bracket for now, might be per round in the future - type: "BEST_OF", - }), - ); + // skip rounds in RR and Swiss that don't have maps (only one group has maps) + if (typeof count !== "number") { + continue; } + + result.set( + key, + new Map(result.get(key)).set(round.number, { + count, + // "best of" / "play all" is per bracket for now, might be per round in the future + type: "BEST_OF", + }), + ); } invariant(result.size > 0, "Expected result to be defined"); @@ -1336,8 +1346,8 @@ function PatternInputs({ onPatternsChange: (patterns: Map) => void; }) { const uniqueCounts = new Set(); - for (const groupCounts of mapCounts.values()) { - for (const { count } of groupCounts.values()) { + for (const roundSetCounts of mapCounts.values()) { + for (const { count } of roundSetCounts.values()) { uniqueCounts.add(count); } } diff --git a/app/features/tournament-bracket/core/Bracket.test.ts b/app/features/tournament-bracket/core/Bracket.test.ts index 491b0c03f..291146748 100644 --- a/app/features/tournament-bracket/core/Bracket.test.ts +++ b/app/features/tournament-bracket/core/Bracket.test.ts @@ -863,11 +863,11 @@ describe("single elimination standings - third place match", () => { let thirdPlaceWinnerId: number | undefined; let thirdPlaceLoserId: number | undefined; if (thirdPlaceMatchReported) { - const thirdPlaceGroupId = Math.max( - ...data.group.map((group) => group.id), - ); + const thirdPlaceRoundId = data.round.find( + (round) => round.section === "finals", + )!.id; const thirdPlaceMatch = data.match.find( - (match) => match.groupId === thirdPlaceGroupId, + (match) => match.roundId === thirdPlaceRoundId, ); invariant(thirdPlaceMatch, "Third place match not found"); thirdPlaceWinnerId = thirdPlaceMatch.opponent1!.id!; @@ -966,7 +966,7 @@ describe("single elimination standings - byes in later rounds", () => { ): MatchData => ({ id, stageId, - groupId: roundId === thirdPlaceRoundId ? 1 : 0, + groupId: 0, roundId, number, opponent1: opponent1 === null ? null : { id: opponent1 }, @@ -983,15 +983,18 @@ describe("single elimination standings - byes in later rounds", () => { number: 1, }, ], - group: [ - { id: 0, stageId, number: 1 }, - { id: 1, stageId, number: 2 }, - ], + group: [{ id: 0, stageId, number: 1 }], round: [ - { id: 0, stageId, groupId: 0, number: 1 }, - { id: 1, stageId, groupId: 0, number: 2 }, - { id: 2, stageId, groupId: 0, number: 3 }, - { id: thirdPlaceRoundId, stageId, groupId: 1, number: 1 }, + { id: 0, stageId, groupId: 0, section: "winners", number: 1 }, + { id: 1, stageId, groupId: 0, section: "winners", number: 2 }, + { id: 2, stageId, groupId: 0, section: "winners", number: 3 }, + { + id: thirdPlaceRoundId, + stageId, + groupId: 0, + section: "finals", + number: 1, + }, ], match: [ match(0, 0, 1, 1, 2, "opponent1"), @@ -1117,23 +1120,26 @@ describe("double elimination standings - projected ties", () => { settings: {}, }); - const groupId = (number: number) => - data.group.find((group) => group.number === number)!.id; - const winnersGroupId = groupId(1); - const losersGroupId = groupId(2); + const winnersRoundIds = new Set( + data.round + .filter((round) => round.section === "winners") + .map((round) => round.id), + ); const losersRoundId = (number: number) => data.round.find( - (round) => round.groupId === losersGroupId && round.number === number, + (round) => round.section === "losers" && round.number === number, )!.id; // play out the entire winners bracket so all losers feed in - let winnersReady = readyMatches(data, (m) => m.groupId === winnersGroupId); + let winnersReady = readyMatches(data, (m) => + winnersRoundIds.has(m.roundId), + ); while (winnersReady.length) { for (const match of winnersReady) { data = reportLowerIdWinner(data, match.id); } - winnersReady = readyMatches(data, (m) => m.groupId === winnersGroupId); + winnersReady = readyMatches(data, (m) => winnersRoundIds.has(m.roundId)); } // losers round 1: both matches -> two teams eliminated, tied 7th/8th @@ -1214,9 +1220,13 @@ describe("single elimination source - underground", () => { settings: {}, }); - const winnersGroupId = data.group.find((group) => group.number === 1)!.id; + const winnersRoundIds = new Set( + data.round + .filter((round) => round.section === "winners") + .map((round) => round.id), + ); const firstRoundId = data.round.find( - (round) => round.groupId === winnersGroupId && round.number === 1, + (round) => round.section === "winners" && round.number === 1, )!.id; // lower id wins, so the higher id in each first-round match is the loser @@ -1225,12 +1235,14 @@ describe("single elimination source - underground", () => { (match) => match.roundId === firstRoundId, ).map((match) => Math.max(match.opponent1!.id!, match.opponent2!.id!)); - let ready = readyMatches(data, (match) => match.groupId === winnersGroupId); + let ready = readyMatches(data, (match) => + winnersRoundIds.has(match.roundId), + ); while (ready.length) { for (const match of ready) { data = reportLowerIdWinner(data, match.id); } - ready = readyMatches(data, (match) => match.groupId === winnersGroupId); + ready = readyMatches(data, (match) => winnersRoundIds.has(match.roundId)); } const tournament = testTournament({ diff --git a/app/features/tournament-bracket/core/Bracket/DoubleEliminationBracket.ts b/app/features/tournament-bracket/core/Bracket/DoubleEliminationBracket.ts index b0ebfc75d..079d8d60b 100644 --- a/app/features/tournament-bracket/core/Bracket/DoubleEliminationBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/DoubleEliminationBracket.ts @@ -5,7 +5,7 @@ import type { RoundData, } from "~/features/tournament-bracket/core/engine/types"; import { invariant } from "~/utils/invariant"; -import type { BracketMapCounts } from "../toMapList"; +import { type BracketMapCounts, roundSetKey } from "../toMapList"; import { Bracket, type Standing } from "./Bracket"; import { cumulativeEliminationsByRound } from "./utils"; @@ -17,42 +17,38 @@ export class DoubleEliminationBracket extends Bracket { defaultRoundBestOfs(data: BracketData) { const result: BracketMapCounts = new Map(); - for (const group of data.group) { - const roundsOfGroup = data.round.filter( - (round) => round.groupId === group.id, + const lastLosersRoundNumber = Math.max( + ...data.round + .filter((round) => round.section === "losers") + .map((round) => round.number), + ); + const defaultOfRound = (round: RoundData) => { + if (round.section === "finals") return 5; + if (round.section === "losers") { + if (round.number === lastLosersRoundNumber) return 5; + return 3; + } + + if (round.number > 2) return 5; + return 3; + }; + + for (const round of data.round) { + const atLeastOneNonByeMatch = data.match.some( + (match) => + match.roundId === round.id && match.opponent1 && match.opponent2, ); - const defaultOfRound = (round: RoundData) => { - if (group.number === 3) return 5; - if (group.number === 2) { - const lastRoundNumber = Math.max( - ...roundsOfGroup.map((each) => each.number), - ); + if (!atLeastOneNonByeMatch) continue; - if (round.number === lastRoundNumber) return 5; - return 3; - } - - if (round.number > 2) return 5; - return 3; - }; - - for (const round of roundsOfGroup) { - const atLeastOneNonByeMatch = data.match.some( - (match) => - match.roundId === round.id && match.opponent1 && match.opponent2, - ); - - if (!atLeastOneNonByeMatch) continue; - - if (!result.get(group.id)) { - result.set(group.id, new Map()); - } - - result - .get(group.id)! - .set(round.number, { count: defaultOfRound(round), type: "BEST_OF" }); + const key = roundSetKey(round); + if (!result.get(key)) { + result.set(key, new Map()); } + + result + .get(key)! + .set(round.number, { count: defaultOfRound(round), type: "BEST_OF" }); } return result; @@ -64,21 +60,27 @@ export class DoubleEliminationBracket extends Bracket { const roundNumberWB = Math.ceil((roundNumber + 1) / 2); - const groupIdWB = this.data.group.find((g) => g.number === 1)?.id; - return this.data.round.find( - (round) => round.number === roundNumberWB && round.groupId === groupIdWB, + (round) => round.number === roundNumberWB && round.section === "winners", ); } + private matchesOfSection(section: RoundData["section"]) { + const roundIds = new Set( + this.data.round + .filter((round) => round.section === section) + .map((round) => round.id), + ); + + return this.data.match.filter((match) => roundIds.has(match.roundId)); + } + protected calculateStandings(): Standing[] { if (!this.enoughTeams) return []; - const losersGroupId = this.data.group.find((g) => g.number === 2)?.id; - - const losersMatches = this.data.match - .filter((match) => match.groupId === losersGroupId) - .sort((a, b) => a.roundId - b.roundId); + const losersMatches = this.matchesOfSection("losers").sort( + (a, b) => a.roundId - b.roundId, + ); const teams: { id: number; lostAt: number }[] = []; @@ -123,16 +125,12 @@ export class DoubleEliminationBracket extends Bracket { } } - // edge case: 1 match only - const noLosersRounds = losersGroupId === undefined; - const grandFinalsNumber = noLosersRounds ? 1 : 3; - const grandFinalsGroupId = this.data.group.find( - (g) => g.number === grandFinalsNumber, - )?.id; - invariant(grandFinalsGroupId !== undefined, "GF group not found"); - const grandFinalMatches = this.data.match.filter( - (match) => match.groupId === grandFinalsGroupId, + // edge case: 1 match only, the winners bracket final is the grand final + const noLosersRounds = losersMatches.length === 0; + const grandFinalMatches = this.matchesOfSection( + noLosersRounds ? "winners" : "finals", ); + invariant(grandFinalMatches.length > 0, "GF matches not found"); // if opponent1 won in DE it means that bracket reset is not played if ( @@ -230,16 +228,9 @@ export class DoubleEliminationBracket extends Bracket { return this.sourceByStandings(placements, rest === true); } - const resolveLosersGroupId = (data: BracketData) => { - const minGroupId = Math.min(...data.round.map((round) => round.groupId)); - - return minGroupId + 1; - }; - const placementsToRoundsIds = (data: BracketData, groupId: number) => { + const placementsToRoundsIds = () => { const firstRoundIsOnlyByes = () => { - const losersMatches = data.match.filter( - (match) => match.groupId === groupId, - ); + const losersMatches = this.matchesOfSection("losers"); const fistRoundId = Math.min(...losersMatches.map((m) => m.roundId)); @@ -252,8 +243,8 @@ export class DoubleEliminationBracket extends Bracket { ); }; - const losersRounds = data.round.filter( - (round) => round.groupId === groupId, + const losersRounds = this.data.round.filter( + (round) => round.section === "losers", ); const orderedRoundsIds = losersRounds .map((round) => round.id) @@ -264,11 +255,7 @@ export class DoubleEliminationBracket extends Bracket { return orderedRoundsIds.slice(0, amountOfRounds); }; - const losersGroupId = resolveLosersGroupId(this.data); - const sourceRoundsIds = placementsToRoundsIds( - this.data, - losersGroupId, - ).sort( + const sourceRoundsIds = placementsToRoundsIds().sort( // teams who made it further in the bracket get higher seed (a, b) => b - a, ); diff --git a/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts b/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts index 3b6c14ef4..d42704c63 100644 --- a/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts @@ -3,7 +3,7 @@ import type { Tables } from "~/db/tables"; import * as Standings from "~/features/tournament/core/Standings"; import type { BracketData } from "~/features/tournament-bracket/core/engine/types"; import { invariant } from "~/utils/invariant"; -import type { BracketMapCounts } from "../toMapList"; +import { type BracketMapCounts, roundSetKey } from "../toMapList"; import { Bracket, type Standing } from "./Bracket"; export class RoundRobinBracket extends Bracket { @@ -357,13 +357,12 @@ export class RoundRobinBracket extends Bracket { const result: BracketMapCounts = new Map(); for (const round of data.round) { - if (!result.get(round.groupId)) { - result.set(round.groupId, new Map()); + const key = roundSetKey(round); + if (!result.get(key)) { + result.set(key, new Map()); } - result - .get(round.groupId)! - .set(round.number, { count: 3, type: "BEST_OF" }); + result.get(key)!.set(round.number, { count: 3, type: "BEST_OF" }); } return result; diff --git a/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts b/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts index c047f13df..4053009b9 100644 --- a/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts @@ -6,7 +6,7 @@ import type { RoundData, } from "~/features/tournament-bracket/core/engine/types"; import { invariant } from "~/utils/invariant"; -import type { BracketMapCounts } from "../toMapList"; +import { type BracketMapCounts, roundSetKey } from "../toMapList"; import { Bracket, type Standing } from "./Bracket"; import { cumulativeEliminationsByRound } from "./utils"; @@ -19,60 +19,54 @@ export class SingleEliminationBracket extends Bracket { const result: BracketMapCounts = new Map(); const maxRoundNumber = Math.max(...data.round.map((round) => round.number)); - for (const group of data.group) { - const roundsOfGroup = data.round.filter( - (round) => round.groupId === group.id, + const defaultOfRound = (round: RoundData) => { + // 3rd place match + if (round.section === "finals") return 5; + + if (round.number > 2) return 5; + + // small brackets + if ( + round.number === maxRoundNumber || + round.number === maxRoundNumber - 1 + ) { + return 5; + } + return 3; + }; + + for (const round of data.round) { + const atLeastOneNonByeMatch = data.match.some( + (match) => + match.roundId === round.id && match.opponent1 && match.opponent2, ); - const defaultOfRound = (round: RoundData) => { - // 3rd place match - if (group.number === 2) return 5; + if (!atLeastOneNonByeMatch) continue; - if (round.number > 2) return 5; - - // small brackets - if ( - round.number === maxRoundNumber || - round.number === maxRoundNumber - 1 - ) { - return 5; - } - return 3; - }; - - for (const round of roundsOfGroup) { - const atLeastOneNonByeMatch = data.match.some( - (match) => - match.roundId === round.id && match.opponent1 && match.opponent2, - ); - - if (!atLeastOneNonByeMatch) continue; - - if (!result.get(group.id)) { - result.set(group.id, new Map()); - } - - result - .get(group.id)! - .set(round.number, { count: defaultOfRound(round), type: "BEST_OF" }); + const key = roundSetKey(round); + if (!result.get(key)) { + result.set(key, new Map()); } + + result + .get(key)! + .set(round.number, { count: defaultOfRound(round), type: "BEST_OF" }); } return result; } - private hasThirdPlaceMatch() { - return R.unique(this.data.match.map((m) => m.groupId)).length > 1; + private thirdPlaceRound() { + return this.data.round.find((round) => round.section === "finals"); } private thirdPlaceMatch() { - if (!this.hasThirdPlaceMatch()) return undefined; + const thirdPlaceRound = this.thirdPlaceRound(); + if (!thirdPlaceRound) return undefined; - const thirdPlaceGroupId = Math.max( - ...this.data.group.map((group) => group.id), + return this.data.match.find( + (match) => match.roundId === thirdPlaceRound.id, ); - - return this.data.match.find((match) => match.groupId === thirdPlaceGroupId); } private thirdPlaceMatchUndecided() { @@ -86,19 +80,10 @@ export class SingleEliminationBracket extends Bracket { protected calculateStandings(): Standing[] { const teams: { id: number; lostAt: number }[] = []; - const matches = (() => { - if (!this.hasThirdPlaceMatch()) { - return this.data.match.slice(); - } - - const thirdPlaceMatch = this.data.match.find( - (m) => m.groupId === Math.max(...this.data.group.map((g) => g.id)), - ); - - return this.data.match.filter( - (m) => m.groupId !== thirdPlaceMatch?.groupId, - ); - })(); + const thirdPlaceRound = this.thirdPlaceRound(); + const matches = thirdPlaceRound + ? this.data.match.filter((m) => m.roundId !== thirdPlaceRound.id) + : this.data.match.slice(); for (const match of matches.sort((a, b) => a.roundId - b.roundId)) { if (!match.winnerSide) { @@ -193,11 +178,8 @@ export class SingleEliminationBracket extends Bracket { : source; } - // third place match lives in a separate (higher) group, the lowest group id is the winners group - const mainGroupId = Math.min(...this.data.group.map((group) => group.id)); - const orderedRoundsIds = this.data.round - .filter((round) => round.groupId === mainGroupId) + .filter((round) => round.section === "winners") .map((round) => round.id) .sort((a, b) => a - b); diff --git a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts index 074065a41..e6b123742 100644 --- a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts @@ -7,7 +7,7 @@ import { invariant } from "~/utils/invariant"; import { logger } from "~/utils/logger"; import { cutToNDecimalPlaces } from "../../../../utils/number"; import { calculateTeamStatus } from "../engine/swiss/team-status"; -import type { BracketMapCounts } from "../toMapList"; +import { type BracketMapCounts, roundSetKey } from "../toMapList"; import { Bracket, type Standing, type TeamTrackRecord } from "./Bracket"; export class SwissBracket extends Bracket { @@ -496,13 +496,12 @@ export class SwissBracket extends Bracket { const result: BracketMapCounts = new Map(); for (const round of data.round) { - if (!result.get(round.groupId)) { - result.set(round.groupId, new Map()); + const key = roundSetKey(round); + if (!result.get(key)) { + result.set(key, new Map()); } - result - .get(round.groupId)! - .set(round.number, { count: 3, type: "BEST_OF" }); + result.get(key)!.set(round.number, { count: 3, type: "BEST_OF" }); } return result; diff --git a/app/features/tournament-bracket/core/PreparedMaps.test.ts b/app/features/tournament-bracket/core/PreparedMaps.test.ts index 2dd4a3ee5..f735c020c 100644 --- a/app/features/tournament-bracket/core/PreparedMaps.test.ts +++ b/app/features/tournament-bracket/core/PreparedMaps.test.ts @@ -391,7 +391,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { }); expect(trimmed?.maps.length).toBe(FOUR_TEAM_SE_PREPARED.maps.length - 1); - expect(trimmed?.maps.some((m) => m.groupId === 1)).toBe(false); + expect(trimmed?.maps.some((m) => m.section === "finals")).toBe(false); }); test("trims the maps (SE - 1 extra round)", () => { @@ -455,9 +455,9 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { expect(trimmed?.maps.length).toBe(EIGHT_TEAM_SE_PREPARED.maps.length - 2); - const uniqueGroupIds = new Set(trimmed?.maps.map((map) => map.groupId)); + const uniqueSections = new Set(trimmed?.maps.map((map) => map.section)); - expect(uniqueGroupIds.size).toBe(1); + expect(uniqueSections.size).toBe(1); }); const doubleEliminationTournament = testTournament({ @@ -487,15 +487,15 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { const expectedFinalsCount = 2; expect( - trimmed?.maps.filter((m) => m.groupId === 0).length, + trimmed?.maps.filter((m) => m.section === "winners").length, "Winners count is wrong", ).toBe(expectedWinnersCount); expect( - trimmed?.maps.filter((m) => m.groupId === 1).length, + trimmed?.maps.filter((m) => m.section === "losers").length, "Losers count is wrong", ).toBe(expectedLosersCount); expect( - trimmed?.maps.filter((m) => m.groupId === 2).length, + trimmed?.maps.filter((m) => m.section === "finals").length, "Finals count is wrong", ).toBe(expectedFinalsCount); }); @@ -523,7 +523,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { maps: [ { roundId: 0, - groupId: 0, + section: "winners", list: [ { mode: "TC", @@ -551,7 +551,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { }, { roundId: 1, - groupId: 0, + section: "winners", list: [ { mode: "CB", @@ -579,7 +579,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { }, { roundId: 2, - groupId: 1, + section: "finals", list: [ { mode: "TC", @@ -615,7 +615,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { maps: [ { roundId: 0, - groupId: 0, + section: "winners", list: [ { mode: "CB", @@ -635,7 +635,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { }, { roundId: 1, - groupId: 0, + section: "winners", list: [ { mode: "RM", @@ -663,7 +663,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { }, { roundId: 2, - groupId: 0, + section: "winners", list: [ { mode: "CB", @@ -691,7 +691,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { }, { roundId: 3, - groupId: 1, + section: "finals", list: [ { mode: "CB", @@ -727,7 +727,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { maps: [ { roundId: 0, - groupId: 0, + section: "winners", list: [ { mode: "SZ", @@ -747,7 +747,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { }, { roundId: 3, - groupId: 1, + section: "losers", list: [ { mode: "CB", @@ -767,7 +767,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { }, { roundId: 1, - groupId: 0, + section: "winners", list: [ { mode: "RM", @@ -787,7 +787,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { }, { roundId: 4, - groupId: 1, + section: "losers", list: [ { mode: "TC", @@ -807,7 +807,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { }, { roundId: 2, - groupId: 0, + section: "winners", list: [ { mode: "CB", @@ -835,7 +835,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { }, { roundId: 5, - groupId: 1, + section: "losers", list: [ { mode: "CB", @@ -855,7 +855,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { }, { roundId: 6, - groupId: 1, + section: "losers", list: [ { mode: "RM", @@ -883,7 +883,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { }, { roundId: 7, - groupId: 2, + section: "finals", list: [ { mode: "RM", @@ -911,7 +911,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { }, { roundId: 8, - groupId: 2, + section: "finals", list: [ { mode: "TC", diff --git a/app/features/tournament-bracket/core/PreparedMaps.ts b/app/features/tournament-bracket/core/PreparedMaps.ts index d4fbcd7d3..8423cc5e5 100644 --- a/app/features/tournament-bracket/core/PreparedMaps.ts +++ b/app/features/tournament-bracket/core/PreparedMaps.ts @@ -218,38 +218,33 @@ function trimMapsByTeamCount({ nullFilledArray(teamCount).map((_, i) => i + 1), ).round; - const groupIds = R.unique(preparedMaps.maps.map((r) => r.groupId)); + const sections = R.unique(preparedMaps.maps.map((r) => r.section)); const result = { ...preparedMaps }; - for (const groupId of groupIds) { - const actualRoundsForGroup = actualRounds.filter( - (r) => r.groupId === groupId, + for (const section of sections) { + const actualRoundsForSection = actualRounds.filter( + (r) => r.section === section, ); - const preparedRoundsForGroup = preparedMaps.maps.filter( - (r) => r.groupId === groupId, + const preparedRoundsForSection = preparedMaps.maps.filter( + (r) => r.section === section, ); - const actualRoundsCount = actualRoundsForGroup.length; + const actualRoundsCount = actualRoundsForSection.length; const trimmedRounds = roundsWithVirtualIds( - preparedRoundsForGroup.slice( - preparedRoundsForGroup.length - actualRoundsCount, + preparedRoundsForSection.slice( + preparedRoundsForSection.length - actualRoundsCount, ), - actualRoundsForGroup.map((r) => r.id).sort((a, b) => a - b), + actualRoundsForSection.map((r) => r.id).sort((a, b) => a - b), ); - result.maps = result.maps.filter((r) => r.groupId !== groupId); + result.maps = result.maps.filter((r) => r.section !== section); result.maps.push(...trimmedRounds); } - result.maps.sort((a, b) => { - if (a.groupId === b.groupId) { - return a.roundId - b.roundId; - } - - return a.groupId - b.groupId; - }); + // round ids follow the creation order of the sections: winners, losers, finals + result.maps.sort((a, b) => a.roundId - b.roundId); return result; } @@ -275,8 +270,9 @@ function thirdPlaceMatchDisappeared({ return false; } - const preparedHasThirdPlace = - R.unique(preparedMaps.maps.map((r) => r.groupId)).length > 1; + const preparedHasThirdPlace = preparedMaps.maps.some( + (r) => r.section === "finals", + ); return preparedHasThirdPlace && teamCount < 4; } @@ -284,7 +280,7 @@ function thirdPlaceMatchDisappeared({ function filterOutThirdPlaceMatch(prepared: PreparedMaps): PreparedMaps { return { ...prepared, - maps: prepared.maps.filter((map) => map.groupId === 0), + maps: prepared.maps.filter((map) => map.section !== "finals"), }; } @@ -489,7 +485,8 @@ function eliminationPlacementSizes({ ].filter((size) => size > 0); const thirdPlaceMatchExists = - type === "single_elimination" && data.group.length > 1; + type === "single_elimination" && + data.round.some((round) => round.section === "finals"); const semiFinalLosersIdx = 2; if (thirdPlaceMatchExists && sizes[semiFinalLosersIdx] === 2) { // the third place match splits the semi final losers into 3rd and 4th @@ -538,15 +535,10 @@ function eliminationRounds({ type: "single_elimination" | "double_elimination"; data: BracketData; }) { - const groupIds = R.unique(data.round.map((round) => round.groupId)); - // third place match lives in a separate (higher) group, as does the losers bracket - const groupId = - type === "double_elimination" - ? Math.min(...groupIds) + 1 - : Math.min(...groupIds); + const section = type === "double_elimination" ? "losers" : "winners"; return data.round - .filter((round) => round.groupId === groupId) + .filter((round) => round.section === section) .sort((a, b) => a.id - b.id); } diff --git a/app/features/tournament-bracket/core/engine/create/builder.ts b/app/features/tournament-bracket/core/engine/create/builder.ts index 4e5dcf692..d8a56ce9c 100644 --- a/app/features/tournament-bracket/core/engine/create/builder.ts +++ b/app/features/tournament-bracket/core/engine/create/builder.ts @@ -6,6 +6,7 @@ import type { ParticipantSlot, ResolvedCreateBracketInput, RoundData, + RoundSection, Seeding, SeedOrdering, StageData, @@ -76,7 +77,14 @@ export class StageCreator { const rounds = helpers.makeRoundRobinMatches(presentSlots); for (let i = 0; i < rounds.length; i++) - this.createRound(stageId, groupId, i + 1, rounds[0].length, rounds[i]); + this.createRound( + stageId, + groupId, + null, + i + 1, + rounds[0].length, + rounds[i], + ); } /** Bipartite round-robin: every A team plays every B team exactly once. */ @@ -94,20 +102,23 @@ export class StageCreator { const rounds = helpers.makeAbDivisionRoundRobinMatches(slotsA, slotsB); for (let i = 0; i < rounds.length; i++) - this.createRound(stageId, groupId, i + 1, rounds[0].length, rounds[i]); + this.createRound( + stageId, + groupId, + null, + i + 1, + rounds[0].length, + rounds[i], + ); } - /** The only bracket in single elimination, the upper one in double elimination. */ + /** The winners section of an elimination group: the only bracket in single elimination, the upper one in double elimination. */ createStandardBracket( stageId: number, - number: number, + groupId: number, slots: ParticipantSlot[], ): StandardBracketResults { const roundCount = helpers.getUpperBracketRoundCount(slots.length); - const groupId = this.insertGroup({ - stageId, - number, - }); let duels = helpers.makePairs(slots); let roundNumber = 1; @@ -118,7 +129,14 @@ export class StageCreator { const matchCount = 2 ** i; duels = this.getCurrentDuels(duels, matchCount); losers.push(duels.map(helpers.byeLoser)); - this.createRound(stageId, groupId, roundNumber++, matchCount, duels); + this.createRound( + stageId, + groupId, + "winners", + roundNumber++, + matchCount, + duels, + ); } return { losers, winner: helpers.byeWinner(duels[0]) }; @@ -127,7 +145,7 @@ export class StageCreator { /** Alternates major (regular) rounds and minor rounds where the major round's winners meet upper bracket losers. */ createLowerBracket( stageId: number, - number: number, + groupId: number, losers: ParticipantSlot[][], ): ParticipantSlot { const participantCount = this.seeding.length; @@ -138,11 +156,6 @@ export class StageCreator { const method = this.getMajorOrdering(participantCount); const ordered = ordering[method](losers[losersId++]); - const groupId = this.insertGroup({ - stageId, - number, - }); - let duels = helpers.makePairs(ordered); let roundNumber = 1; @@ -151,7 +164,14 @@ export class StageCreator { // Major round. duels = this.getCurrentDuels(duels, matchCount, true); - this.createRound(stageId, groupId, roundNumber++, matchCount, duels); + this.createRound( + stageId, + groupId, + "losers", + roundNumber++, + matchCount, + duels, + ); // Minor round. const minorOrdering = this.getMinorOrdering( @@ -166,30 +186,29 @@ export class StageCreator { losers[losersId++], minorOrdering, ); - this.createRound(stageId, groupId, roundNumber++, matchCount, duels); + this.createRound( + stageId, + groupId, + "losers", + roundNumber++, + matchCount, + duels, + ); } return helpers.byeWinnerToGrandFinal(duels[0]); } - /** Rounds of 1 match each, used for finals. */ - createUniqueMatchBracket( - stageId: number, - number: number, - duels: Duel[], - ): void { - const groupId = this.insertGroup({ - stageId, - number, - }); - + /** The finals section: rounds of 1 match each (grand finals + bracket reset, or a consolation final). */ + createFinals(stageId: number, groupId: number, duels: Duel[]): void { for (let i = 0; i < duels.length; i++) - this.createRound(stageId, groupId, i + 1, 1, [duels[i]]); + this.createRound(stageId, groupId, "finals", i + 1, 1, [duels[i]]); } createRound( stageId: number, groupId: number, + section: RoundSection | null, roundNumber: number, matchCount: number, duels: Duel[], @@ -198,6 +217,7 @@ export class StageCreator { number: roundNumber, stageId, groupId, + section, }); for (let i = 0; i < matchCount; i++) { diff --git a/app/features/tournament-bracket/core/engine/create/double-elimination.test.ts b/app/features/tournament-bracket/core/engine/create/double-elimination.test.ts index d65720d5f..b24533e5e 100644 --- a/app/features/tournament-bracket/core/engine/create/double-elimination.test.ts +++ b/app/features/tournament-bracket/core/engine/create/double-elimination.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "vitest"; +import type { BracketData } from "../types"; import { createResolved } from "./index"; describe("Create double elimination stage", () => { @@ -11,8 +12,12 @@ describe("Create double elimination stage", () => { expect(data.stage[0].type).toBe("double_elimination"); - expect(data.group.length).toBe(3); - expect(data.round.length).toBe(4 + 6 + 2); + expect(data.group.length).toBe(1); + expect(roundCountBySection(data)).toEqual({ + winners: 4, + losers: 6, + finals: 2, + }); expect(data.match.length).toBe(31); }); @@ -33,8 +38,21 @@ describe("Create double elimination stage", () => { settings: {}, }); - expect(data.group.length).toBe(3); - expect(data.round.length).toBe(3 + 4 + 2); + expect(data.group.length).toBe(1); + expect(roundCountBySection(data)).toEqual({ + winners: 3, + losers: 4, + finals: 2, + }); expect(data.match.length).toBe(15); }); }); + +function roundCountBySection(data: BracketData) { + const counts: Record = {}; + for (const round of data.round) { + counts[String(round.section)] = (counts[String(round.section)] ?? 0) + 1; + } + + return counts; +} diff --git a/app/features/tournament-bracket/core/engine/create/double-elimination.ts b/app/features/tournament-bracket/core/engine/create/double-elimination.ts index 87148bd50..6434c70c6 100644 --- a/app/features/tournament-bracket/core/engine/create/double-elimination.ts +++ b/app/features/tournament-bracket/core/engine/create/double-elimination.ts @@ -3,21 +3,22 @@ import type { StageCreator } from "./builder"; import * as helpers from "./helpers"; import { ordering, STANDARD_BRACKET_FIRST_ROUND_ORDERING } from "./seeding"; -/** Winner bracket (WB), loser bracket (LB) and a double grand final between the winners of both. */ +/** One group holding the winner bracket (WB), loser bracket (LB) and a double grand final between the winners of both. */ export function createDoubleElimination(creator: StageCreator): void { const slots = creator.getSlots(); const stage = creator.createStage(); + const groupId = creator.insertGroup({ stageId: stage.id, number: 1 }); const ordered = ordering[STANDARD_BRACKET_FIRST_ROUND_ORDERING](slots); const { losers: losersWb, winner: winnerWb } = creator.createStandardBracket( stage.id, - 1, + groupId, ordered, ); if (helpers.isDoubleEliminationNecessary(slots.length)) { - const winnerLb = creator.createLowerBracket(stage.id, 2, losersWb); - createGrandFinal(creator, stage.id, winnerWb, winnerLb); + const winnerLb = creator.createLowerBracket(stage.id, groupId, losersWb); + createGrandFinal(creator, stage.id, groupId, winnerWb, winnerLb); } } @@ -25,6 +26,7 @@ export function createDoubleElimination(creator: StageCreator): void { function createGrandFinal( creator: StageCreator, stageId: number, + groupId: number, winnerWb: ParticipantSlot, winnerLb: ParticipantSlot, ): void { @@ -33,5 +35,5 @@ function createGrandFinal( [{ id: null }, { id: null }], ]; - creator.createUniqueMatchBracket(stageId, 3, finalDuels); + creator.createFinals(stageId, groupId, finalDuels); } diff --git a/app/features/tournament-bracket/core/engine/create/index.ts b/app/features/tournament-bracket/core/engine/create/index.ts index bb8e56ed2..764152cf7 100644 --- a/app/features/tournament-bracket/core/engine/create/index.ts +++ b/app/features/tournament-bracket/core/engine/create/index.ts @@ -110,6 +110,6 @@ function attachRoundMaps( } function toRoundMaps(input: RoundMapsInput): TournamentRoundMaps { - const { roundId, groupId, ...maps } = input; + const { roundId, section, ...maps } = input; return maps; } diff --git a/app/features/tournament-bracket/core/engine/create/single-elimination.test.ts b/app/features/tournament-bracket/core/engine/create/single-elimination.test.ts index b6e4773d4..6d2490c65 100644 --- a/app/features/tournament-bracket/core/engine/create/single-elimination.test.ts +++ b/app/features/tournament-bracket/core/engine/create/single-elimination.test.ts @@ -37,8 +37,9 @@ describe("Create single elimination stage", () => { settings: { consolationFinal: true }, }); - expect(data.group.length).toBe(2); + expect(data.group.length).toBe(1); expect(data.round.length).toBe(4); + expect(data.round.at(-1)?.section).toBe("finals"); expect(data.match.length).toBe(8); }); diff --git a/app/features/tournament-bracket/core/engine/create/single-elimination.ts b/app/features/tournament-bracket/core/engine/create/single-elimination.ts index 52dad719b..dce9d2034 100644 --- a/app/features/tournament-bracket/core/engine/create/single-elimination.ts +++ b/app/features/tournament-bracket/core/engine/create/single-elimination.ts @@ -2,24 +2,26 @@ import type { Duel, ParticipantSlot } from "../types"; import type { StageCreator } from "./builder"; import { ordering, STANDARD_BRACKET_FIRST_ROUND_ORDERING } from "./seeding"; -/** One bracket and optionally a consolation final between semi-final losers. */ +/** One group holding the bracket and optionally a consolation final between semi-final losers. */ export function createSingleElimination(creator: StageCreator): void { const slots = creator.getSlots(); const stage = creator.createStage(); + const groupId = creator.insertGroup({ stageId: stage.id, number: 1 }); const ordered = ordering[STANDARD_BRACKET_FIRST_ROUND_ORDERING](slots); - const { losers } = creator.createStandardBracket(stage.id, 1, ordered); - createConsolationFinal(creator, stage.id, losers); + const { losers } = creator.createStandardBracket(stage.id, groupId, ordered); + createConsolationFinal(creator, stage.id, groupId, losers); } /** Consolation final for the semi final losers. */ function createConsolationFinal( creator: StageCreator, stageId: number, + groupId: number, losers: ParticipantSlot[][], ): void { if (!creator.settings.consolationFinal) return; const semiFinalLosers = losers[losers.length - 2] as Duel; - creator.createUniqueMatchBracket(stageId, 2, [semiFinalLosers]); + creator.createFinals(stageId, groupId, [semiFinalLosers]); } diff --git a/app/features/tournament-bracket/core/engine/create/swiss.ts b/app/features/tournament-bracket/core/engine/create/swiss.ts index 0a9720488..9d463693e 100644 --- a/app/features/tournament-bracket/core/engine/create/swiss.ts +++ b/app/features/tournament-bracket/core/engine/create/swiss.ts @@ -32,6 +32,7 @@ export function createSwiss(input: ResolvedCreateBracketInput): BracketData { nullFilledArray(roundCount).map((_, i) => ({ id: roundId++, groupId: g.id, + section: null, number: i + 1, stageId: 0, })), diff --git a/app/features/tournament-bracket/core/engine/propagation/helpers.ts b/app/features/tournament-bracket/core/engine/propagation/helpers.ts index 6d325b833..991f0ae81 100644 --- a/app/features/tournament-bracket/core/engine/propagation/helpers.ts +++ b/app/features/tournament-bracket/core/engine/propagation/helpers.ts @@ -5,6 +5,7 @@ import type { MatchData, MatchResults, MatchResultsInput, + RoundSection, SeedOrdering, Side, StageData, @@ -185,16 +186,16 @@ export function isSwiss(stage: StageData): boolean { return stage.type === "swiss"; } -/** Type of group a match is located in. */ +/** Where in the elimination stage a match of a round with the section is located. */ export function getMatchLocation( stageType: StageType, - groupNumber: number, + section: RoundSection | null, ): GroupType { - if (isWinnerBracket(stageType, groupNumber)) return "winner_bracket"; + if (isWinnerBracket(stageType, section)) return "winner_bracket"; - if (isLoserBracket(stageType, groupNumber)) return "loser_bracket"; + if (isLoserBracket(stageType, section)) return "loser_bracket"; - if (isFinalGroup(stageType, groupNumber)) return "final_group"; + if (isFinalGroup(section)) return "final_group"; return "single_bracket"; } @@ -242,18 +243,21 @@ function getLoserRoundLoserCount( } /** Not the opposite of `isLoserBracket()`: the only bracket of single elimination is neither. */ -function isWinnerBracket(stageType: StageType, groupNumber: number): boolean { - return stageType === "double_elimination" && groupNumber === 1; +function isWinnerBracket( + stageType: StageType, + section: RoundSection | null, +): boolean { + return stageType === "double_elimination" && section === "winners"; } -function isLoserBracket(stageType: StageType, groupNumber: number): boolean { - return stageType === "double_elimination" && groupNumber === 2; +function isLoserBracket( + stageType: StageType, + section: RoundSection | null, +): boolean { + return stageType === "double_elimination" && section === "losers"; } /** Consolation final or grand final. */ -function isFinalGroup(stageType: StageType, groupNumber: number): boolean { - return ( - (stageType === "single_elimination" && groupNumber === 2) || - (stageType === "double_elimination" && groupNumber === 3) - ); +function isFinalGroup(section: RoundSection | null): boolean { + return section === "finals"; } diff --git a/app/features/tournament-bracket/core/engine/propagation/reset-result.ts b/app/features/tournament-bracket/core/engine/propagation/reset-result.ts index 4daf6cd70..a88cde820 100644 --- a/app/features/tournament-bracket/core/engine/propagation/reset-result.ts +++ b/app/features/tournament-bracket/core/engine/propagation/reset-result.ts @@ -22,13 +22,13 @@ export function resetMatchResults( const stage = store.stageById(stored.stageId); if (!stage) throw new Error("Stage not found."); - const group = store.groupById(stored.groupId); - if (!group) throw new Error("Group not found."); + const round = store.roundById(stored.roundId); + if (!round) throw new Error("Round not found."); const { roundNumber, roundCount } = propagator.getRoundPositionalInfo( stored.roundId, ); - const matchLocation = helpers.getMatchLocation(stage.type, group.number); + const matchLocation = helpers.getMatchLocation(stage.type, round.section); const nextMatches = stage.type !== "round_robin" && stage.type !== "swiss" ? propagator.getNextMatches( diff --git a/app/features/tournament-bracket/core/engine/propagation/store.ts b/app/features/tournament-bracket/core/engine/propagation/store.ts index 9cdf3d93a..99530623c 100644 --- a/app/features/tournament-bracket/core/engine/propagation/store.ts +++ b/app/features/tournament-bracket/core/engine/propagation/store.ts @@ -1,8 +1,8 @@ import type { BracketData, - GroupData, MatchData, RoundData, + RoundSection, StageData, } from "../types"; @@ -10,11 +10,9 @@ import type { export class Store { readonly data: BracketData; private readonly stagesById: Map; - private readonly groupsById: Map; private readonly roundsById: Map; private readonly matchesById: Map; - private readonly groupsByStageId: Map; - private readonly roundsByGroupId: Map; + private readonly roundsBySection: Map; private readonly matchesByRoundId: Map; private readonly changedMatchIds = new Set(); @@ -22,17 +20,11 @@ export class Store { this.data = structuredClone(data); this.stagesById = indexById(this.data.stage); - this.groupsById = indexById(this.data.group); this.roundsById = indexById(this.data.round); this.matchesById = indexById(this.data.match); - this.groupsByStageId = groupByKey( - this.data.group, - (group) => group.stageId, - ); - this.roundsByGroupId = groupByKey( - this.data.round, - (round) => round.groupId, + this.roundsBySection = groupByKey(this.data.round, (round) => + sectionKey(round.groupId, round.section), ); this.matchesByRoundId = groupByKey( this.data.match, @@ -44,10 +36,6 @@ export class Store { return this.stagesById.get(id) ?? null; } - groupById(id: number): GroupData | null { - return this.groupsById.get(id) ?? null; - } - roundById(id: number): RoundData | null { return this.roundsById.get(id) ?? null; } @@ -56,13 +44,13 @@ export class Store { return this.matchesById.get(id) ?? null; } - groupByNumber(stageId: number, groupNumber: number): GroupData | null { - const groups = this.groupsByStageId.get(stageId); - return groups?.find((group) => group.number === groupNumber) ?? null; - } - - roundByNumber(groupId: number, roundNumber: number): RoundData | null { - const rounds = this.roundsByGroupId.get(groupId); + /** Round of a group's section, e.g. round 2 of the losers bracket. */ + roundByNumber( + groupId: number, + section: RoundSection | null, + roundNumber: number, + ): RoundData | null { + const rounds = this.roundsBySection.get(sectionKey(groupId, section)); return rounds?.find((round) => round.number === roundNumber) ?? null; } @@ -71,8 +59,8 @@ export class Store { return matches?.find((match) => match.number === matchNumber) ?? null; } - roundCountInGroup(groupId: number): number { - return this.roundsByGroupId.get(groupId)?.length ?? 0; + roundCountInSection(groupId: number, section: RoundSection | null): number { + return this.roundsBySection.get(sectionKey(groupId, section))?.length ?? 0; } matchCountInRound(roundId: number): number { @@ -95,12 +83,16 @@ export class Store { } } +function sectionKey(groupId: number, section: RoundSection | null) { + return `${groupId}-${section}`; +} + function indexById(rows: T[]): Map { return new Map(rows.map((row) => [row.id, row])); } -function groupByKey(rows: T[], key: (row: T) => number): Map { - const result = new Map(); +function groupByKey(rows: T[], key: (row: T) => K): Map { + const result = new Map(); for (const row of rows) { const existing = result.get(key(row)); diff --git a/app/features/tournament-bracket/core/engine/propagation/traversal.ts b/app/features/tournament-bracket/core/engine/propagation/traversal.ts index 07a10363b..86c6367c4 100644 --- a/app/features/tournament-bracket/core/engine/propagation/traversal.ts +++ b/app/features/tournament-bracket/core/engine/propagation/traversal.ts @@ -1,9 +1,9 @@ import { matchStatus, winnerSideByScore } from "../status"; import type { - GroupData, GroupType, MatchData, MatchResultsInput, + RoundSection, Side, StageData, StageType, @@ -33,10 +33,10 @@ export class Propagator { const stage = this.store.stageById(match.stageId); if (!stage) throw new Error("Stage not found."); - const group = this.store.groupById(match.groupId); - if (!group) throw new Error("Group not found."); - - const matchLocation = helpers.getMatchLocation(stage.type, group.number); + const matchLocation = helpers.getMatchLocation( + stage.type, + this.sectionOf(match), + ); this.updateNext(match, matchLocation, stage, roundNumber, roundCount); } @@ -191,13 +191,14 @@ export class Propagator { if (helpers.hasBye(match)) this.updateRelatedMatches(match); } + /** Round number and count within the round's section (a round robin group's rounds, the losers bracket...). */ getRoundPositionalInfo(roundId: number): RoundPositionalInfo { const round = this.store.roundById(roundId); if (!round) throw new Error("Round not found."); return { roundNumber: round.number, - roundCount: this.store.roundCountInGroup(round.groupId), + roundCount: this.store.roundCountInSection(round.groupId, round.section), }; } @@ -219,12 +220,7 @@ export class Propagator { case "winner_bracket": return this.getNextMatchesWB(match, stage, roundNumber, roundCount); case "loser_bracket": - return this.getNextMatchesLB( - match, - stage.type, - roundNumber, - roundCount, - ); + return this.getNextMatchesLB(match, roundNumber, roundCount); case "final_group": return this.getNextMatchesFinal(match, roundNumber, roundCount); default: @@ -238,14 +234,13 @@ export class Propagator { roundNumber: number, roundCount: number, ): (MatchData | null)[] { - const loserBracket = this.getLoserBracket(match.stageId); - if (loserBracket === null) + if (!this.hasSection(match.groupId, "losers")) // Only one match in the stage, there is no loser bracket. return []; const roundNumberLB = roundNumber > 1 ? (roundNumber - 1) * 2 : 1; - const participantCount = this.participantCount(match.stageId); + const participantCount = this.participantCount(match.groupId); const method = helpers.getLoserOrdering(participantCount, roundNumberLB); const actualMatchNumberLB = helpers.findLoserMatchNumber( participantCount, @@ -261,7 +256,12 @@ export class Propagator { roundNumber, roundCount, ), - this.findMatch(loserBracket.id, roundNumberLB, actualMatchNumberLB), + this.findMatch( + match.groupId, + "losers", + roundNumberLB, + actualMatchNumberLB, + ), ]; } @@ -274,62 +274,55 @@ export class Propagator { if (stageType === "single_elimination") return this.getNextMatchesUpperBracketSingleElimination( match, - stageType, roundNumber, roundCount, ); if (stageType === "double_elimination" && roundNumber === roundCount) - return [this.getFirstMatchFinal(match, stageType)]; + return [this.getFirstMatchFinal(match)]; - return [this.getDiagonalMatch(match.groupId, roundNumber, match.number)]; + return [this.getDiagonalMatch(match, roundNumber)]; } private getNextMatchesUpperBracketSingleElimination( match: MatchData, - stageType: StageType, roundNumber: number, roundCount: number, ): MatchData[] { if (roundNumber === roundCount - 1) { - const final = this.getFirstMatchFinal(match, stageType); + const final = this.getFirstMatchFinal(match); return [ - this.getDiagonalMatch(match.groupId, roundNumber, match.number), + this.getDiagonalMatch(match, roundNumber), ...(final ? [final] : []), ]; } if (roundNumber === roundCount) return []; - return [this.getDiagonalMatch(match.groupId, roundNumber, match.number)]; + return [this.getDiagonalMatch(match, roundNumber)]; } private getNextMatchesLB( match: MatchData, - stageType: StageType, roundNumber: number, roundCount: number, ): MatchData[] { if (roundNumber === roundCount) { - const final = this.getFirstMatchFinal(match, stageType); + const final = this.getFirstMatchFinal(match); return final ? [final] : []; } if (roundNumber % 2 === 1) - return this.getMatchAfterMajorRoundLB(match, roundNumber); + return [this.getParallelMatch(match, roundNumber)]; - return this.getMatchAfterMinorRoundLB(match, roundNumber); + return [this.getDiagonalMatch(match, roundNumber)]; } - /** First match of the final group (consolation final or grand final). */ - private getFirstMatchFinal( - match: MatchData, - stageType: StageType, - ): MatchData | null { - const finalGroupId = this.getFinalGroupId(match.stageId, stageType); - if (finalGroupId === null) return null; + /** First match of the finals section (consolation final or grand final) of the match's group. */ + private getFirstMatchFinal(match: MatchData): MatchData | null { + if (!this.hasSection(match.groupId, "finals")) return null; - return this.findMatch(finalGroupId, 1, 1); + return this.findMatch(match.groupId, "finals", 1, 1); } private getNextMatchesFinal( @@ -345,84 +338,55 @@ export class Propagator { return []; } - return [this.findMatch(match.groupId, roundNumber + 1, 1)]; + return [this.findMatch(match.groupId, "finals", roundNumber + 1, 1)]; } - private getMatchAfterMajorRoundLB( - match: MatchData, - roundNumber: number, - ): MatchData[] { - return [this.getParallelMatch(match.groupId, roundNumber, match.number)]; - } - - private getMatchAfterMinorRoundLB( - match: MatchData, - roundNumber: number, - ): MatchData[] { - return [this.getDiagonalMatch(match.groupId, roundNumber, match.number)]; - } - - private getFinalGroupId( - stageId: number, - stageType: StageType, - ): number | null { - const groupNumber = - stageType === "single_elimination" - ? 2 /* Consolation final */ - : 3; /* Grand final */ - const finalGroup = this.store.groupByNumber(stageId, groupNumber); - if (!finalGroup) return null; - return finalGroup.id; - } - - /** The only bracket in single elimination, the winner bracket in double elimination. */ - private getUpperBracket(stageId: number): GroupData { - const winnerBracket = this.store.groupByNumber(stageId, 1); - if (!winnerBracket) throw new Error("Winner bracket not found."); - return winnerBracket; - } - - /** Derived from the upper bracket's first round (two participants per match, BYEs included). */ - private participantCount(stageId: number): number { - const upperBracket = this.getUpperBracket(stageId); - const firstRound = this.store.roundByNumber(upperBracket.id, 1); + /** Derived from the winners section's first round (two participants per match, BYEs included). */ + private participantCount(groupId: number): number { + const firstRound = this.store.roundByNumber(groupId, "winners", 1); if (!firstRound) throw new Error("First round not found."); return this.store.matchCountInRound(firstRound.id) * 2; } - private getLoserBracket(stageId: number): GroupData | null { - return this.store.groupByNumber(stageId, 2); + private hasSection(groupId: number, section: RoundSection): boolean { + return this.store.roundCountInSection(groupId, section) > 0; + } + + private sectionOf(match: MatchData): RoundSection | null { + const round = this.store.roundById(match.roundId); + if (!round) throw new Error("Round not found."); + + return round.section; } /** Corresponding match in the next round, like Round 1 to Round 2 in single elimination. */ - private getDiagonalMatch( - groupId: number, - roundNumber: number, - matchNumber: number, - ): MatchData { + private getDiagonalMatch(match: MatchData, roundNumber: number): MatchData { return this.findMatch( - groupId, + match.groupId, + this.sectionOf(match), roundNumber + 1, - helpers.getDiagonalMatchNumber(matchNumber), + helpers.getDiagonalMatchNumber(match.number), ); } /** Same match number in the next round, like major round to minor round in the loser bracket. */ - private getParallelMatch( - groupId: number, - roundNumber: number, - matchNumber: number, - ): MatchData { - return this.findMatch(groupId, roundNumber + 1, matchNumber); + private getParallelMatch(match: MatchData, roundNumber: number): MatchData { + return this.findMatch( + match.groupId, + this.sectionOf(match), + roundNumber + 1, + match.number, + ); } findMatch( groupId: number, + section: RoundSection | null, roundNumber: number, matchNumber: number, ): MatchData { - const round = this.store.roundByNumber(groupId, roundNumber); + const round = this.store.roundByNumber(groupId, section, roundNumber); if (!round) throw new Error("Round not found."); diff --git a/app/features/tournament-bracket/core/engine/status.ts b/app/features/tournament-bracket/core/engine/status.ts index 1f37165ac..cbfc1dac3 100644 --- a/app/features/tournament-bracket/core/engine/status.ts +++ b/app/features/tournament-bracket/core/engine/status.ts @@ -141,7 +141,7 @@ function isWaitingForPreviousRound( if (!round || round.number === 1) return false; const previousRound = context.roundByGroupAndNumber.get( - roundKey(round.groupId, round.number - 1), + roundKey(round, round.number - 1), ); if (!previousRound) return false; @@ -171,7 +171,7 @@ function bracketContext(data: BracketData): BracketContext { const roundByGroupAndNumber = new Map(); for (const round of data.round) { roundsById.set(round.id, round); - roundByGroupAndNumber.set(roundKey(round.groupId, round.number), round); + roundByGroupAndNumber.set(roundKey(round, round.number), round); } const matchesByRoundId = new Map(); @@ -199,6 +199,9 @@ function bracketContext(data: BracketData): BracketContext { }; } -function roundKey(groupId: number, roundNumber: number) { - return `${groupId}-${roundNumber}`; +function roundKey( + round: Pick, + roundNumber: number, +) { + return `${round.groupId}-${round.section}-${roundNumber}`; } diff --git a/app/features/tournament-bracket/core/engine/types.ts b/app/features/tournament-bracket/core/engine/types.ts index 38910b850..b5919fe01 100644 --- a/app/features/tournament-bracket/core/engine/types.ts +++ b/app/features/tournament-bracket/core/engine/types.ts @@ -9,7 +9,10 @@ export type Side = "opponent1" | "opponent2"; export type StageType = Tables["TournamentStage"]["type"]; -/** Group types of an elimination stage; `final_group` exists in both single and double elimination. */ +/** Part of an elimination group a round belongs to, `null` in round robin and swiss. */ +export type RoundSection = NonNullable; + +/** Where a match sits in an elimination stage, derived from the stage type and the round's section; `final_group` exists in both single and double elimination. */ export type GroupType = | "single_bracket" | "winner_bracket" @@ -82,6 +85,8 @@ export interface RoundData { id: number; stageId: number; groupId: number; + section: RoundSection | null; + /** Restarts from 1 per group, and in an elimination group per section. */ number: number; maps?: TournamentRoundMaps | null; /** Datetime the round is played by default (leagues). */ @@ -151,7 +156,7 @@ export interface CreateBracketInput { /** One round's map info as picked by the organizer against a bracket preview. */ export type RoundMapsInput = TournamentRoundMaps & { roundId: number; - groupId?: number; + section?: RoundSection | null; }; /** {@link CreateBracketInput} with settings already resolved to internal {@link StageSettings}. */ diff --git a/app/features/tournament-bracket/core/rounds.ts b/app/features/tournament-bracket/core/rounds.ts index 9a93d53dd..4a87dc9b7 100644 --- a/app/features/tournament-bracket/core/rounds.ts +++ b/app/features/tournament-bracket/core/rounds.ts @@ -1,30 +1,18 @@ -import * as R from "remeda"; import type { BracketData } from "~/features/tournament-bracket/core/engine/types"; import { TOURNAMENT } from "../../tournament/tournament-constants"; +/** Rounds of one side of an elimination bracket in play order with their display names. `winners` includes the grand finals, `single` the consolation final. */ export function getRounds(args: { bracketData: BracketData; type: "winners" | "losers" | "single"; }) { - const groupIds = args.bracketData.group.flatMap((group) => { - if (args.type === "winners" && group.number === 2) return []; - if (args.type === "losers" && group.number !== 2) return []; - - return group.id; - }); - let showingBracketReset = args.bracketData.round.length > 1; const rounds = args.bracketData.round - .flatMap((round) => { - if ( - typeof round.groupId === "number" && - !groupIds.includes(round.groupId) - ) { - return []; - } - - return round; - }) + .filter((round) => + args.type === "losers" + ? round.section === "losers" + : round.section !== "losers", + ) .filter((round, i, allRounds) => { const isBracketReset = args.type === "winners" && i === allRounds.length - 1; @@ -53,7 +41,7 @@ export function getRounds(args: { const hasThirdPlaceMatch = args.type === "single" && - R.unique(args.bracketData.match.map((m) => m.groupId)).length > 1; + args.bracketData.round.some((round) => round.section === "finals"); const namedRounds = rounds.map((round, i) => { const name = () => { if ( diff --git a/app/features/tournament-bracket/core/tests/mocks-li.ts b/app/features/tournament-bracket/core/tests/mocks-li.ts index c56bdca2d..44e346c90 100644 --- a/app/features/tournament-bracket/core/tests/mocks-li.ts +++ b/app/features/tournament-bracket/core/tests/mocks-li.ts @@ -57,46 +57,16 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ number: 1, stageId: 1429, }, - { - id: 4360, - number: 2, - stageId: 1429, - }, - { - id: 4361, - number: 3, - stageId: 1429, - }, { id: 4362, number: 1, stageId: 1430, }, - { - id: 4363, - number: 2, - stageId: 1430, - }, - { - id: 4364, - number: 3, - stageId: 1430, - }, { id: 4365, number: 1, stageId: 1431, }, - { - id: 4366, - number: 2, - stageId: 1431, - }, - { - id: 4367, - number: 3, - stageId: 1431, - }, ], round: [ { @@ -108,6 +78,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ count: 3, type: "PLAY_ALL", }, + section: null, }, { id: 13349, @@ -118,6 +89,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ count: 3, type: "PLAY_ALL", }, + section: null, }, { id: 13350, @@ -128,6 +100,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ count: 3, type: "PLAY_ALL", }, + section: null, }, { id: 13351, @@ -138,6 +111,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ count: 3, type: "PLAY_ALL", }, + section: null, }, { id: 13352, @@ -148,6 +122,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ count: 3, type: "PLAY_ALL", }, + section: null, }, { id: 13353, @@ -158,6 +133,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ count: 3, type: "PLAY_ALL", }, + section: null, }, { id: 13354, @@ -168,6 +144,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ count: 3, type: "PLAY_ALL", }, + section: null, }, { id: 13355, @@ -178,6 +155,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ count: 3, type: "PLAY_ALL", }, + section: null, }, { id: 13356, @@ -188,6 +166,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ count: 3, type: "PLAY_ALL", }, + section: null, }, { id: 13357, @@ -198,6 +177,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ count: 3, type: "PLAY_ALL", }, + section: null, }, { id: 13358, @@ -208,6 +188,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ count: 3, type: "PLAY_ALL", }, + section: null, }, { id: 13359, @@ -218,6 +199,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ count: 3, type: "PLAY_ALL", }, + section: null, }, { id: 13452, @@ -229,6 +211,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "winners", }, { id: 13453, @@ -240,6 +223,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "winners", }, { id: 13454, @@ -251,6 +235,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "winners", }, { id: 13455, @@ -262,10 +247,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "winners", }, { id: 13456, - groupId: 4360, + groupId: 4359, number: 1, stageId: 1429, maps: { @@ -273,10 +259,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "losers", }, { id: 13457, - groupId: 4360, + groupId: 4359, number: 2, stageId: 1429, maps: { @@ -284,10 +271,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "losers", }, { id: 13458, - groupId: 4360, + groupId: 4359, number: 3, stageId: 1429, maps: { @@ -295,10 +283,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "losers", }, { id: 13459, - groupId: 4360, + groupId: 4359, number: 4, stageId: 1429, maps: { @@ -306,10 +295,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "losers", }, { id: 13460, - groupId: 4360, + groupId: 4359, number: 5, stageId: 1429, maps: { @@ -317,10 +307,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "losers", }, { id: 13461, - groupId: 4360, + groupId: 4359, number: 6, stageId: 1429, maps: { @@ -328,10 +319,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "losers", }, { id: 13462, - groupId: 4361, + groupId: 4359, number: 1, stageId: 1429, maps: { @@ -339,10 +331,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "finals", }, { id: 13463, - groupId: 4361, + groupId: 4359, number: 2, stageId: 1429, maps: { @@ -350,6 +343,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "finals", }, { id: 13464, @@ -361,6 +355,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "winners", }, { id: 13465, @@ -372,6 +367,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "winners", }, { id: 13466, @@ -383,6 +379,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "winners", }, { id: 13467, @@ -394,10 +391,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "winners", }, { id: 13468, - groupId: 4363, + groupId: 4362, number: 1, stageId: 1430, maps: { @@ -405,10 +403,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "losers", }, { id: 13469, - groupId: 4363, + groupId: 4362, number: 2, stageId: 1430, maps: { @@ -416,10 +415,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "losers", }, { id: 13470, - groupId: 4363, + groupId: 4362, number: 3, stageId: 1430, maps: { @@ -427,10 +427,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "losers", }, { id: 13471, - groupId: 4363, + groupId: 4362, number: 4, stageId: 1430, maps: { @@ -438,10 +439,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "losers", }, { id: 13472, - groupId: 4363, + groupId: 4362, number: 5, stageId: 1430, maps: { @@ -449,10 +451,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "losers", }, { id: 13473, - groupId: 4363, + groupId: 4362, number: 6, stageId: 1430, maps: { @@ -460,10 +463,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "losers", }, { id: 13474, - groupId: 4364, + groupId: 4362, number: 1, stageId: 1430, maps: { @@ -471,10 +475,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "finals", }, { id: 13475, - groupId: 4364, + groupId: 4362, number: 2, stageId: 1430, maps: { @@ -482,6 +487,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "finals", }, { id: 13476, @@ -493,6 +499,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "winners", }, { id: 13477, @@ -504,6 +511,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "winners", }, { id: 13478, @@ -515,6 +523,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "winners", }, { id: 13479, @@ -526,10 +535,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "winners", }, { id: 13480, - groupId: 4366, + groupId: 4365, number: 1, stageId: 1431, maps: { @@ -537,10 +547,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "losers", }, { id: 13481, - groupId: 4366, + groupId: 4365, number: 2, stageId: 1431, maps: { @@ -548,10 +559,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "losers", }, { id: 13482, - groupId: 4366, + groupId: 4365, number: 3, stageId: 1431, maps: { @@ -559,10 +571,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "losers", }, { id: 13483, - groupId: 4366, + groupId: 4365, number: 4, stageId: 1431, maps: { @@ -570,10 +583,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "losers", }, { id: 13484, - groupId: 4366, + groupId: 4365, number: 5, stageId: 1431, maps: { @@ -581,10 +595,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "losers", }, { id: 13485, - groupId: 4366, + groupId: 4365, number: 6, stageId: 1431, maps: { @@ -592,10 +607,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "losers", }, { id: 13486, - groupId: 4367, + groupId: 4365, number: 1, stageId: 1431, maps: { @@ -603,10 +619,11 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "finals", }, { id: 13487, - groupId: 4367, + groupId: 4365, number: 2, stageId: 1431, maps: { @@ -614,6 +631,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: "finals", }, ], match: [ @@ -4758,7 +4776,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37890, - groupId: 4360, + groupId: 4359, number: 1, opponent1: { id: 17749, @@ -4777,7 +4795,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37891, - groupId: 4360, + groupId: 4359, number: 2, opponent1: { id: 17697, @@ -4796,7 +4814,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37892, - groupId: 4360, + groupId: 4359, number: 3, opponent1: { id: 17768, @@ -4815,7 +4833,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37893, - groupId: 4360, + groupId: 4359, number: 4, opponent1: { id: 16974, @@ -4834,7 +4852,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37894, - groupId: 4360, + groupId: 4359, number: 1, opponent1: { id: 17280, @@ -4852,7 +4870,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37895, - groupId: 4360, + groupId: 4359, number: 2, opponent1: { id: 17208, @@ -4870,7 +4888,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37896, - groupId: 4360, + groupId: 4359, number: 3, opponent1: { id: 17457, @@ -4888,7 +4906,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37897, - groupId: 4360, + groupId: 4359, number: 4, opponent1: { id: 17713, @@ -4906,7 +4924,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37898, - groupId: 4360, + groupId: 4359, number: 1, opponent1: { id: 17749, @@ -4923,7 +4941,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37899, - groupId: 4360, + groupId: 4359, number: 2, opponent1: { id: 17067, @@ -4940,7 +4958,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37900, - groupId: 4360, + groupId: 4359, number: 1, opponent1: { id: 17168, @@ -4958,7 +4976,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37901, - groupId: 4360, + groupId: 4359, number: 2, opponent1: { id: 17710, @@ -4976,7 +4994,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37902, - groupId: 4360, + groupId: 4359, number: 1, opponent1: { id: 17749, @@ -4993,7 +5011,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37903, - groupId: 4360, + groupId: 4359, number: 1, opponent1: { id: 16984, @@ -5011,7 +5029,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37904, - groupId: 4361, + groupId: 4359, number: 1, opponent1: { id: 17561, @@ -5029,7 +5047,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37905, - groupId: 4361, + groupId: 4359, number: 1, opponent1: { id: null, @@ -5315,7 +5333,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37921, - groupId: 4363, + groupId: 4362, number: 1, opponent1: { id: 16828, @@ -5334,7 +5352,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37922, - groupId: 4363, + groupId: 4362, number: 2, opponent1: { id: 16872, @@ -5353,7 +5371,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37923, - groupId: 4363, + groupId: 4362, number: 3, opponent1: { id: 16806, @@ -5372,7 +5390,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37924, - groupId: 4363, + groupId: 4362, number: 4, opponent1: { id: 17434, @@ -5391,7 +5409,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37925, - groupId: 4363, + groupId: 4362, number: 1, opponent1: { id: 17423, @@ -5409,7 +5427,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37926, - groupId: 4363, + groupId: 4362, number: 2, opponent1: { id: 17188, @@ -5427,7 +5445,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37927, - groupId: 4363, + groupId: 4362, number: 3, opponent1: { id: 16830, @@ -5445,7 +5463,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37928, - groupId: 4363, + groupId: 4362, number: 4, opponent1: { id: 16825, @@ -5463,7 +5481,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37929, - groupId: 4363, + groupId: 4362, number: 1, opponent1: { id: 16828, @@ -5480,7 +5498,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37930, - groupId: 4363, + groupId: 4362, number: 2, opponent1: { id: 16830, @@ -5497,7 +5515,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37931, - groupId: 4363, + groupId: 4362, number: 1, opponent1: { id: 16803, @@ -5515,7 +5533,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37932, - groupId: 4363, + groupId: 4362, number: 2, opponent1: { id: 16887, @@ -5533,7 +5551,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37933, - groupId: 4363, + groupId: 4362, number: 1, opponent1: { id: 16803, @@ -5550,7 +5568,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37934, - groupId: 4363, + groupId: 4362, number: 1, opponent1: { id: 16829, @@ -5568,7 +5586,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37935, - groupId: 4364, + groupId: 4362, number: 1, opponent1: { id: 16910, @@ -5586,7 +5604,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37936, - groupId: 4364, + groupId: 4362, number: 1, opponent1: { id: 16910, @@ -5874,7 +5892,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37952, - groupId: 4366, + groupId: 4365, number: 1, opponent1: { id: 16821, @@ -5893,7 +5911,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37953, - groupId: 4366, + groupId: 4365, number: 2, opponent1: { id: 17609, @@ -5912,7 +5930,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37954, - groupId: 4366, + groupId: 4365, number: 3, opponent1: { id: 16805, @@ -5931,7 +5949,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37955, - groupId: 4366, + groupId: 4365, number: 4, opponent1: { id: 17138, @@ -5950,7 +5968,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37956, - groupId: 4366, + groupId: 4365, number: 1, opponent1: { id: 17769, @@ -5968,7 +5986,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37957, - groupId: 4366, + groupId: 4365, number: 2, opponent1: { id: 17368, @@ -5986,7 +6004,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37958, - groupId: 4366, + groupId: 4365, number: 3, opponent1: { id: 16802, @@ -6004,7 +6022,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37959, - groupId: 4366, + groupId: 4365, number: 4, opponent1: { id: 17698, @@ -6022,7 +6040,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37960, - groupId: 4366, + groupId: 4365, number: 1, opponent1: { id: 17769, @@ -6039,7 +6057,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37961, - groupId: 4366, + groupId: 4365, number: 2, opponent1: { id: 16805, @@ -6056,7 +6074,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37962, - groupId: 4366, + groupId: 4365, number: 1, opponent1: { id: 17354, @@ -6074,7 +6092,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37963, - groupId: 4366, + groupId: 4365, number: 2, opponent1: { id: 17529, @@ -6092,7 +6110,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37964, - groupId: 4366, + groupId: 4365, number: 1, opponent1: { id: 17354, @@ -6109,7 +6127,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37965, - groupId: 4366, + groupId: 4365, number: 1, opponent1: { id: 17634, @@ -6127,7 +6145,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37966, - groupId: 4367, + groupId: 4365, number: 1, opponent1: { id: 17765, @@ -6145,7 +6163,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, { id: 37967, - groupId: 4367, + groupId: 4365, number: 1, opponent1: { id: 17765, diff --git a/app/features/tournament-bracket/core/tests/mocks-sos.ts b/app/features/tournament-bracket/core/tests/mocks-sos.ts index fffb169ee..963c8da12 100644 --- a/app/features/tournament-bracket/core/tests/mocks-sos.ts +++ b/app/features/tournament-bracket/core/tests/mocks-sos.ts @@ -85,6 +85,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10931, @@ -95,6 +96,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10932, @@ -105,6 +107,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10933, @@ -115,6 +118,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10934, @@ -125,6 +129,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10935, @@ -135,6 +140,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10936, @@ -145,6 +151,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10937, @@ -155,6 +162,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10938, @@ -165,6 +173,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10939, @@ -175,6 +184,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10940, @@ -185,6 +195,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10941, @@ -195,6 +206,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10942, @@ -205,6 +217,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10943, @@ -215,6 +228,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10944, @@ -225,6 +239,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10945, @@ -235,6 +250,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10946, @@ -245,6 +261,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10947, @@ -255,6 +272,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10948, @@ -265,6 +283,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10949, @@ -275,6 +294,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10950, @@ -285,6 +305,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10951, @@ -295,6 +316,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10952, @@ -305,6 +327,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10953, @@ -315,6 +338,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10954, @@ -325,6 +349,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10955, @@ -335,6 +360,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10956, @@ -345,6 +371,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10957, @@ -355,6 +382,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10958, @@ -365,6 +393,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10959, @@ -375,6 +404,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10960, @@ -385,6 +415,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10961, @@ -395,6 +426,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, { id: 10962, @@ -405,6 +437,7 @@ export const SWIM_OR_SINK_167 = ( count: 3, type: "BEST_OF", }, + section: null, }, ], match: [ diff --git a/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts b/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts index e3eef1edf..99dc27159 100644 --- a/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts +++ b/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts @@ -35,6 +35,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: null, }, { id: 13716, @@ -46,6 +47,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: null, }, { id: 13717, @@ -57,6 +59,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: null, }, { id: 13718, @@ -68,6 +71,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ type: "BEST_OF", pickBan: "COUNTERPICK", }, + section: null, }, ], match: [ diff --git a/app/features/tournament-bracket/core/tests/mocks.ts b/app/features/tournament-bracket/core/tests/mocks.ts index 82acd989f..da1071290 100644 --- a/app/features/tournament-bracket/core/tests/mocks.ts +++ b/app/features/tournament-bracket/core/tests/mocks.ts @@ -68,162 +68,189 @@ export const PADDLING_POOL_257 = () => groupId: 85, number: 1, stageId: 28, + section: null, }, { id: 387, groupId: 85, number: 2, stageId: 28, + section: null, }, { id: 388, groupId: 85, number: 3, stageId: 28, + section: null, }, { id: 389, groupId: 86, number: 1, stageId: 28, + section: null, }, { id: 390, groupId: 86, number: 2, stageId: 28, + section: null, }, { id: 391, groupId: 86, number: 3, stageId: 28, + section: null, }, { id: 392, groupId: 87, number: 1, stageId: 28, + section: null, }, { id: 393, groupId: 87, number: 2, stageId: 28, + section: null, }, { id: 394, groupId: 87, number: 3, stageId: 28, + section: null, }, { id: 395, groupId: 88, number: 1, stageId: 28, + section: null, }, { id: 396, groupId: 88, number: 2, stageId: 28, + section: null, }, { id: 397, groupId: 88, number: 3, stageId: 28, + section: null, }, { id: 398, groupId: 89, number: 1, stageId: 28, + section: null, }, { id: 399, groupId: 89, number: 2, stageId: 28, + section: null, }, { id: 400, groupId: 89, number: 3, stageId: 28, + section: null, }, { id: 401, groupId: 90, number: 1, stageId: 28, + section: null, }, { id: 402, groupId: 90, number: 2, stageId: 28, + section: null, }, { id: 403, groupId: 90, number: 3, stageId: 28, + section: null, }, { id: 404, groupId: 91, number: 1, stageId: 28, + section: null, }, { id: 405, groupId: 91, number: 2, stageId: 28, + section: null, }, { id: 406, groupId: 91, number: 3, stageId: 28, + section: null, }, { id: 407, groupId: 92, number: 1, stageId: 28, + section: null, }, { id: 408, groupId: 92, number: 2, stageId: 28, + section: null, }, { id: 409, groupId: 92, number: 3, stageId: 28, + section: null, }, { id: 410, groupId: 93, number: 1, stageId: 28, + section: null, }, { id: 411, groupId: 93, number: 2, stageId: 28, + section: null, }, { id: 412, groupId: 93, number: 3, stageId: 28, + section: null, }, ], match: [ @@ -2591,6 +2618,7 @@ export const PADDLING_POOL_255 = () => number: 1, stageId: 20, maps: null, + section: null, }, { id: 286, @@ -2598,6 +2626,7 @@ export const PADDLING_POOL_255 = () => number: 2, stageId: 20, maps: null, + section: null, }, { id: 287, @@ -2605,6 +2634,7 @@ export const PADDLING_POOL_255 = () => number: 3, stageId: 20, maps: null, + section: null, }, { id: 288, @@ -2612,6 +2642,7 @@ export const PADDLING_POOL_255 = () => number: 1, stageId: 20, maps: null, + section: null, }, { id: 289, @@ -2619,6 +2650,7 @@ export const PADDLING_POOL_255 = () => number: 2, stageId: 20, maps: null, + section: null, }, { id: 290, @@ -2626,6 +2658,7 @@ export const PADDLING_POOL_255 = () => number: 3, stageId: 20, maps: null, + section: null, }, { id: 291, @@ -2633,6 +2666,7 @@ export const PADDLING_POOL_255 = () => number: 1, stageId: 20, maps: null, + section: null, }, { id: 292, @@ -2640,6 +2674,7 @@ export const PADDLING_POOL_255 = () => number: 2, stageId: 20, maps: null, + section: null, }, { id: 293, @@ -2647,6 +2682,7 @@ export const PADDLING_POOL_255 = () => number: 3, stageId: 20, maps: null, + section: null, }, { id: 294, @@ -2654,6 +2690,7 @@ export const PADDLING_POOL_255 = () => number: 1, stageId: 20, maps: null, + section: null, }, { id: 295, @@ -2661,6 +2698,7 @@ export const PADDLING_POOL_255 = () => number: 2, stageId: 20, maps: null, + section: null, }, { id: 296, @@ -2668,6 +2706,7 @@ export const PADDLING_POOL_255 = () => number: 3, stageId: 20, maps: null, + section: null, }, { id: 297, @@ -2675,6 +2714,7 @@ export const PADDLING_POOL_255 = () => number: 1, stageId: 20, maps: null, + section: null, }, { id: 298, @@ -2682,6 +2722,7 @@ export const PADDLING_POOL_255 = () => number: 2, stageId: 20, maps: null, + section: null, }, { id: 299, @@ -2689,6 +2730,7 @@ export const PADDLING_POOL_255 = () => number: 3, stageId: 20, maps: null, + section: null, }, { id: 300, @@ -2696,6 +2738,7 @@ export const PADDLING_POOL_255 = () => number: 1, stageId: 20, maps: null, + section: null, }, { id: 301, @@ -2703,6 +2746,7 @@ export const PADDLING_POOL_255 = () => number: 2, stageId: 20, maps: null, + section: null, }, { id: 302, @@ -2710,6 +2754,7 @@ export const PADDLING_POOL_255 = () => number: 3, stageId: 20, maps: null, + section: null, }, { id: 303, @@ -2717,6 +2762,7 @@ export const PADDLING_POOL_255 = () => number: 1, stageId: 20, maps: null, + section: null, }, { id: 304, @@ -2724,6 +2770,7 @@ export const PADDLING_POOL_255 = () => number: 2, stageId: 20, maps: null, + section: null, }, { id: 305, @@ -2731,6 +2778,7 @@ export const PADDLING_POOL_255 = () => number: 3, stageId: 20, maps: null, + section: null, }, { id: 306, @@ -2738,6 +2786,7 @@ export const PADDLING_POOL_255 = () => number: 1, stageId: 20, maps: null, + section: null, }, { id: 307, @@ -2745,6 +2794,7 @@ export const PADDLING_POOL_255 = () => number: 2, stageId: 20, maps: null, + section: null, }, { id: 308, @@ -2752,6 +2802,7 @@ export const PADDLING_POOL_255 = () => number: 3, stageId: 20, maps: null, + section: null, }, { id: 309, @@ -2759,6 +2810,7 @@ export const PADDLING_POOL_255 = () => number: 1, stageId: 20, maps: null, + section: null, }, { id: 310, @@ -2766,6 +2818,7 @@ export const PADDLING_POOL_255 = () => number: 2, stageId: 20, maps: null, + section: null, }, { id: 311, @@ -2773,6 +2826,7 @@ export const PADDLING_POOL_255 = () => number: 3, stageId: 20, maps: null, + section: null, }, ], match: [ @@ -5106,16 +5160,6 @@ export const IN_THE_ZONE_32 = ({ number: 1, stageId: 16, }, - { - id: 47, - number: 2, - stageId: 16, - }, - { - id: 48, - number: 3, - stageId: 16, - }, ], round: [ { @@ -5123,90 +5167,105 @@ export const IN_THE_ZONE_32 = ({ groupId: 46, number: 1, stageId: 16, + section: "winners", }, { id: 239, groupId: 46, number: 2, stageId: 16, + section: "winners", }, { id: 240, groupId: 46, number: 3, stageId: 16, + section: "winners", }, { id: 241, groupId: 46, number: 4, stageId: 16, + section: "winners", }, { id: 242, groupId: 46, number: 5, stageId: 16, + section: "winners", }, { id: 243, - groupId: 47, + groupId: 46, number: 1, stageId: 16, + section: "losers", }, { id: 244, - groupId: 47, + groupId: 46, number: 2, stageId: 16, + section: "losers", }, { id: 245, - groupId: 47, + groupId: 46, number: 3, stageId: 16, + section: "losers", }, { id: 246, - groupId: 47, + groupId: 46, number: 4, stageId: 16, + section: "losers", }, { id: 247, - groupId: 47, + groupId: 46, number: 5, stageId: 16, + section: "losers", }, { id: 248, - groupId: 47, + groupId: 46, number: 6, stageId: 16, + section: "losers", }, { id: 249, - groupId: 47, + groupId: 46, number: 7, stageId: 16, + section: "losers", }, { id: 250, - groupId: 47, + groupId: 46, number: 8, stageId: 16, + section: "losers", }, { id: 251, - groupId: 48, + groupId: 46, number: 1, stageId: 16, + section: "finals", }, { id: 252, - groupId: 48, + groupId: 46, number: 2, stageId: 16, + section: "finals", }, ], match: [ @@ -5740,7 +5799,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1265, - groupId: 47, + groupId: 46, number: 1, opponent1: { id: 605, @@ -5758,7 +5817,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1266, - groupId: 47, + groupId: 46, number: 2, opponent1: { id: 551, @@ -5776,7 +5835,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1267, - groupId: 47, + groupId: 46, number: 3, opponent1: { id: 611, @@ -5794,7 +5853,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1268, - groupId: 47, + groupId: 46, number: 4, opponent1: { id: 612, @@ -5812,7 +5871,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1269, - groupId: 47, + groupId: 46, number: 5, opponent1: { id: 608, @@ -5830,7 +5889,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1270, - groupId: 47, + groupId: 46, number: 6, opponent1: { id: 597, @@ -5848,7 +5907,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1271, - groupId: 47, + groupId: 46, number: 7, opponent1: { id: 606, @@ -5866,7 +5925,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1272, - groupId: 47, + groupId: 46, number: 8, opponent1: { id: 589, @@ -5884,7 +5943,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1273, - groupId: 47, + groupId: 46, number: 1, opponent1: { id: 590, @@ -5901,7 +5960,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1274, - groupId: 47, + groupId: 46, number: 2, opponent1: { id: 603, @@ -5918,7 +5977,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1275, - groupId: 47, + groupId: 46, number: 3, opponent1: { id: 591, @@ -5935,7 +5994,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1276, - groupId: 47, + groupId: 46, number: 4, opponent1: { id: 594, @@ -5952,7 +6011,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1277, - groupId: 47, + groupId: 46, number: 5, opponent1: { id: 595, @@ -5969,7 +6028,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1278, - groupId: 47, + groupId: 46, number: 6, opponent1: { id: 596, @@ -5986,7 +6045,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1279, - groupId: 47, + groupId: 46, number: 7, opponent1: { id: 609, @@ -6003,7 +6062,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1280, - groupId: 47, + groupId: 46, number: 8, opponent1: { id: 509, @@ -6020,7 +6079,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1281, - groupId: 47, + groupId: 46, number: 1, opponent1: { id: 590, @@ -6036,7 +6095,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1282, - groupId: 47, + groupId: 46, number: 2, opponent1: { id: 591, @@ -6052,7 +6111,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1283, - groupId: 47, + groupId: 46, number: 3, opponent1: { id: 595, @@ -6068,7 +6127,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1284, - groupId: 47, + groupId: 46, number: 4, opponent1: { id: 609, @@ -6084,7 +6143,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1285, - groupId: 47, + groupId: 46, number: 1, opponent1: { id: 564, @@ -6101,7 +6160,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1286, - groupId: 47, + groupId: 46, number: 2, opponent1: { id: 546, @@ -6118,7 +6177,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1287, - groupId: 47, + groupId: 46, number: 3, opponent1: { id: 592, @@ -6135,7 +6194,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1288, - groupId: 47, + groupId: 46, number: 4, opponent1: { id: 507, @@ -6152,7 +6211,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1289, - groupId: 47, + groupId: 46, number: 1, opponent1: { id: 564, @@ -6168,7 +6227,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1290, - groupId: 47, + groupId: 46, number: 2, opponent1: { id: 592, @@ -6184,7 +6243,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1291, - groupId: 47, + groupId: 46, number: 1, opponent1: { id: 547, @@ -6201,7 +6260,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1292, - groupId: 47, + groupId: 46, number: 2, opponent1: { id: 499, @@ -6218,7 +6277,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1293, - groupId: 47, + groupId: 46, number: 1, opponent1: { id: 547, @@ -6234,7 +6293,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1294, - groupId: 47, + groupId: 46, number: 1, opponent1: { id: 552, @@ -6251,7 +6310,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1295, - groupId: 48, + groupId: 46, number: 1, opponent1: { id: 548, @@ -6268,7 +6327,7 @@ export const IN_THE_ZONE_32 = ({ }, { id: 1296, - groupId: 48, + groupId: 46, number: 1, opponent1: { id: 548, diff --git a/app/features/tournament-bracket/core/toMapList.test.ts b/app/features/tournament-bracket/core/toMapList.test.ts index 021ecb6a6..eca15db29 100644 --- a/app/features/tournament-bracket/core/toMapList.test.ts +++ b/app/features/tournament-bracket/core/toMapList.test.ts @@ -8,6 +8,7 @@ import { type BracketMapCounts, type GenerateTournamentRoundMaplistArgs, generateTournamentRoundMaplist, + roundSetKey, } from "./toMapList"; const MAPS_PER_ROUND = 3; @@ -18,12 +19,22 @@ const rankedPool = SENDOUQ_MAP_POOL.stageModePairs.filter( /** Rounds of the given groups, ids running from 1 in group order. */ const roundsOf = ( - groups: Array<{ groupId: number; roundCount: number }>, + groups: Array<{ + groupId: number; + roundCount: number; + section?: RoundData["section"]; + }>, ): RoundData[] => { const rounds: RoundData[] = []; - for (const { groupId, roundCount } of groups) { + for (const { groupId, roundCount, section = null } of groups) { for (let number = 1; number <= roundCount; number++) { - rounds.push({ id: rounds.length + 1, stageId: 1, groupId, number }); + rounds.push({ + id: rounds.length + 1, + stageId: 1, + groupId, + section, + number, + }); } } @@ -33,15 +44,17 @@ const roundsOf = ( const mapCountsOf = (rounds: RoundData[]): BracketMapCounts => { const counts: BracketMapCounts = new Map(); for (const round of rounds) { - const group = counts.get(round.groupId) ?? new Map(); - group.set(round.number, { count: MAPS_PER_ROUND, type: "BEST_OF" }); - counts.set(round.groupId, group); + const roundSet = counts.get(roundSetKey(round)) ?? new Map(); + roundSet.set(round.number, { count: MAPS_PER_ROUND, type: "BEST_OF" }); + counts.set(roundSetKey(round), roundSet); } return counts; }; -const singleGroupRounds = roundsOf([{ groupId: 0, roundCount: 3 }]); +const singleGroupRounds = roundsOf([ + { groupId: 0, roundCount: 3, section: "winners" }, +]); const generate = ( overrides: Partial & { diff --git a/app/features/tournament-bracket/core/toMapList.ts b/app/features/tournament-bracket/core/toMapList.ts index b84ac9493..bdc88ad26 100644 --- a/app/features/tournament-bracket/core/toMapList.ts +++ b/app/features/tournament-bracket/core/toMapList.ts @@ -10,11 +10,27 @@ import { logger } from "~/utils/logger"; import { assertUnreachable } from "~/utils/types"; export type BracketMapCounts = Map< - // round.groupId -> - number, + // roundSetKey(round) -> + string, // round.number -> Map>; +/** Identifies the rounds numbered together: a round robin/swiss group's rounds, or one section of an elimination group. */ +export function roundSetKey(round: Pick) { + return round.section + ? `${round.groupId}:${round.section}` + : `${round.groupId}`; +} + +const ELIMINATION_SECTION_PLAY_ORDER: Record< + NonNullable, + number +> = { + winners: 0, + finals: 1, + losers: 2, +}; + export interface GenerateTournamentRoundMaplistArgs { /** The tournament's effective map pool, see `Tournament.mapPool`. */ pool: Array<{ mode: ModeShort; stageId: StageId }>; @@ -136,28 +152,15 @@ function sortRounds( rounds: RoundData[], type: Tables["TournamentStage"]["type"], ) { - const groupIds = rounds.map((x) => x.groupId); - const minGroupId = Math.min(...groupIds); - const maxGroupId = Math.max(...groupIds); - - // winners bracket first, then grands, then losers bracket - const doubleEliminationGroupRank = (groupId: number) => { - if (groupId === minGroupId) return 0; - if (groupId === maxGroupId) return 1; - return 2; - }; + // winners bracket first, then grands (or the 3rd place match), then losers bracket + const sectionRank = (round: RoundData) => + round.section ? ELIMINATION_SECTION_PLAY_ORDER[round.section] : 0; return rounds.toSorted((a, b) => { - if (type === "double_elimination") { - const rankDiff = - doubleEliminationGroupRank(a.groupId) - - doubleEliminationGroupRank(b.groupId); + if (type === "double_elimination" || type === "single_elimination") { + const rankDiff = sectionRank(a) - sectionRank(b); if (rankDiff !== 0) return rankDiff; } - // finals and 3rd place match last - if (type === "single_elimination" && a.groupId !== b.groupId) { - return a.groupId - b.groupId; - } return a.number - b.number; }); @@ -169,15 +172,15 @@ function resolveRoundMapCount( type: Tables["TournamentStage"]["type"], ) { // rr/swiss groups share the map list, the one with the most rounds covers every round number - const groupId = + const key = type === "round_robin" || type === "swiss" - ? fullestGroupIdByCounts(counts) - : round.groupId; + ? fullestRoundSetKeyByCounts(counts) + : roundSetKey(round); - const count = counts.get(groupId)?.get(round.number)?.count; + const count = counts.get(key)?.get(round.number)?.count; if (typeof count === "undefined") { logger.warn( - `No map count found for round ${round.number} (group ${round.groupId})`, + `No map count found for round ${round.number} (group ${round.groupId}, section ${round.section})`, ); return 5; } @@ -185,12 +188,11 @@ function resolveRoundMapCount( return count; } -function fullestGroupIdByCounts(counts: BracketMapCounts) { - let fullestGroupId = counts.keys().next().value as number; - for (const [groupId, roundCounts] of counts) { - if (roundCounts.size > counts.get(fullestGroupId)!.size) - fullestGroupId = groupId; +function fullestRoundSetKeyByCounts(counts: BracketMapCounts) { + let fullestKey = counts.keys().next().value as string; + for (const [key, roundCounts] of counts) { + if (roundCounts.size > counts.get(fullestKey)!.size) fullestKey = key; } - return fullestGroupId; + return fullestKey; } diff --git a/app/features/tournament-bracket/tournament-bracket-schemas.ts b/app/features/tournament-bracket/tournament-bracket-schemas.ts index a8c99122f..936ee678d 100644 --- a/app/features/tournament-bracket/tournament-bracket-schemas.ts +++ b/app/features/tournament-bracket/tournament-bracket-schemas.ts @@ -19,7 +19,10 @@ import { safeJSONParse, stageId, } from "~/utils/schema"; -import { TOURNAMENT } from "../tournament/tournament-constants"; +import { + TOURNAMENT, + TOURNAMENT_ROUND_SECTIONS, +} from "../tournament/tournament-constants"; import * as PickBan from "./core/PickBan"; import * as PreparedMaps from "./core/PreparedMaps"; @@ -117,7 +120,7 @@ const customPickBanFlow = v.optional( const tournamentRoundMaps = v.object({ roundId: v.pipe(v.number(), v.integer(), v.minValue(0)), - groupId: v.pipe(v.number(), v.integer(), v.minValue(0)), + section: v.nullable(v.picklist(TOURNAMENT_ROUND_SECTIONS)), list: v.optional( v.nullable( v.array( diff --git a/app/features/tournament-match/TournamentMatchRepository.server.ts b/app/features/tournament-match/TournamentMatchRepository.server.ts index 2a9a8b46b..e6a6a11d4 100644 --- a/app/features/tournament-match/TournamentMatchRepository.server.ts +++ b/app/features/tournament-match/TournamentMatchRepository.server.ts @@ -515,7 +515,7 @@ export function findByTournamentTeamId(tournamentTeamId: number) { "otherTeam.id as otherTeamId", "TournamentRound.number as roundNumber", "TournamentRound.stageId", - "TournamentGroup.number as groupNumber", + "TournamentRound.section", jsonArrayFrom( eb .selectFrom("TournamentMatchGameResult") @@ -586,6 +586,10 @@ export function findByTournamentTeamId(tournamentTeamId: number) { ) .orderBy("TournamentRound.stageId", "asc") .orderBy("TournamentGroup.number", "asc") + .orderBy( + sql`case "TournamentRound"."section" when 'winners' then 1 when 'losers' then 2 when 'finals' then 3 else 0 end`, + "asc", + ) .orderBy("TournamentRound.number", "asc") .execute(); } diff --git a/app/features/tournament/core/Standings.test.ts b/app/features/tournament/core/Standings.test.ts index 9a524ac3e..34d311c1a 100644 --- a/app/features/tournament/core/Standings.test.ts +++ b/app/features/tournament/core/Standings.test.ts @@ -615,12 +615,14 @@ function singleEliminationWithPendingThirdPlaceMatch() { seeding: [1, 2, 3, 4, 5, 6, 7, 8], settings: { consolationFinal: true }, }); - const thirdPlaceGroupId = Math.max(...data.group.map((group) => group.id)); + const thirdPlaceRoundId = data.round.find( + (round) => round.section === "finals", + )!.id; while (true) { const pending = data.match.find( (match) => - match.groupId !== thirdPlaceGroupId && + match.roundId !== thirdPlaceRoundId && typeof match.opponent1?.id === "number" && typeof match.opponent2?.id === "number" && !match.winnerSide, diff --git a/app/features/tournament/core/sets.server.test.ts b/app/features/tournament/core/sets.server.test.ts index 58e2004a3..2cc186a07 100644 --- a/app/features/tournament/core/sets.server.test.ts +++ b/app/features/tournament/core/sets.server.test.ts @@ -8,7 +8,7 @@ const ALL_ROUNDS: AllRoundsItem[] = [ stageName: "Main bracket", stageType: "single_elimination", roundNumber: 1, - groupNumber: 1, + section: "winners", }, ]; @@ -26,7 +26,7 @@ function playedSetRow( otherTeamName: "Opponent", roundNumber: 1, stageId: 1, - groupNumber: 1, + section: "winners", matches: [ { mode: "SZ", stageId: 1, source: "BOTH", wasWinner: 1 }, { mode: "TC", stageId: 2, source: "BOTH", wasWinner: 0 }, diff --git a/app/features/tournament/core/sets.server.ts b/app/features/tournament/core/sets.server.ts index a09e4e12d..88b278cfe 100644 --- a/app/features/tournament/core/sets.server.ts +++ b/app/features/tournament/core/sets.server.ts @@ -10,7 +10,7 @@ export interface AllRoundsItem { stageName: string; stageType: Tables["TournamentStage"]["type"]; roundNumber: number; - groupNumber: number; + section: Tables["TournamentRound"]["section"]; } export interface PlayedSet { @@ -63,23 +63,26 @@ export function tournamentTeamSets({ return set.roundNumber; } - if (set.groupNumber === 3) { + if ( + round.stageType === "double_elimination" && + set.section === "finals" + ) { if (set.roundNumber === 2) return "bracket_reset"; return "grand_finals"; } - const maxRoundNumberOfGroup = Math.max( + const maxRoundNumberOfSection = Math.max( ...allRounds .filter( (candidate) => - candidate.groupNumber === set.groupNumber && + candidate.section === set.section && candidate.stageId === set.stageId, ) .map((candidate) => candidate.roundNumber), ); - if (set.roundNumber === maxRoundNumberOfGroup) { + if (set.roundNumber === maxRoundNumberOfSection) { return "finals"; } @@ -92,7 +95,7 @@ export function tournamentTeamSets({ round: { round: resolveRound(), type: resolveRoundType({ - groupNumber: set.groupNumber, + section: set.section, stageType: round.stageType, }), }, @@ -122,10 +125,10 @@ function scoreFromTeamPerspective( } function resolveRoundType({ - groupNumber, + section, stageType, }: { - groupNumber: number; + section: Tables["TournamentRound"]["section"]; stageType: Tables["TournamentStage"]["type"]; }) { if (stageType === "single_elimination") { @@ -140,16 +143,16 @@ function resolveRoundType({ return "swiss"; } - if (groupNumber === 1 || groupNumber === 3) { + if (section === "winners" || section === "finals") { return "winners"; } - if (groupNumber === 2) { + if (section === "losers") { return "losers"; } logger.warn( - `resolveRoundType: groupNumber ${groupNumber} and stageType ${stageType} not handled`, + `resolveRoundType: section ${section} and stageType ${stageType} not handled`, ); return "single_elim"; } diff --git a/app/features/tournament/loaders/to.$id.teams.$tid.server.ts b/app/features/tournament/loaders/to.$id.teams.$tid.server.ts index 37bf85a6f..f6786a351 100644 --- a/app/features/tournament/loaders/to.$id.teams.$tid.server.ts +++ b/app/features/tournament/loaders/to.$id.teams.$tid.server.ts @@ -41,8 +41,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { await TournamentMatchRepository.findByTournamentTeamId(tournamentTeamId); const allRounds: AllRoundsItem[] = data.round.map((round) => { const stage = data.stage.find((s) => s.id === round.stageId); - const group = data.group.find((g) => g.id === round.groupId); - invariant(stage && group, "Stage or group not found for round"); + invariant(stage, "Stage not found for round"); invariant(stage.name, "Stage from the database is missing a name"); return { @@ -50,7 +49,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { stageName: stage.name, stageType: stage.type, roundNumber: round.number, - groupNumber: group.number, + section: round.section, }; }); diff --git a/app/features/tournament/tournament-constants.ts b/app/features/tournament/tournament-constants.ts index 6aa14b40e..bea72bab6 100644 --- a/app/features/tournament/tournament-constants.ts +++ b/app/features/tournament/tournament-constants.ts @@ -38,6 +38,15 @@ export const TOURNAMENT_STAGE_TYPES = [ "swiss", ] as const; +/** Parts of an elimination group that number their rounds separately: the (winners) bracket, the losers bracket and the finals (grand finals or a consolation final). */ +export const TOURNAMENT_ROUND_SECTIONS = [ + "winners", + "losers", + "finals", +] as const; + +export type TournamentRoundSection = (typeof TOURNAMENT_ROUND_SECTIONS)[number]; + /** AUTO = teams pick map pools ahead and each round's map list is made automatically, TO = the TO picks the maps. */ export type TournamentMapPickingStyle = "TO" | "AUTO"; diff --git a/migrations/20260920110044-tournament-round-section.ts b/migrations/20260920110044-tournament-round-section.ts new file mode 100644 index 000000000..69b8f194d --- /dev/null +++ b/migrations/20260920110044-tournament-round-section.ts @@ -0,0 +1,179 @@ +import { type Kysely, sql } from "kysely"; + +/** + * A tournament group is now always a set of participants playing among themselves. The winners bracket, + * losers bracket and finals of an elimination stage were separate groups; they collapse into one group + * and the new `TournamentRound.section` tells them apart. Round numbers restart per section, so the + * (number, groupId) uniqueness of rounds is rebuilt to include the section. + */ +export async function up(db: Kysely): Promise { + // a no-op inside a transaction, and needed off so dropping the old rounds table doesn't cascade + await sql`pragma foreign_keys = off`.execute(db); + + try { + await db.transaction().execute(async (trx) => { + await sql` + create table "TournamentRound_new" ( + "id" integer primary key, + "stageId" integer not null, + "groupId" integer not null, + "number" integer not null, + "maps" text, + "defaultPlayTime" integer, + "section" text check ("section" in ('winners', 'losers', 'finals')), + foreign key ("stageId") references "TournamentStage" ("id") on delete cascade, + foreign key ("groupId") references "TournamentGroup" ("id") on delete cascade + ) strict + `.execute(trx); + + await sql` + insert into "TournamentRound_new" ("id", "stageId", "groupId", "number", "maps", "defaultPlayTime", "section") + select + "TournamentRound"."id", + "TournamentRound"."stageId", + coalesce("MainGroup"."id", "TournamentRound"."groupId"), + "TournamentRound"."number", + "TournamentRound"."maps", + "TournamentRound"."defaultPlayTime", + case + when "TournamentStage"."type" = 'single_elimination' and "TournamentGroup"."number" = 1 then 'winners' + when "TournamentStage"."type" = 'single_elimination' and "TournamentGroup"."number" = 2 then 'finals' + when "TournamentStage"."type" = 'double_elimination' and "TournamentGroup"."number" = 1 then 'winners' + when "TournamentStage"."type" = 'double_elimination' and "TournamentGroup"."number" = 2 then 'losers' + when "TournamentStage"."type" = 'double_elimination' and "TournamentGroup"."number" = 3 then 'finals' + else null + end + from "TournamentRound" + inner join "TournamentGroup" on "TournamentGroup"."id" = "TournamentRound"."groupId" + inner join "TournamentStage" on "TournamentStage"."id" = "TournamentRound"."stageId" + left join "TournamentGroup" as "MainGroup" + on "MainGroup"."stageId" = "TournamentStage"."id" + and "MainGroup"."number" = 1 + and "TournamentStage"."type" in ('single_elimination', 'double_elimination') + order by "TournamentRound"."id" asc + `.execute(trx); + + const unsectioned = await sql<{ count: number }>` + select count(*) as "count" + from "TournamentRound_new" + inner join "TournamentStage" on "TournamentStage"."id" = "TournamentRound_new"."stageId" + where "TournamentStage"."type" in ('single_elimination', 'double_elimination') + and "TournamentRound_new"."section" is null + `.execute(trx); + if (unsectioned.rows[0].count > 0) { + throw new Error( + `${unsectioned.rows[0].count} elimination rounds could not be assigned a section`, + ); + } + + await sql`drop table "TournamentRound"`.execute(trx); + await sql`alter table "TournamentRound_new" rename to "TournamentRound"`.execute( + trx, + ); + + await trx.schema + .createIndex("tournament_round_stage_id") + .on("TournamentRound") + .column("stageId") + .execute(); + await trx.schema + .createIndex("tournament_round_group_id") + .on("TournamentRound") + .column("groupId") + .execute(); + // nulls never collide in a unique index, so the unsectioned rounds get an index of their own + await sql` + create unique index "tournament_round_group_id_number_section" + on "TournamentRound" ("groupId", "number", "section") + where "section" is not null + `.execute(trx); + await sql` + create unique index "tournament_round_group_id_number" + on "TournamentRound" ("groupId", "number") + where "section" is null + `.execute(trx); + + await sql` + update "TournamentMatch" + set "groupId" = ( + select "MainGroup"."id" + from "TournamentGroup" as "MainGroup" + where "MainGroup"."stageId" = "TournamentMatch"."stageId" + and "MainGroup"."number" = 1 + ) + where "stageId" in ( + select "id" from "TournamentStage" + where "type" in ('single_elimination', 'double_elimination') + ) + `.execute(trx); + + await sql` + delete from "TournamentGroup" + where "number" > 1 + and "stageId" in ( + select "id" from "TournamentStage" + where "type" in ('single_elimination', 'double_elimination') + ) + `.execute(trx); + + await rewritePreparedMaps(trx); + + const violations = await sql`pragma foreign_key_check`.execute(trx); + if (violations.rows.length > 0) { + throw new Error( + `foreign key check failed: ${JSON.stringify(violations.rows.slice(0, 5))}`, + ); + } + }); + } finally { + await sql`pragma foreign_keys = on`.execute(db); + } +} + +const SECTION_BY_GROUP_ID: Record> = { + single_elimination: { 0: "winners", 1: "finals" }, + double_elimination: { 0: "winners", 1: "losers", 2: "finals" }, +}; + +function parseJson(value: unknown) { + return typeof value === "string" ? JSON.parse(value) : value; +} + +/** Prepared map entries pointed at the local group id of the bracket preview, now they carry the section instead. */ +async function rewritePreparedMaps(trx: Kysely) { + const tournaments = await trx + .selectFrom("Tournament") + .select(["id", "settings", "preparedMaps"]) + .where("preparedMaps", "is not", null) + .execute(); + + for (const tournament of tournaments) { + const bracketProgression: Array<{ type: string }> = parseJson( + tournament.settings, + ).bracketProgression; + const preparedByBracket: Array<{ + maps: Array>; + } | null> = parseJson(tournament.preparedMaps); + + const rewritten = preparedByBracket.map((prepared, bracketIdx) => { + if (!prepared) return prepared; + + const sections = + SECTION_BY_GROUP_ID[bracketProgression[bracketIdx]?.type]; + + return { + ...prepared, + maps: prepared.maps.map(({ groupId, ...maps }) => ({ + ...maps, + section: sections?.[groupId as number] ?? null, + })), + }; + }); + + await trx + .updateTable("Tournament") + .set({ preparedMaps: JSON.stringify(rewritten) }) + .where("id", "=", tournament.id) + .execute(); + } +} From aa1d3f1b42d7ebebbb13f9d96a6a4daaf84bf2d8 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:42:02 +0300 Subject: [PATCH 25/50] Pin friends --- app/db/tables.ts | 4 + .../availability/availability-schemas.ts | 12 +- app/features/calendar/routes/events.tsx | 1 + .../friends/FriendRepository.server.test.ts | 66 ++++++++ .../friends/FriendRepository.server.ts | 50 +++++- .../friends/actions/friends.server.ts | 16 ++ .../friends/friends-constants.test.ts | 36 +++++ app/features/friends/friends-constants.ts | 16 ++ .../friends/friends-schemas.server.ts | 4 + app/features/friends/friends-schemas.ts | 14 +- .../friends/loaders/friends.server.ts | 8 +- .../friends/routes/friends.module.css | 13 +- app/features/friends/routes/friends.tsx | 85 ++++++++-- app/features/settings/settings-schemas.ts | 4 +- .../sidebar/core/sidebar.server.test.ts | 129 ++++++++++++++++ app/features/sidebar/core/sidebar.server.ts | 145 ++++++++---------- app/utils/schema.ts | 5 + changelog/2026-09-20-pin-friends.md | 4 + changelog/2026-09-20-tab-scroll-reset.md | 5 + e2e/friends.spec.ts | 59 +++++++ e2e/pages/friends/friends-page.ts | 21 ++- locales/da/friends.json | 4 +- locales/de/friends.json | 4 +- locales/en/friends.json | 4 +- locales/es-ES/friends.json | 4 +- locales/es-US/friends.json | 4 +- locales/fr-CA/friends.json | 4 +- locales/fr-EU/friends.json | 4 +- locales/he/friends.json | 4 +- locales/it/friends.json | 4 +- locales/ja/friends.json | 4 +- locales/ko/friends.json | 4 +- locales/nl/friends.json | 4 +- locales/pl/friends.json | 4 +- locales/pt-BR/friends.json | 4 +- locales/ru/friends.json | 4 +- locales/zh/friends.json | 4 +- .../20260920115346-friendship-pinned.ts | 19 +++ 38 files changed, 651 insertions(+), 129 deletions(-) create mode 100644 app/features/friends/friends-constants.test.ts create mode 100644 app/features/sidebar/core/sidebar.server.test.ts create mode 100644 changelog/2026-09-20-pin-friends.md create mode 100644 changelog/2026-09-20-tab-scroll-reset.md create mode 100644 migrations/20260920115346-friendship-pinned.ts diff --git a/app/db/tables.ts b/app/db/tables.ts index e61110516..e15be88e9 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -918,6 +918,10 @@ export interface Friendship { userOneId: number; userTwoId: number; createdAt: Generated; + /** userOne keeps this friend at the top of their friends list */ + isPinnedByUserOne: Generated; + /** userTwo keeps this friend at the top of their friends list */ + isPinnedByUserTwo: Generated; } /** Pending friend request from one user to another. */ diff --git a/app/features/availability/availability-schemas.ts b/app/features/availability/availability-schemas.ts index 5aa089e43..f048ec509 100644 --- a/app/features/availability/availability-schemas.ts +++ b/app/features/availability/availability-schemas.ts @@ -9,7 +9,13 @@ import { stringConstant, textField, } from "~/form/fields"; -import { _action, id, superRefine, type ValidationCtx } from "~/utils/schema"; +import { + _action, + id, + revalidateRoot, + superRefine, + type ValidationCtx, +} from "~/utils/schema"; import { AVAILABILITY } from "./availability-constants"; const DAY_MINUTES = 24 * 60; @@ -54,7 +60,7 @@ export const saveWeekSchema = v.object({ export const dismissScheduleNudgeSchema = v.object({ _action: _action("DISMISS_SCHEDULE_NUDGE"), - revalidateRoot: v.optional(v.nullable(v.literal(true))), + revalidateRoot, }); export const saveScheduleVisibilitySchema = v.object({ @@ -63,7 +69,7 @@ export const saveScheduleVisibilitySchema = v.object({ label: "labels.scheduleSharedWith", minLength: 0, }), - revalidateRoot: v.optional(v.nullable(v.literal(true))), + revalidateRoot, }); export const eventsActionSchema = v.union([ diff --git a/app/features/calendar/routes/events.tsx b/app/features/calendar/routes/events.tsx index 8c5e87795..63e4f9343 100644 --- a/app/features/calendar/routes/events.tsx +++ b/app/features/calendar/routes/events.tsx @@ -86,6 +86,7 @@ export default function EventsPage() { controlled active={filter === value} defaultShouldRevalidate={false} + preventScrollReset > {viewLabels[value]} diff --git a/app/features/friends/FriendRepository.server.test.ts b/app/features/friends/FriendRepository.server.test.ts index 2e01af3e3..f0b0c06a9 100644 --- a/app/features/friends/FriendRepository.server.test.ts +++ b/app/features/friends/FriendRepository.server.test.ts @@ -420,3 +420,69 @@ describe("findByUserIdWithActivity", () => { expect(result).toHaveLength(0); }); }); + +describe("updateOwnFriendshipPinned", () => { + beforeEach(async () => { + await createUsers(3); + }); + + const isPinnedFor = async (userId: number) => { + const [friend] = await FriendRepository.findByUserIdWithActivity(userId); + + return friend.isPinned; + }; + + test("pins the friendship for the acting user only", async () => { + const friendship = await FriendshipFactory.create({ + userOneId: users.id(1), + userTwoId: users.id(2), + }); + + await withUserId(users.id(2), () => + FriendRepository.updateOwnFriendshipPinned({ + friendshipId: friendship.id, + isPinned: true, + }), + ); + + expect(await isPinnedFor(users.id(2))).toBe(1); + expect(await isPinnedFor(users.id(1))).toBe(0); + }); + + test("unpins a pinned friendship", async () => { + const friendship = await FriendshipFactory.create({ + userOneId: users.id(1), + userTwoId: users.id(2), + }); + + await withUserId(users.id(1), async () => { + await FriendRepository.updateOwnFriendshipPinned({ + friendshipId: friendship.id, + isPinned: true, + }); + await FriendRepository.updateOwnFriendshipPinned({ + friendshipId: friendship.id, + isPinned: false, + }); + }); + + expect(await isPinnedFor(users.id(1))).toBe(0); + }); + + test("does not pin a friendship the user is not part of", async () => { + const friendship = await FriendshipFactory.create({ + userOneId: users.id(1), + userTwoId: users.id(2), + }); + + await withUserId(users.id(3), () => + FriendRepository.updateOwnFriendshipPinned({ + friendshipId: friendship.id, + isPinned: true, + }), + ); + + expect(await isPinnedFor(users.id(1))).toBe(0); + expect(await isPinnedFor(users.id(2))).toBe(0); + }); +}); diff --git a/app/features/friends/FriendRepository.server.ts b/app/features/friends/FriendRepository.server.ts index cde2ef1ae..73ea79da5 100644 --- a/app/features/friends/FriendRepository.server.ts +++ b/app/features/friends/FriendRepository.server.ts @@ -1,10 +1,11 @@ import { sub } from "date-fns"; import { type SelectQueryBuilder, sql } from "kysely"; import { db } from "~/db/sql"; -import type { DB } from "~/db/tables"; +import type { DB, DBBoolean } from "~/db/tables"; import { actorId } from "~/features/auth/core/user.server"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import { commonUserSelect } from "~/utils/kysely.server"; +import { toDBBoolean } from "~/utils/sql"; import { FRIEND } from "./friends-constants"; export async function findByUserIdWithActivity(userId: number) { @@ -33,9 +34,16 @@ export async function findByUserIdWithActivity(userId: number) { ]), ), ) - .select([ + .select((eb) => [ "Friendship.id as friendshipId", "Friendship.createdAt as friendshipCreatedAt", + eb + .case() + .when("Friendship.userOneId", "=", userId) + .then(eb.ref("Friendship.isPinnedByUserOne")) + .else(eb.ref("Friendship.isPinnedByUserTwo")) + .end() + .as("isPinned"), ]) .orderBy("Friendship.createdAt", "desc") .execute(), @@ -58,6 +66,7 @@ export async function findByUserIdWithActivity(userId: number) { ...row, friendshipId: null as number | null, friendshipCreatedAt: null as number | null, + isPinned: 0 as DBBoolean, })), ]; } @@ -228,6 +237,43 @@ export async function deleteOwnFriendshipById(id: number) { .execute(); } +/** Pins or unpins the friendship for the acting user only, the other side keeps their own choice. */ +export async function updateOwnFriendshipPinned({ + friendshipId, + isPinned, +}: { + friendshipId: number; + isPinned: boolean; +}) { + const userId = actorId(); + const value = toDBBoolean(isPinned); + + return db + .updateTable("Friendship") + .set((eb) => ({ + isPinnedByUserOne: eb + .case() + .when("Friendship.userOneId", "=", userId) + .then(value) + .else(eb.ref("Friendship.isPinnedByUserOne")) + .end(), + isPinnedByUserTwo: eb + .case() + .when("Friendship.userTwoId", "=", userId) + .then(value) + .else(eb.ref("Friendship.isPinnedByUserTwo")) + .end(), + })) + .where("Friendship.id", "=", friendshipId) + .where((eb) => + eb.or([ + eb("Friendship.userOneId", "=", userId), + eb("Friendship.userTwoId", "=", userId), + ]), + ) + .execute(); +} + export async function findPendingReceivedRequests(receiverId: number) { return db .selectFrom("FriendRequest") diff --git a/app/features/friends/actions/friends.server.ts b/app/features/friends/actions/friends.server.ts index a2fe72a16..4dda9a03a 100644 --- a/app/features/friends/actions/friends.server.ts +++ b/app/features/friends/actions/friends.server.ts @@ -67,6 +67,22 @@ export const action: ActionFunction = async ({ request }) => { break; } + case "PIN_FRIEND": { + await FriendRepository.updateOwnFriendshipPinned({ + friendshipId: result.data.friendshipId, + isPinned: true, + }); + + break; + } + case "UNPIN_FRIEND": { + await FriendRepository.updateOwnFriendshipPinned({ + friendshipId: result.data.friendshipId, + isPinned: false, + }); + + break; + } case "ACCEPT_REQUEST": { const friendRequest = await FriendRepository.findFriendRequestByIdAndReceiver({ diff --git a/app/features/friends/friends-constants.test.ts b/app/features/friends/friends-constants.test.ts new file mode 100644 index 000000000..9b425517b --- /dev/null +++ b/app/features/friends/friends-constants.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "vitest"; +import { friendSectionSortValue } from "./friends-constants"; + +describe("friendSectionSortValue", () => { + test.each([ + { + why: "pinned with activity", + isPinned: true, + activityType: "SENDOUQ", + expected: 0, + }, + { + why: "unpinned with activity", + isPinned: false, + activityType: "TOURNAMENT_MATCH", + expected: 1, + }, + { + why: "pinned without activity", + isPinned: true, + activityType: null, + expected: 2, + }, + { + why: "unpinned without activity", + isPinned: false, + activityType: null, + expected: 3, + }, + ] as const)( + "$why sorts as $expected", + ({ isPinned, activityType, expected }) => { + expect(friendSectionSortValue({ isPinned, activityType })).toBe(expected); + }, + ); +}); diff --git a/app/features/friends/friends-constants.ts b/app/features/friends/friends-constants.ts index 7500215cf..8806e7b21 100644 --- a/app/features/friends/friends-constants.ts +++ b/app/features/friends/friends-constants.ts @@ -38,3 +38,19 @@ export function friendActivitySortValue(type: FriendActivityType | null) { if (isInProgressFriendActivity(type)) return 2; return 0; } + +/** Section of the friends list, pinned friends with activity at the top and idle unpinned ones at the bottom. Lower sorts first. */ +export function friendSectionSortValue({ + isPinned, + activityType, +}: { + isPinned: boolean; + activityType: FriendActivityType | null; +}) { + const hasActivity = activityType !== null; + + if (isPinned && hasActivity) return 0; + if (hasActivity) return 1; + if (isPinned) return 2; + return 3; +} diff --git a/app/features/friends/friends-schemas.server.ts b/app/features/friends/friends-schemas.server.ts index 89cf52974..d88486117 100644 --- a/app/features/friends/friends-schemas.server.ts +++ b/app/features/friends/friends-schemas.server.ts @@ -7,7 +7,9 @@ import { cancelFriendRequestSchema, declineFriendRequestSchema, deleteFriendSchema, + pinFriendSchema, sendFriendRequestBaseSchema, + unpinFriendSchema, } from "./friends-schemas"; const sendFriendRequestSchemaServer = v.pipeAsync( @@ -54,4 +56,6 @@ export const friendsActionSchema = v.unionAsync([ deleteFriendSchema, acceptFriendRequestSchema, declineFriendRequestSchema, + pinFriendSchema, + unpinFriendSchema, ]); diff --git a/app/features/friends/friends-schemas.ts b/app/features/friends/friends-schemas.ts index 7dda77d34..5b15abdff 100644 --- a/app/features/friends/friends-schemas.ts +++ b/app/features/friends/friends-schemas.ts @@ -1,6 +1,6 @@ import * as v from "valibot"; import { stringConstant, userSearch } from "~/form/fields"; -import { _action, id } from "~/utils/schema"; +import { _action, id, revalidateRoot } from "~/utils/schema"; export const sendFriendRequestBaseSchema = v.object({ _action: stringConstant("SEND_REQUEST"), @@ -26,3 +26,15 @@ export const declineFriendRequestSchema = v.object({ _action: _action("DECLINE_REQUEST"), friendRequestId: id, }); + +export const pinFriendSchema = v.object({ + _action: _action("PIN_FRIEND"), + friendshipId: id, + revalidateRoot, +}); + +export const unpinFriendSchema = v.object({ + _action: _action("UNPIN_FRIEND"), + friendshipId: id, + revalidateRoot, +}); diff --git a/app/features/friends/loaders/friends.server.ts b/app/features/friends/loaders/friends.server.ts index 73192c1f9..ebff4bd70 100644 --- a/app/features/friends/loaders/friends.server.ts +++ b/app/features/friends/loaders/friends.server.ts @@ -4,7 +4,10 @@ import * as FriendSchedule from "~/features/availability/core/FriendSchedule.ser import { getViewerTimezone } from "~/features/timezone/timezone-context.server"; import { userPage } from "~/utils/urls"; import * as FriendRepository from "../FriendRepository.server"; -import { friendActivitySortValue } from "../friends-constants"; +import { + friendActivitySortValue, + friendSectionSortValue, +} from "../friends-constants"; import { resolveFriendActivity, resolveSendouQMatchStreams, @@ -63,9 +66,11 @@ export const loader = async () => { tournamentId: activity.tournamentId ?? friend.tournamentId, streamUrl: activity.streamUrl, friendshipCreatedAt: friend.friendshipCreatedAt, + isPinned: Boolean(friend.isPinned), schedule: schedules.get(friend.id) ?? null, }; }), + [(friend) => friendSectionSortValue(friend), "asc"], [(friend) => friendActivitySortValue(friend.activityType), "desc"], [(friend) => (friend.schedule ? 1 : 0), "desc"], [(friend) => friend.friendshipCreatedAt ?? 0, "desc"], @@ -100,6 +105,7 @@ export const loader = async () => { matchId: activity.matchId, tournamentId: activity.tournamentId ?? tm.tournamentId, streamUrl: activity.streamUrl, + isPinned: false, schedule: schedules.get(tm.id) ?? null, }; }), diff --git a/app/features/friends/routes/friends.module.css b/app/features/friends/routes/friends.module.css index c2f34dfe4..d435c26a5 100644 --- a/app/features/friends/routes/friends.module.css +++ b/app/features/friends/routes/friends.module.css @@ -37,11 +37,20 @@ gap: var(--s-1-5); } -.scheduleSlot { +.rowActions { + display: flex; + gap: var(--s-1-5); + margin-block-end: var(--s-1); +} + +.actionSlot { display: flex; width: 18px; - margin-block-end: var(--s-1); & > button { height: auto; } } + +.pinForm { + display: contents; +} diff --git a/app/features/friends/routes/friends.tsx b/app/features/friends/routes/friends.tsx index d05e73b23..b38b851b5 100644 --- a/app/features/friends/routes/friends.tsx +++ b/app/features/friends/routes/friends.tsx @@ -1,7 +1,8 @@ -import { CalendarDays } from "lucide-react"; +import { CalendarDays, Pin } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { Link, type MetaFunction, useLoaderData } from "react-router"; +import * as R from "remeda"; import { ActionButton } from "~/components/ActionButton"; import { Avatar } from "~/components/Avatar"; import { Divider } from "~/components/Divider"; @@ -15,11 +16,14 @@ import { useSearchParam } from "~/modules/search-params/hooks"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { FriendMenu } from "../components/FriendMenu"; +import { friendSectionSortValue } from "../friends-constants"; import { acceptFriendRequestSchema, cancelFriendRequestSchema, declineFriendRequestSchema, + pinFriendSchema, sendFriendRequestBaseSchema, + unpinFriendSchema, } from "../friends-schemas"; import { friendsSearchParams, @@ -212,6 +216,7 @@ function FriendsListSection() { secondary controlled active={filter === value} + preventScrollReset > {viewLabels[value]} @@ -234,22 +239,71 @@ function FriendsListSection() { } function FriendRow({ item }: { item: ShownItem }) { + const friendshipId = + "friendshipId" in item && typeof item.friendshipId === "number" + ? item.friendshipId + : null; + return (
-
- {item.schedule ? ( - - ) : null} +
+
+ {item.schedule ? ( + + ) : null} +
+
+ {typeof friendshipId === "number" ? ( + + ) : null} +
); } +function PinButton({ + userId, + username, + friendshipId, + isPinned, +}: { + userId: number; + username: string; + friendshipId: number; + isPinned: boolean; +}) { + const { t } = useTranslation(["friends"]); + + return ( + } + aria-label={t( + isPinned ? "friends:friendsList.unpin" : "friends:friendsList.pin", + { name: username }, + )} + aria-pressed={isPinned} + testId={`friend-pin-button-${userId}`} + /> + ); +} + function ScheduleButton({ userId, username, @@ -298,10 +352,11 @@ function resolveShownItems( ...data.teamMembers.filter((tm) => !friendIds.has(tm.id)), ]; - // same order the loader sorted each group in: active first, then the ones - // who shared a schedule - const sortValue = (item: (typeof combined)[number]) => - (item.subtitle ? 2 : 0) + (item.schedule ? 1 : 0); - - return combined.sort((a, b) => sortValue(b) - sortValue(a)); + // same order the loader sorted each group in: pinned and active first, then + // the ones who shared a schedule + return R.sortBy( + combined, + [(item) => friendSectionSortValue(item), "asc"], + [(item) => (item.schedule ? 1 : 0), "desc"], + ); } diff --git a/app/features/settings/settings-schemas.ts b/app/features/settings/settings-schemas.ts index d17cf9ff1..d3d5f2d6b 100644 --- a/app/features/settings/settings-schemas.ts +++ b/app/features/settings/settings-schemas.ts @@ -1,11 +1,11 @@ import * as v from "valibot"; import { hidden, select, stringConstant, toggle } from "~/form/fields"; -import { themeInputSchema } from "~/utils/schema"; +import { revalidateRoot, themeInputSchema } from "~/utils/schema"; export const customThemeSchema = v.object({ _action: stringConstant("UPDATE_CUSTOM_THEME"), newValue: hidden(v.nullable(themeInputSchema), null), - revalidateRoot: v.optional(v.nullable(v.literal(true))), + revalidateRoot, }); export const clockFormatSchema = v.object({ diff --git a/app/features/sidebar/core/sidebar.server.test.ts b/app/features/sidebar/core/sidebar.server.test.ts new file mode 100644 index 000000000..a672fed35 --- /dev/null +++ b/app/features/sidebar/core/sidebar.server.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "vitest"; +import type { FriendActivityType } from "~/features/friends/friends-constants"; +import { orderSidebarFriends } from "./sidebar.server"; + +describe("orderSidebarFriends", () => { + test("ranks pinned active, active, pinned idle and idle in that order", () => { + const ordered = orderSidebarFriends([ + entry("idle"), + entry("pinnedIdle", { isPinned: true }), + entry("active", { activityType: "SENDOUQ_MATCH" }), + entry("pinnedActive", { + isPinned: true, + activityType: "SENDOUQ_MATCH", + }), + ]); + + expect(idsOf(ordered)).toEqual([ + "pinnedActive", + "active", + "pinnedIdle", + "idle", + ]); + }); + + test("keeps the activity quotas in order within a section", () => { + const ordered = orderSidebarFriends([ + entry("q1", { activityType: "SENDOUQ" }), + entry("q2", { activityType: "SENDOUQ" }), + entry("q3", { activityType: "SENDOUQ" }), + entry("sub1", { activityType: "TOURNAMENT_SUB" }), + entry("sub2", { activityType: "TOURNAMENT_SUB" }), + entry("sub3", { activityType: "TOURNAMENT_SUB" }), + entry("match", { activityType: "SENDOUQ_MATCH" }), + ]); + + expect(idsOf(ordered)).toEqual([ + "q1", + "q2", + "sub1", + "sub2", + "q3", + "sub3", + "match", + ]); + }); + + test("pinning a friend does not disturb the quota order of the rest", () => { + const ordered = orderSidebarFriends([ + entry("q1", { activityType: "SENDOUQ" }), + entry("q2", { activityType: "SENDOUQ" }), + entry("q3", { activityType: "SENDOUQ", isPinned: true }), + entry("sub1", { activityType: "TOURNAMENT_SUB" }), + ]); + + expect(idsOf(ordered)).toEqual(["q3", "q1", "q2", "sub1"]); + }); + + test("shows a pinned friend that would otherwise fall past the visible quota", () => { + const ordered = orderSidebarFriends([ + ...Array.from({ length: 10 }, (_, i) => entry(`idle${i}`)), + entry("pinned", { isPinned: true }), + ]); + + expect(ordered).toHaveLength(8); + expect(idsOf(ordered)[0]).toBe("pinned"); + }); + + test("drops active friends past the visible quota", () => { + const ordered = orderSidebarFriends( + Array.from({ length: 12 }, (_, i) => + entry(`match${i}`, { activityType: "TOURNAMENT_MATCH" }), + ), + ); + + expect(idsOf(ordered)).toEqual([ + "match0", + "match1", + "match2", + "match3", + "match4", + "match5", + "match6", + "match7", + ]); + }); + + test("places friends before team members sharing their activity", () => { + const ordered = orderSidebarFriends([ + entry("teamMember", { + isFriend: false, + activityType: "SENDOUQ_MATCH", + }), + entry("friend", { activityType: "SENDOUQ_MATCH" }), + ]); + + expect(idsOf(ordered)).toEqual(["friend", "teamMember"]); + }); + + test("ranks an active team member above an idle friend", () => { + const ordered = orderSidebarFriends([ + entry("idleFriend"), + entry("activeTeamMember", { + isFriend: false, + activityType: "SENDOUQ", + }), + ]); + + expect(idsOf(ordered)).toEqual(["activeTeamMember", "idleFriend"]); + }); +}); + +function entry( + id: string, + { + isFriend = true, + isPinned = false, + activityType = null, + }: { + isFriend?: boolean; + isPinned?: boolean; + activityType?: FriendActivityType | null; + } = {}, +) { + return { id, isFriend, isPinned, activityType }; +} + +function idsOf(entries: Array<{ id: string }>) { + return entries.map(({ id }) => id); +} diff --git a/app/features/sidebar/core/sidebar.server.ts b/app/features/sidebar/core/sidebar.server.ts index 32d579e1e..ae7f6b0f5 100644 --- a/app/features/sidebar/core/sidebar.server.ts +++ b/app/features/sidebar/core/sidebar.server.ts @@ -17,6 +17,7 @@ import { import * as FriendRepository from "~/features/friends/FriendRepository.server"; import { type FriendActivityType, + friendSectionSortValue, isInProgressFriendActivity, } from "~/features/friends/friends-constants"; import { @@ -330,8 +331,8 @@ function resolveFriends( friendsWithActivity: FriendWithActivity[], streamedSendouQMatches: ReadonlyMap, ) { - const activityForRow = (row: FriendWithActivity) => - resolveFriendActivity({ + const entries = R.uniqueBy(friendsWithActivity, (f) => f.id).map((row) => { + const activity = resolveFriendActivity({ friendId: row.id, tournamentId: row.tournamentId, tournamentName: row.tournamentName, @@ -340,91 +341,69 @@ function resolveFriends( sendouQMatchStreams: streamedSendouQMatches, }); - const unique = R.uniqueBy(friendsWithActivity, (f) => f.id); - const friendRows = unique.filter((f) => f.friendshipId !== null); - const teamMemberRows = unique.filter((f) => f.friendshipId === null); + return { + row, + activity, + isFriend: row.friendshipId !== null, + isPinned: Boolean(row.isPinned), + activityType: activity.type, + }; + }); - const activeFriends: SidebarFriend[] = []; - const sendouqFriends: SidebarFriend[] = []; - const tournamentSubFriends: SidebarFriend[] = []; - const inactiveFriends: FriendWithActivity[] = []; + return orderSidebarFriends(entries).map(({ row, activity }) => + rowToSidebarFriend(row, activity), + ); +} - for (const friend of friendRows) { - const activity = activityForRow(friend); +type OrderableFriend = { + /** Mutual friend rather than a team member shown alongside them. */ + isFriend: boolean; + isPinned: boolean; + activityType: FriendActivityType | null; +}; - if (!activity.type) { - inactiveFriends.push(friend); - continue; - } +/** Pinned friends with activity, the rest with activity, pinned idle friends, the rest, truncated to what fits. Each section keeps its own order of activity type quotas, friends before team members. */ +export function orderSidebarFriends(entries: T[]) { + const friends = entries.filter(({ isFriend }) => isFriend); + const teamMembers = entries.filter(({ isFriend }) => !isFriend); - const sidebarFriend = rowToSidebarFriend(friend, activity); + const sendouqFriends = friends.filter( + ({ activityType }) => activityType === "SENDOUQ", + ); + const tournamentSubFriends = friends.filter( + ({ activityType }) => activityType === "TOURNAMENT_SUB", + ); + const inMatchFriends = friends.filter(({ activityType }) => + isInProgressFriendActivity(activityType), + ); + const idleFriends = friends.filter(({ activityType }) => !activityType); + const activeTeamMembers = teamMembers.filter( + ({ activityType }) => activityType, + ); + const idleTeamMembers = teamMembers.filter( + ({ activityType }) => !activityType, + ); - if (isInProgressFriendActivity(activity.type)) { - activeFriends.push(sidebarFriend); - } else if (activity.type === "SENDOUQ") { - sendouqFriends.push(sidebarFriend); - } else { - tournamentSubFriends.push(sidebarFriend); - } - } + const ordered = [ + ...sendouqFriends.slice(0, SENDOUQ_QUOTA), + ...tournamentSubFriends.slice(0, TOURNAMENT_SUB_QUOTA), + ...sendouqFriends.slice(SENDOUQ_QUOTA), + ...tournamentSubFriends.slice(TOURNAMENT_SUB_QUOTA), + ...inMatchFriends, + ...activeTeamMembers, + ...idleFriends, + ...idleTeamMembers, + ]; - const result: SidebarFriend[] = []; - - const sendouqToShow = sendouqFriends.slice(0, SENDOUQ_QUOTA); - const tournamentToShow = tournamentSubFriends.slice(0, TOURNAMENT_SUB_QUOTA); - - result.push(...sendouqToShow, ...tournamentToShow); - - const remaining = MAX_FRIENDS_VISIBLE - result.length; - if (remaining > 0) { - const extraSendouq = sendouqFriends.slice(SENDOUQ_QUOTA); - const extraTournament = tournamentSubFriends.slice(TOURNAMENT_SUB_QUOTA); - result.push(...[...extraSendouq, ...extraTournament].slice(0, remaining)); - } - - if (result.length < MAX_FRIENDS_VISIBLE) { - result.push(...activeFriends.slice(0, MAX_FRIENDS_VISIBLE - result.length)); - } - - if (result.length < MAX_FRIENDS_VISIBLE) { - const shownIds = new Set(result.map((f) => f.id)); - const inactiveTeamMembers: FriendWithActivity[] = []; - - for (const tm of teamMemberRows) { - if (result.length >= MAX_FRIENDS_VISIBLE) break; - if (shownIds.has(tm.id)) continue; - - const activity = activityForRow(tm); - if (!activity.type) { - inactiveTeamMembers.push(tm); - continue; - } - - result.push(rowToSidebarFriend(tm, activity)); - shownIds.add(tm.id); - } - - for (const friend of inactiveFriends) { - if (result.length >= MAX_FRIENDS_VISIBLE) break; - if (shownIds.has(friend.id)) continue; - - result.push(rowToSidebarFriend(friend, null)); - shownIds.add(friend.id); - } - - for (const tm of inactiveTeamMembers) { - if (result.length >= MAX_FRIENDS_VISIBLE) break; - - result.push(rowToSidebarFriend(tm, null)); - } - } - - return result.slice(0, MAX_FRIENDS_VISIBLE); + return R.sortBy(ordered, friendSectionSortValue).slice( + 0, + MAX_FRIENDS_VISIBLE, + ); } function rowToSidebarFriend( row: FriendWithActivity, - activity: FriendActivity | null, + activity: FriendActivity, ): SidebarFriend { return { id: row.id, @@ -433,12 +412,12 @@ function rowToSidebarFriend( discordAvatar: row.discordAvatar, customAvatarUrl: row.customAvatarUrl, url: userPage({ discordId: row.discordId, customUrl: row.customUrl }), - subtitle: activity?.subtitle ?? "", - badge: activity?.badge ?? "", - activityType: activity?.type ?? null, - matchId: activity?.matchId ?? null, - tournamentId: activity?.tournamentId ?? row.tournamentId, - streamUrl: activity?.streamUrl ?? null, + subtitle: activity.subtitle ?? "", + badge: activity.badge ?? "", + activityType: activity.type, + matchId: activity.matchId, + tournamentId: activity.tournamentId ?? row.tournamentId, + streamUrl: activity.streamUrl, }; } diff --git a/app/utils/schema.ts b/app/utils/schema.ts index b3edbc479..1d63266ba 100644 --- a/app/utils/schema.ts +++ b/app/utils/schema.ts @@ -510,6 +510,11 @@ export function checkboxValueToBoolean(value: unknown) { export const _action = (value: T) => preprocess(deduplicate, v.literal(value)); +/** Marker field asking the root loader to revalidate too (see `shouldRevalidate` in root.tsx), `true` in a JSON body and `"true"` in form data. */ +export const revalidateRoot = v.optional( + v.nullable(v.union([v.literal(true), v.literal("true")])), +); + /** Works around a bug at least in Safari 15 where a SubmitButton value might get sent twice */ export function deduplicate(value: unknown) { if (Array.isArray(value)) { diff --git a/changelog/2026-09-20-pin-friends.md b/changelog/2026-09-20-pin-friends.md new file mode 100644 index 000000000..8914c7338 --- /dev/null +++ b/changelog/2026-09-20-pin-friends.md @@ -0,0 +1,4 @@ +--- +type: feature +--- +Friends can be pinned from the friends page, keeping them at the top of the friends list and the sidebar diff --git a/changelog/2026-09-20-tab-scroll-reset.md b/changelog/2026-09-20-tab-scroll-reset.md new file mode 100644 index 000000000..54a61eaee --- /dev/null +++ b/changelog/2026-09-20-tab-scroll-reset.md @@ -0,0 +1,5 @@ +--- +navItem: calendar +type: bug +--- +Switching tabs on the friends and events pages no longer scrolls back to the top diff --git a/e2e/friends.spec.ts b/e2e/friends.spec.ts index 13ab4baf7..2434fc72f 100644 --- a/e2e/friends.spec.ts +++ b/e2e/friends.spec.ts @@ -17,6 +17,7 @@ import { } from "./helpers/sidebar"; import { FriendsPage } from "./pages/friends/friends-page"; import { NotificationPopover } from "./pages/layout/notification-popover"; +import { SideNav } from "./pages/layout/side-nav"; const WEDNESDAY = 2; @@ -114,6 +115,64 @@ test.describe("Friends", () => { await friends.locators.nextWeekToggle.click(); await expect(friends.locators.noScheduleText).toBeVisible(); }); + + test("pinned friends sort to the top of their section on the page and in the sidebar", async ({ + page, + factories, + }) => { + const [pinnedQueueing, queueing, pinnedIdle, idle] = await createNamedUsers( + factories, + ["PinQueue", "PlainQueue", "PinIdle", "PlainIdle"], + ); + await befriend( + factories, + [idle.id, pinnedIdle.id, queueing.id, pinnedQueueing.id], + ADMIN_ID, + ); + await factories.SQGroupFactory.create({ memberUserIds: [queueing.id] }); + await factories.SQGroupFactory.create({ + memberUserIds: [pinnedQueueing.id], + }); + + await impersonate(page, ADMIN_ID); + + const friends = new FriendsPage(page); + await friends.goto(); + + await friends.togglePin(pinnedIdle.id); + await friends.togglePin(pinnedQueueing.id); + await expect(friends.pinButton(pinnedIdle.id)).toHaveAttribute( + "aria-pressed", + "true", + ); + await expect(friends.pinButton(idle.id)).toHaveAttribute( + "aria-pressed", + "false", + ); + + await expectTopToBottom([ + friends.row(pinnedQueueing.id), + friends.row(queueing.id), + friends.row(pinnedIdle.id), + friends.row(idle.id), + ]); + + // the sidebar refreshes with the page, no navigation needed + const sideNav = new SideNav(page); + await expectTopToBottom([ + sideNav.friend("PinQueue").trigger, + sideNav.friend("PlainQueue").trigger, + sideNav.friend("PinIdle").trigger, + sideNav.friend("PlainIdle").trigger, + ]); + + await friends.goto(); + await friends.togglePin(pinnedIdle.id); + await expect(friends.pinButton(pinnedIdle.id)).toHaveAttribute( + "aria-pressed", + "false", + ); + }); }); function daySlot(dayIndex: number, start: string, end: string) { diff --git a/e2e/pages/friends/friends-page.ts b/e2e/pages/friends/friends-page.ts index 66b8fa1f1..66f73be97 100644 --- a/e2e/pages/friends/friends-page.ts +++ b/e2e/pages/friends/friends-page.ts @@ -52,9 +52,11 @@ export class FriendsPage { ); } - /** Scoped to the page content: the mobile friends panel still shows the same name for a frame after navigating here from it. */ + /** Scoped to the page content: the mobile friends panel still shows the same name for a frame after navigating here from it. Exact, as the pin button's label names the friend too. */ friendButton(name: string) { - return this.page.getByRole("main").getByRole("button", { name }); + return this.page + .getByRole("main") + .getByRole("button", { name, exact: true }); } friend(name: string) { @@ -69,6 +71,14 @@ export class FriendsPage { return this.page.getByTestId(`friend-schedule-button-${userId}`); } + pinButton(userId: number) { + return this.page.getByTestId(`friend-pin-button-${userId}`); + } + + async togglePin(userId: number) { + await waitForPOSTResponse(this.page, () => this.pinButton(userId).click()); + } + /** One day row of the open week modal, Monday being 0. */ day(dayIndex: number) { return this.locators.scheduleDays.getByRole("listitem").nth(dayIndex); @@ -82,8 +92,11 @@ class FriendMenu { constructor(page: Page, name: string) { this.page = page; // scoped to the page content because the sidebar's friends section shows - // a button with the same name once the friendship data refreshes - this.trigger = page.getByRole("main").getByRole("button", { name }); + // a button with the same name once the friendship data refreshes, exact + // because the pin button's label names the friend too + this.trigger = page + .getByRole("main") + .getByRole("button", { name, exact: true }); } async deleteFriend() { diff --git a/locales/da/friends.json b/locales/da/friends.json index c6a2e1a7e..94d0e6267 100644 --- a/locales/da/friends.json +++ b/locales/da/friends.json @@ -21,5 +21,7 @@ "view.all": "", "teamMembers.empty": "", "unseenRequests_one": "", - "unseenRequests_other": "" + "unseenRequests_other": "", + "friendsList.pin": "", + "friendsList.unpin": "" } diff --git a/locales/de/friends.json b/locales/de/friends.json index c6a2e1a7e..94d0e6267 100644 --- a/locales/de/friends.json +++ b/locales/de/friends.json @@ -21,5 +21,7 @@ "view.all": "", "teamMembers.empty": "", "unseenRequests_one": "", - "unseenRequests_other": "" + "unseenRequests_other": "", + "friendsList.pin": "", + "friendsList.unpin": "" } diff --git a/locales/en/friends.json b/locales/en/friends.json index 9041ef6e9..5dfcbfd27 100644 --- a/locales/en/friends.json +++ b/locales/en/friends.json @@ -21,5 +21,7 @@ "view.all": "All", "teamMembers.empty": "No team members yet", "unseenRequests_one": "{{count}} unseen friend request", - "unseenRequests_other": "{{count}} unseen friend requests" + "unseenRequests_other": "{{count}} unseen friend requests", + "friendsList.pin": "Pin {{name}} to the top", + "friendsList.unpin": "Unpin {{name}}" } diff --git a/locales/es-ES/friends.json b/locales/es-ES/friends.json index 2a716040e..5618ab94b 100644 --- a/locales/es-ES/friends.json +++ b/locales/es-ES/friends.json @@ -22,5 +22,7 @@ "teamMembers.empty": "Aún no hay miembros en el equipo", "unseenRequests_one": "{{count}} solicitud de amistad sin ver", "unseenRequests_many": "", - "unseenRequests_other": "{{count}} solicitudes de amistad sin ver" + "unseenRequests_other": "{{count}} solicitudes de amistad sin ver", + "friendsList.pin": "", + "friendsList.unpin": "" } diff --git a/locales/es-US/friends.json b/locales/es-US/friends.json index 2a716040e..5618ab94b 100644 --- a/locales/es-US/friends.json +++ b/locales/es-US/friends.json @@ -22,5 +22,7 @@ "teamMembers.empty": "Aún no hay miembros en el equipo", "unseenRequests_one": "{{count}} solicitud de amistad sin ver", "unseenRequests_many": "", - "unseenRequests_other": "{{count}} solicitudes de amistad sin ver" + "unseenRequests_other": "{{count}} solicitudes de amistad sin ver", + "friendsList.pin": "", + "friendsList.unpin": "" } diff --git a/locales/fr-CA/friends.json b/locales/fr-CA/friends.json index c56663f98..666eb8792 100644 --- a/locales/fr-CA/friends.json +++ b/locales/fr-CA/friends.json @@ -22,5 +22,7 @@ "teamMembers.empty": "", "unseenRequests_one": "", "unseenRequests_many": "", - "unseenRequests_other": "" + "unseenRequests_other": "", + "friendsList.pin": "", + "friendsList.unpin": "" } diff --git a/locales/fr-EU/friends.json b/locales/fr-EU/friends.json index c56663f98..666eb8792 100644 --- a/locales/fr-EU/friends.json +++ b/locales/fr-EU/friends.json @@ -22,5 +22,7 @@ "teamMembers.empty": "", "unseenRequests_one": "", "unseenRequests_many": "", - "unseenRequests_other": "" + "unseenRequests_other": "", + "friendsList.pin": "", + "friendsList.unpin": "" } diff --git a/locales/he/friends.json b/locales/he/friends.json index a73596580..f757ec75e 100644 --- a/locales/he/friends.json +++ b/locales/he/friends.json @@ -22,5 +22,7 @@ "teamMembers.empty": "", "unseenRequests_one": "", "unseenRequests_two": "", - "unseenRequests_other": "" + "unseenRequests_other": "", + "friendsList.pin": "", + "friendsList.unpin": "" } diff --git a/locales/it/friends.json b/locales/it/friends.json index c56663f98..666eb8792 100644 --- a/locales/it/friends.json +++ b/locales/it/friends.json @@ -22,5 +22,7 @@ "teamMembers.empty": "", "unseenRequests_one": "", "unseenRequests_many": "", - "unseenRequests_other": "" + "unseenRequests_other": "", + "friendsList.pin": "", + "friendsList.unpin": "" } diff --git a/locales/ja/friends.json b/locales/ja/friends.json index 7ee6b8c8a..4364c2c48 100644 --- a/locales/ja/friends.json +++ b/locales/ja/friends.json @@ -19,5 +19,7 @@ "view.friends": "", "view.teamMembers": "", "view.all": "", - "teamMembers.empty": "" + "teamMembers.empty": "", + "friendsList.pin": "", + "friendsList.unpin": "" } diff --git a/locales/ko/friends.json b/locales/ko/friends.json index 7ee6b8c8a..4364c2c48 100644 --- a/locales/ko/friends.json +++ b/locales/ko/friends.json @@ -19,5 +19,7 @@ "view.friends": "", "view.teamMembers": "", "view.all": "", - "teamMembers.empty": "" + "teamMembers.empty": "", + "friendsList.pin": "", + "friendsList.unpin": "" } diff --git a/locales/nl/friends.json b/locales/nl/friends.json index c6a2e1a7e..94d0e6267 100644 --- a/locales/nl/friends.json +++ b/locales/nl/friends.json @@ -21,5 +21,7 @@ "view.all": "", "teamMembers.empty": "", "unseenRequests_one": "", - "unseenRequests_other": "" + "unseenRequests_other": "", + "friendsList.pin": "", + "friendsList.unpin": "" } diff --git a/locales/pl/friends.json b/locales/pl/friends.json index 6e35b7508..b8b030566 100644 --- a/locales/pl/friends.json +++ b/locales/pl/friends.json @@ -23,5 +23,7 @@ "unseenRequests_one": "", "unseenRequests_few": "", "unseenRequests_many": "", - "unseenRequests_other": "" + "unseenRequests_other": "", + "friendsList.pin": "", + "friendsList.unpin": "" } diff --git a/locales/pt-BR/friends.json b/locales/pt-BR/friends.json index c56663f98..666eb8792 100644 --- a/locales/pt-BR/friends.json +++ b/locales/pt-BR/friends.json @@ -22,5 +22,7 @@ "teamMembers.empty": "", "unseenRequests_one": "", "unseenRequests_many": "", - "unseenRequests_other": "" + "unseenRequests_other": "", + "friendsList.pin": "", + "friendsList.unpin": "" } diff --git a/locales/ru/friends.json b/locales/ru/friends.json index 6e35b7508..b8b030566 100644 --- a/locales/ru/friends.json +++ b/locales/ru/friends.json @@ -23,5 +23,7 @@ "unseenRequests_one": "", "unseenRequests_few": "", "unseenRequests_many": "", - "unseenRequests_other": "" + "unseenRequests_other": "", + "friendsList.pin": "", + "friendsList.unpin": "" } diff --git a/locales/zh/friends.json b/locales/zh/friends.json index 123ff8bba..7ab4096b3 100644 --- a/locales/zh/friends.json +++ b/locales/zh/friends.json @@ -20,5 +20,7 @@ "view.teamMembers": "队伍成员", "view.all": "全部", "teamMembers.empty": "暂无队伍成员", - "unseenRequests": "有 {{count}} 条未查看的好友请求" + "unseenRequests": "有 {{count}} 条未查看的好友请求", + "friendsList.pin": "", + "friendsList.unpin": "" } diff --git a/migrations/20260920115346-friendship-pinned.ts b/migrations/20260920115346-friendship-pinned.ts new file mode 100644 index 000000000..64959a910 --- /dev/null +++ b/migrations/20260920115346-friendship-pinned.ts @@ -0,0 +1,19 @@ +import type { Kysely } from "kysely"; + +/** Either side of a friendship can pin it to the top of their friends list */ +export async function up(db: Kysely): Promise { + await db.transaction().execute(async (trx) => { + await trx.schema + .alterTable("Friendship") + .addColumn("isPinnedByUserOne", "integer", (col) => + col.notNull().defaultTo(0), + ) + .execute(); + await trx.schema + .alterTable("Friendship") + .addColumn("isPinnedByUserTwo", "integer", (col) => + col.notNull().defaultTo(0), + ) + .execute(); + }); +} From 871093bd17799e2a96f19c64cd8fec78470059b0 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:43:34 +0300 Subject: [PATCH 26/50] Scrims association filter --- .../associations/core/Association.test.ts | 41 +++++++ app/features/associations/core/Association.ts | 18 ++++ .../associations/routes/associations.tsx | 65 ++++++----- .../scrims/loaders/scrims.server.test.ts | 102 ++++++++++++++++++ app/features/scrims/loaders/scrims.server.ts | 74 +++++++++---- app/features/scrims/routes/scrims.tsx | 37 ++++++- .../scrims/scrims-search-params.test.ts | 7 ++ app/features/scrims/scrims-search-params.ts | 5 + app/features/scrims/scrims-urls.ts | 5 + .../2026-09-20-scrims-association-filter.md | 5 + locales/da/scrims.json | 2 + locales/de/scrims.json | 2 + locales/en/scrims.json | 2 + locales/es-ES/scrims.json | 2 + locales/es-US/scrims.json | 2 + locales/fr-CA/scrims.json | 2 + locales/fr-EU/scrims.json | 2 + locales/he/scrims.json | 2 + locales/it/scrims.json | 2 + locales/ja/scrims.json | 2 + locales/ko/scrims.json | 2 + locales/nl/scrims.json | 2 + locales/pl/scrims.json | 2 + locales/pt-BR/scrims.json | 2 + locales/ru/scrims.json | 2 + locales/zh/scrims.json | 2 + 26 files changed, 343 insertions(+), 48 deletions(-) create mode 100644 app/features/scrims/loaders/scrims.server.test.ts create mode 100644 app/features/scrims/scrims-urls.ts create mode 100644 changelog/2026-09-20-scrims-association-filter.md diff --git a/app/features/associations/core/Association.test.ts b/app/features/associations/core/Association.test.ts index 9ade813c4..a3004663d 100644 --- a/app/features/associations/core/Association.test.ts +++ b/app/features/associations/core/Association.test.ts @@ -181,3 +181,44 @@ describe("isVisible", () => { expect(Association.isVisible(args)).toBe(true); }); }); + +describe("mentionsAssociation", () => { + test.each([ + { why: "no visibility", visibility: null, expected: false }, + { + why: "current association", + visibility: { forAssociation: 1 }, + expected: true, + }, + { + why: "other association", + visibility: { forAssociation: 2 }, + expected: false, + }, + { + why: "virtual association", + visibility: { forAssociation: "+1" as const }, + expected: false, + }, + { + why: "association later in the schedule", + visibility: { + forAssociation: 2, + notFoundInstructions: [{ at: 0, forAssociation: 1 }], + }, + expected: true, + }, + { + why: "schedule going public only", + visibility: { + forAssociation: 2, + notFoundInstructions: [{ at: 0, forAssociation: null }], + }, + expected: false, + }, + ])("$why", ({ visibility, expected }) => { + expect( + Association.mentionsAssociation({ visibility, associationId: 1 }), + ).toBe(expected); + }); +}); diff --git a/app/features/associations/core/Association.ts b/app/features/associations/core/Association.ts index 58f518635..fe1f5d457 100644 --- a/app/features/associations/core/Association.ts +++ b/app/features/associations/core/Association.ts @@ -53,6 +53,24 @@ export function isVisible(args: IsVisibleArgs) { ); } +/** Whether the association is in the visibility at any point of its schedule, not only right now. */ +export function mentionsAssociation({ + visibility, + associationId, +}: { + visibility: AssociationVisibility | null; + associationId: number; +}) { + if (!visibility) return false; + + return ( + visibility.forAssociation === associationId || + (visibility.notFoundInstructions ?? []).some( + (instruction) => instruction.forAssociation === associationId, + ) + ); +} + export function isPublic(args: Omit) { return isVisible({ associations: null, diff --git a/app/features/associations/routes/associations.tsx b/app/features/associations/routes/associations.tsx index 83615d8de..aba7b6405 100644 --- a/app/features/associations/routes/associations.tsx +++ b/app/features/associations/routes/associations.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import { type MetaFunction, Outlet, useLoaderData } from "react-router"; import { ActionButton } from "~/components/ActionButton"; -import { SendouButton } from "~/components/elements/Button"; +import { LinkButton, SendouButton } from "~/components/elements/Button"; import { FormWithConfirm } from "~/components/FormWithConfirm"; import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; @@ -16,6 +16,7 @@ import { loader, } from "~/features/associations/loaders/associations.server"; import { useUser } from "~/features/auth/core/user"; +import { scrimsByAssociationPage } from "~/features/scrims/scrims-urls"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useHasPermission } from "~/modules/permissions/hooks"; import { metaTags } from "~/utils/remix"; @@ -133,35 +134,43 @@ function AssociationSection({ ?.username, })}
- {!canManage || newAdmin ? ( - + - + {!canManage || newAdmin ? ( + - {t("scrims:associations.leave.action")} - - - ) : null} + + {t("scrims:associations.leave.action")} + + + ) : null} +
{association.members?.map((member) => ( users.id(1); +const otherUserId = () => users.id(2); +const requesterId = () => users.id(3); + +const scrimsLoader = wrappedLoader>({ loader }); + +describe("scrims loader", () => { + beforeEach(async () => { + await users.create(3); + }); + + const createAssociation = () => + AssociationFactory.create( + { userId: otherUserId() }, + { memberUserIds: [viewerId()] }, + ); + + const loadFilteredByAssociation = (associationId: number) => + scrimsLoader({ + user: viewerId(), + url: scrimsSearchParams.href(scrimsPage(), { associationId }), + }); + + test("only lists posts of the association filtered by", async () => { + const association = await createAssociation(); + + const { id: associationPostId } = await ScrimPostFactory.create({ + users: [{ userId: otherUserId(), isOwner: 1 }], + visibility: { forAssociation: association.id }, + }); + await ScrimPostFactory.create({ + users: [{ userId: otherUserId(), isOwner: 1 }], + }); + + const data = await loadFilteredByAssociation(association.id); + + expect(data.posts.neutral.map((post) => post.id)).toEqual([ + associationPostId, + ]); + }); + + test("keeps the viewer's own public post listed while filtering by association", async () => { + const association = await createAssociation(); + + const { id: ownPostId } = await ScrimPostFactory.create({ + users: [{ userId: viewerId(), isOwner: 1 }], + }); + + const data = await loadFilteredByAssociation(association.id); + + expect(data.posts.owned.map((post) => post.id)).toEqual([ownPostId]); + }); + + test("keeps a booked scrim listed while filtering by association", async () => { + const association = await createAssociation(); + + const { id: bookedPostId } = await ScrimPostFactory.create( + { + users: [{ userId: viewerId(), isOwner: 1 }], + }, + { + requests: [ + { + users: [{ userId: requesterId(), isOwner: 1 }], + isAccepted: true, + }, + ], + }, + ); + + const data = await loadFilteredByAssociation(association.id); + + expect(data.posts.booked.map((post) => post.id)).toEqual([bookedPostId]); + }); + + test("ignores a filter for an association the viewer is not in", async () => { + const otherAssociation = await AssociationFactory.create({ + userId: otherUserId(), + }); + + const { id: postId } = await ScrimPostFactory.create({ + users: [{ userId: otherUserId(), isOwner: 1 }], + }); + + const data = await loadFilteredByAssociation(otherAssociation.id); + + expect(data.associationFilter).toBeNull(); + expect(data.posts.neutral.map((post) => post.id)).toEqual([postId]); + }); +}); diff --git a/app/features/scrims/loaders/scrims.server.ts b/app/features/scrims/loaders/scrims.server.ts index 3fa2e7b00..0a6fd9e82 100644 --- a/app/features/scrims/loaders/scrims.server.ts +++ b/app/features/scrims/loaders/scrims.server.ts @@ -20,7 +20,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { ? await AssociationsRepository.findByMemberUserId(user?.id) : null; - const { weekdayTimes, weekendTimes, divs, useDefaults } = + const { weekdayTimes, weekendTimes, divs, useDefaults, associationId } = scrimsSearchParams.parse(request); const filtersFromSearchParams = { weekdayTimes, weekendTimes, divs }; @@ -31,26 +31,46 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { ? (user?.preferences?.defaultScrimsFilters ?? Scrim.defaultFilters()) : filtersFromSearchParams; - const posts = (await ScrimPostRepository.findAllRelevant()) - .filter( - (post) => - (user && Scrim.isParticipating(post, user.id)) || - Association.isVisible({ - associations, - visibility: post.visibility, - contentOwnerUserId: post.users.find((u) => u.isOwner)?.id, - }), - ) - .map((post) => ({ - ...post, - visibility: null, - isPrivate: !Association.isPublic({ + // a filter for an association the viewer is not in is ignored rather than showing an empty page + const associationFilter = + associations?.actual.find( + (association) => association.id === associationId, + ) ?? null; + + const visiblePosts = (await ScrimPostRepository.findAllRelevant()).filter( + (post) => + (user && Scrim.isParticipating(post, user.id)) || + Association.isVisible({ + associations, visibility: post.visibility, + contentOwnerUserId: post.users.find((u) => u.isOwner)?.id, }), - })); + ); + + const divided = dividePosts(visiblePosts, user?.id); + + // the association filter narrows browsing only, the viewer's own and booked posts stay listed + const dividedPosts = { + neutral: divided.neutral + .filter( + (post) => + !associationFilter || + Association.mentionsAssociation({ + visibility: post.visibility, + associationId: associationFilter.id, + }), + ) + .map(censorVisibility), + owned: divided.owned.map(censorVisibility), + booked: divided.booked.map(censorVisibility), + }; const cardUserIds = R.unique( - posts.flatMap((post) => [ + [ + ...dividedPosts.neutral, + ...dividedPosts.owned, + ...dividedPosts.booked, + ].flatMap((post) => [ ...post.users.map((postUser) => postUser.id), ...post.requests.flatMap((postRequest) => postRequest.users.map((requestUser) => requestUser.id), @@ -58,7 +78,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { ]), ); - const dividedPosts = dividePosts(posts, user?.id); const teams = user ? await TeamRepository.findAllByMemberUserId(user.id) : []; return { @@ -73,6 +92,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { viewerId: user?.id ?? null, }), filters, + associationFilter: associationFilter + ? { id: associationFilter.id, name: associationFilter.name } + : null, + associationOptions: + associations?.actual.map((association) => ({ + id: association.id, + name: association.name, + })) ?? [], canSaveAsDefault: user != null && !R.isDeepEqual( @@ -82,6 +109,17 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }; }; +/** Replaces the raw visibility with a flag, so that association details do not reach the client. */ +function censorVisibility(post: ScrimPost): ScrimPost { + return { + ...post, + visibility: null, + isPrivate: !Association.isPublic({ + visibility: post.visibility, + }), + }; +} + /** How the viewer's teams relate to the requestable posts, one entry per post: what the fit indicators on cards and in the request dialog resolve from. */ async function rosterAvailability({ posts, diff --git a/app/features/scrims/routes/scrims.tsx b/app/features/scrims/routes/scrims.tsx index bbc4696b5..d868c5861 100644 --- a/app/features/scrims/routes/scrims.tsx +++ b/app/features/scrims/routes/scrims.tsx @@ -8,6 +8,7 @@ import * as R from "remeda"; import * as v from "valibot"; import { EmptyState } from "~/components/EmptyState"; import { LinkButton, SendouButton } from "~/components/elements/Button"; +import { SendouSelect, SendouSelectItem } from "~/components/elements/Select"; import { FilterBar } from "~/components/filter-bar/FilterBar"; import { LocaleTime } from "~/components/LocaleTime"; import { associationsPage } from "~/features/associations/associations-urls"; @@ -197,6 +198,35 @@ function Filters() { setParams({ ...filters, ...partial, useDefaults: false }); }; + const associationPill = + data.associationOptions.length > 0 + ? [ + { + key: "association", + name: t("scrims:filters.association"), + formattedValue: data.associationFilter?.name ?? null, + onRemove: () => setParams({ associationId: null }), + testId: "association-filter", + popover: ( + + setParams({ associationId: key as number | null }) + } + > + {({ id, name }) => ( + + {name} + + )} + + ), + }, + ] + : []; + return ( ), }, + ...associationPill, ]} onReset={ - !Scrim.filtersAreDefault(filters) + !Scrim.filtersAreDefault(filters) || data.associationFilter ? () => - writeFilters({ + setParams({ weekdayTimes: null, weekendTimes: null, divs: null, + associationId: null, + useDefaults: false, }) : undefined } diff --git a/app/features/scrims/scrims-search-params.test.ts b/app/features/scrims/scrims-search-params.test.ts index ebea18d3c..bb4ef7e7c 100644 --- a/app/features/scrims/scrims-search-params.test.ts +++ b/app/features/scrims/scrims-search-params.test.ts @@ -25,6 +25,7 @@ describe("scrimsSearchParams", () => { ], pendingRequestPostId: [null, 1, 987654], useDefaults: [true, false], + associationId: [null, 1, 42], }); }); @@ -49,5 +50,11 @@ describe("scrimsSearchParams", () => { ["-2"], ["1.5"], ]); + assertDecodesToDefault(scrimsSearchParams, "associationId", [ + ["abc"], + ["0"], + ["-2"], + ["1.5"], + ]); }); }); diff --git a/app/features/scrims/scrims-search-params.ts b/app/features/scrims/scrims-search-params.ts index 45122c437..96a504ec1 100644 --- a/app/features/scrims/scrims-search-params.ts +++ b/app/features/scrims/scrims-search-params.ts @@ -9,6 +9,11 @@ export const scrimsSearchParams = SearchParams.define({ divs: SP.custom(divsCodec, { loader: true }), /** False once the user has edited the filters, making the URL win over their saved defaults. */ useDefaults: SP.param(v.boolean(), { default: true, loader: true }), + /** Only posts whose visibility ever mentioned this association, not part of the saved default filters. */ + associationId: SP.param( + v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), + { loader: true }, + ), pendingRequestPostId: SP.param( v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), { diff --git a/app/features/scrims/scrims-urls.ts b/app/features/scrims/scrims-urls.ts new file mode 100644 index 000000000..156c786a0 --- /dev/null +++ b/app/features/scrims/scrims-urls.ts @@ -0,0 +1,5 @@ +import { scrimsPage } from "~/utils/urls"; +import { scrimsSearchParams } from "./scrims-search-params"; + +export const scrimsByAssociationPage = (associationId: number) => + scrimsSearchParams.href(scrimsPage(), { associationId }); diff --git a/changelog/2026-09-20-scrims-association-filter.md b/changelog/2026-09-20-scrims-association-filter.md new file mode 100644 index 000000000..388119aa7 --- /dev/null +++ b/changelog/2026-09-20-scrims-association-filter.md @@ -0,0 +1,5 @@ +--- +navItem: scrims +type: feature +--- +Scrims can be filtered by association, with a "View scrims" shortcut on the associations page diff --git a/locales/da/scrims.json b/locales/da/scrims.json index fca64413d..9b7ea4e39 100644 --- a/locales/da/scrims.json +++ b/locales/da/scrims.json @@ -14,6 +14,7 @@ "filters.weekdayTimes": "", "filters.weekendTimes": "", "filters.divs": "", + "filters.association": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", @@ -51,6 +52,7 @@ "associations.join.title": "", "associations.delete.title": "", "associations.admin": "", + "associations.viewScrims": "", "associations.leave.title": "", "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "", diff --git a/locales/de/scrims.json b/locales/de/scrims.json index d00d2fc95..8b4e638e5 100644 --- a/locales/de/scrims.json +++ b/locales/de/scrims.json @@ -14,6 +14,7 @@ "filters.weekdayTimes": "", "filters.weekendTimes": "", "filters.divs": "", + "filters.association": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", @@ -51,6 +52,7 @@ "associations.join.title": "Assoziation {{name}} beitreten?", "associations.delete.title": "Assoziation {{name}} löschen?", "associations.admin": "Admin: {{username}}", + "associations.viewScrims": "", "associations.leave.title": "Assoziation {{name}} verlassen?", "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "Verlassen", diff --git a/locales/en/scrims.json b/locales/en/scrims.json index cf3cfbef4..436bdfaa6 100644 --- a/locales/en/scrims.json +++ b/locales/en/scrims.json @@ -14,6 +14,7 @@ "filters.weekdayTimes": "Weekday times", "filters.weekendTimes": "Weekend times", "filters.divs": "Divs", + "filters.association": "Association", "filters.showFiltered": "Show filtered ({{count}})", "filters.hideFiltered": "Hide filtered ({{count}})", "filters.showPendingRequests": "Show pending requests ({{count}})", @@ -51,6 +52,7 @@ "associations.join.title": "Join {{name}} association?", "associations.delete.title": "Delete {{name}} association?", "associations.admin": "Admin: {{username}}", + "associations.viewScrims": "View scrims", "associations.leave.title": "Leave {{name}} association?", "associations.leave.titleWithNewAdmin": "Leave {{name}} association? {{username}} will become the new admin", "associations.leave.action": "Leave", diff --git a/locales/es-ES/scrims.json b/locales/es-ES/scrims.json index 8f9767ab0..5124a4bf9 100644 --- a/locales/es-ES/scrims.json +++ b/locales/es-ES/scrims.json @@ -14,6 +14,7 @@ "filters.weekdayTimes": "Horarios entre semana", "filters.weekendTimes": "Horarios de fin de semana", "filters.divs": "Divs", + "filters.association": "", "filters.showFiltered": "Mostrar filtrados ({{count}})", "filters.hideFiltered": "Ocultar filtrados ({{count}})", "filters.showPendingRequests": "Mostrar peticiones pendientes ({{count}})", @@ -51,6 +52,7 @@ "associations.join.title": "¿Unirse a la asociación {{name}}?", "associations.delete.title": "¿Eliminar la asociación {{name}}?", "associations.admin": "Administrador: {{username}}", + "associations.viewScrims": "", "associations.leave.title": "¿Salir de la asociación {{name}}?", "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "Salir", diff --git a/locales/es-US/scrims.json b/locales/es-US/scrims.json index 8f9767ab0..5124a4bf9 100644 --- a/locales/es-US/scrims.json +++ b/locales/es-US/scrims.json @@ -14,6 +14,7 @@ "filters.weekdayTimes": "Horarios entre semana", "filters.weekendTimes": "Horarios de fin de semana", "filters.divs": "Divs", + "filters.association": "", "filters.showFiltered": "Mostrar filtrados ({{count}})", "filters.hideFiltered": "Ocultar filtrados ({{count}})", "filters.showPendingRequests": "Mostrar peticiones pendientes ({{count}})", @@ -51,6 +52,7 @@ "associations.join.title": "¿Unirse a la asociación {{name}}?", "associations.delete.title": "¿Eliminar la asociación {{name}}?", "associations.admin": "Administrador: {{username}}", + "associations.viewScrims": "", "associations.leave.title": "¿Salir de la asociación {{name}}?", "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "Salir", diff --git a/locales/fr-CA/scrims.json b/locales/fr-CA/scrims.json index fca64413d..9b7ea4e39 100644 --- a/locales/fr-CA/scrims.json +++ b/locales/fr-CA/scrims.json @@ -14,6 +14,7 @@ "filters.weekdayTimes": "", "filters.weekendTimes": "", "filters.divs": "", + "filters.association": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", @@ -51,6 +52,7 @@ "associations.join.title": "", "associations.delete.title": "", "associations.admin": "", + "associations.viewScrims": "", "associations.leave.title": "", "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "", diff --git a/locales/fr-EU/scrims.json b/locales/fr-EU/scrims.json index 58fea6d1a..e1c4357e7 100644 --- a/locales/fr-EU/scrims.json +++ b/locales/fr-EU/scrims.json @@ -14,6 +14,7 @@ "filters.weekdayTimes": "", "filters.weekendTimes": "", "filters.divs": "", + "filters.association": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", @@ -51,6 +52,7 @@ "associations.join.title": "Rejoindre l'association {{name}} ?", "associations.delete.title": "Supprimer l'association {{name}} ?", "associations.admin": "Admin: {{username}}", + "associations.viewScrims": "", "associations.leave.title": "Quitter l'association {{name}} ?", "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "Quitter", diff --git a/locales/he/scrims.json b/locales/he/scrims.json index fca64413d..9b7ea4e39 100644 --- a/locales/he/scrims.json +++ b/locales/he/scrims.json @@ -14,6 +14,7 @@ "filters.weekdayTimes": "", "filters.weekendTimes": "", "filters.divs": "", + "filters.association": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", @@ -51,6 +52,7 @@ "associations.join.title": "", "associations.delete.title": "", "associations.admin": "", + "associations.viewScrims": "", "associations.leave.title": "", "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "", diff --git a/locales/it/scrims.json b/locales/it/scrims.json index fca64413d..9b7ea4e39 100644 --- a/locales/it/scrims.json +++ b/locales/it/scrims.json @@ -14,6 +14,7 @@ "filters.weekdayTimes": "", "filters.weekendTimes": "", "filters.divs": "", + "filters.association": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", @@ -51,6 +52,7 @@ "associations.join.title": "", "associations.delete.title": "", "associations.admin": "", + "associations.viewScrims": "", "associations.leave.title": "", "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "", diff --git a/locales/ja/scrims.json b/locales/ja/scrims.json index fca64413d..9b7ea4e39 100644 --- a/locales/ja/scrims.json +++ b/locales/ja/scrims.json @@ -14,6 +14,7 @@ "filters.weekdayTimes": "", "filters.weekendTimes": "", "filters.divs": "", + "filters.association": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", @@ -51,6 +52,7 @@ "associations.join.title": "", "associations.delete.title": "", "associations.admin": "", + "associations.viewScrims": "", "associations.leave.title": "", "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "", diff --git a/locales/ko/scrims.json b/locales/ko/scrims.json index fca64413d..9b7ea4e39 100644 --- a/locales/ko/scrims.json +++ b/locales/ko/scrims.json @@ -14,6 +14,7 @@ "filters.weekdayTimes": "", "filters.weekendTimes": "", "filters.divs": "", + "filters.association": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", @@ -51,6 +52,7 @@ "associations.join.title": "", "associations.delete.title": "", "associations.admin": "", + "associations.viewScrims": "", "associations.leave.title": "", "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "", diff --git a/locales/nl/scrims.json b/locales/nl/scrims.json index fca64413d..9b7ea4e39 100644 --- a/locales/nl/scrims.json +++ b/locales/nl/scrims.json @@ -14,6 +14,7 @@ "filters.weekdayTimes": "", "filters.weekendTimes": "", "filters.divs": "", + "filters.association": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", @@ -51,6 +52,7 @@ "associations.join.title": "", "associations.delete.title": "", "associations.admin": "", + "associations.viewScrims": "", "associations.leave.title": "", "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "", diff --git a/locales/pl/scrims.json b/locales/pl/scrims.json index fca64413d..9b7ea4e39 100644 --- a/locales/pl/scrims.json +++ b/locales/pl/scrims.json @@ -14,6 +14,7 @@ "filters.weekdayTimes": "", "filters.weekendTimes": "", "filters.divs": "", + "filters.association": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", @@ -51,6 +52,7 @@ "associations.join.title": "", "associations.delete.title": "", "associations.admin": "", + "associations.viewScrims": "", "associations.leave.title": "", "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "", diff --git a/locales/pt-BR/scrims.json b/locales/pt-BR/scrims.json index fca64413d..9b7ea4e39 100644 --- a/locales/pt-BR/scrims.json +++ b/locales/pt-BR/scrims.json @@ -14,6 +14,7 @@ "filters.weekdayTimes": "", "filters.weekendTimes": "", "filters.divs": "", + "filters.association": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", @@ -51,6 +52,7 @@ "associations.join.title": "", "associations.delete.title": "", "associations.admin": "", + "associations.viewScrims": "", "associations.leave.title": "", "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "", diff --git a/locales/ru/scrims.json b/locales/ru/scrims.json index fca64413d..9b7ea4e39 100644 --- a/locales/ru/scrims.json +++ b/locales/ru/scrims.json @@ -14,6 +14,7 @@ "filters.weekdayTimes": "", "filters.weekendTimes": "", "filters.divs": "", + "filters.association": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", @@ -51,6 +52,7 @@ "associations.join.title": "", "associations.delete.title": "", "associations.admin": "", + "associations.viewScrims": "", "associations.leave.title": "", "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "", diff --git a/locales/zh/scrims.json b/locales/zh/scrims.json index fed6f4d1a..215a8aecf 100644 --- a/locales/zh/scrims.json +++ b/locales/zh/scrims.json @@ -14,6 +14,7 @@ "filters.weekdayTimes": "工作日时间", "filters.weekendTimes": "周末时间", "filters.divs": "", + "filters.association": "", "filters.showFiltered": "显示已过滤内容 ({{count}})", "filters.hideFiltered": "隐藏已过滤内容 ({{count}})", "filters.showPendingRequests": "显示待处理请求 ({{count}})", @@ -51,6 +52,7 @@ "associations.join.title": "加入群组 {{name}} ?", "associations.delete.title": "删除群组 {{name}} ?", "associations.admin": "管理员: {{username}}", + "associations.viewScrims": "", "associations.leave.title": "退出群组 {{name}} ?", "associations.leave.titleWithNewAdmin": "", "associations.leave.action": "退出", From ce39bdddd92ff331113eca229b136b4fff276ccc Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:13:32 +0300 Subject: [PATCH 27/50] Association starred members do more --- .../AssociationRepository.server.ts | 47 +++++-- .../actions/associations.server.ts | 39 +++--- .../loaders/associations.server.ts | 2 +- .../associations/routes/associations.test.ts | 109 ++++++++++++++++ .../associations/routes/associations.tsx | 118 +++++++++--------- changelog/2026-09-20-association-managers.md | 2 +- e2e/associations.spec.ts | 5 +- locales/en/scrims.json | 8 +- 8 files changed, 238 insertions(+), 92 deletions(-) diff --git a/app/features/associations/AssociationRepository.server.ts b/app/features/associations/AssociationRepository.server.ts index f61618fff..8a9bfe359 100644 --- a/app/features/associations/AssociationRepository.server.ts +++ b/app/features/associations/AssociationRepository.server.ts @@ -90,25 +90,54 @@ async function findBy( return associations.map((a) => { const members = a.members ?? []; - const adminIds = members - .filter((member) => member.role === "ADMIN") - .map((user) => user.id); + const adminIds = memberIdsWithRole(members, "ADMIN"); + const managerIds = memberIdsWithRole(members, "MANAGER"); return { ...a, + members: a.members?.map((member) => ({ + ...member, + permissions: { + REMOVE: memberRemoverIds({ member, adminIds, managerIds }), + }, + })), permissions: { MANAGE: adminIds, - SHARE_INVITE_LINK: [ - ...adminIds, - ...members - .filter((member) => member.role === "MANAGER") - .map((user) => user.id), - ], + MANAGE_INVITE_LINK: [...adminIds, ...managerIds], }, }; }); } +function memberIdsWithRole( + members: Array<{ id: number; role: Tables["AssociationMember"]["role"] }>, + role: Tables["AssociationMember"]["role"], +) { + return members + .filter((member) => member.role === role) + .map((member) => member.id); +} + +/** Admins can remove anyone but themselves, managers only regular members. */ +function memberRemoverIds({ + member, + adminIds, + managerIds, +}: { + member: { id: number; role: Tables["AssociationMember"]["role"] }; + adminIds: Array; + managerIds: Array; +}) { + const removerIds = + member.role === "ADMIN" + ? [] + : member.role === "MANAGER" + ? adminIds + : [...adminIds, ...managerIds]; + + return removerIds.filter((id) => id !== member.id); +} + const DEFAULT_VIRTUAL_ASSOCIATIONS: Array = [ "FRIENDS", ]; diff --git a/app/features/associations/actions/associations.server.ts b/app/features/associations/actions/associations.server.ts index 5dd0264d6..7358e172e 100644 --- a/app/features/associations/actions/associations.server.ts +++ b/app/features/associations/actions/associations.server.ts @@ -22,13 +22,13 @@ export const action = async ({ request }: ActionFunctionArgs) => { switch (data._action) { case "REMOVE_MEMBER": { - await validateHasManagePermissions(data.associationId); - - errorToastIfFalsy( - data.userId !== user.id, - "Cannot remove yourself from the association", + const association = await findAssociation(data.associationId); + const memberToRemove = badRequestIfFalsy( + association.members!.find((member) => member.id === data.userId), ); + requirePermission(memberToRemove, "REMOVE"); + await AssociationRepository.deleteMember({ userId: data.userId, associationId: data.associationId, @@ -38,8 +38,9 @@ export const action = async ({ request }: ActionFunctionArgs) => { } case "ADD_MANAGER": case "REMOVE_MANAGER": { - const association = await validateHasManagePermissions( + const association = await requireAssociationPermission( data.associationId, + "MANAGE", ); errorToastIfFalsy( @@ -58,14 +59,17 @@ export const action = async ({ request }: ActionFunctionArgs) => { break; } case "DELETE_ASSOCIATION": { - await validateHasManagePermissions(data.associationId); + await requireAssociationPermission(data.associationId, "MANAGE"); await AssociationRepository.deleteById(data.associationId); break; } case "REFRESH_INVITE_CODE": { - await validateHasManagePermissions(data.associationId); + await requireAssociationPermission( + data.associationId, + "MANAGE_INVITE_LINK", + ); await AssociationRepository.refreshInviteCode(data.associationId); @@ -105,11 +109,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { break; } case "LEAVE_ASSOCIATION": { - const association = badRequestIfFalsy( - await AssociationRepository.findById(data.associationId, { - withMembers: true, - }), - ); + const association = await findAssociation(data.associationId); const isAdmin = association.permissions.MANAGE.includes(user.id); const newAdmin = isAdmin @@ -137,12 +137,19 @@ export const action = async ({ request }: ActionFunctionArgs) => { return null; }; -async function validateHasManagePermissions(associationId: number) { - const association = badRequestIfFalsy( +async function findAssociation(associationId: number) { + return badRequestIfFalsy( await AssociationRepository.findById(associationId, { withMembers: true }), ); +} - requirePermission(association, "MANAGE"); +async function requireAssociationPermission( + associationId: number, + permission: "MANAGE" | "MANAGE_INVITE_LINK", +) { + const association = await findAssociation(associationId); + + requirePermission(association, permission); return association; } diff --git a/app/features/associations/loaders/associations.server.ts b/app/features/associations/loaders/associations.server.ts index ff276850f..575d5c6d1 100644 --- a/app/features/associations/loaders/associations.server.ts +++ b/app/features/associations/loaders/associations.server.ts @@ -18,7 +18,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const associationsWithInviteCodes = await Promise.all( associations.map(async (association) => ({ ...association, - inviteCode: association.permissions.SHARE_INVITE_LINK.includes(user.id) + inviteCode: association.permissions.MANAGE_INVITE_LINK.includes(user.id) ? await AssociationRepository.findInviteCodeById(association.id) : undefined, })), diff --git a/app/features/associations/routes/associations.test.ts b/app/features/associations/routes/associations.test.ts index 915c0c603..2d826c13e 100644 --- a/app/features/associations/routes/associations.test.ts +++ b/app/features/associations/routes/associations.test.ts @@ -70,6 +70,115 @@ describe("Associations page action", () => { expect(associations[0]!.inviteCode).toBeUndefined(); }); + test("starred member can reset the invite link", async () => { + const association = await AssociationFactory.create( + { userId: adminId() }, + { managerUserIds: [memberId()] }, + ); + + const before = await associationsLoader({ user: memberId() }); + + await associationsAction( + { _action: "REFRESH_INVITE_CODE", associationId: association.id }, + { user: memberId() }, + ); + + const after = await associationsLoader({ user: memberId() }); + expect(after.associations[0]!.inviteCode).toBeTruthy(); + expect(after.associations[0]!.inviteCode).not.toBe( + before.associations[0]!.inviteCode, + ); + }); + + test("unstarred member can't reset the invite link", async () => { + const association = await AssociationFactory.create( + { userId: adminId() }, + { memberUserIds: [memberId()] }, + ); + + await expect( + associationsAction( + { _action: "REFRESH_INVITE_CODE", associationId: association.id }, + { user: memberId() }, + ), + ).rejects.toThrow(); + }); + + test("starred member can remove an unstarred member", async () => { + const association = await AssociationFactory.create( + { userId: adminId() }, + { managerUserIds: [memberId()], memberUserIds: [otherMemberId()] }, + ); + + await associationsAction( + { + _action: "REMOVE_MEMBER", + associationId: association.id, + userId: otherMemberId(), + }, + { user: memberId() }, + ); + + const { associations } = await associationsLoader({ user: memberId() }); + expect(associations[0]!.members!.map((member) => member.id)).not.toContain( + otherMemberId(), + ); + }); + + test("starred member can't remove another starred member", async () => { + const association = await AssociationFactory.create( + { userId: adminId() }, + { managerUserIds: [memberId(), otherMemberId()] }, + ); + + await expect( + associationsAction( + { + _action: "REMOVE_MEMBER", + associationId: association.id, + userId: otherMemberId(), + }, + { user: memberId() }, + ), + ).rejects.toThrow(); + }); + + test("starred member can't remove the admin", async () => { + const association = await AssociationFactory.create( + { userId: adminId() }, + { managerUserIds: [memberId()] }, + ); + + await expect( + associationsAction( + { + _action: "REMOVE_MEMBER", + associationId: association.id, + userId: adminId(), + }, + { user: memberId() }, + ), + ).rejects.toThrow(); + }); + + test("admin can't remove themselves", async () => { + const association = await AssociationFactory.create( + { userId: adminId() }, + { memberUserIds: [memberId()] }, + ); + + await expect( + associationsAction( + { + _action: "REMOVE_MEMBER", + associationId: association.id, + userId: adminId(), + }, + { user: adminId() }, + ), + ).rejects.toThrow(); + }); + test("member can't star another member", async () => { const association = await AssociationFactory.create( { userId: adminId() }, diff --git a/app/features/associations/routes/associations.tsx b/app/features/associations/routes/associations.tsx index aba7b6405..f99140b54 100644 --- a/app/features/associations/routes/associations.tsx +++ b/app/features/associations/routes/associations.tsx @@ -177,7 +177,7 @@ function AssociationSection({ key={member.id} member={member} associationId={association.id} - showControls={canManage && member.id !== user?.id} + canStar={canManage && member.id !== user?.id} /> ))}
@@ -190,7 +190,6 @@ function AssociationSection({ ) : null} @@ -200,11 +199,9 @@ function AssociationSection({ function AssociationInviteCodeActions({ associationId, inviteCode, - canResetLink, }: { associationId: number; inviteCode: string; - canResetLink: boolean; }) { const { t } = useTranslation(["common", "scrims"]); const { copyToClipboard, copySuccess } = useCopyToClipboard(); @@ -225,17 +222,15 @@ function AssociationInviteCodeActions({ aria-label="Copy to clipboard" />
- {canResetLink ? ( - - {t("scrims:associations.shareLink.reset")} - - ) : null} + + {t("scrims:associations.shareLink.reset")} +
); } @@ -243,21 +238,22 @@ function AssociationInviteCodeActions({ function AssociationMember({ member, associationId, - showControls, + canStar, }: { member: NonNullable< AssociationsLoaderData["associations"][number]["members"] >[number]; associationId: number; - showControls?: boolean; + canStar: boolean; }) { const { t } = useTranslation(["common", "scrims"]); + const canRemove = useHasPermission(member, "REMOVE"); return (
- {!showControls && member.role === "MANAGER" ? ( + {!canStar && member.role === "MANAGER" ? ( ) : null}
- {showControls ? ( + {canStar || canRemove ? (
- - } - aria-label={t( - member.role === "MANAGER" - ? "scrims:associations.manager.remove" - : "scrims:associations.manager.add", - { username: member.username }, - )} - /> - - } - className="small-text" - variant="minimal-destructive" + variant="minimal" size="small" - type="submit" + className="small-text" + icon={ + + } + aria-label={t( + member.role === "MANAGER" + ? "scrims:associations.manager.remove" + : "scrims:associations.manager.add", + { username: member.username }, + )} /> - + ) : null} + {canRemove ? ( + + } + className="small-text" + variant="minimal-destructive" + size="small" + type="submit" + /> + + ) : null}
) : null}
diff --git a/changelog/2026-09-20-association-managers.md b/changelog/2026-09-20-association-managers.md index 7b1eadbba..ffc7b34e6 100644 --- a/changelog/2026-09-20-association-managers.md +++ b/changelog/2026-09-20-association-managers.md @@ -2,4 +2,4 @@ navItem: associations type: feature --- -Association admins can star members to let them share the invite link, and hand the association over by leaving +Association admins can star members to share the work of running it. Starred members can share and reset the invite link and remove unstarred members. With at least one starred member the admin can leave, and the longest-standing starred member takes over. \ No newline at end of file diff --git a/e2e/associations.spec.ts b/e2e/associations.spec.ts index 53ae13b23..1a583bd2c 100644 --- a/e2e/associations.spec.ts +++ b/e2e/associations.spec.ts @@ -69,7 +69,8 @@ test.describe("Associations", () => { await associations.goto(); await expect(associations.locators.inviteLinkInputs).toHaveCount(1); - await isNotVisible(associations.locators.resetLinkButton); + await expect(associations.locators.resetLinkButton).toBeVisible(); + await isNotVisible(associations.locators.deleteButtons); await impersonate(page, ADMIN_ID); await associations.goto(); @@ -78,7 +79,7 @@ test.describe("Associations", () => { await impersonate(page, NZAP_TEST_ID); await associations.goto(); - await expect(associations.locators.resetLinkButton).toBeVisible(); + await expect(associations.locators.deleteButtons).toHaveCount(1); }); test("joins and leaves an association", async ({ page, factories }) => { diff --git a/locales/en/scrims.json b/locales/en/scrims.json index 436bdfaa6..96f3c83b3 100644 --- a/locales/en/scrims.json +++ b/locales/en/scrims.json @@ -58,10 +58,10 @@ "associations.leave.action": "Leave", "associations.shareLink.title": "Share link to add members", "associations.shareLink.reset": "Reset link", - "associations.manager.label": "Can share the invite link", - "associations.manager.explanation": "Starred members can share the invite link. With at least one starred member you can leave the association, and the longest-standing starred member will become its new admin.", - "associations.manager.add": "Let {{username}} share the invite link", - "associations.manager.remove": "Stop {{username}} from sharing the invite link", + "associations.manager.label": "Starred member", + "associations.manager.explanation": "Starred members can share and reset the invite link and remove unstarred members. With at least one starred member you can leave the association, and the longest-standing starred member will become its new admin.", + "associations.manager.add": "Star {{username}}", + "associations.manager.remove": "Unstar {{username}}", "associations.removeMember.title": "Remove {{username}} from the association?", "associations.forms.title": "Creating a new association", "banner.canceled.header": "Canceled by {{user}}", From 9feab41d05df0c7425f2d88dd547fee25061c79d Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:41:52 +0300 Subject: [PATCH 28/50] Add trust proxy --- patches/@react-router__serve@8.1.0.patch | 3 ++- pnpm-lock.yaml | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/patches/@react-router__serve@8.1.0.patch b/patches/@react-router__serve@8.1.0.patch index 22beaff6c..e8c9ef9f9 100644 --- a/patches/@react-router__serve@8.1.0.patch +++ b/patches/@react-router__serve@8.1.0.patch @@ -2,11 +2,12 @@ diff --git a/dist/cli.js b/dist/cli.js index 7871cebe46f6df886b76364db5346adfa1838622..cbb32659bb2a57c2cd847ddfd35a9a07ab22a033 100644 --- a/dist/cli.js +++ b/dist/cli.js -@@ -110,7 +110,6 @@ async function run() { +@@ -110,7 +110,7 @@ async function run() { }; let app = express(); app.disable("x-powered-by"); - if (!isRSCBuild) app.use(compression()); ++ app.set("trust proxy", true); let expressPublicPath = getExpressPath(build.publicPath); app.use(path.posix.join(expressPublicPath, "assets"), express.static(path.join(build.assetsBuildDirectory, "assets"), { immutable: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eddc755b8..3dafc4e24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false patchedDependencies: - '@react-router/serve@8.1.0': 0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738 + '@react-router/serve@8.1.0': 08673b8f56959ba82db6ff8ed55ba89920d92eed8e1beedede3eb64f5a44f673 '@techstark/opencv-js@5.0.0-release.1': 0e23b4243e8e2f88294c00eefdf8dfc3624f59442bd0c8d075be89a8136144f3 i18next@26.3.6: 34c245cebb3f9c7861d2576a3a44228364cb898037f86b23a5bbf8b20f55fb1a kysely@0.29.0: a3e94339939b1be5b70610601e96fff19ed5678aab525de24b52dfc5212c686a @@ -44,7 +44,7 @@ importers: version: 8.3.1(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2) '@react-router/serve': specifier: 8.1.0 - version: 8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2) + version: 8.1.0(patch_hash=08673b8f56959ba82db6ff8ed55ba89920d92eed8e1beedede3eb64f5a44f673)(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2) '@techstark/opencv-js': specifier: 5.0.0-release.1 version: 5.0.0-release.1(patch_hash=0e23b4243e8e2f88294c00eefdf8dfc3624f59442bd0c8d075be89a8136144f3) @@ -186,7 +186,7 @@ importers: version: 1.63.0 '@react-router/dev': specifier: 8.3.1 - version: 8.3.1(@react-router/serve@8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2))(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1)) + version: 8.3.1(@react-router/serve@8.1.0(patch_hash=08673b8f56959ba82db6ff8ed55ba89920d92eed8e1beedede3eb64f5a44f673)(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2))(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1)) '@types/node': specifier: 26.5.1 version: 26.5.1 @@ -5661,7 +5661,7 @@ snapshots: '@radix-ui/rect@1.1.1': {} - '@react-router/dev@8.3.1(@react-router/serve@8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2))(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1))': + '@react-router/dev@8.3.1(@react-router/serve@8.1.0(patch_hash=08673b8f56959ba82db6ff8ed55ba89920d92eed8e1beedede3eb64f5a44f673)(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2))(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@babel/core': 7.29.7 '@babel/generator': 7.29.8 @@ -5689,7 +5689,7 @@ snapshots: valibot: 1.5.0(typescript@7.0.2) vite: 8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.1) optionalDependencies: - '@react-router/serve': 8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2) + '@react-router/serve': 8.1.0(patch_hash=08673b8f56959ba82db6ff8ed55ba89920d92eed8e1beedede3eb64f5a44f673)(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2) typescript: 7.0.2 transitivePeerDependencies: - babel-plugin-macros @@ -5717,7 +5717,7 @@ snapshots: optionalDependencies: typescript: 7.0.2 - '@react-router/serve@8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2)': + '@react-router/serve@8.1.0(patch_hash=08673b8f56959ba82db6ff8ed55ba89920d92eed8e1beedede3eb64f5a44f673)(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2)': dependencies: '@react-router/express': 8.1.0(express@5.2.1)(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2) '@react-router/node': 8.1.0(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@7.0.2) From 65f29db7e1b7d24ff14a7a99f3c8d98dc7344d40 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:02:58 +0300 Subject: [PATCH 29/50] Revert "Dialog scroll lock" This reverts commit 5906cdeb77c35e92cfaba46401fcea5b9b418fda. --- .../elements/Dialog.browser.test.tsx | 48 ----------- app/components/elements/Dialog.tsx | 85 +------------------ changelog/2026-09-20-dialog-scroll-lock.md | 4 - e2e/pages/sendouq/sendouq-match-page.ts | 2 +- 4 files changed, 2 insertions(+), 137 deletions(-) delete mode 100644 changelog/2026-09-20-dialog-scroll-lock.md diff --git a/app/components/elements/Dialog.browser.test.tsx b/app/components/elements/Dialog.browser.test.tsx index 10b0d737c..1d073b907 100644 --- a/app/components/elements/Dialog.browser.test.tsx +++ b/app/components/elements/Dialog.browser.test.tsx @@ -120,54 +120,6 @@ describe("SendouDialog", () => { expect(openDialog().open).toBe(true); }); - test("locks page scrolling while open without changing the page width", async () => { - const tall = document.createElement("div"); - tall.style.height = "300vh"; - document.body.appendChild(tall); - cleanupFns.push(() => tall.remove()); - const root = document.documentElement; - const widthBefore = root.clientWidth; - - const screen = await render( - withRouter( - Open} - showCloseButton - > - Content - , - ), - ); - - await screen.getByRole("button", { name: "Open" }).click(); - await expect.element(screen.getByText("Content")).toBeVisible(); - await vi.waitFor(() => expect(root.style.overflow).toBe("hidden")); - expect(root.clientWidth).toBe(widthBefore); - - await screen.getByRole("button", { name: "Close" }).click(); - await vi.waitFor(() => expect(root.style.overflow).toBe("")); - expect(root.style.scrollbarGutter).toBe(""); - expect(root.clientWidth).toBe(widthBefore); - }); - - test("releases the scroll lock when an open dialog unmounts", async () => { - const screen = await render( - withRouter( - {}}> - Content - , - ), - ); - await expect.element(screen.getByText("Content")).toBeVisible(); - await vi.waitFor(() => - expect(document.documentElement.style.overflow).toBe("hidden"), - ); - - await screen.unmount(); - expect(document.documentElement.style.overflow).toBe(""); - }); - test("focuses the dialog itself instead of the close button on open", async () => { await render( withRouter( diff --git a/app/components/elements/Dialog.tsx b/app/components/elements/Dialog.tsx index 3bbdd53c3..5f6df54a7 100644 --- a/app/components/elements/Dialog.tsx +++ b/app/components/elements/Dialog.tsx @@ -67,19 +67,9 @@ function DialogElement({ children, ref, }: DialogElementProps) { - const dialogRef = React.useRef(null); - useScrollLockWhileOpen(dialogRef); - return ( { - dialogRef.current = dialog; - if (typeof ref === "function") { - ref(dialog); - } else if (ref) { - ref.current = dialog; - } - }} + ref={ref} id={id} className={clsx(className, { [styles.blurredBackdrop]: blurredBackdrop, @@ -110,79 +100,6 @@ function closeOnBackdropClick(event: React.MouseEvent) { } } -/** - * Locks page scrolling for as long as the dialog is open. Open state is read - * from the DOM (`toggle` events plus the initial `open`) rather than React - * state, so a dialog opened before hydration or closed natively is covered. - */ -function useScrollLockWhileOpen( - dialogRef: React.RefObject, -) { - React.useEffect(() => { - const dialog = dialogRef.current; - if (!dialog) return; - - let release: (() => void) | undefined; - const syncLock = (isOpen: boolean) => { - if (isOpen) { - release ??= lockPageScroll(); - } else { - release?.(); - release = undefined; - } - }; - const onToggle = (event: Event) => { - syncLock((event as ToggleEvent).newState === "open"); - }; - - dialog.addEventListener("toggle", onToggle); - syncLock(dialog.open); - return () => { - dialog.removeEventListener("toggle", onToggle); - syncLock(false); - }; - }, [dialogRef]); -} - -let pageScrollLocks = 0; -let restorePageScroll: (() => void) | undefined; - -/** - * Hides the root scrollbar without the page reflowing into its space: when a - * classic scrollbar was taking up width, `scrollbar-gutter: stable` keeps that - * width reserved. Overlay scrollbars take no width so nothing is reserved. - * Reference counted so nested dialogs release the lock only once all close. - */ -function lockPageScroll() { - if (pageScrollLocks++ === 0) { - const root = document.documentElement; - const scrollbarTakesWidth = window.innerWidth > root.clientWidth; - const previous = { - overflow: root.style.overflow, - scrollbarGutter: root.style.scrollbarGutter, - }; - - root.style.overflow = "hidden"; - if (scrollbarTakesWidth) { - root.style.scrollbarGutter = "stable"; - } - restorePageScroll = () => { - root.style.overflow = previous.overflow; - root.style.scrollbarGutter = previous.scrollbarGutter; - }; - } - - let released = false; - return () => { - if (released) return; - released = true; - if (--pageScrollLocks === 0) { - restorePageScroll?.(); - restorePageScroll = undefined; - } - }; -} - /** Invoker commands open and close the dialog natively; this guards the JS fallback for browsers without them. */ function supportsInvokerCommands() { return "commandForElement" in HTMLButtonElement.prototype; diff --git a/changelog/2026-09-20-dialog-scroll-lock.md b/changelog/2026-09-20-dialog-scroll-lock.md deleted file mode 100644 index e50102c9a..000000000 --- a/changelog/2026-09-20-dialog-scroll-lock.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -type: bug ---- -The page behind an open dialog no longer scrolls, and the layout stays put when the scrollbar is hidden diff --git a/e2e/pages/sendouq/sendouq-match-page.ts b/e2e/pages/sendouq/sendouq-match-page.ts index 58b764cdc..9f25d1886 100644 --- a/e2e/pages/sendouq/sendouq-match-page.ts +++ b/e2e/pages/sendouq/sendouq-match-page.ts @@ -251,7 +251,7 @@ export class SendouQMatchPage { } /** Waits out the gap the loss confirm keeps between arming and accepting the second tap. */ -async function waitOutLossConfirmMinGap(page: Page) { +export async function waitOutLossConfirmMinGap(page: Page) { // biome-ignore lint/nursery/noPlaywrightWaitForTimeout: the min gap after arming has no observable end await page.waitForTimeout(LOSS_CONFIRM_MIN_GAP_MS); } From 68f008079cd902790db287ac0dc9b2a3a9c2710f Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:03:21 +0300 Subject: [PATCH 30/50] Changelog img generation script tweaks --- app/features/changelog/changelog-constants.ts | 4 ++-- docs/dev/how-to.md | 2 +- scripts/generate-changelog-image.ts | 13 +++++++++---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/features/changelog/changelog-constants.ts b/app/features/changelog/changelog-constants.ts index 429cd8026..5ebfed60a 100644 --- a/app/features/changelog/changelog-constants.ts +++ b/app/features/changelog/changelog-constants.ts @@ -22,12 +22,12 @@ export const DISCORD_EMOJI_NAMES: Record = { articles: "articles", vods: "vods", art: "art", - "tier-list-maker": "tier_list_maker", + "tier-list-maker": "tierlistmaker", links: "links", maps: "maps", medal: "medal", t: "t", - u: "u", + u: "user", associations: "associations", badges: "badges", luti: "luti", diff --git a/docs/dev/how-to.md b/docs/dev/how-to.md index b218ffbaf..7b84d8aa6 100644 --- a/docs/dev/how-to.md +++ b/docs/dev/how-to.md @@ -80,7 +80,7 @@ The image posted on social media on update day is built from the entry files in 1) Every commit with a user facing change adds one `changelog/YYYY-MM-DD-.md` per change. Frontmatter is `navItem` (optional, one of `NAV_ICONS` or a list of them such as `navItem: [calendar, scrims]`, omitted = sendou.ink logo) and `type` (`feature` or `bug`). The body is a one line headline, optionally followed by a markdown bullet list for a bigger release. Entries are never deleted, they are the update history. 2) Preview and tweak the graphic on the `/admin/changelog-image` page (dev only). Without a `?since=` it renders every entry ever committed. -3) With the dev server running, `pnpm run changelog:image ` writes `scripts/output/update-.png` from the entries added since that commit, copies the image to the clipboard. +3) With the dev server running, `pnpm run changelog:image ` (add `--port ` if the dev server is not on the default port) writes `scripts/output/update-.png` from the entries added since that commit, copies the image to the clipboard. ## Add a new translation string diff --git a/scripts/generate-changelog-image.ts b/scripts/generate-changelog-image.ts index 2ecdd5e65..d81e4bee6 100644 --- a/scripts/generate-changelog-image.ts +++ b/scripts/generate-changelog-image.ts @@ -8,7 +8,7 @@ import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; +import { parseArgs, promisify } from "node:util"; import { chromium, type Page } from "@playwright/test"; import { format } from "date-fns"; import sharp from "sharp"; @@ -25,7 +25,12 @@ try { // .env is optional, the dev server port can also come from the environment } -const CHANGELOG_IMAGE_PAGE_URL = `http://localhost:${process.env.PORT ?? 5173}/admin/changelog-image`; +const { values: args, positionals } = parseArgs({ + options: { port: { type: "string" } }, + allowPositionals: true, +}); + +const CHANGELOG_IMAGE_PAGE_URL = `http://localhost:${args.port ?? process.env.PORT ?? 5173}/admin/changelog-image`; const OUT_DIR = fileURLToPath(new URL("./output", import.meta.url)); @@ -40,10 +45,10 @@ interface ChangelogEntry { } async function main() { - const since = process.argv[2]; + const since = positionals[0]; if (!since) { throw new Error( - "Usage: pnpm run changelog:image ", + "Usage: pnpm run changelog:image [--port ]", ); } From f2915a384c968c0f2b9ff05cac810bcbe0946ea1 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:12:19 +0300 Subject: [PATCH 31/50] Remove unused export --- e2e/pages/sendouq/sendouq-match-page.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/pages/sendouq/sendouq-match-page.ts b/e2e/pages/sendouq/sendouq-match-page.ts index 9f25d1886..58b764cdc 100644 --- a/e2e/pages/sendouq/sendouq-match-page.ts +++ b/e2e/pages/sendouq/sendouq-match-page.ts @@ -251,7 +251,7 @@ export class SendouQMatchPage { } /** Waits out the gap the loss confirm keeps between arming and accepting the second tap. */ -export async function waitOutLossConfirmMinGap(page: Page) { +async function waitOutLossConfirmMinGap(page: Page) { // biome-ignore lint/nursery/noPlaywrightWaitForTimeout: the min gap after arming has no observable end await page.waitForTimeout(LOSS_CONFIRM_MIN_GAP_MS); } From 7e95ae2eb3fd8ca8857372566c79e9937cb5e8f2 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:05:50 +0300 Subject: [PATCH 32/50] Fix ViewTransition jank --- app/components/elements/Dialog.tsx | 9 +++ app/components/elements/Menu.tsx | 9 ++- app/components/elements/Popover.tsx | 14 +++- app/components/elements/Select.tsx | 9 ++- app/features/sendouq/components/GroupCard.tsx | 9 ++- .../components/LFGGroupCard.tsx | 9 ++- app/root.tsx | 24 ++++-- app/styles/common.css | 19 +++++ app/utils/view-transition.ts | 78 ++++++++++++++++++- 9 files changed, 163 insertions(+), 17 deletions(-) diff --git a/app/components/elements/Dialog.tsx b/app/components/elements/Dialog.tsx index 5f6df54a7..eaf7c6357 100644 --- a/app/components/elements/Dialog.tsx +++ b/app/components/elements/Dialog.tsx @@ -8,6 +8,10 @@ import { type SendouButtonProps, } from "~/components/elements/Button"; import { useHydrated } from "~/hooks/useHydrated"; +import { + useReportModalOpen, + useTopLayerViewTransitionStyle, +} from "~/utils/view-transition"; import styles from "./Dialog.module.css"; interface DialogElementProps { @@ -35,6 +39,7 @@ interface DialogElementProps { */ export function SendouModal({ ref, ...rest }: DialogElementProps) { const isHydrated = useHydrated(); + useReportModalOpen(isHydrated); if (!isHydrated) return null; return createPortal( @@ -67,10 +72,13 @@ function DialogElement({ children, ref, }: DialogElementProps) { + const topLayerStyle = useTopLayerViewTransitionStyle(); + return ( (null); const [open, setOpen] = React.useState(false); + useReportModalOpen(open); const [contentKey, remountContent] = React.useReducer( (key: number) => key + 1, diff --git a/app/components/elements/Menu.tsx b/app/components/elements/Menu.tsx index 818b78bd1..7c3d536e8 100644 --- a/app/components/elements/Menu.tsx +++ b/app/components/elements/Menu.tsx @@ -6,6 +6,7 @@ import { focusMoveForKey, rovingFocusIndex, } from "~/utils/roving-focus"; +import { useTopLayerViewTransitionStyle } from "~/utils/view-transition"; import { Image } from "../Image"; import { useAnchorPositioning } from "./anchor-positioning"; import styles from "./Menu.module.css"; @@ -44,6 +45,7 @@ export function SendouMenu({ eager, }: SendouMenuProps) { const uid = useAnchorSafeId(); + const topLayerStyle = useTopLayerViewTransitionStyle(); const popoverId = `${uid}-menu`; const anchorName = `--menu-anchor-${uid}`; @@ -132,7 +134,12 @@ export function SendouMenu({ [styles.scrolling]: scrolling, [styles.opensLeft]: opensLeft, })} - style={{ positionAnchor: anchorName } as React.CSSProperties} + style={ + { + positionAnchor: anchorName, + ...topLayerStyle, + } as React.CSSProperties + } data-placement={placement} onBeforeToggle={onBeforeToggle} onToggle={onToggle} diff --git a/app/components/elements/Popover.tsx b/app/components/elements/Popover.tsx index 08666c353..cf7b6a2fa 100644 --- a/app/components/elements/Popover.tsx +++ b/app/components/elements/Popover.tsx @@ -2,6 +2,7 @@ import clsx from "clsx"; import * as React from "react"; import { flushSync } from "react-dom"; import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; +import { useTopLayerViewTransitionStyle } from "~/utils/view-transition"; import { type AnchorPlacement, useAnchorPositioning, @@ -117,6 +118,7 @@ export function SendouPopover({ const popoverRef = React.useRef(null); const triggerContainerRef = React.useRef(null); + const topLayerStyle = useTopLayerViewTransitionStyle(); const setOpen = (next: boolean) => { if (!isControlled) { @@ -200,7 +202,12 @@ export function SendouPopover({ id={popoverId} popover="auto" className={clsx(styles.content, popoverClassName)} - style={{ positionAnchor: anchorName } as React.CSSProperties} + style={ + { + positionAnchor: anchorName, + ...topLayerStyle, + } as React.CSSProperties + } role="dialog" tabIndex={-1} data-placement={placement} @@ -232,6 +239,7 @@ export function SendouAnchoredPopover({ const anchorName = `--popover-anchor-${uid}`; const popoverRef = React.useRef(null); + const topLayerStyle = useTopLayerViewTransitionStyle(); // before the positioning effect, so the content is placed by its first paint useIsomorphicLayoutEffect(() => { @@ -278,7 +286,9 @@ export function SendouAnchoredPopover({ ref={popoverRef} popover="auto" className={styles.content} - style={{ positionAnchor: anchorName } as React.CSSProperties} + style={ + { positionAnchor: anchorName, ...topLayerStyle } as React.CSSProperties + } role="dialog" tabIndex={-1} aria-label={ariaLabel} diff --git a/app/components/elements/Select.tsx b/app/components/elements/Select.tsx index fb7f543cc..6c79ca718 100644 --- a/app/components/elements/Select.tsx +++ b/app/components/elements/Select.tsx @@ -6,6 +6,7 @@ import { SendouBottomTexts } from "~/components/elements/BottomTexts"; import { SendouButton } from "~/components/elements/Button"; import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; import { type FocusMove, rovingFocusIndex } from "~/utils/roving-focus"; +import { useTopLayerViewTransitionStyle } from "~/utils/view-transition"; import { Image } from "../Image"; import { useAnchorPositioning } from "./anchor-positioning"; import { @@ -144,6 +145,7 @@ export function SendouSelect({ }: SendouSelectProps) { const { t } = useTranslation(["common"]); const uid = useAnchorSafeId(); + const topLayerStyle = useTopLayerViewTransitionStyle(); const popoverId = `${uid}-select-popover`; const listboxId = `${uid}-select-listbox`; const anchorName = `--select-anchor-${uid}`; @@ -573,7 +575,12 @@ export function SendouSelect({ id={popoverId} popover="auto" className={clsx(styles.popover, popoverClassName)} - style={{ positionAnchor: anchorName } as React.CSSProperties} + style={ + { + positionAnchor: anchorName, + ...topLayerStyle, + } as React.CSSProperties + } onBeforeToggle={onPopoverBeforeToggle} onToggle={onPopoverToggle} onKeyDown={onPopoverKeyDown} diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index a105dd1e7..8027885d5 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -30,7 +30,10 @@ import { TIERS_PAGE, tierImageUrl, } from "~/utils/urls"; -import { finishUpdateIfUnmoved } from "~/utils/view-transition"; +import { + finishUpdateIfUnmoved, + usePageViewTransitionClass, +} from "~/utils/view-transition"; import type { SQGroup, SQGroupMember, @@ -307,10 +310,12 @@ function GroupCardContainer({ isOwnGroup: boolean; children: React.ReactNode; }) { + const updateClass = usePageViewTransitionClass("card-update"); + if (isOwnGroup) return <>{children}; return ( - + {children} ); diff --git a/app/features/tournament-lfg/components/LFGGroupCard.tsx b/app/features/tournament-lfg/components/LFGGroupCard.tsx index 3b32fc654..fce123d74 100644 --- a/app/features/tournament-lfg/components/LFGGroupCard.tsx +++ b/app/features/tournament-lfg/components/LFGGroupCard.tsx @@ -24,7 +24,10 @@ import type { UnifiedLanguageCode } from "~/modules/i18n/config"; import { languagesUnified } from "~/modules/i18n/config"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { navIconUrl } from "~/utils/urls"; -import { finishUpdateIfUnmoved } from "~/utils/view-transition"; +import { + finishUpdateIfUnmoved, + usePageViewTransitionClass, +} from "~/utils/view-transition"; import { lookingSchema, updateGroupFormSchema, @@ -182,10 +185,12 @@ function LFGGroupCardContainer({ isOwnGroup: boolean; children: React.ReactNode; }) { + const updateClass = usePageViewTransitionClass("card-update"); + if (isOwnGroup) return <>{children}; return ( - + {children} ); diff --git a/app/root.tsx b/app/root.tsx index 7d789a380..fe38e68cf 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -82,6 +82,10 @@ import "~/styles/common.css"; import "~/styles/utils.css"; import "~/styles/flags.css"; import "nprogress/nprogress.css"; +import { + OpenModalsContext, + useHoverCursorForViewTransitions, +} from "~/utils/view-transition"; const PRELOAD_TRANSLATION_TIMEOUT_MS = 3000; @@ -199,6 +203,7 @@ function Document({ usePreloadTranslation(); useLoadingIndicator(); useTriggerToasts(); + useHoverCursorForViewTransitions(); const htmlStyle: Record = { ...Object.fromEntries(customThemeStyle), @@ -427,6 +432,7 @@ function useCustomThemeVars() { export default function App() { const rootData = useLoaderData(); + const [openModals, setOpenModals] = React.useState(0); // Move overflow:hidden from html to body to allow position: sticky and position: fixed // elements to work properly when a React Aria Component disabled scrolling @@ -467,14 +473,16 @@ export default function App() { }, []); return ( - - - - - + + + + + + + ); } diff --git a/app/styles/common.css b/app/styles/common.css index ddc9eeedd..eea711042 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -17,12 +17,31 @@ pointer-events: none; } + /* captured elements are not hit tested while they animate, keep the cursor they had (see useHoverCursorForViewTransitions) */ + html:active-view-transition { + cursor: var(--hover-cursor, auto); + } + /* a card React decided not to animate is left as an old-only snapshot that would still fade for the full duration, holding the next render for a transition of no change */ ::view-transition-old(.card-update):only-child { animation-duration: 0s; } + /* top layer elements (see useTopLayerViewTransitionStyle) show their live state above the rest */ + ::view-transition-group(.top-layer) { + z-index: 2; + animation: none; + } + + ::view-transition-old(.top-layer) { + display: none; + } + + ::view-transition-new(.top-layer) { + animation: none; + } + body { width: 100%; min-height: 100vh; diff --git a/app/utils/view-transition.ts b/app/utils/view-transition.ts index 47b4836ee..b593462a1 100644 --- a/app/utils/view-transition.ts +++ b/app/utils/view-transition.ts @@ -1,4 +1,5 @@ import type { ViewTransitionInstance } from "react"; +import * as React from "react"; interface PseudoElement { getAnimations(): Animation[]; @@ -11,6 +12,16 @@ type PseudoElements = Record< >; const MEASURED_PROPERTIES = ["transform", "width", "height"] as const; +const NUMBER_PATTERN = /-?\d*\.?\d+/g; +const HOVER_CURSOR_PROPERTY = "--hover-cursor"; + +/** How many modal dialogs are open, provided by the root. */ +export const OpenModalsContext = React.createContext<{ + count: number; + setCount: React.Dispatch>; +}>({ count: 0, setCount: () => {} }); +/** Keyframes and computed style serialize lengths at different precisions (209.328px vs 209.312px). */ +const SUBPIXEL_TOLERANCE = 0.5; /** * `onUpdate` handler for a `` that ends its animation right away when the @@ -53,6 +64,71 @@ function hasMoved(groupAnimation: Animation, group: PseudoElement) { groupAnimation.currentTime = startTime; return MEASURED_PROPERTIES.some( - (property, i) => property in from && from[property] !== to[i], + (property, i) => + property in from && !withinTolerance(String(from[property]), to[i]), ); } + +function withinTolerance(a: string, b: string) { + if (a.replace(NUMBER_PATTERN, "") !== b.replace(NUMBER_PATTERN, "")) { + return false; + } + const aNumbers = a.match(NUMBER_PATTERN) ?? []; + const bNumbers = b.match(NUMBER_PATTERN) ?? []; + return aNumbers.every( + (value, i) => + Math.abs(Number(value) - Number(bNumbers[i])) <= SUBPIXEL_TOLERANCE, + ); +} + +/** + * Inline style for an element shown in the top layer (a popover or dialog). Snapshots paint above + * the top layer, so unless the element is captured on its own the page's transitions cover it. + * `common.css` keeps the capture static and on top of the others. + */ +export function useTopLayerViewTransitionStyle(): React.CSSProperties { + const uid = React.useId().replace(/[^a-zA-Z0-9-]/g, ""); + return { + viewTransitionName: `top-layer-${uid}`, + viewTransitionClass: "top-layer", + }; +} + +/** + * Records the cursor of the hovered element on the root. While a transition runs the browser + * skips the captured elements when hit testing, so the cursor would fall back to the page's for + * a few frames; `common.css` shows the recorded one instead. + */ +export function useHoverCursorForViewTransitions() { + React.useEffect(() => { + const onPointerOver = (event: PointerEvent) => { + if (!(event.target instanceof Element)) return; + document.documentElement.style.setProperty( + HOVER_CURSOR_PROPERTY, + getComputedStyle(event.target).cursor, + ); + }; + document.addEventListener("pointerover", onPointerOver, { passive: true }); + return () => document.removeEventListener("pointerover", onPointerOver); + }, []); +} + +/** Counts the calling dialog as an open modal while `isOpen`, see `usePageViewTransitionClass`. */ +export function useReportModalOpen(isOpen: boolean) { + const { setCount } = React.useContext(OpenModalsContext); + + React.useEffect(() => { + if (!isOpen) return; + setCount((count) => count + 1); + return () => setCount((count) => count - 1); + }, [isOpen, setCount]); +} + +/** + * The `` class for an element of the page: `"none"` while a modal dialog is open. + * Snapshots paint above the dialog's backdrop, so a transition under it would show the page + * unblurred for a few frames, and there is nothing worth animating under a modal anyway. + */ +export function usePageViewTransitionClass(className: string) { + return React.useContext(OpenModalsContext).count > 0 ? "none" : className; +} From fa6deebc3785d1c5c10dbb3485fef345f07b7755 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 21 Sep 2026 07:26:27 +0300 Subject: [PATCH 33/50] Restyle global status indicator exclamation mark --- .../global-status/components/GlobalStatusIndicator.module.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/features/global-status/components/GlobalStatusIndicator.module.css b/app/features/global-status/components/GlobalStatusIndicator.module.css index e21c64b70..8b832abc1 100644 --- a/app/features/global-status/components/GlobalStatusIndicator.module.css +++ b/app/features/global-status/components/GlobalStatusIndicator.module.css @@ -60,5 +60,7 @@ } .alertBadge { - color: var(--color-warning); + background-color: var(--color-warning-low); + border: 1.5px solid var(--color-warning); + color: var(--color-warning-high); } From 807ab2eb96149ad70d8495d97215cb5b198abfe3 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 21 Sep 2026 08:03:57 +0300 Subject: [PATCH 34/50] Rework verified social links badge --- .../user-page/UserRepository.server.ts | 35 ++++++-- .../user-page/components/Widget.module.css | 52 ++++++++---- app/features/user-page/components/Widget.tsx | 79 ++++++++++++------- .../2026-09-21-social-links-widget-names.md | 5 ++ 4 files changed, 121 insertions(+), 50 deletions(-) create mode 100644 changelog/2026-09-21-social-links-widget-names.md diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index a6d13927a..537d6ef93 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -1525,24 +1525,45 @@ export async function findSocialLinksByUserId(userId: number) { if (!user) return []; const links: Array< - | { type: "url"; value: string } - | { type: "popover"; platform: "discord"; value: string } + | { + type: "url"; + platform: "twitch" | "youtube" | "bsky"; + /** Account name on the platform, null if only an id is known */ + name: string | null; + url: string; + } + | { type: "text"; platform: "discord"; name: string } > = []; if (user.twitch) { - links.push({ type: "url", value: twitchUrl(user.twitch) }); + links.push({ + type: "url", + platform: "twitch", + name: user.twitch, + url: twitchUrl(user.twitch), + }); } if (user.youtubeId) { - links.push({ type: "url", value: youtubeUrl(user.youtubeId) }); + links.push({ + type: "url", + platform: "youtube", + name: null, + url: youtubeUrl(user.youtubeId), + }); } if (user.bsky) { - links.push({ type: "url", value: bskyUrl(user.bsky) }); + links.push({ + type: "url", + platform: "bsky", + name: user.bsky, + url: bskyUrl(user.bsky), + }); } if (user.discordUniqueName) { links.push({ - type: "popover", + type: "text", platform: "discord", - value: user.discordUniqueName, + name: user.discordUniqueName, }); } diff --git a/app/features/user-page/components/Widget.module.css b/app/features/user-page/components/Widget.module.css index 287cc8dd9..8750d72e0 100644 --- a/app/features/user-page/components/Widget.module.css +++ b/app/features/user-page/components/Widget.module.css @@ -310,11 +310,44 @@ } } -.socialLinksIcons { +.socialLinksList { display: flex; - gap: var(--s-2); - justify-content: center; - flex-wrap: wrap; + flex-direction: column; + gap: var(--s-1); +} + +.socialLinkIconCircle { + border-radius: var(--radius-full); + padding: 0; + width: 24px; + height: 24px; + + & svg { + width: 14px; + height: 14px; + } +} + +.linkRow { + display: flex; + align-items: center; + justify-content: flex-start; + gap: var(--s-1-5); + width: 100%; + height: auto; + padding: var(--s-0-5) var(--s-1); + border-radius: var(--radius-field); + color: var(--color-text); + font-size: var(--font-sm); + font-weight: var(--weight-body); + + &:is(a, button):hover { + background-color: var(--color-bg-high); + } +} + +.socialLinkName { + overflow-wrap: anywhere; } .gameBadgeGrid { @@ -337,16 +370,7 @@ .friendsList { display: flex; flex-direction: column; - gap: var(--s-2); -} - -.friendLink { - padding: var(--s-0-5) var(--s-1); - border-radius: var(--radius-field); - - &:hover { - background-color: var(--color-bg-higher); - } + gap: var(--s-1); } .mapModePreferences { diff --git a/app/features/user-page/components/Widget.tsx b/app/features/user-page/components/Widget.tsx index 4122e9349..ab43ee2d2 100644 --- a/app/features/user-page/components/Widget.tsx +++ b/app/features/user-page/components/Widget.tsx @@ -830,6 +830,28 @@ const urlToIcon = (url: string) => { return ; }; +const SOCIAL_PLATFORM_FALLBACK_NAMES = { + twitch: "Twitch", + youtube: "YouTube", + bsky: "Bluesky", + discord: "Discord", +} as const; + +const platformToIcon = ( + platform: "twitch" | "youtube" | "bsky" | "discord", +) => { + switch (platform) { + case "twitch": + return ; + case "youtube": + return ; + case "bsky": + return ; + case "discord": + return ; + } +}; + function SocialLinksWidget({ data, }: { @@ -838,43 +860,42 @@ function SocialLinksWidget({ if (data.length === 0) return null; return ( -
- {data.map((link, i) => { - if (link.type === "popover") { - return ( - - {link.platform === "discord" ? : null} - - } +
+ {data.map((link) => { + const content = ( + <> +
- {link.value} - + {platformToIcon(link.platform)} +
+ + {link.name ?? SOCIAL_PLATFORM_FALLBACK_NAMES[link.platform]} + + + ); + + if (link.type === "text") { + return ( +
+ {content} +
); } - const type = urlToLinkType(link.value); return ( - {urlToIcon(link.value)} + {content} ); })} @@ -962,7 +983,7 @@ function FriendsWidget({ return (
{itemsToDisplay.map((friend) => ( - + ))} {!everythingVisible ? (
diff --git a/changelog/2026-09-21-social-links-widget-names.md b/changelog/2026-09-21-social-links-widget-names.md new file mode 100644 index 000000000..eb1a7ff37 --- /dev/null +++ b/changelog/2026-09-21-social-links-widget-names.md @@ -0,0 +1,5 @@ +--- +navItem: u +type: feature +--- +Verified social links on your profile now show your account name on each platform From 6048512a0886f71a5c6ea7145dad9d20b4b1742e Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:27:50 +0300 Subject: [PATCH 35/50] Custom kits widget --- .../user-page/components/Widget.module.css | 48 +++++++++++ app/features/user-page/components/Widget.tsx | 51 ++++++++++- .../components/WidgetSettingsForm.tsx | 2 + .../core/widgets/portfolio-loaders.server.ts | 6 ++ .../user-page/core/widgets/portfolio.ts | 7 ++ .../core/widgets/widget-form-schemas.ts | 20 +++++ .../u.$identifier.edit-widgets.module.css | 2 +- app/features/user-page/user-page-constants.ts | 1 + app/form/FormField.tsx | 26 +++++- app/form/fields.ts | 42 +++++++-- .../SubSpecialSelectFormField.module.css | 9 ++ app/form/fields/SubSpecialSelectFormField.tsx | 85 +++++++++++++++++++ app/form/types.ts | 5 +- changelog/2026-09-21-custom-kits-widget.md | 8 ++ locales/da/forms.json | 3 + locales/da/user.json | 2 + locales/de/forms.json | 3 + locales/de/user.json | 2 + locales/en/forms.json | 3 + locales/en/user.json | 2 + locales/es-ES/forms.json | 3 + locales/es-ES/user.json | 2 + locales/es-US/forms.json | 3 + locales/es-US/user.json | 2 + locales/fr-CA/forms.json | 3 + locales/fr-CA/user.json | 2 + locales/fr-EU/forms.json | 3 + locales/fr-EU/user.json | 2 + locales/he/forms.json | 3 + locales/he/user.json | 2 + locales/it/forms.json | 3 + locales/it/user.json | 2 + locales/ja/forms.json | 3 + locales/ja/user.json | 2 + locales/ko/forms.json | 3 + locales/ko/user.json | 2 + locales/nl/forms.json | 3 + locales/nl/user.json | 2 + locales/pl/forms.json | 3 + locales/pl/user.json | 2 + locales/pt-BR/forms.json | 3 + locales/pt-BR/user.json | 2 + locales/ru/forms.json | 3 + locales/ru/user.json | 2 + locales/zh/forms.json | 3 + locales/zh/user.json | 2 + 46 files changed, 383 insertions(+), 9 deletions(-) create mode 100644 app/form/fields/SubSpecialSelectFormField.module.css create mode 100644 app/form/fields/SubSpecialSelectFormField.tsx create mode 100644 changelog/2026-09-21-custom-kits-widget.md diff --git a/app/features/user-page/components/Widget.module.css b/app/features/user-page/components/Widget.module.css index 8750d72e0..b4adedffc 100644 --- a/app/features/user-page/components/Widget.module.css +++ b/app/features/user-page/components/Widget.module.css @@ -153,6 +153,54 @@ background-color: var(--color-bg-high); } +.customKits { + display: flex; + flex-direction: column; + padding-inline: var(--s-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-box); + background-color: var(--color-bg-high); +} + +.customKit { + display: flex; + align-items: center; + gap: var(--s-2); + padding-block: var(--s-2); + + & + & { + border-top: 1px solid var(--color-border); + } +} + +.customKitWeapon { + display: flex; + flex-shrink: 0; + padding: var(--s-1-5); + border-radius: 100%; + background-color: var(--color-bg-higher); +} + +.customKitName { + flex: 1; + min-width: 0; + font-size: var(--font-xs); + font-weight: var(--weight-semi); +} + +.customKitParts { + display: flex; + flex-shrink: 0; + gap: var(--s-1); +} + +.customKitPart { + display: flex; + padding: var(--s-1); + border-radius: 100%; + background-color: var(--color-bg-higher); +} + .weaponGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); diff --git a/app/features/user-page/components/Widget.tsx b/app/features/user-page/components/Widget.tsx index ab43ee2d2..79315a022 100644 --- a/app/features/user-page/components/Widget.tsx +++ b/app/features/user-page/components/Widget.tsx @@ -6,7 +6,14 @@ import { BuildCard } from "~/components/BuildCard"; import { Divider } from "~/components/Divider"; import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; -import { Image, ModeImage, StageImage, WeaponImage } from "~/components/Image"; +import { + Image, + ModeImage, + SpecialWeaponImage, + StageImage, + SubWeaponImage, + WeaponImage, +} from "~/components/Image"; import { BskyIcon } from "~/components/icons/Bsky"; import { DiscordIcon } from "~/components/icons/Discord"; import { TwitchIcon } from "~/components/icons/Twitch"; @@ -255,6 +262,10 @@ export function Widget({ return widget.data.length === 0 ? null : ( ); + case "custom-kits": + return widget.data.length === 0 ? null : ( + + ); case "sens": return typeof widget.data.motionSens !== "number" && typeof widget.data.stickSens !== "number" ? null : ( @@ -700,6 +711,44 @@ function WeaponPool({ ); } +function CustomKits({ + kits, +}: { + kits: Extract["data"]; +}) { + const { t } = useTranslation(["weapons"]); + + return ( +
+ {kits.map((kit, i) => ( +
+
+ +
+
+ {t(`weapons:MAIN_${kit.weaponSplId}`)} +
+
+
+ +
+
+ +
+
+
+ ))} +
+ ); +} + function PeakXpWeapon({ weaponSplId, peakXp, diff --git a/app/features/user-page/components/WidgetSettingsForm.tsx b/app/features/user-page/components/WidgetSettingsForm.tsx index 3ecc9aeee..6d7be4ff4 100644 --- a/app/features/user-page/components/WidgetSettingsForm.tsx +++ b/app/features/user-page/components/WidgetSettingsForm.tsx @@ -87,6 +87,8 @@ function WidgetFormFields({ widgetId }: { widgetId: string }) { return ; case "weapon-pool": return ; + case "custom-kits": + return ; case "sens": return ; case "art": diff --git a/app/features/user-page/core/widgets/portfolio-loaders.server.ts b/app/features/user-page/core/widgets/portfolio-loaders.server.ts index e386977dd..b08044747 100644 --- a/app/features/user-page/core/widgets/portfolio-loaders.server.ts +++ b/app/features/user-page/core/widgets/portfolio-loaders.server.ts @@ -316,6 +316,12 @@ export const WIDGET_LOADERS = { isTenStar: tenStarWeaponSplIds.includes(weapon.id) ? 1 : 0, })); }, + "custom-kits": async ( + _userId: number, + settings: ExtractWidgetSettings<"custom-kits">, + ) => { + return settings.kits; + }, "social-links": async (userId: number) => { return UserRepository.findSocialLinksByUserId(userId); }, diff --git a/app/features/user-page/core/widgets/portfolio.ts b/app/features/user-page/core/widgets/portfolio.ts index e84a7dfa7..6cec0f4a5 100644 --- a/app/features/user-page/core/widgets/portfolio.ts +++ b/app/features/user-page/core/widgets/portfolio.ts @@ -9,6 +9,7 @@ import { bioMdSchema, bioSchema, countdownSchema, + customKitsSchema, favoriteStageSchema, gameBadgesSchema, gameBadgesSmallSchema, @@ -61,6 +62,12 @@ export const ALL_WIDGETS = { schema: weaponPoolWidgetSchema, defaultSettings: { weaponPool: [] }, }), + defineWidget({ + id: "custom-kits", + slot: "side", + schema: customKitsSchema, + defaultSettings: { kits: [] }, + }), defineWidget({ id: "lfg-posts", slot: "main", navItem: "lfg" }), defineWidget({ id: "sens", diff --git a/app/features/user-page/core/widgets/widget-form-schemas.ts b/app/features/user-page/core/widgets/widget-form-schemas.ts index 60681189c..f4d79518c 100644 --- a/app/features/user-page/core/widgets/widget-form-schemas.ts +++ b/app/features/user-page/core/widgets/widget-form-schemas.ts @@ -9,10 +9,13 @@ import { badges, customField, datetime, + fieldset, numberField, select, selectDynamic, + specialWeaponSelect, stageSelect, + subWeaponSelect, textArea, textAreaOptional, textField, @@ -104,6 +107,22 @@ export const weaponPoolWidgetSchema = v.object({ }), }); +export const customKitsSchema = v.object({ + kits: array({ + label: "labels.customKits", + max: USER.CUSTOM_KITS_MAX, + field: fieldset({ + fields: v.object({ + weaponSplId: weaponSelect({ label: "labels.weapon" }), + subWeaponId: subWeaponSelect({ label: "labels.subWeapon" }), + specialWeaponId: specialWeaponSelect({ + label: "labels.specialWeapon", + }), + }), + }), + }), +}); + const CONTROLLERS = [ "s1-pro-con", "s2-pro-con", @@ -220,6 +239,7 @@ const WIDGET_FORM_SCHEMAS: Record = { "peak-xp-unverified": peakXpUnverifiedSchema, "peak-xp-weapon": peakXpWeaponSchema, "weapon-pool": weaponPoolWidgetSchema, + "custom-kits": customKitsSchema, sens: sensSchema, art: artSchema, links: linksSchema, diff --git a/app/features/user-page/routes/u.$identifier.edit-widgets.module.css b/app/features/user-page/routes/u.$identifier.edit-widgets.module.css index 5299195ee..f98c0f53e 100644 --- a/app/features/user-page/routes/u.$identifier.edit-widgets.module.css +++ b/app/features/user-page/routes/u.$identifier.edit-widgets.module.css @@ -215,7 +215,7 @@ .widgetSettings { padding: var(--s-3); margin-top: var(--s-1); - background-color: var(--color-bg-higher); + background-color: var(--color-bg); border-radius: var(--radius-box); } diff --git a/app/features/user-page/user-page-constants.ts b/app/features/user-page/user-page-constants.ts index b47b78cb1..1d52f62d8 100644 --- a/app/features/user-page/user-page-constants.ts +++ b/app/features/user-page/user-page-constants.ts @@ -15,6 +15,7 @@ export const USER = { GAME_BADGES_MAX: 8, GAME_BADGES_SMALL_MAX: 4, WEAPON_POOL_WIDGET_MAX: 7, + CUSTOM_KITS_MAX: 3, COUNTDOWN_TITLE_MAX_LENGTH: 50, MARKDOWN_WIDGET_MAX_LENGTH: 2000, TIER_LIST_WIDGET_MAX_LENGTH: 4000, diff --git a/app/form/FormField.tsx b/app/form/FormField.tsx index 62469dbd4..b791c8615 100644 --- a/app/form/FormField.tsx +++ b/app/form/FormField.tsx @@ -1,5 +1,10 @@ import * as React from "react"; -import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types"; +import type { + MainWeaponId, + SpecialWeaponId, + StageId, + SubWeaponId, +} from "~/modules/in-game-lists/types"; import type { AnySyncSchema } from "~/utils/schema"; import { formRegistry } from "./fields"; import { ArrayFormField } from "./fields/ArrayFormField"; @@ -16,6 +21,7 @@ import { } from "./fields/InputGroupFormField"; import { SelectFormField } from "./fields/SelectFormField"; import { StageSelectFormField } from "./fields/StageSelectFormField"; +import { SubSpecialSelectFormField } from "./fields/SubSpecialSelectFormField"; import { SwitchFormField } from "./fields/SwitchFormField"; import { TeamSearchFormField } from "./fields/TeamSearchFormField"; import { TextareaFormField } from "./fields/TextareaFormField"; @@ -596,6 +602,24 @@ export function FormField({ ); } + if ( + formField.type === "sub-weapon-select" || + formField.type === "special-weapon-select" + ) { + return ( + void + } + /> + ); + } + return (
Unsupported form field type: {(formField as FormFieldType).type}
); diff --git a/app/form/fields.ts b/app/form/fields.ts index b172bb11c..c82001579 100644 --- a/app/form/fields.ts +++ b/app/form/fields.ts @@ -5,7 +5,12 @@ import { inGameNameIsValid, normalizeInGameName, } from "~/features/user-page/in-game-name"; -import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types"; +import type { + MainWeaponId, + SpecialWeaponId, + StageId, + SubWeaponId, +} from "~/modules/in-game-lists/types"; import { canonicalWeaponSplId } from "~/modules/in-game-lists/weapon-ids"; import type { AnySyncSchema, DayMonthYear } from "~/utils/schema"; import { @@ -16,7 +21,9 @@ import { preprocess, safeNullableStringSchema, safeStringSchema, + specialWeaponId, stageId, + subWeaponId, timeString, weaponSplId, } from "~/utils/schema"; @@ -993,7 +1000,7 @@ type WeaponSelectArgs = WithTypedTranslationKeys< export function weaponSelect( args: WeaponSelectArgs, ): v.GenericSchema { - return register(weaponSplId, weaponSelectMetadata(args, true)) as never; + return register(weaponSplId, weaponSelectMetadata(args)) as never; } export function weaponSelectOptional( @@ -1001,16 +1008,41 @@ export function weaponSelectOptional( ): v.OptionalSchema, undefined> { return register( v.optional(weaponSplId), - weaponSelectMetadata(args, false), + weaponSelectMetadata(args, "weapon-select", false), ) as never; } -function weaponSelectMetadata(args: WeaponSelectArgs, required: boolean) { +export function subWeaponSelect( + args: WeaponSelectArgs, +): v.GenericSchema { + return register( + subWeaponId, + weaponSelectMetadata(args, "sub-weapon-select"), + ) as never; +} + +export function specialWeaponSelect( + args: WeaponSelectArgs, +): v.GenericSchema { + return register( + specialWeaponId, + weaponSelectMetadata(args, "special-weapon-select"), + ) as never; +} + +function weaponSelectMetadata( + args: WeaponSelectArgs, + type: + | "weapon-select" + | "sub-weapon-select" + | "special-weapon-select" = "weapon-select", + required = true, +) { return { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), - type: "weapon-select" as const, + type, initialValue: null, required, }; diff --git a/app/form/fields/SubSpecialSelectFormField.module.css b/app/form/fields/SubSpecialSelectFormField.module.css new file mode 100644 index 000000000..d16f57611 --- /dev/null +++ b/app/form/fields/SubSpecialSelectFormField.module.css @@ -0,0 +1,9 @@ +.root { + width: 100%; +} + +.option { + display: flex; + align-items: center; + gap: var(--s-2); +} diff --git a/app/form/fields/SubSpecialSelectFormField.tsx b/app/form/fields/SubSpecialSelectFormField.tsx new file mode 100644 index 000000000..8263199ed --- /dev/null +++ b/app/form/fields/SubSpecialSelectFormField.tsx @@ -0,0 +1,85 @@ +import { useTranslation } from "react-i18next"; +import { SendouSelect, SendouSelectItem } from "~/components/elements/Select"; +import { Image } from "~/components/Image"; +import type { + SpecialWeaponId, + SubWeaponId, +} from "~/modules/in-game-lists/types"; +import { + specialWeaponIds, + subWeaponIds, +} from "~/modules/in-game-lists/weapon-ids"; +import { specialWeaponImageUrl, subWeaponImageUrl } from "~/utils/urls"; +import type { FormFieldProps } from "../types"; +import { FormFieldMessages, useTranslatedTexts } from "./FormFieldWrapper"; +import styles from "./SubSpecialSelectFormField.module.css"; + +type SubSpecialSelectFormFieldProps = FormFieldProps< + "sub-weapon-select" | "special-weapon-select" +> & { + weaponType: "SUB" | "SPECIAL"; + value: SubWeaponId | SpecialWeaponId | null; + onChange: (value: SubWeaponId | SpecialWeaponId | null) => void; + disabled?: boolean; +}; + +export function SubSpecialSelectFormField({ + name, + label, + bottomText, + error, + required, + weaponType, + value, + onChange, + onBlur, + disabled, +}: SubSpecialSelectFormFieldProps) { + const { t } = useTranslation(["weapons"]); + const { translatedLabel } = useTranslatedTexts({ label }); + + const options: Array<{ id: number; name: string; imgPath: string }> = + weaponType === "SUB" + ? subWeaponIds.map((id) => ({ + id, + name: t(`weapons:SUB_${id}`), + imgPath: subWeaponImageUrl(id), + })) + : specialWeaponIds.map((id) => ({ + id, + name: t(`weapons:SPECIAL_${id}`), + imgPath: specialWeaponImageUrl(id), + })); + + return ( +
+ { + const newValue = key === null ? null : (Number(key) as SubWeaponId); + onChange(newValue); + onBlur?.(newValue); + }} + isRequired={required} + clearable={!required} + isDisabled={disabled} + > + {(option) => ( + + + + {option.name} + + + )} + + +
+ ); +} diff --git a/app/form/types.ts b/app/form/types.ts index 620cdadad..5596592bb 100644 --- a/app/form/types.ts +++ b/app/form/types.ts @@ -182,6 +182,7 @@ interface FormFieldStageSelect extends FormFieldBase { required: boolean; } +/** Shared by the main, sub and special weapon selects, which differ only in what they list. */ interface FormFieldWeaponSelect extends FormFieldBase { required: boolean; } @@ -213,7 +214,9 @@ export type FormField = | FormFieldBadges<"badges"> | FormFieldTrophies<"trophies"> | FormFieldStageSelect<"stage-select"> - | FormFieldWeaponSelect<"weapon-select">; + | FormFieldWeaponSelect<"weapon-select"> + | FormFieldWeaponSelect<"sub-weapon-select"> + | FormFieldWeaponSelect<"special-weapon-select">; export type FormFieldProps = Omit< Extract, diff --git a/changelog/2026-09-21-custom-kits-widget.md b/changelog/2026-09-21-custom-kits-widget.md new file mode 100644 index 000000000..fe6821d81 --- /dev/null +++ b/changelog/2026-09-21-custom-kits-widget.md @@ -0,0 +1,8 @@ +--- +navItem: u +type: feature +--- +New profile widget: Custom Kits + +- Build weapon kits of your own design by mixing any main, sub and special weapon +- Show up to 3 of them on your profile diff --git a/locales/da/forms.json b/locales/da/forms.json index 68b46bc07..9e59ab0ab 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/da/user.json b/locales/da/user.json index 23f787c89..10f6eb27c 100644 --- a/locales/da/user.json +++ b/locales/da/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -128,6 +129,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/de/forms.json b/locales/de/forms.json index 3928ef2c5..c631b4a7f 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/de/user.json b/locales/de/user.json index 55cc6fa1a..af6ff25c1 100644 --- a/locales/de/user.json +++ b/locales/de/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -128,6 +129,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/en/forms.json b/locales/en/forms.json index 952febfbe..f6f5f27be 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "Favorite Stage", "labels.peakXp": "Peak XP", "labels.weapon": "Weapon", + "labels.customKits": "Kits", + "labels.subWeapon": "Sub weapon", + "labels.specialWeapon": "Special weapon", "labels.artSource": "Art source", "options.artSource.ALL": "All", "options.artSource.MADE-BY": "Made by me", diff --git a/locales/en/user.json b/locales/en/user.json index 236c395d8..6ac82fa4b 100644 --- a/locales/en/user.json +++ b/locales/en/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "X Rank Peaks Per Mode", "widget.builds": "Builds", "widget.weapon-pool": "Weapon Pool", + "widget.custom-kits": "Custom Kits", "widget.sens": "Sensitivity", "widget.art": "Art", "widget.commissions": "Commissions", @@ -128,6 +129,7 @@ "widgets.description.x-rank-peaks": "Show your peak X Rank placement for each mode, optionally select division", "widgets.description.builds": "Display your 3 most recent builds", "widgets.description.weapon-pool": "Display your match profile weapon pool or a custom list of weapons", + "widgets.description.custom-kits": "Show off weapon kits of your own design", "widgets.description.sens": "Show your sensitivity settings and controller of choice", "widgets.description.art": "Display your 3 most recent art pieces", "widgets.description.commissions": "Show your commission status and details", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index e240d3e23..65aa1e49a 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "Escenario favorito", "labels.peakXp": "XP máximo", "labels.weapon": "Arma", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "Fuente del arte", "options.artSource.ALL": "Todos", "options.artSource.MADE-BY": "Creado por mí", diff --git a/locales/es-ES/user.json b/locales/es-ES/user.json index a7cdf0655..7aabc581e 100644 --- a/locales/es-ES/user.json +++ b/locales/es-ES/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "Picos de Rango X por modo", "widget.builds": "Builds", "widget.weapon-pool": "Grupo de armas", + "widget.custom-kits": "", "widget.sens": "Sensibilidad", "widget.art": "Arte", "widget.commissions": "Comisiones", @@ -129,6 +130,7 @@ "widgets.description.x-rank-peaks": "Muestra tu récord en Rango X de cada modo, con opción de elegir la división", "widgets.description.builds": "Muestra tus 3 builds más recientes", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "Muestra tus ajustes de sensibilidad y el mando que usas", "widgets.description.art": "Muestra tus 3 obras de arte más recientes", "widgets.description.commissions": "Muestra el estado y los detalles de tus comisiones", diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index fbb482be2..3ab9ad9fe 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "Escenario favorito", "labels.peakXp": "XP máximo", "labels.weapon": "Arma", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "Fuente del arte", "options.artSource.ALL": "Todos", "options.artSource.MADE-BY": "Creado por mí", diff --git a/locales/es-US/user.json b/locales/es-US/user.json index d9992a1b6..8f5026d6d 100644 --- a/locales/es-US/user.json +++ b/locales/es-US/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "Picos de Rango X por modo", "widget.builds": "Builds", "widget.weapon-pool": "Grupo de armas", + "widget.custom-kits": "", "widget.sens": "Sensibilidad", "widget.art": "Arte", "widget.commissions": "Comisiones", @@ -129,6 +130,7 @@ "widgets.description.x-rank-peaks": "Muestra tu récord en Rango X de cada modo, con opción de elegir la división", "widgets.description.builds": "Muestra tus 3 builds más recientes", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "Muestra tus ajustes de sensibilidad y el control que usas", "widgets.description.art": "Muestra tus 3 obras de arte más recientes", "widgets.description.commissions": "Muestra el estado y los detalles de tus comisiones", diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index 79a541a22..92240e233 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/fr-CA/user.json b/locales/fr-CA/user.json index 4fe0f538d..29263d692 100644 --- a/locales/fr-CA/user.json +++ b/locales/fr-CA/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -129,6 +130,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index fcbeb8888..26b5ea4c1 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/fr-EU/user.json b/locales/fr-EU/user.json index 93fca72a0..7c541460b 100644 --- a/locales/fr-EU/user.json +++ b/locales/fr-EU/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -129,6 +130,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/he/forms.json b/locales/he/forms.json index a2b7fbbec..4ccb80558 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/he/user.json b/locales/he/user.json index afeab6a77..2a002d212 100644 --- a/locales/he/user.json +++ b/locales/he/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -129,6 +130,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/it/forms.json b/locales/it/forms.json index dd440c1dc..6a4e3091e 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/it/user.json b/locales/it/user.json index 39c0391f4..0d8e3e35b 100644 --- a/locales/it/user.json +++ b/locales/it/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -129,6 +130,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/ja/forms.json b/locales/ja/forms.json index 4c46c89f9..da4f0f1a4 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/ja/user.json b/locales/ja/user.json index 74415da32..999725109 100644 --- a/locales/ja/user.json +++ b/locales/ja/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -126,6 +127,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/ko/forms.json b/locales/ko/forms.json index dfe0f85d2..4797a19d8 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/ko/user.json b/locales/ko/user.json index e2ba02c02..efbdc34ff 100644 --- a/locales/ko/user.json +++ b/locales/ko/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -126,6 +127,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/nl/forms.json b/locales/nl/forms.json index 28e412623..708e1b018 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/nl/user.json b/locales/nl/user.json index 6791a56e4..73f29b767 100644 --- a/locales/nl/user.json +++ b/locales/nl/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -128,6 +129,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/pl/forms.json b/locales/pl/forms.json index e4ae5670a..911201d74 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/pl/user.json b/locales/pl/user.json index 419e2514d..de56b625a 100644 --- a/locales/pl/user.json +++ b/locales/pl/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -130,6 +131,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index 3bb04395e..a110f7042 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/pt-BR/user.json b/locales/pt-BR/user.json index d11c02bc8..9af94f0d0 100644 --- a/locales/pt-BR/user.json +++ b/locales/pt-BR/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -129,6 +130,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/ru/forms.json b/locales/ru/forms.json index 919187487..94de1aa6a 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/ru/user.json b/locales/ru/user.json index 33aa60179..2e3f87534 100644 --- a/locales/ru/user.json +++ b/locales/ru/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -130,6 +131,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/zh/forms.json b/locales/zh/forms.json index cd3bacb29..2319084b8 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "喜爱的场地", "labels.peakXp": "最高 XP", "labels.weapon": "武器", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "作品来源", "options.artSource.ALL": "全部", "options.artSource.MADE-BY": "由我创作", diff --git a/locales/zh/user.json b/locales/zh/user.json index b37ed1f42..3390a90e6 100644 --- a/locales/zh/user.json +++ b/locales/zh/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "各模式最高X战力", "widget.builds": "配装", "widget.weapon-pool": "武器池", + "widget.custom-kits": "", "widget.sens": "灵敏度设置", "widget.art": "插画", "widget.commissions": "约稿", @@ -126,6 +127,7 @@ "widgets.description.x-rank-peaks": "展示您各个模式的最高X战力,可自由选择组别", "widgets.description.builds": "展示您最近的 3 套配装", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "展示您的灵敏度设置和首选控制器", "widgets.description.art": "展示您最近发布的 3 件插画作品", "widgets.description.commissions": "展示您的约稿开放状态与详情", From 7976224d534ce40de631135dbe71eed10549a53b Mon Sep 17 00:00:00 2001 From: hfcRed Date: Mon, 21 Sep 2026 16:41:40 -0400 Subject: [PATCH 36/50] Prevent content within markdown bios from leaking outside of the container --- app/features/user-page/components/Widget.module.css | 4 ++++ app/features/user-page/components/Widget.tsx | 2 +- changelog/2026-09-21-markdown-bio-leaks.md | 5 +++++ 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 changelog/2026-09-21-markdown-bio-leaks.md diff --git a/app/features/user-page/components/Widget.module.css b/app/features/user-page/components/Widget.module.css index b4adedffc..8de5b8a50 100644 --- a/app/features/user-page/components/Widget.module.css +++ b/app/features/user-page/components/Widget.module.css @@ -7,6 +7,10 @@ overflow: hidden; } +.mdBio { + clip-path: view-box; +} + .header { display: flex; align-items: center; diff --git a/app/features/user-page/components/Widget.tsx b/app/features/user-page/components/Widget.tsx index 79315a022..410bbf06f 100644 --- a/app/features/user-page/components/Widget.tsx +++ b/app/features/user-page/components/Widget.tsx @@ -88,7 +88,7 @@ export function Widget({ return widget.data.bio ?
{widget.data.bio}
: null; case "bio-md": return widget.data.bio ? ( -
+
{widget.data.bio}
) : null; diff --git a/changelog/2026-09-21-markdown-bio-leaks.md b/changelog/2026-09-21-markdown-bio-leaks.md new file mode 100644 index 000000000..f053ff301 --- /dev/null +++ b/changelog/2026-09-21-markdown-bio-leaks.md @@ -0,0 +1,5 @@ +--- +navItem: u +type: bug +--- +Prevent content within markdown bios on user pages from affecting the page outside of the bio container From 2b31ecd0faa30dac0e427a807ca409257ad60a35 Mon Sep 17 00:00:00 2001 From: hfcRed <101019309+hfcRed@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:15:01 -0400 Subject: [PATCH 37/50] Add scroll lock, replace CSS anchors with floating layer, and various fixes (#3407) --- AGENTS.md | 2 +- app/browser-test-setup.ts | 2 + app/components/MobileNav.module.css | 6 +- app/components/MobileNav.tsx | 2 + .../elements/Dialog.browser.test.tsx | 145 +++++- app/components/elements/Dialog.module.css | 23 +- app/components/elements/Dialog.tsx | 50 +- app/components/elements/Menu.module.css | 22 +- app/components/elements/Menu.tsx | 32 +- .../elements/Popover.browser.test.tsx | 30 +- app/components/elements/Popover.module.css | 33 +- app/components/elements/Popover.tsx | 75 ++- app/components/elements/Select.module.css | 21 +- app/components/elements/Select.tsx | 30 +- app/components/elements/Toast.module.css | 2 +- .../anchor-positioning.browser.test.tsx | 190 -------- app/components/elements/anchor-positioning.ts | 334 -------------- .../elements/floating-layer.test.ts | 284 ++++++++++++ app/components/elements/floating-layer.ts | 241 ++++++++++ app/components/elements/tests/keyboard.ts | 23 + .../useCloseOnScrollClip.browser.test.tsx | 173 ------- .../elements/useCloseOnScrollClip.ts | 93 ---- .../useFloatingLayer.browser.test.tsx | 426 ++++++++++++++++++ app/components/elements/useFloatingLayer.ts | 317 +++++++++++++ .../useScrollIntoView.browser.test.tsx | 99 ++++ app/components/elements/useScrollIntoView.ts | 51 +++ .../layout/GlobalSearch.browser.test.tsx | 37 ++ app/components/layout/GlobalSearch.module.css | 24 +- app/components/layout/GlobalSearch.tsx | 8 +- .../layout/SearchResults.module.css | 2 +- .../layout/TopNavMenus.browser.test.tsx | 57 +++ app/components/layout/TopNavMenus.module.css | 4 +- app/components/layout/TopNavMenus.tsx | 68 ++- app/components/layout/index.tsx | 6 +- .../user-card/components/UserCard.module.css | 7 +- .../user-page/components/Widget.module.css | 3 +- app/hooks/useScrollLock.ts | 45 ++ app/hooks/useVisualViewport.ts | 30 ++ app/hooks/useVisualViewportHeight.ts | 29 -- .../scroll-lock/scroll-lock.browser.test.ts | 104 +++++ app/modules/scroll-lock/scroll-lock.ts | 67 +++ app/root.tsx | 39 -- app/styles/vars.css | 39 +- app/utils/visual-viewport.ts | 48 ++ changelog/2026-09-20-floating-layer.md | 4 + changelog/2026-09-20-modals-keyboard.md | 4 + changelog/2026-09-20-scroll-lock.md | 4 + changelog/2026-09-20-search-modal-back.md | 4 + docs/dev/architecture.md | 2 +- docs/dev/overlays.md | 67 +++ e2e/navigation.spec.ts | 40 +- 51 files changed, 2363 insertions(+), 1085 deletions(-) delete mode 100644 app/components/elements/anchor-positioning.browser.test.tsx delete mode 100644 app/components/elements/anchor-positioning.ts create mode 100644 app/components/elements/floating-layer.test.ts create mode 100644 app/components/elements/floating-layer.ts create mode 100644 app/components/elements/tests/keyboard.ts delete mode 100644 app/components/elements/useCloseOnScrollClip.browser.test.tsx delete mode 100644 app/components/elements/useCloseOnScrollClip.ts create mode 100644 app/components/elements/useFloatingLayer.browser.test.tsx create mode 100644 app/components/elements/useFloatingLayer.ts create mode 100644 app/components/elements/useScrollIntoView.browser.test.tsx create mode 100644 app/components/elements/useScrollIntoView.ts create mode 100644 app/components/layout/GlobalSearch.browser.test.tsx create mode 100644 app/components/layout/TopNavMenus.browser.test.tsx create mode 100644 app/hooks/useScrollLock.ts create mode 100644 app/hooks/useVisualViewport.ts delete mode 100644 app/hooks/useVisualViewportHeight.ts create mode 100644 app/modules/scroll-lock/scroll-lock.browser.test.ts create mode 100644 app/modules/scroll-lock/scroll-lock.ts create mode 100644 app/utils/visual-viewport.ts create mode 100644 changelog/2026-09-20-floating-layer.md create mode 100644 changelog/2026-09-20-modals-keyboard.md create mode 100644 changelog/2026-09-20-scroll-lock.md create mode 100644 changelog/2026-09-20-search-modal-back.md create mode 100644 docs/dev/overlays.md diff --git a/AGENTS.md b/AGENTS.md index 71ba99c07..90232fa2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ - note: any formatting issue (such as tabs vs. spaces) can be resolved by running the `pnpm run biome:fix` command - typical way to structure pure logic is into Modules divided by logical domains which are imported with the "* as Module" import and then used like so "Module.foo()". These functions always need JSDoc. - non-exported functions typically do not need JSDoc or at least it can be kept short -- more topic docs live in `docs/dev/` — notably [architecture.md](./docs/dev/architecture.md) (feature folder layout) and [permissions.md](./docs/dev/permissions.md) (authorization: global roles via `requireRole()`/`useHasRole()`, per-object `permissions` computed in repositories) +- more topic docs live in `docs/dev/` — notably [architecture.md](./docs/dev/architecture.md) (feature folder layout), [permissions.md](./docs/dev/permissions.md) (authorization: global roles via `requireRole()`/`useHasRole()`, per-object `permissions` computed in repositories) and [overlays.md](./docs/dev/overlays.md) (popovers, menus, selects and dialogs: the floating layer, scroll lock and mobile keyboard handling) ## Commands diff --git a/app/browser-test-setup.ts b/app/browser-test-setup.ts index ad31b411c..02e59e1f6 100644 --- a/app/browser-test-setup.ts +++ b/app/browser-test-setup.ts @@ -10,6 +10,8 @@ import "~/styles/utils.css"; import "~/styles/flags.css"; document.documentElement.classList.add("dark"); +document.documentElement.style.setProperty("--popover-boundary-top", "0px"); +document.documentElement.style.setProperty("--popover-boundary-bottom", "0px"); i18next.use(initReactI18next).init({ ...config, diff --git a/app/components/MobileNav.module.css b/app/components/MobileNav.module.css index a0a530c9b..3f480267f 100644 --- a/app/components/MobileNav.module.css +++ b/app/components/MobileNav.module.css @@ -3,7 +3,7 @@ position: fixed; /* captured on its own so animating content slides under it instead of over it */ view-transition-name: layout-mobile-nav; - inset: auto 0 0 0; + inset: auto var(--scrollbar-width, 0px) 0 0; width: auto; height: auto; margin: 0; @@ -102,7 +102,7 @@ .panel { position: fixed; - inset: auto 0 var(--mobile-nav-height) 0; + inset: auto var(--scrollbar-width, 0px) var(--mobile-nav-height) 0; margin: 0; padding: 0; border: none; @@ -176,7 +176,7 @@ .menuOverlay { position: fixed; - inset: 0 0 var(--mobile-nav-height) 0; + inset: 0 var(--scrollbar-width, 0px) var(--mobile-nav-height) 0; margin: 0; padding: 0; border: none; diff --git a/app/components/MobileNav.tsx b/app/components/MobileNav.tsx index 9df7d6f0d..96b4b678a 100644 --- a/app/components/MobileNav.tsx +++ b/app/components/MobileNav.tsx @@ -22,6 +22,7 @@ import { FriendMenu } from "~/features/friends/components/FriendMenu"; import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants"; import { canAccessTrophies } from "~/features/trophies/trophies-utils"; import { useClosePopoversOnNavigation } from "~/hooks/useClosePopoversOnNavigation"; +import { useScrollLock } from "~/hooks/useScrollLock"; import { useUnseenFriendRequests } from "~/hooks/useUnseenFriendRequests"; import type { RootLoaderData } from "~/root"; import { @@ -79,6 +80,7 @@ export function MobileNav({ sidebarData }: { sidebarData: SidebarData }) { PANEL_TYPES.map((panel) => [panel, panelDomId(uid, panel)]), ) as PanelIds; + useScrollLock(activePanel !== null); useClosePopoversOnNavigation(rootRef); const chatContextRef = React.useRef(chatContext); diff --git a/app/components/elements/Dialog.browser.test.tsx b/app/components/elements/Dialog.browser.test.tsx index 1d073b907..41d96a3c0 100644 --- a/app/components/elements/Dialog.browser.test.tsx +++ b/app/components/elements/Dialog.browser.test.tsx @@ -3,7 +3,7 @@ import { hydrateRoot } from "react-dom/client"; import { renderToString } from "react-dom/server"; import { createMemoryRouter, RouterProvider } from "react-router"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { page } from "vitest/browser"; +import { page, userEvent } from "vitest/browser"; import { render } from "vitest-browser-react"; import { SendouDialog } from "./Dialog"; @@ -29,13 +29,77 @@ function openDialog() { return dialog; } -function clickDialogAt(dialog: HTMLDialogElement, x: number, y: number) { +/** A press on the dialog element, the way one on its backdrop arrives; it starts where it ends unless `pressedAt` says otherwise. */ +function clickDialogAt( + dialog: HTMLDialogElement, + x: number, + y: number, + { pressedAt = { x, y } }: { pressedAt?: { x: number; y: number } } = {}, +) { + dialog.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + clientX: pressedAt.x, + clientY: pressedAt.y, + }), + ); dialog.dispatchEvent( new MouseEvent("click", { bubbles: true, clientX: x, clientY: y }), ); } describe("SendouDialog", () => { + test("keeps a backdrop click from reaching the page underneath", async () => { + const onClose = vi.fn(); + const onBehindClick = vi.fn(); + await render( + withRouter( + <> + + + Content + + , + ), + ); + await expect.element(page.getByText("Content")).toBeVisible(); + + // forced past the actionability check, as the backdrop covers the button; + // the press then lands on the backdrop at the button's spot + await userEvent.click(page.getByText("Behind"), { force: true }); + + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()); + expect(onBehindClick).not.toHaveBeenCalled(); + }); + + test("keeps a dismissable dialog open when a press starts inside its box and ends outside", async () => { + const onClose = vi.fn(); + await render( + withRouter( + + Content + , + ), + ); + await expect.element(page.getByText("Content")).toBeVisible(); + + const dialog = openDialog(); + const rect = dialog.getBoundingClientRect(); + clickDialogAt(dialog, rect.right + 5, rect.bottom + 5, { + pressedAt: { x: rect.left + 1, y: rect.top + 1 }, + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(onClose).not.toHaveBeenCalled(); + expect(dialog.open).toBe(true); + }); + test("closes a dismissable dialog on a backdrop click", async () => { const onClose = vi.fn(); await render( @@ -120,6 +184,83 @@ describe("SendouDialog", () => { expect(openDialog().open).toBe(true); }); + test("locks page scrolling while open without moving the page content", async () => { + const content = document.createElement("div"); + content.style.height = "300vh"; + content.style.width = "100%"; + document.body.appendChild(content); + cleanupFns.push(() => content.remove()); + + const screen = await render( + withRouter( + Open} + showCloseButton + > + Content + , + ), + ); + // after the render, whose container shares the body's flex row with the probe + const widthBefore = content.getBoundingClientRect().width; + + await screen.getByRole("button", { name: "Open" }).click(); + await expect.element(screen.getByText("Content")).toBeVisible(); + await vi.waitFor(() => expect(document.body.style.overflow).toBe("hidden")); + expect(content.getBoundingClientRect().width).toBe(widthBefore); + + await screen.getByRole("button", { name: "Close" }).click(); + await vi.waitFor(() => expect(document.body.style.overflow).toBe("")); + expect(document.body.style.paddingRight).toBe(""); + expect(content.getBoundingClientRect().width).toBe(widthBefore); + }); + + test("releases the scroll lock when an open dialog unmounts", async () => { + const screen = await render( + withRouter( + {}}> + Content + , + ), + ); + await expect.element(screen.getByText("Content")).toBeVisible(); + await vi.waitFor(() => expect(document.body.style.overflow).toBe("hidden")); + + await screen.unmount(); + await vi.waitFor(() => expect(document.body.style.overflow).toBe("")); + }); + + test("lets tall content take the visible height less the mobile margins", async () => { + const { innerWidth, innerHeight } = window; + await page.viewport(375, 667); + + try { + await render( + withRouter( + {}}> +
+ , + ), + ); + await expect.element(page.getByRole("dialog")).toBeVisible(); + + const dialog = openDialog(); + // the open animation scales the box, and the rect includes transforms + await Promise.all( + dialog.getAnimations().map((animation) => animation.finished), + ); + const rect = dialog.getBoundingClientRect(); + const maxHeight = Number.parseFloat(getComputedStyle(dialog).maxHeight); + + expect(maxHeight).toBeGreaterThan(window.innerHeight * 0.9); + expect(rect.height).toBeCloseTo(maxHeight, 0); + expect(rect.top).toBeCloseTo((window.innerHeight - rect.height) / 2, 0); + } finally { + await page.viewport(innerWidth, innerHeight); + } + }); + test("focuses the dialog itself instead of the close button on open", async () => { await render( withRouter( diff --git a/app/components/elements/Dialog.module.css b/app/components/elements/Dialog.module.css index 4f7c35854..274cceeae 100644 --- a/app/components/elements/Dialog.module.css +++ b/app/components/elements/Dialog.module.css @@ -1,7 +1,19 @@ .modal { + --dialog-padding-block: var(--s-6); + width: calc(100% - 2rem); max-width: 28rem; - max-height: min(80dvh, calc(var(--visual-viewport-height, 100dvh) - 10rem)); + inset-block-start: var(--visual-viewport-offset-top, 0px); + inset-block-end: calc( + 100% - + var(--visual-viewport-offset-top, 0px) - + var(--visual-viewport-height, 100%) + ); + max-height: calc( + var(--visual-viewport-height, 100dvh) - + 2 * + var(--modal-margin-block) + ); overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; @@ -9,7 +21,7 @@ border: 1px solid var(--color-border); background-color: var(--color-bg); color: var(--color-text); - padding: var(--s-6); + padding: var(--dialog-padding-block) var(--s-6); text-align: left; vertical-align: middle; box-shadow: @@ -18,6 +30,10 @@ margin: auto; outline: none; animation: zoom-in-95 300ms ease-out; + + @media (width < 600px) { + --dialog-padding-block: var(--s-4); + } } .blurredBackdrop::backdrop { @@ -40,6 +56,7 @@ min-width: 100%; height: 100dvh; max-height: 100dvh; + inset-block: 0; border-radius: 0; margin: 0; padding-block-start: calc(env(safe-area-inset-top) + var(--s-6)); @@ -67,7 +84,7 @@ } .noHeading { - margin-block-start: -14px; + margin-block-start: calc(10px - var(--dialog-padding-block)); } .heading { diff --git a/app/components/elements/Dialog.tsx b/app/components/elements/Dialog.tsx index eaf7c6357..814af1e08 100644 --- a/app/components/elements/Dialog.tsx +++ b/app/components/elements/Dialog.tsx @@ -8,6 +8,7 @@ import { type SendouButtonProps, } from "~/components/elements/Button"; import { useHydrated } from "~/hooks/useHydrated"; +import { useScrollLockWhileOpen } from "~/hooks/useScrollLock"; import { useReportModalOpen, useTopLayerViewTransitionStyle, @@ -73,10 +74,20 @@ function DialogElement({ ref, }: DialogElementProps) { const topLayerStyle = useTopLayerViewTransitionStyle(); + const dialogRef = React.useRef(null); + const backdropPressHandlers = useBackdropDismiss(isDismissable); + useScrollLockWhileOpen(dialogRef); return ( { + dialogRef.current = dialog; + if (typeof ref === "function") { + ref(dialog); + } else if (ref) { + ref.current = dialog; + } + }} id={id} style={topLayerStyle} className={clsx(className, { @@ -85,27 +96,44 @@ function DialogElement({ aria-label={ariaLabel} aria-labelledby={ariaLabelledby} tabIndex={-1} - closedby={isDismissable ? "any" : "closerequest"} + closedby="closerequest" onClose={onClose} - onClick={isDismissable ? closeOnBackdropClick : undefined} + {...backdropPressHandlers} > {children} ); } -// Safari 26 is missing `closedby`, close on backdrop clicks manually -function closeOnBackdropClick(event: React.MouseEvent) { - if (event.target !== event.currentTarget) return; +// Native `closedby` closes on pointer up so the click can land on stuff like buttons underneath the backdrop +// We just roll our own click handler here because that can't "leak" through +function useBackdropDismiss(enabled: boolean | undefined) { + const pressStartedOnBackdropRef = React.useRef(false); + + if (!enabled) return {}; + + return { + onPointerDown: (event: React.PointerEvent) => { + pressStartedOnBackdropRef.current = isOnBackdrop(event); + }, + onClick: (event: React.MouseEvent) => { + if (pressStartedOnBackdropRef.current && isOnBackdrop(event)) { + event.currentTarget.close(); + } + }, + }; +} + +function isOnBackdrop(event: React.MouseEvent) { + if (event.target !== event.currentTarget) return false; const rect = event.currentTarget.getBoundingClientRect(); - const outside = + + return ( event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || - event.clientY > rect.bottom; - if (outside) { - event.currentTarget.close(); - } + event.clientY > rect.bottom + ); } /** Invoker commands open and close the dialog natively; this guards the JS fallback for browsers without them. */ diff --git a/app/components/elements/Menu.module.css b/app/components/elements/Menu.module.css index 196354e1e..d383e1897 100644 --- a/app/components/elements/Menu.module.css +++ b/app/components/elements/Menu.module.css @@ -1,38 +1,24 @@ .triggerContainer { display: contents; - - > * { - anchor-name: var(--menu-anchor); - } } .popover { - position: fixed; - position-area: block-end span-inline-end; - margin: var(--s-2) 0; + position: absolute; + margin: 0; outline: none; border-radius: var(--radius-box); background-color: var(--color-bg-high); border: var(--border-style); width: max-content; - max-width: calc(100vw - var(--s-4)); + max-width: var(--floating-available-width, 100vw); font-size: var(--font-sm); font-weight: var(--weight-semi); padding: var(--s-2); color: var(--color-text); - - &[data-placement="bottom end"], - &[data-placement="bottom right"] { - position-area: block-end span-inline-start; - } -} - -.opensLeft { - position-area: block-end span-inline-start; } .scrolling { - max-height: 300px !important; + max-height: min(300px, var(--floating-available-height, 100vh)); overflow-y: auto; } diff --git a/app/components/elements/Menu.tsx b/app/components/elements/Menu.tsx index 7c3d536e8..15b09dc66 100644 --- a/app/components/elements/Menu.tsx +++ b/app/components/elements/Menu.tsx @@ -8,15 +8,14 @@ import { } from "~/utils/roving-focus"; import { useTopLayerViewTransitionStyle } from "~/utils/view-transition"; import { Image } from "../Image"; -import { useAnchorPositioning } from "./anchor-positioning"; import styles from "./Menu.module.css"; import { focusLeftTo, isOwnToggle, - useAnchorSafeId, + usePopoverTargetOnceHydrated, useShowPopoverOnOpen, } from "./Popover"; -import { useCloseOnScrollClip } from "./useCloseOnScrollClip"; +import { useFloatingLayer } from "./useFloatingLayer"; type MenuPlacement = "bottom start" | "bottom end" | "bottom right"; @@ -27,7 +26,7 @@ interface SendouMenuProps { children: React.ReactNode; popoverClassName?: string; placement?: MenuPlacement; - /** Render the items while closed too, so the menu works before hydration (and without JavaScript). */ + /** Render the items while closed too, so they are in the server markup and ready the moment the menu opens. */ eager?: boolean; } @@ -44,10 +43,9 @@ export function SendouMenu({ popoverClassName, eager, }: SendouMenuProps) { - const uid = useAnchorSafeId(); + const popoverId = `${React.useId()}-menu`; + const popoverTarget = usePopoverTargetOnceHydrated(popoverId); const topLayerStyle = useTopLayerViewTransitionStyle(); - const popoverId = `${uid}-menu`; - const anchorName = `--menu-anchor-${uid}`; const [open, setOpen] = React.useState(false); const popoverRef = React.useRef(null); @@ -63,12 +61,10 @@ export function SendouMenu({ open, onOpen: () => setOpen(true), }); - useCloseOnScrollClip(open, popoverRef, () => - popoverRef.current?.hidePopover(), - ); - useAnchorPositioning({ + + useFloatingLayer({ isOpen: open, - popoverRef, + floatingRef: popoverRef, getAnchor: () => triggerContainerRef.current?.firstElementChild ?? null, placement: opensLeft || (placement && placement !== "bottom start") @@ -115,11 +111,10 @@ export function SendouMenu({ {React.cloneElement(trigger, { - popoverTarget: popoverId, + popoverTarget, "aria-expanded": open, "aria-haspopup": "menu", })} @@ -132,15 +127,8 @@ export function SendouMenu({ tabIndex={-1} className={clsx(styles.popover, "scrollbar", popoverClassName, { [styles.scrolling]: scrolling, - [styles.opensLeft]: opensLeft, })} - style={ - { - positionAnchor: anchorName, - ...topLayerStyle, - } as React.CSSProperties - } - data-placement={placement} + style={topLayerStyle} onBeforeToggle={onBeforeToggle} onToggle={onToggle} onKeyDown={onKeyDown} diff --git a/app/components/elements/Popover.browser.test.tsx b/app/components/elements/Popover.browser.test.tsx index ae1325827..fd1419838 100644 --- a/app/components/elements/Popover.browser.test.tsx +++ b/app/components/elements/Popover.browser.test.tsx @@ -1,6 +1,6 @@ import { hydrateRoot } from "react-dom/client"; import { renderToString } from "react-dom/server"; -import { afterEach, describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { page, userEvent } from "vitest/browser"; import { render } from "vitest-browser-react"; import { SendouPopover } from "./Popover"; @@ -93,4 +93,32 @@ describe("SendouPopover", () => { await expect.element(page.getByText("Popover content")).toBeVisible(); expect(popover.matches(":popover-open")).toBe(true); }); + + test("does not open before hydration, when nothing could place it", async () => { + const app = ( + Open}> + Popover content + + ); + + const container = document.createElement("div"); + container.innerHTML = renderToString(app); + document.body.appendChild(container); + cleanupFns.push(() => container.remove()); + + const trigger = container.querySelector("button"); + const popover = container.querySelector("[popover]"); + if (!trigger || !popover) throw new Error("no popover rendered"); + trigger.click(); + expect(popover.matches(":popover-open")).toBe(false); + + const root = hydrateRoot(container, app); + cleanupFns.push(() => root.unmount()); + + await vi.waitFor(() => + expect(trigger.getAttribute("popovertarget")).toBe(popover.id), + ); + trigger.click(); + await expect.element(page.getByText("Popover content")).toBeVisible(); + }); }); diff --git a/app/components/elements/Popover.module.css b/app/components/elements/Popover.module.css index 392ff7b5a..ea11b9f81 100644 --- a/app/components/elements/Popover.module.css +++ b/app/components/elements/Popover.module.css @@ -1,17 +1,12 @@ .triggerContainer { display: contents; - - > * { - anchor-name: var(--popover-anchor); - } } .content { - position: fixed; - position-area: block-end; - justify-self: anchor-center; - margin: var(--s-2) 0; - max-width: min(20rem, calc(100vw - var(--s-4))); + position: absolute; + margin: 0; + max-width: min(20rem, var(--floating-available-width, 100vw)); + max-height: var(--floating-available-height, none); overflow: auto; padding: var(--s-2); border: var(--border-style); @@ -22,24 +17,4 @@ background-color: var(--color-bg); color: var(--color-text); outline: none; - - &[data-placement="top"] { - position-area: block-start; - } - - &[data-placement="bottom start"] { - position-area: block-end span-inline-end; - justify-self: unset; - } - - &[data-placement="bottom end"] { - position-area: block-end span-inline-start; - justify-self: unset; - } - - &[data-placement="right"] { - position-area: inline-end; - justify-self: unset; - align-self: anchor-center; - } } diff --git a/app/components/elements/Popover.tsx b/app/components/elements/Popover.tsx index cf7b6a2fa..626c53bcf 100644 --- a/app/components/elements/Popover.tsx +++ b/app/components/elements/Popover.tsx @@ -1,21 +1,14 @@ import clsx from "clsx"; import * as React from "react"; import { flushSync } from "react-dom"; +import { useHydrated } from "~/hooks/useHydrated"; import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; import { useTopLayerViewTransitionStyle } from "~/utils/view-transition"; -import { - type AnchorPlacement, - useAnchorPositioning, -} from "./anchor-positioning"; import styles from "./Popover.module.css"; -import { useCloseOnScrollClip } from "./useCloseOnScrollClip"; +import { type FloatingPlacement, useFloatingLayer } from "./useFloatingLayer"; +import { useScrollIntoView } from "./useScrollIntoView"; -export type PopoverPlacement = AnchorPlacement; - -/** `useId` values hold characters CSS idents can't (e.g. `:`), strip them for anchor names. */ -export function useAnchorSafeId() { - return React.useId().replace(/[^a-zA-Z0-9-]/g, ""); -} +export type PopoverPlacement = FloatingPlacement; /** * `toggle` does not bubble natively but React propagates it anyway, so an @@ -26,6 +19,10 @@ export function isOwnToggle(event: React.ToggleEvent) { return event.target === event.currentTarget; } +export function usePopoverTargetOnceHydrated(popoverId: string) { + return useHydrated() ? popoverId : undefined; +} + /** * Shows a popover once React has committed `open`, so content mounted only * while open is in the popover's first painted frame instead of appearing a @@ -33,7 +30,7 @@ export function isOwnToggle(event: React.ToggleEvent) { * browser's own open (the trigger's `popoverTarget`) is cancelled there and * redone through `onOpen` in the next frame, still before it paints, as a * popover cannot be shown from inside the show operation being cancelled. - * Call it before `useAnchorPositioning` so the popover is showing by the time + * Call it before `useFloatingLayer` so the popover is showing by the time * that measures it. */ export function useShowPopoverOnOpen({ @@ -86,10 +83,10 @@ export function focusLeftTo( /** * Popover opened by `trigger` (a SendouButton); controlled or uncontrolled. Renders through the - * native popover API with CSS anchor positioning. + * native popover API, placed next to the trigger by `useFloatingLayer`. * - * With `eager` the content is rendered while closed too, so the popover opens with its content - * before hydration (and without JavaScript altogether). + * With `eager` the content is rendered while closed too, so it is in the server markup and there + * is nothing left to mount when the popover opens. */ export function SendouPopover({ children, @@ -108,9 +105,8 @@ export function SendouPopover({ isOpen?: boolean; eager?: boolean; }) { - const uid = useAnchorSafeId(); - const popoverId = `${uid}-popover`; - const anchorName = `--popover-anchor-${uid}`; + const popoverId = `${React.useId()}-popover`; + const popoverTarget = usePopoverTargetOnceHydrated(popoverId); const [isControlled] = React.useState(isOpen !== undefined); const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false); @@ -153,13 +149,15 @@ export function SendouPopover({ open, onOpen: () => setOpen(true), }); - useCloseOnScrollClip(open, popoverRef, () => setOpen(false)); - useAnchorPositioning({ + useScrollIntoView( + open, + () => triggerContainerRef.current?.firstElementChild ?? null, + ); + useFloatingLayer({ isOpen: open, - popoverRef, + floatingRef: popoverRef, getAnchor: () => triggerContainerRef.current?.firstElementChild ?? null, placement, - constrainHeight: true, }); const onToggle = (event: React.ToggleEvent) => { @@ -189,11 +187,10 @@ export function SendouPopover({ {React.cloneElement(trigger, { - popoverTarget: popoverId, + popoverTarget, "aria-haspopup": "dialog", })} @@ -202,15 +199,9 @@ export function SendouPopover({ id={popoverId} popover="auto" className={clsx(styles.content, popoverClassName)} - style={ - { - positionAnchor: anchorName, - ...topLayerStyle, - } as React.CSSProperties - } + style={topLayerStyle} role="dialog" tabIndex={-1} - data-placement={placement} onBeforeToggle={onBeforeToggle} onToggle={onToggle} onBlur={onBlur} @@ -235,38 +226,28 @@ export function SendouAnchoredPopover({ triggerRef: React.RefObject; "aria-label"?: string; }) { - const uid = useAnchorSafeId(); - const anchorName = `--popover-anchor-${uid}`; - const popoverRef = React.useRef(null); const topLayerStyle = useTopLayerViewTransitionStyle(); // before the positioning effect, so the content is placed by its first paint useIsomorphicLayoutEffect(() => { - const trigger = triggerRef.current; const popover = popoverRef.current; if (!popover) return; if (isOpen) { - trigger?.style.setProperty("anchor-name", anchorName); if (!popover.matches(":popover-open")) { popover.showPopover(); } } else if (popover.matches(":popover-open")) { popover.hidePopover(); } + }, [isOpen]); - return () => { - trigger?.style.removeProperty("anchor-name"); - }; - }, [isOpen, triggerRef, anchorName]); - - useCloseOnScrollClip(isOpen, popoverRef, () => onOpenChange(false)); - useAnchorPositioning({ + useScrollIntoView(isOpen, () => triggerRef.current); + useFloatingLayer({ isOpen, - popoverRef, + floatingRef: popoverRef, getAnchor: () => triggerRef.current, - constrainHeight: true, }); const onToggle = (event: React.ToggleEvent) => { @@ -286,9 +267,7 @@ export function SendouAnchoredPopover({ ref={popoverRef} popover="auto" className={styles.content} - style={ - { positionAnchor: anchorName, ...topLayerStyle } as React.CSSProperties - } + style={topLayerStyle} role="dialog" tabIndex={-1} aria-label={ariaLabel} diff --git a/app/components/elements/Select.module.css b/app/components/elements/Select.module.css index 8933b9775..bce5375a5 100644 --- a/app/components/elements/Select.module.css +++ b/app/components/elements/Select.module.css @@ -72,17 +72,11 @@ } .popover { - position: fixed; - position-area: block-end; - width: anchor-size(width); - min-width: anchor-size(width); - /* fills the space the position-area leaves (its containing block), less the - gap to the anchor (the popover's own margin) and the padding to the - viewport edge; which side that space is on gets pinned when the popover - opens, which also sets the cap in pixels as WebKit does not resolve this - percentage */ - max-height: calc(100% - var(--s-2) - 12px); - margin: var(--s-2) 0; + position: absolute; + margin: 0; + width: var(--floating-anchor-width); + min-width: var(--floating-anchor-width); + max-height: var(--floating-available-height, none); padding: var(--s-1); border: var(--border-style); border-radius: var(--radius-box); @@ -96,11 +90,6 @@ &:popover-open { display: flex; } - - /* opened upwards the sticky header is the ceiling, not the viewport top */ - &[data-side="above"] { - max-height: calc(100% - var(--s-2) - 12px - var(--popover-boundary-top)); - } } .listBox { diff --git a/app/components/elements/Select.tsx b/app/components/elements/Select.tsx index 6c79ca718..cfab43b5b 100644 --- a/app/components/elements/Select.tsx +++ b/app/components/elements/Select.tsx @@ -8,15 +8,15 @@ import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; import { type FocusMove, rovingFocusIndex } from "~/utils/roving-focus"; import { useTopLayerViewTransitionStyle } from "~/utils/view-transition"; import { Image } from "../Image"; -import { useAnchorPositioning } from "./anchor-positioning"; import { focusLeftTo, isOwnToggle, - useAnchorSafeId, + usePopoverTargetOnceHydrated, useShowPopoverOnOpen, } from "./Popover"; import styles from "./Select.module.css"; -import { useCloseOnScrollClip } from "./useCloseOnScrollClip"; +import { useFloatingLayer } from "./useFloatingLayer"; +import { useScrollIntoView } from "./useScrollIntoView"; export type SelectKey = string | number; @@ -96,7 +96,7 @@ export interface SendouSelectProps { /** * A customizable select component with optional search functionality, - * rendered through the native popover API with CSS anchor positioning. + * rendered through the native popover API and placed by `useFloatingLayer`. * * Options mount only while the popover is open (plus the selected one, so the * trigger can show it); the trigger's content is read straight from the @@ -144,11 +144,11 @@ export function SendouSelect({ children, }: SendouSelectProps) { const { t } = useTranslation(["common"]); - const uid = useAnchorSafeId(); + const uid = React.useId(); const topLayerStyle = useTopLayerViewTransitionStyle(); const popoverId = `${uid}-select-popover`; + const popoverTarget = usePopoverTargetOnceHydrated(popoverId); const listboxId = `${uid}-select-listbox`; - const anchorName = `--select-anchor-${uid}`; const labelId = label ? `${uid}-select-label` : undefined; const valueId = `${uid}-select-value`; const triggerId = `${uid}-select-trigger`; @@ -203,13 +203,11 @@ export function SendouSelect({ open, onOpen: () => setOpen(true), }); - useCloseOnScrollClip(open, popoverRef, () => setOpen(false)); - useAnchorPositioning({ + useScrollIntoView(open, () => triggerElementRef.current); + useFloatingLayer({ isOpen: open, - popoverRef, + floatingRef: popoverRef, getAnchor: () => triggerElementRef.current, - matchAnchorWidth: true, - constrainHeight: true, }); // after positioning, so the selection scrolls into the space the list ends up with useIsomorphicLayoutEffect(() => { @@ -539,8 +537,7 @@ export function SendouSelect({ : undefined } data-required={isRequired || undefined} - popoverTarget={popoverId} - style={{ anchorName } as React.CSSProperties} + popoverTarget={popoverTarget} onKeyDown={onTriggerKeyDown} > ({ id={popoverId} popover="auto" className={clsx(styles.popover, popoverClassName)} - style={ - { - positionAnchor: anchorName, - ...topLayerStyle, - } as React.CSSProperties - } + style={topLayerStyle} onBeforeToggle={onPopoverBeforeToggle} onToggle={onPopoverToggle} onKeyDown={onPopoverKeyDown} diff --git a/app/components/elements/Toast.module.css b/app/components/elements/Toast.module.css index ad1da1bf2..3776b1be5 100644 --- a/app/components/elements/Toast.module.css +++ b/app/components/elements/Toast.module.css @@ -4,7 +4,7 @@ position: fixed; inset: unset; top: calc(var(--layout-nav-height) + var(--s-2)); - right: 10px; + right: calc(10px + var(--scrollbar-width, 0px)); margin: 0; padding: 0; border: none; diff --git a/app/components/elements/anchor-positioning.browser.test.tsx b/app/components/elements/anchor-positioning.browser.test.tsx deleted file mode 100644 index 5689f6f61..000000000 --- a/app/components/elements/anchor-positioning.browser.test.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; -import { render } from "vitest-browser-react"; -import { SendouPopover } from "./Popover"; -import { SendouSelect, SendouSelectItem } from "./Select"; - -const SEASONS = [{ id: 1, name: "Season 1" }]; -const MANY_SEASONS = Array.from({ length: 40 }, (_, index) => ({ - id: index + 1, - name: `Season ${index + 1}`, -})); - -let disablingStyle: HTMLStyleElement | null = null; - -afterEach(() => { - disablingStyle?.remove(); - disablingStyle = null; - vi.restoreAllMocks(); -}); - -/** The test browser has anchor positioning, so the fallback has to be forced on. */ -function disableAnchorPositioning() { - vi.spyOn(CSS, "supports").mockReturnValue(false); - - disablingStyle = document.createElement("style"); - disablingStyle.textContent = `[popover] { - position-area: none !important; - justify-self: normal !important; - align-self: normal !important; - }`; - document.head.append(disablingStyle); -} - -function rectOf(element: Element) { - return element.getBoundingClientRect(); -} - -describe("useAnchorPositioning", () => { - test("centers the popover under its trigger", async () => { - disableAnchorPositioning(); - const screen = await render( -
- Filters}> - Filter by season - -
, - ); - - const trigger = screen.getByRole("button", { name: "Filters" }); - await trigger.click(); - - const triggerRect = rectOf(trigger.element()); - const popoverRect = rectOf(screen.getByRole("dialog").element()); - - expect(popoverRect.top).toBeGreaterThanOrEqual(triggerRect.bottom); - expect( - Math.abs( - popoverRect.left + - popoverRect.width / 2 - - (triggerRect.left + triggerRect.width / 2), - ), - ).toBeLessThan(2); - }); - - test("gives the select popover the width of its trigger", async () => { - disableAnchorPositioning(); - const screen = await render( -
- - {({ id, name }: (typeof SEASONS)[number]) => ( - - {name} - - )} - -
, - ); - - const trigger = screen.getByRole("button", { name: /Pick a season/ }); - await trigger.click(); - await expect - .element(screen.getByRole("option", { name: "Season 1" })) - .toBeVisible(); - - const triggerRect = rectOf(trigger.element()); - const popover = document.querySelector("[popover]"); - const popoverRect = rectOf(popover as Element); - - expect(popoverRect.width).toBeCloseTo(triggerRect.width, 0); - expect(popoverRect.left).toBeCloseTo(triggerRect.left, 0); - expect(popoverRect.top).toBeGreaterThanOrEqual(triggerRect.bottom); - }); - - test("opens a select upwards when its options do not fit below the trigger", async () => { - const screen = await render(); - - const trigger = screen.getByRole("button", { name: /Pick a season/ }); - await trigger.click(); - await expect - .element(screen.getByRole("option", { name: "Season 1", exact: true })) - .toBeVisible(); - - const triggerRect = rectOf(trigger.element()); - const popoverRect = rectOf(document.querySelector("[popover]") as Element); - - expect(popoverRect.bottom).toBeLessThanOrEqual(triggerRect.top); - expect(popoverRect.top).toBeGreaterThanOrEqual(0); - }); - - test("caps a long select to the space below its trigger", async () => { - const screen = await render( -
- - {({ id, name }: (typeof MANY_SEASONS)[number]) => ( - - {name} - - )} - -
, - ); - - const trigger = screen.getByRole("button", { name: /Pick a season/ }); - await trigger.click(); - await expect - .element(screen.getByRole("option", { name: "Season 1", exact: true })) - .toBeVisible(); - - const popover = document.querySelector("[popover]") as HTMLElement; - const listbox = screen.getByRole("listbox").element(); - const spaceBelow = - window.innerHeight - - rectOf(trigger.element()).bottom - - Number.parseFloat(getComputedStyle(popover).marginTop) - - 12; - - // an explicit cap, as WebKit does not resolve the percentage one in the CSS - expect(Number.parseFloat(popover.style.maxHeight)).toBeCloseTo( - spaceBelow, - 0, - ); - expect(rectOf(popover).bottom).toBeLessThanOrEqual(window.innerHeight); - expect(listbox.scrollHeight).toBeGreaterThan(listbox.clientHeight); - }); - - test("keeps the select where it opened when searching shrinks the list", async () => { - const screen = await render(); - - const trigger = screen.getByRole("button", { name: /Pick a season/ }); - await trigger.click(); - await expect - .element(screen.getByRole("option", { name: "Season 1", exact: true })) - .toBeVisible(); - const popover = document.querySelector("[popover]") as Element; - const bottomOnOpen = rectOf(popover).bottom; - - await screen.getByRole("combobox").fill("Season 40"); - await expect - .element(screen.getByRole("option", { name: "Season 40" })) - .toBeVisible(); - - expect(rectOf(popover).bottom).toBeCloseTo(bottomOnOpen, 0); - }); -}); - -function SelectNearViewportBottom() { - return ( -
- - {({ id, name }: (typeof MANY_SEASONS)[number]) => ( - - {name} - - )} - -
- ); -} diff --git a/app/components/elements/anchor-positioning.ts b/app/components/elements/anchor-positioning.ts deleted file mode 100644 index 36a584042..000000000 --- a/app/components/elements/anchor-positioning.ts +++ /dev/null @@ -1,334 +0,0 @@ -import * as React from "react"; -import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; - -const VIEWPORT_PADDING = 12; -const POSITION_PROPERTIES = [ - "top", - "right", - "bottom", - "left", - "max-height", - "width", -]; - -/** Mirrors the `position-area` values the popovers declare in their CSS. */ -export type AnchorPlacement = - | "top" - | "bottom" - | "right" - | "bottom start" - | "bottom end"; - -/** The `position-area` of the side each placement asks for, and of the opposite one. */ -const POSITION_AREAS: Record< - AnchorPlacement, - { preferred: string; flipped: string } -> = { - top: { preferred: "block-start", flipped: "block-end" }, - bottom: { preferred: "block-end", flipped: "block-start" }, - "bottom start": { - preferred: "block-end span-inline-end", - flipped: "block-start span-inline-end", - }, - "bottom end": { - preferred: "block-end span-inline-start", - flipped: "block-start span-inline-start", - }, - right: { preferred: "inline-end", flipped: "inline-start" }, -}; - -/** - * Opens a popover on the side of its anchor that fits its content, keeping it - * there for as long as it stays open. - * - * Where CSS anchor positioning is supported it only pins the `position-area` - * and, with `constrainHeight`, caps the height in pixels (WebKit resolves a - * percentage `max-height` of an anchor-positioned box against nothing, so a - * long list would run past the viewport with nothing to scroll). The CSS - * handles the rest. `position-try-fallbacks` is deliberately not used: - * it flips only when a side overflows, so a popover capped to the space it has - * never flips, and on iOS 26 a popover carrying it locks up the page for good - * when it leaves the top layer during a navigation. Presumably an iOS 26 WebKit - * bug, so a pure CSS solution is worth retrying once it is fixed upstream, but - * verify it in the iOS simulator before deploying. Browsers without anchor - * positioning (Chrome < 125, Safari < 26, Firefox < 147), where the popover - * would land in the top left corner of the viewport, get positioned here in full. - */ -export function useAnchorPositioning({ - isOpen, - popoverRef, - getAnchor, - placement = "bottom", - matchAnchorWidth = false, - constrainHeight = false, -}: { - isOpen: boolean; - popoverRef: React.RefObject; - getAnchor: () => Element | null; - placement?: AnchorPlacement; - /** Take the anchor's width, like the CSS `width: anchor-size(width)` does. */ - matchAnchorWidth?: boolean; - /** Cap the height to the space on the chosen side. Only for popovers that scroll their content. */ - constrainHeight?: boolean; -}) { - const getAnchorRef = React.useRef(getAnchor); - getAnchorRef.current = getAnchor; - - useIsomorphicLayoutEffect(() => { - const popover = popoverRef.current; - if (!isOpen || !popover) return; - - const anchorPositioned = CSS.supports("anchor-name: --a"); - - /** Picked on the first measurement, so growing or shrinking content cannot move the popover. */ - let fitsPreferred: boolean | null = null; - - const position = () => { - const anchor = getAnchorRef.current(); - // a popover shown after this effect (a controlled one) measures as hidden - if (!anchor || !popover.matches(":popover-open")) return; - - fitsPreferred ??= preferredSideFits(popover, anchor, placement); - const below = placement === "top" ? !fitsPreferred : fitsPreferred; - if (placement !== "right") { - popover.dataset.side = below ? "below" : "above"; - } - - if (anchorPositioned) { - const area = POSITION_AREAS[placement]; - popover.style.setProperty( - "position-area", - fitsPreferred ? area.preferred : area.flipped, - ); - if (constrainHeight) { - popover.style.setProperty( - "max-height", - px(availableHeight(popover, anchor, placement, below)), - ); - } - return; - } - - applyStyles( - popover, - positionStyles(popover, anchor, { - below, - placement, - matchAnchorWidth, - constrainHeight, - }), - ); - }; - position(); - - popover.addEventListener("toggle", position); - let contentObserver: ResizeObserver | undefined; - if (!anchorPositioned) { - // the popover is fixed, so it has to follow an anchor moved by scrolling - window.addEventListener("scroll", position, { - capture: true, - passive: true, - }); - window.addEventListener("resize", position); - contentObserver = new ResizeObserver(position); - contentObserver.observe(popover); - } - - return () => { - popover.removeEventListener("toggle", position); - window.removeEventListener("scroll", position, { capture: true }); - window.removeEventListener("resize", position); - contentObserver?.disconnect(); - delete popover.dataset.side; - popover.style.removeProperty("position-area"); - applyStyles(popover, {}); - }; - }, [isOpen, popoverRef, placement, matchAnchorWidth, constrainHeight]); -} - -/** Whether to keep to the side the placement asks for; the roomier one is taken when the content does not fit there. */ -function preferredSideFits( - popover: HTMLElement, - anchor: Element, - placement: AnchorPlacement, -) { - const anchorRect = anchor.getBoundingClientRect(); - const computed = getComputedStyle(popover); - - if (placement === "right") { - const spaceInlineStart = anchorRect.left - VIEWPORT_PADDING; - const spaceInlineEnd = - window.innerWidth - anchorRect.right - VIEWPORT_PADDING; - const [preferred, other] = - computed.direction === "rtl" - ? [spaceInlineStart, spaceInlineEnd] - : [spaceInlineEnd, spaceInlineStart]; - const width = popover.getBoundingClientRect().width; - return width <= preferred || preferred >= other; - } - - const { above, below } = spaceAroundAnchor(anchorRect, computed); - const [preferred, other] = - placement === "top" ? [above, below] : [below, above]; - return naturalHeight(popover) <= preferred || preferred >= other; -} - -/** The height the popover may take on the side it opened to. */ -function availableHeight( - popover: HTMLElement, - anchor: Element, - placement: AnchorPlacement, - below: boolean, -) { - if (placement === "right") { - return Math.max(0, window.innerHeight - 2 * VIEWPORT_PADDING); - } - const space = spaceAroundAnchor( - anchor.getBoundingClientRect(), - getComputedStyle(popover), - ); - return Math.max(0, below ? space.below : space.above); -} - -/** - * Height each side of the anchor has for the popover, its margin and the - * viewport padding taken out. Above the anchor the sticky header - * (`--popover-boundary-top`) is the ceiling, not the top of the viewport. - */ -function spaceAroundAnchor(anchorRect: DOMRect, computed: CSSStyleDeclaration) { - return { - above: - anchorRect.top - - (Number.parseFloat(computed.getPropertyValue("--popover-boundary-top")) || - 0) - - VIEWPORT_PADDING - - Number.parseFloat(computed.marginBottom), - below: - window.innerHeight - - anchorRect.bottom - - VIEWPORT_PADDING - - Number.parseFloat(computed.marginTop), - }; -} - -/** The height the content wants, which the `max-height` capping it to one side's space hides. */ -function naturalHeight(popover: HTMLElement) { - const capped = popover.style.maxHeight; - popover.style.setProperty("max-height", "none"); - const height = popover.getBoundingClientRect().height; - if (capped) { - popover.style.setProperty("max-height", capped); - } else { - popover.style.removeProperty("max-height"); - } - return height; -} - -function positionStyles( - popover: HTMLElement, - anchor: Element, - { - below, - placement, - matchAnchorWidth, - constrainHeight, - }: { - below: boolean; - placement: AnchorPlacement; - matchAnchorWidth: boolean; - constrainHeight: boolean; - }, -) { - const anchorRect = anchor.getBoundingClientRect(); - const popoverRect = popover.getBoundingClientRect(); - const computed = getComputedStyle(popover); - const isRtl = computed.direction === "rtl"; - // the margins of the popover offset it from the inset it is given, which is - // the gap to the anchor in the block axis and drift to undo everywhere else - const marginTop = Number.parseFloat(computed.marginTop); - const marginLeft = Number.parseFloat(computed.marginLeft); - - const width = matchAnchorWidth ? anchorRect.width : popoverRect.width; - - const styles: Record = matchAnchorWidth - ? { width: px(anchorRect.width) } - : {}; - - if (placement === "right") { - // inline-end of the anchor, flipping over it like `flip-inline` does - const height = Math.max(popoverRect.height, popover.scrollHeight); - const spaceInlineStart = anchorRect.left - VIEWPORT_PADDING; - const spaceInlineEnd = - window.innerWidth - anchorRect.right - VIEWPORT_PADDING; - const [preferred, other] = isRtl - ? [spaceInlineStart, spaceInlineEnd] - : [spaceInlineEnd, spaceInlineStart]; - const towardsInlineEnd = width <= preferred || preferred >= other; - - return { - ...styles, - top: px(anchorRect.top + anchorRect.height / 2 - height / 2 - marginTop), - bottom: "auto", - ...horizontalPlacement( - towardsInlineEnd !== isRtl ? anchorRect.right : anchorRect.left - width, - width, - marginLeft, - ), - }; - } - - const alignedToAnchorLeft = - placement === "bottom start" - ? !isRtl - : placement === "bottom end" - ? isRtl - : null; - const left = - alignedToAnchorLeft === null - ? anchorRect.left + anchorRect.width / 2 - width / 2 - : alignedToAnchorLeft - ? anchorRect.left - : anchorRect.right - width; - - return { - ...styles, - ...(below - ? { top: px(anchorRect.bottom), bottom: "auto" } - : { top: "auto", bottom: px(window.innerHeight - anchorRect.top) }), - ...(constrainHeight - ? { "max-height": px(availableHeight(popover, anchor, placement, below)) } - : {}), - ...horizontalPlacement(left, width, marginLeft), - }; -} - -function horizontalPlacement(left: number, width: number, marginLeft: number) { - const rightmost = Math.max( - VIEWPORT_PADDING, - window.innerWidth - VIEWPORT_PADDING - width, - ); - - return { - left: px( - Math.min(Math.max(left, VIEWPORT_PADDING), rightmost) - marginLeft, - ), - right: "auto", - }; -} - -/** Writes the positioning properties, clearing the ones the placement leaves out. */ -function applyStyles(popover: HTMLElement, styles: Record) { - for (const property of POSITION_PROPERTIES) { - const value = styles[property]; - - if (value === undefined) { - popover.style.removeProperty(property); - } else if (popover.style.getPropertyValue(property) !== value) { - popover.style.setProperty(property, value); - } - } -} - -function px(value: number) { - return `${Math.round(value)}px`; -} diff --git a/app/components/elements/floating-layer.test.ts b/app/components/elements/floating-layer.test.ts new file mode 100644 index 000000000..54ad17225 --- /dev/null +++ b/app/components/elements/floating-layer.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, test } from "vitest"; +import * as FloatingLayer from "./floating-layer"; + +const BOUNDS: FloatingLayer.Bounds = { + top: 0, + right: 1000, + bottom: 800, + left: 0, +}; +const VIEWPORT = { width: 1000, height: 800 }; +const GAP = 8; +const PADDING = 12; + +function anchorAt(top: number, left = 100): FloatingLayer.Rect { + return { top, left, width: 200, height: 40 }; +} + +describe("FloatingLayer.resolve", () => { + test.each([ + { + why: "stays below when the content fits there", + anchorTop: 100, + height: 300, + side: "bottom", + availableHeight: 640, + }, + { + why: "flips above when the content does not fit below but has more room there", + anchorTop: 700, + height: 300, + side: "top", + availableHeight: 680, + }, + { + why: "takes the roomier side when the content fits on neither", + anchorTop: 700, + height: 900, + side: "top", + availableHeight: 680, + }, + { + why: "keeps the asked side when it is the roomier one though the content fits on neither", + anchorTop: 100, + height: 900, + side: "bottom", + availableHeight: 640, + }, + ])("$why", ({ anchorTop, height, side, availableHeight }) => { + const resolution = FloatingLayer.resolve({ + anchor: anchorAt(anchorTop), + floating: { width: 200, height }, + bounds: BOUNDS, + placement: "bottom", + gap: GAP, + padding: PADDING, + rtl: false, + }); + + expect(resolution.side).toBe(side); + expect(resolution.availableHeight).toBe(availableHeight); + expect(resolution.availableWidth).toBe(976); + }); + + test("keeps a sticky header at the top of the bounds out of the room above", () => { + const resolution = FloatingLayer.resolve({ + anchor: anchorAt(300), + floating: { width: 200, height: 250 }, + bounds: { ...BOUNDS, top: 55 }, + placement: "top", + gap: GAP, + padding: PADDING, + rtl: false, + }); + + expect(resolution.side).toBe("bottom"); + expect(resolution.availableHeight).toBe(440); + }); + + test("puts a beside placement on the other side when the content has more room there", () => { + const resolution = FloatingLayer.resolve({ + anchor: { top: 100, left: 800, width: 100, height: 40 }, + floating: { width: 200, height: 40 }, + bounds: BOUNDS, + placement: "right", + gap: GAP, + padding: PADDING, + rtl: false, + }); + + expect(resolution.side).toBe("left"); + expect(resolution.availableWidth).toBe(780); + expect(resolution.availableHeight).toBe(776); + }); + + test.each([ + { placement: "bottom", rtl: false, align: "center", origin: "50% 0%" }, + { placement: "bottom start", rtl: false, align: "start", origin: "0% 0%" }, + { placement: "bottom end", rtl: false, align: "end", origin: "100% 0%" }, + { placement: "bottom end", rtl: true, align: "end", origin: "0% 0%" }, + { placement: "top", rtl: false, align: "center", origin: "50% 100%" }, + { placement: "right", rtl: false, align: "center", origin: "0% 50%" }, + ] as const)( + "$placement (rtl: $rtl) aligns $align with its origin at $origin", + ({ placement, rtl, align, origin }) => { + const resolution = FloatingLayer.resolve({ + anchor: anchorAt(100, 400), + floating: { width: 100, height: 50 }, + bounds: BOUNDS, + placement, + gap: GAP, + padding: PADDING, + rtl, + }); + + expect(resolution.align).toBe(align); + expect(resolution.transformOrigin).toBe(origin); + }, + ); +}); + +describe("FloatingLayer.spaceAcross", () => { + test.each([ + { placement: "bottom", space: 976 }, + { placement: "top", space: 976 }, + { placement: "right", space: 776 }, + ] as const)("$placement has $space across", ({ placement, space }) => { + expect(FloatingLayer.spaceAcross(BOUNDS, placement, PADDING)).toBe(space); + }); +}); + +describe("FloatingLayer.isVerticalPlacement", () => { + test.each([ + { placement: "bottom start", vertical: true }, + { placement: "top", vertical: true }, + { placement: "right", vertical: false }, + ] as const)("$placement -> $vertical", ({ placement, vertical }) => { + expect(FloatingLayer.isVerticalPlacement(placement)).toBe(vertical); + }); +}); + +describe("FloatingLayer.insets", () => { + const box = { width: 100, height: 50 }; + + test.each([ + { + why: "centers under the anchor", + anchor: anchorAt(100), + floating: box, + side: "bottom", + align: "center", + rtl: false, + expected: { top: 148, right: null, bottom: null, left: 150 }, + }, + { + why: "hangs from the edge facing the anchor above it", + anchor: anchorAt(100), + floating: box, + side: "top", + align: "center", + rtl: false, + expected: { top: null, right: null, bottom: 708, left: 150 }, + }, + { + why: "lines up with the start edge of the anchor", + anchor: anchorAt(100), + floating: box, + side: "bottom", + align: "start", + rtl: false, + expected: { top: 148, right: null, bottom: null, left: 100 }, + }, + { + why: "lines up with the end edge of the anchor", + anchor: anchorAt(100), + floating: box, + side: "bottom", + align: "end", + rtl: false, + expected: { top: 148, right: null, bottom: null, left: 200 }, + }, + { + why: "reads start as the right edge in rtl", + anchor: anchorAt(100), + floating: box, + side: "bottom", + align: "start", + rtl: true, + expected: { top: 148, right: null, bottom: null, left: 200 }, + }, + { + why: "shifts back inside the bounds on the right", + anchor: anchorAt(100, 900), + floating: box, + side: "bottom", + align: "center", + rtl: false, + expected: { top: 148, right: null, bottom: null, left: 888 }, + }, + { + why: "shifts back inside the bounds on the left", + anchor: { top: 100, left: 0, width: 50, height: 40 }, + floating: box, + side: "bottom", + align: "center", + rtl: false, + expected: { top: 148, right: null, bottom: null, left: 12 }, + }, + { + why: "sits at the padding when wider than the bounds", + anchor: anchorAt(100), + floating: { width: 1200, height: 50 }, + side: "bottom", + align: "center", + rtl: false, + expected: { top: 148, right: null, bottom: null, left: 12 }, + }, + { + why: "sits beside the anchor", + anchor: { top: 300, left: 100, width: 100, height: 40 }, + floating: box, + side: "right", + align: "center", + rtl: false, + expected: { top: 295, right: null, bottom: null, left: 208 }, + }, + { + why: "hangs from the edge facing the anchor on its left", + anchor: { top: 300, left: 100, width: 100, height: 40 }, + floating: box, + side: "left", + align: "center", + rtl: false, + expected: { top: 295, right: 908, bottom: null, left: null }, + }, + { + why: "shifts down inside the bounds beside a high anchor", + anchor: { top: 10, left: 100, width: 100, height: 40 }, + floating: box, + side: "right", + align: "center", + rtl: false, + expected: { top: 12, right: null, bottom: null, left: 208 }, + }, + ] as const)("$why", ({ anchor, floating, side, align, rtl, expected }) => { + expect( + FloatingLayer.insets({ + anchor, + floating, + bounds: BOUNDS, + side, + align, + gap: GAP, + padding: PADDING, + rtl, + viewport: VIEWPORT, + }), + ).toEqual(expected); + }); +}); + +describe("FloatingLayer.documentInsets", () => { + test.each([ + { + why: "moves the near edges down the document by the scroll offset", + insets: { top: 148, right: null, bottom: null, left: 150 }, + scroll: { x: 0, y: 300 }, + expected: { top: 448, right: null, bottom: null, left: 150 }, + }, + { + why: "moves the far edges the other way", + insets: { top: null, right: 908, bottom: 708, left: null }, + scroll: { x: 40, y: 300 }, + expected: { top: null, right: 868, bottom: 408, left: null }, + }, + { + why: "leaves an unscrolled page as it is", + insets: { top: 148, right: null, bottom: null, left: 150 }, + scroll: { x: 0, y: 0 }, + expected: { top: 148, right: null, bottom: null, left: 150 }, + }, + ])("$why", ({ insets, scroll, expected }) => { + expect(FloatingLayer.documentInsets(insets, scroll)).toEqual(expected); + }); +}); diff --git a/app/components/elements/floating-layer.ts b/app/components/elements/floating-layer.ts new file mode 100644 index 000000000..161832533 --- /dev/null +++ b/app/components/elements/floating-layer.ts @@ -0,0 +1,241 @@ +export type Side = "top" | "bottom" | "left" | "right"; +export type Align = "start" | "center" | "end"; + +export type Placement = + | "top" + | "bottom" + | "right" + | "bottom start" + | "bottom end"; + +export interface Size { + width: number; + height: number; +} + +export interface Rect extends Size { + top: number; + left: number; +} + +export interface Bounds { + top: number; + right: number; + bottom: number; + left: number; +} + +export interface Resolution { + side: Side; + align: Align; + availableWidth: number; + availableHeight: number; + transformOrigin: string; +} + +export interface Insets { + top: number | null; + right: number | null; + bottom: number | null; + left: number | null; +} + +const OPPOSITE_SIDE: Record = { + top: "bottom", + bottom: "top", + left: "right", + right: "left", +}; + +const ALIGN_ORIGIN: Record = { + start: "0%", + center: "50%", + end: "100%", +}; + +export function resolve({ + anchor, + floating, + bounds, + placement, + gap, + padding, + rtl, +}: { + anchor: Rect; + floating: Size; + bounds: Bounds; + placement: Placement; + gap: number; + padding: number; + rtl: boolean; +}): Resolution { + const { side: preferred, align } = parsePlacement(placement); + + const space = spaceAround(anchor, bounds, gap, padding); + const opposite = OPPOSITE_SIDE[preferred]; + const needed = isVertical(preferred) ? floating.height : floating.width; + const across = spaceAcross(bounds, placement, padding); + + const side = + needed <= space[preferred] || space[preferred] >= space[opposite] + ? preferred + : opposite; + + return { + side, + align, + availableWidth: Math.max(0, isVertical(side) ? across : space[side]), + availableHeight: Math.max(0, isVertical(side) ? space[side] : across), + transformOrigin: transformOrigin(side, align, rtl), + }; +} + +export function spaceAcross( + bounds: Bounds, + placement: Placement, + padding: number, +) { + const size = isVerticalPlacement(placement) + ? bounds.right - bounds.left + : bounds.bottom - bounds.top; + + return Math.max(0, size - 2 * padding); +} + +export function isVerticalPlacement(placement: Placement) { + return isVertical(parsePlacement(placement).side); +} + +export function insets({ + anchor, + floating, + bounds, + side, + align, + gap, + padding, + rtl, + viewport, +}: { + anchor: Rect; + floating: Size; + bounds: Bounds; + side: Side; + align: Align; + gap: number; + padding: number; + rtl: boolean; + viewport: Size; +}): Insets { + if (isVertical(side)) { + const left = clamp( + alignedStart( + anchor.left, + anchor.width, + floating.width, + physicalAlign(align, rtl), + ), + bounds.left + padding, + bounds.right - padding - floating.width, + ); + + return side === "bottom" + ? { + top: anchor.top + anchor.height + gap, + right: null, + bottom: null, + left, + } + : { + top: null, + right: null, + bottom: viewport.height - (anchor.top - gap), + left, + }; + } + + const top = clamp( + alignedStart(anchor.top, anchor.height, floating.height, align), + bounds.top + padding, + bounds.bottom - padding - floating.height, + ); + + return side === "right" + ? { top, right: null, bottom: null, left: anchor.left + anchor.width + gap } + : { + top, + right: viewport.width - (anchor.left - gap), + bottom: null, + left: null, + }; +} + +export function documentInsets( + viewportInsets: Insets, + scroll: { x: number; y: number }, +): Insets { + const { top, right, bottom, left } = viewportInsets; + + return { + top: top === null ? null : top + scroll.y, + right: right === null ? null : right - scroll.x, + bottom: bottom === null ? null : bottom - scroll.y, + left: left === null ? null : left + scroll.x, + }; +} + +function parsePlacement(placement: Placement): { side: Side; align: Align } { + const [side, align = "center"] = placement.split(" ") as [Side, Align?]; + return { side, align }; +} + +function isVertical(side: Side) { + return side === "top" || side === "bottom"; +} + +function spaceAround( + anchor: Rect, + bounds: Bounds, + gap: number, + padding: number, +): Record { + const taken = gap + padding; + + return { + top: anchor.top - bounds.top - taken, + bottom: bounds.bottom - (anchor.top + anchor.height) - taken, + left: anchor.left - bounds.left - taken, + right: bounds.right - (anchor.left + anchor.width) - taken, + }; +} + +function alignedStart( + anchorStart: number, + anchorSize: number, + floatingSize: number, + align: Align, +) { + if (align === "start") return anchorStart; + if (align === "end") return anchorStart + anchorSize - floatingSize; + return anchorStart + anchorSize / 2 - floatingSize / 2; +} + +function physicalAlign(align: Align, rtl: boolean): Align { + if (!rtl || align === "center") return align; + return align === "start" ? "end" : "start"; +} + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(value, max)); +} + +function transformOrigin(side: Side, align: Align, rtl: boolean) { + if (isVertical(side)) { + const x = ALIGN_ORIGIN[physicalAlign(align, rtl)]; + return side === "bottom" ? `${x} 0%` : `${x} 100%`; + } + + const y = ALIGN_ORIGIN[align]; + return side === "right" ? `0% ${y}` : `100% ${y}`; +} diff --git a/app/components/elements/tests/keyboard.ts b/app/components/elements/tests/keyboard.ts new file mode 100644 index 000000000..980c12e65 --- /dev/null +++ b/app/components/elements/tests/keyboard.ts @@ -0,0 +1,23 @@ +import { invariant } from "~/utils/invariant"; + +export const KEYBOARD_HEIGHT = 300; + +export function openKeyboard() { + const viewport = window.visualViewport; + invariant(viewport); + + const shrunk = viewport.height - KEYBOARD_HEIGHT; + Object.defineProperty(viewport, "height", { + configurable: true, + get: () => shrunk, + }); + viewport.dispatchEvent(new Event("resize")); +} + +export function closeKeyboard() { + const viewport = window.visualViewport; + invariant(viewport); + + Reflect.deleteProperty(viewport, "height"); + viewport.dispatchEvent(new Event("resize")); +} diff --git a/app/components/elements/useCloseOnScrollClip.browser.test.tsx b/app/components/elements/useCloseOnScrollClip.browser.test.tsx deleted file mode 100644 index ba617fa22..000000000 --- a/app/components/elements/useCloseOnScrollClip.browser.test.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import * as React from "react"; -import { afterEach, describe, expect, test, vi } from "vitest"; -import { render } from "vitest-browser-react"; -import { invariant } from "~/utils/invariant"; -import { useCloseOnScrollClip } from "./useCloseOnScrollClip"; - -const PAGE_HEIGHT = 5000; - -afterEach(() => { - window.scrollTo(0, 0); - closeKeyboard(); -}); - -function Overlay({ - top, - height, - close, -}: { - top: number; - height: number; - close: () => void; -}) { - const ref = React.useRef(null); - useCloseOnScrollClip(true, ref, close); - - return ( - <> -
-
- - ); -} - -function ScrollingOverlay({ - height, - close, -}: { - height: number; - close: () => void; -}) { - const ref = React.useRef(null); - useCloseOnScrollClip(true, ref, close); - - return ( - <> -
-
-
-
- - ); -} - -const settle = () => new Promise((resolve) => setTimeout(resolve, 150)); - -const KEYBOARD_HEIGHT = 300; - -/** Shrinks the visual viewport the way the virtual keyboard opening does. */ -function openKeyboard() { - const viewport = window.visualViewport; - invariant(viewport); - - const shrunk = viewport.height - KEYBOARD_HEIGHT; - Object.defineProperty(viewport, "height", { - configurable: true, - get: () => shrunk, - }); - viewport.dispatchEvent(new Event("resize")); -} - -function closeKeyboard() { - const viewport = window.visualViewport; - invariant(viewport); - - Reflect.deleteProperty(viewport, "height"); - viewport.dispatchEvent(new Event("resize")); -} - -describe("useCloseOnScrollClip", () => { - test("closes once scrolling clips a popover that was fully visible", async () => { - const close = vi.fn(); - await render(); - await settle(); - expect(close).not.toHaveBeenCalled(); - - window.scrollTo(0, 250); - - await vi.waitFor(() => expect(close).toHaveBeenCalledOnce()); - }); - - test("never closes a popover too tall to have been fully visible", async () => { - const close = vi.fn(); - await render(); - await settle(); - - window.scrollTo(0, 250); - await settle(); - - expect(close).not.toHaveBeenCalled(); - }); - - test("does not close a popover that was already clipped when it opened", async () => { - const close = vi.fn(); - await render(); - await settle(); - - window.scrollTo(0, 250); - await settle(); - - expect(close).not.toHaveBeenCalled(); - }); - - test("never closes over the scroll the virtual keyboard opening causes", async () => { - const close = vi.fn(); - await render(); - await settle(); - - openKeyboard(); - window.scrollTo(0, 250); - await settle(); - - expect(close).not.toHaveBeenCalled(); - }); - - test("forgets a scroll the keyboard only lands after", async () => { - const close = vi.fn(); - await render(); - await settle(); - - window.scrollTo(0, 250); - // the scroll can reach the page before the keyboard has shrunk the viewport - window.dispatchEvent(new Event("scroll")); - openKeyboard(); - await settle(); - - expect(close).not.toHaveBeenCalled(); - }); - - test("never closes over a scroll of the popover's own content", async () => { - const close = vi.fn(); - const screen = await render( - , - ); - await settle(); - - const scroller = document.querySelector( - '[data-testid="scroller"]', - ); - invariant(scroller); - scroller.scrollTop = 500; - await settle(); - - // the content growing then clips it, which alone must never close - screen.rerender(); - await settle(); - - expect(close).not.toHaveBeenCalled(); - }); -}); diff --git a/app/components/elements/useCloseOnScrollClip.ts b/app/components/elements/useCloseOnScrollClip.ts deleted file mode 100644 index 5b130cc5b..000000000 --- a/app/components/elements/useCloseOnScrollClip.ts +++ /dev/null @@ -1,93 +0,0 @@ -import * as React from "react"; - -const VISIBLE_RATIO_THRESHOLD = 0.98; -/** A visual viewport shorter than the window by more than this is the virtual keyboard, not collapsing browser chrome. */ -const KEYBOARD_MIN_HEIGHT = 150; - -/** - * Closes an open popover once scrolling clips it against the sticky header - * (`--popover-boundary-top`) or the bottom of the viewport. - * - * Only scrolling may close: a popover clipped by its own content growing (the - * moment before anchor positioning flips it into view), one too tall to ever - * fit fully, or one measured before it is shown must not close itself. The - * virtual keyboard opening is not scrolling either, even though the browser - * scrolls the page to keep the focused field in view as it does: a popover - * left under the keyboard beats one that closes as its own search input is - * focused. - */ -export function useCloseOnScrollClip( - isOpen: boolean, - elementRef: React.RefObject, - close: () => void, -) { - const closeRef = React.useRef(close); - closeRef.current = close; - - React.useEffect(() => { - if (!isOpen) return; - const element = elementRef.current; - if (!element) return; - - const marginTop = - Number.parseFloat( - getComputedStyle(element).getPropertyValue("--popover-boundary-top"), - ) || 0; - - let wasFullyVisible = false; - let scrolledSinceFullyVisible = false; - - const onScroll = (event: Event) => { - // the popover scrolling its own content (e.g. a select revealing the - // selected option) is not the page moving out from under it - if (event.target instanceof Node && element.contains(event.target)) { - return; - } - if (keyboardIsOpen()) return; - scrolledSinceFullyVisible = true; - }; - window.addEventListener("scroll", onScroll, { - capture: true, - passive: true, - }); - - // the keyboard can land after the scroll it causes, which then has to be forgotten - const onViewportResize = () => { - if (keyboardIsOpen()) { - scrolledSinceFullyVisible = false; - } - }; - window.visualViewport?.addEventListener("resize", onViewportResize); - - const observer = new IntersectionObserver( - (entries) => { - const entry = entries.at(-1); - if (!entry) return; - if (entry.intersectionRatio >= VISIBLE_RATIO_THRESHOLD) { - wasFullyVisible = true; - scrolledSinceFullyVisible = false; - } else if (wasFullyVisible && scrolledSinceFullyVisible) { - closeRef.current(); - } - }, - { - threshold: [0, VISIBLE_RATIO_THRESHOLD], - rootMargin: `${-marginTop}px 0px 0px 0px`, - }, - ); - observer.observe(element); - - return () => { - window.removeEventListener("scroll", onScroll, { capture: true }); - window.visualViewport?.removeEventListener("resize", onViewportResize); - observer.disconnect(); - }; - }, [isOpen, elementRef]); -} - -function keyboardIsOpen() { - const viewport = window.visualViewport; - if (!viewport) return false; - - return window.innerHeight - viewport.height > KEYBOARD_MIN_HEIGHT; -} diff --git a/app/components/elements/useFloatingLayer.browser.test.tsx b/app/components/elements/useFloatingLayer.browser.test.tsx new file mode 100644 index 000000000..e4ddb722e --- /dev/null +++ b/app/components/elements/useFloatingLayer.browser.test.tsx @@ -0,0 +1,426 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { page } from "vitest/browser"; +import { render } from "vitest-browser-react"; +import { invariant } from "~/utils/invariant"; +import { SendouPopover } from "./Popover"; +import { SendouSelect, SendouSelectItem } from "./Select"; +import { closeKeyboard, KEYBOARD_HEIGHT, openKeyboard } from "./tests/keyboard"; + +const SEASONS = [{ id: 1, name: "Season 1" }]; +const MANY_SEASONS = Array.from({ length: 40 }, (_, index) => ({ + id: index + 1, + name: `Season ${index + 1}`, +})); + +afterEach(() => { + window.scrollTo(0, 0); + closeKeyboard(); +}); + +function rectOf(element: Element) { + return element.getBoundingClientRect(); +} + +function openPopover() { + const popover = document.querySelector("[popover]"); + invariant(popover); + return popover; +} + +function lengthOf(element: Element, property: string) { + return Number.parseFloat( + getComputedStyle(element).getPropertyValue(property), + ); +} + +/** A popover takes focus a frame after opening, scrolling itself into view, which would undo a scroll made before then. */ +async function waitForFocus(popover: Element) { + await vi.waitFor(() => expect(document.activeElement).toBe(popover)); +} + +function ManySeasonsSelect() { + return ( + + {({ id, name }: (typeof MANY_SEASONS)[number]) => ( + + {name} + + )} + + ); +} + +describe("useFloatingLayer", () => { + test("centers the popover under its trigger", async () => { + const screen = await render( +
+ Filters}> + Filter by season + +
, + ); + + const trigger = screen.getByRole("button", { name: "Filters" }); + await trigger.click(); + + const popover = screen.getByRole("dialog").element(); + const triggerRect = rectOf(trigger.element()); + const popoverRect = rectOf(popover); + + expect(popoverRect.top).toBeCloseTo( + triggerRect.bottom + lengthOf(popover, "--floating-gap"), + 0, + ); + expect( + Math.abs( + popoverRect.left + + popoverRect.width / 2 - + (triggerRect.left + triggerRect.width / 2), + ), + ).toBeLessThan(2); + expect(popover.getAttribute("data-side")).toBe("bottom"); + expect(popover.getAttribute("data-align")).toBe("center"); + }); + + test("gives the select popover the width of its trigger", async () => { + const screen = await render( +
+ + {({ id, name }: (typeof SEASONS)[number]) => ( + + {name} + + )} + +
, + ); + + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1" })) + .toBeVisible(); + + const triggerRect = rectOf(trigger.element()); + const popoverRect = rectOf(openPopover()); + + expect(popoverRect.width).toBeCloseTo(triggerRect.width, 0); + expect(popoverRect.left).toBeCloseTo(triggerRect.left, 0); + expect(popoverRect.top).toBeGreaterThanOrEqual(triggerRect.bottom); + }); + + test("opens a select upwards when its options do not fit below the trigger", async () => { + const screen = await render( +
+ +
, + ); + + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1", exact: true })) + .toBeVisible(); + + const triggerRect = rectOf(trigger.element()); + const popover = openPopover(); + const popoverRect = rectOf(popover); + + expect(popoverRect.bottom).toBeLessThanOrEqual(triggerRect.top); + expect(popoverRect.top).toBeGreaterThanOrEqual(0); + expect(popover.getAttribute("data-side")).toBe("top"); + }); + + test("caps a long select to the space below its trigger", async () => { + const screen = await render( +
+ +
, + ); + + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1", exact: true })) + .toBeVisible(); + + const popover = openPopover(); + const listbox = screen.getByRole("listbox").element(); + const spaceBelow = Math.floor( + window.innerHeight - + lengthOf(popover, "--popover-boundary-bottom") - + rectOf(trigger.element()).bottom - + lengthOf(popover, "--floating-gap") - + lengthOf(popover, "--floating-viewport-padding"), + ); + + expect(lengthOf(popover, "--floating-available-height")).toBe(spaceBelow); + expect(Number.parseFloat(getComputedStyle(popover).maxHeight)).toBe( + spaceBelow, + ); + expect(rectOf(popover).bottom).toBeLessThanOrEqual(window.innerHeight); + expect(listbox.scrollHeight).toBeGreaterThan(listbox.clientHeight); + }); + + test("keeps the select where it opened when searching shrinks the list", async () => { + const screen = await render( +
+ +
, + ); + + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1", exact: true })) + .toBeVisible(); + const popover = openPopover(); + const bottomOnOpen = rectOf(popover).bottom; + + await screen.getByRole("combobox").fill("Season 40"); + await expect + .element(screen.getByRole("option", { name: "Season 40" })) + .toBeVisible(); + + expect(rectOf(popover).bottom).toBeCloseTo(bottomOnOpen, 0); + }); + + test("follows its trigger when the page scrolls", async () => { + const screen = await render( +
+ Filters}> + Filter by season + +
, + ); + + const trigger = screen.getByRole("button", { name: "Filters" }); + await trigger.click(); + const popover = screen.getByRole("dialog").element(); + const gap = lengthOf(popover, "--floating-gap"); + await waitForFocus(popover); + + window.scrollTo(0, 40); + + await vi.waitFor(() => { + expect(rectOf(popover).top).toBeCloseTo( + rectOf(trigger.element()).bottom + gap, + 0, + ); + }); + expect(popover.matches(":popover-open")).toBe(true); + }); + + test("keeps a select out from under the keyboard taking the space below it", async () => { + const screen = await render( +
+ +
, + ); + + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1", exact: true })) + .toBeVisible(); + const popover = openPopover(); + const viewport = window.visualViewport; + invariant(viewport); + expect(rectOf(popover).bottom).toBeGreaterThan( + viewport.height - KEYBOARD_HEIGHT, + ); + + openKeyboard(); + + await vi.waitFor(() => { + expect(rectOf(popover).bottom).toBeLessThanOrEqual(viewport.height); + }); + expect(popover.matches(":popover-open")).toBe(true); + }); + + test("flips over when scrolling leaves it more room on the other side", async () => { + const screen = await render( +
+ +
, + ); + + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1", exact: true })) + .toBeVisible(); + const popover = openPopover(); + expect(popover.getAttribute("data-side")).toBe("top"); + + window.scrollTo(0, 400); + + await vi.waitFor(() => { + expect(popover.getAttribute("data-side")).toBe("bottom"); + }); + expect(rectOf(popover).top).toBeCloseTo( + rectOf(trigger.element()).bottom + lengthOf(popover, "--floating-gap"), + 0, + ); + expect(popover.matches(":popover-open")).toBe(true); + }); + + test("keeps above the mobile nav's floor", async () => { + const { innerWidth, innerHeight } = window; + const root = document.documentElement; + const floorBefore = root.style.getPropertyValue( + "--popover-boundary-bottom", + ); + await page.viewport(375, 667); + // what the layout sets at the mobile breakpoint, with no layout rendered here + root.style.setProperty("--popover-boundary-bottom", "55px"); + + try { + const screen = await render( +
+ +
, + ); + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1", exact: true })) + .toBeVisible(); + + const popover = openPopover(); + const floor = lengthOf(popover, "--popover-boundary-bottom"); + expect(floor).toBeGreaterThan(0); + expect(rectOf(popover).bottom).toBeLessThanOrEqual( + window.innerHeight - floor, + ); + } finally { + root.style.setProperty("--popover-boundary-bottom", floorBefore); + await page.viewport(innerWidth, innerHeight); + } + }); + + test("caps the content again after the viewport shrinks with the popover above its trigger", async () => { + const { innerWidth, innerHeight } = window; + + try { + // pixels rather than viewport units, so the trigger stays put when the + // viewport shrinks, and high enough up to stay in view once it has + const screen = await render( +
+ +
, + ); + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1", exact: true })) + .toBeVisible(); + const popover = openPopover(); + expect(popover.getAttribute("data-side")).toBe("top"); + + await page.viewport(innerWidth, innerHeight - 40); + + await vi.waitFor(() => { + expect( + Number.parseFloat(getComputedStyle(popover).maxHeight), + ).toBeLessThanOrEqual(window.innerHeight); + }); + expect(getComputedStyle(popover).visibility).toBe("visible"); + expect(rectOf(popover).top).toBeGreaterThanOrEqual(0); + } finally { + await page.viewport(innerWidth, innerHeight); + } + }); + + test("stays put under an anchor in a sticky header while the page scrolls", async () => { + const screen = await render( +
+
+ Filters}> + Filter by season + +
+
, + ); + + const trigger = screen.getByRole("button", { name: "Filters" }); + await trigger.click(); + const popover = screen.getByRole("dialog").element(); + const topBefore = rectOf(popover).top; + expect(getComputedStyle(popover).position).toBe("fixed"); + await waitForFocus(popover); + + window.scrollTo(0, 200); + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + + expect(rectOf(popover).top).toBeCloseTo(topBefore, 0); + expect(rectOf(popover).top).toBeCloseTo( + rectOf(trigger.element()).bottom + lengthOf(popover, "--floating-gap"), + 0, + ); + }); + + test("hides while its trigger is scrolled out of sight and shows again once it is back", async () => { + const screen = await render( +
+ Filters}> + Filter by season + +
, + ); + + await screen.getByRole("button", { name: "Filters" }).click(); + const popover = screen.getByRole("dialog").element(); + expect(getComputedStyle(popover).visibility).toBe("visible"); + await waitForFocus(popover); + + window.scrollTo(0, 600); + await vi.waitFor(() => { + expect(getComputedStyle(popover).visibility).toBe("hidden"); + }); + expect(popover.matches(":popover-open")).toBe(true); + + window.scrollTo(0, 0); + await vi.waitFor(() => { + expect(getComputedStyle(popover).visibility).toBe("visible"); + }); + }); + + test("hides once a scrolling container clips its trigger", async () => { + const screen = await render( +
+
+ Filters}> + Filter by season + +
+
+
, + ); + + await screen.getByRole("button", { name: "Filters" }).click(); + const popover = screen.getByRole("dialog").element(); + const scroller = document.querySelector( + '[data-testid="scroller"]', + ); + invariant(scroller); + await waitForFocus(popover); + + scroller.scrollTop = 300; + + await vi.waitFor(() => { + expect(getComputedStyle(popover).visibility).toBe("hidden"); + }); + }); +}); diff --git a/app/components/elements/useFloatingLayer.ts b/app/components/elements/useFloatingLayer.ts new file mode 100644 index 000000000..1cb1e9ac7 --- /dev/null +++ b/app/components/elements/useFloatingLayer.ts @@ -0,0 +1,317 @@ +import * as React from "react"; +import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; +import { visibleViewportRect } from "~/utils/visual-viewport"; +import * as FloatingLayer from "./floating-layer"; + +const GAP_PROPERTY = "--floating-gap"; +const PADDING_PROPERTY = "--floating-viewport-padding"; +const CEILING_PROPERTY = "--popover-boundary-top"; +const FLOOR_PROPERTY = "--popover-boundary-bottom"; + +const AVAILABLE_WIDTH_PROPERTY = "--floating-available-width"; +const AVAILABLE_HEIGHT_PROPERTY = "--floating-available-height"; +const ANCHOR_WIDTH_PROPERTY = "--floating-anchor-width"; +const ANCHOR_HEIGHT_PROPERTY = "--floating-anchor-height"; +const TRANSFORM_ORIGIN_PROPERTY = "--floating-transform-origin"; + +const OUTPUT_PROPERTIES = [ + AVAILABLE_WIDTH_PROPERTY, + AVAILABLE_HEIGHT_PROPERTY, + ANCHOR_WIDTH_PROPERTY, + ANCHOR_HEIGHT_PROPERTY, + TRANSFORM_ORIGIN_PROPERTY, +]; + +const INSET_PROPERTIES = ["top", "right", "bottom", "left"] as const; +const UNCAPPED = "10000000px"; + +export type FloatingPlacement = FloatingLayer.Placement; + +export function useFloatingLayer({ + isOpen, + floatingRef, + getAnchor, + placement = "bottom", +}: { + isOpen: boolean; + floatingRef: React.RefObject; + getAnchor: () => Element | null; + placement?: FloatingPlacement; +}) { + const getAnchorRef = React.useRef(getAnchor); + getAnchorRef.current = getAnchor; + + useIsomorphicLayoutEffect(() => { + const floating = floatingRef.current; + if (!isOpen || !floating) return; + + let natural: FloatingLayer.Size | null = null; + let lastBounds: FloatingLayer.Bounds | null = null; + let lastResolution: FloatingLayer.Resolution | null = null; + let viewportAnchored: boolean | null = null; + let frameId: number | null = null; + + const update = () => { + const anchor = getAnchorRef.current(); + if (!anchor || !floating.matches(":popover-open")) return; + + if (viewportAnchored === null) { + viewportAnchored = isViewportAnchored(anchor); + floating.style.position = viewportAnchored ? "fixed" : "absolute"; + } + + const computed = getComputedStyle(floating); + const gap = lengthOf(computed, GAP_PROPERTY); + const padding = lengthOf(computed, PADDING_PROPERTY); + const rtl = computed.direction === "rtl"; + const bounds = floatingBounds(floating); + const anchorRect = anchor.getBoundingClientRect(); + const detached = isAnchorDetached( + anchor, + anchorRect, + computed, + viewportAnchored, + ); + floating.style.visibility = detached ? "hidden" : ""; + if (detached) return; + + floating.style.setProperty(ANCHOR_WIDTH_PROPERTY, px(anchorRect.width)); + floating.style.setProperty(ANCHOR_HEIGHT_PROPERTY, px(anchorRect.height)); + + if (natural === null || !sameBounds(bounds, lastBounds)) { + lastBounds = bounds; + natural = naturalSize(floating, bounds, placement, padding); + lastResolution = null; + } + + const resolution = FloatingLayer.resolve({ + anchor: anchorRect, + floating: natural, + bounds, + placement, + gap, + padding, + rtl, + }); + if (!sameResolution(resolution, lastResolution)) { + lastResolution = resolution; + floating.style.setProperty( + AVAILABLE_WIDTH_PROPERTY, + px(Math.floor(resolution.availableWidth)), + ); + floating.style.setProperty( + AVAILABLE_HEIGHT_PROPERTY, + px(Math.floor(resolution.availableHeight)), + ); + floating.style.setProperty( + TRANSFORM_ORIGIN_PROPERTY, + resolution.transformOrigin, + ); + floating.dataset.side = resolution.side; + floating.dataset.align = resolution.align; + } + + const root = document.documentElement; + const viewportInsets = FloatingLayer.insets({ + anchor: anchorRect, + floating: floating.getBoundingClientRect(), + bounds, + side: resolution.side, + align: resolution.align, + gap, + padding, + rtl, + viewport: { width: root.clientWidth, height: root.clientHeight }, + }); + const insets = viewportAnchored + ? viewportInsets + : FloatingLayer.documentInsets(viewportInsets, { + x: window.scrollX, + y: window.scrollY, + }); + for (const property of INSET_PROPERTIES) { + const value = insets[property]; + floating.style.setProperty( + property, + value === null ? "auto" : px(roundToDevicePixels(value)), + ); + } + }; + update(); + + const scheduleUpdate = () => { + if (frameId !== null) return; + frameId = requestAnimationFrame(() => { + frameId = null; + update(); + }); + }; + const onScroll = (event: Event) => { + if (event.target instanceof Node && floating.contains(event.target)) { + return; + } + update(); + }; + + const resizeObserver = new ResizeObserver(scheduleUpdate); + resizeObserver.observe(floating); + const anchor = getAnchorRef.current(); + if (anchor) { + resizeObserver.observe(anchor); + } + + floating.addEventListener("toggle", update); + window.addEventListener("scroll", onScroll, { + capture: true, + passive: true, + }); + window.addEventListener("resize", scheduleUpdate); + window.visualViewport?.addEventListener("resize", scheduleUpdate); + window.visualViewport?.addEventListener("scroll", scheduleUpdate); + + return () => { + resizeObserver.disconnect(); + floating.removeEventListener("toggle", update); + window.removeEventListener("scroll", onScroll, { capture: true }); + window.removeEventListener("resize", scheduleUpdate); + window.visualViewport?.removeEventListener("resize", scheduleUpdate); + window.visualViewport?.removeEventListener("scroll", scheduleUpdate); + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + for (const property of [ + "position", + "visibility", + ...INSET_PROPERTIES, + ...OUTPUT_PROPERTIES, + ]) { + floating.style.removeProperty(property); + } + delete floating.dataset.side; + delete floating.dataset.align; + }; + }, [isOpen, floatingRef, placement]); +} + +function naturalSize( + floating: HTMLElement, + bounds: FloatingLayer.Bounds, + placement: FloatingPlacement, + padding: number, +): FloatingLayer.Size { + const vertical = FloatingLayer.isVerticalPlacement(placement); + + floating.style.setProperty( + vertical ? AVAILABLE_WIDTH_PROPERTY : AVAILABLE_HEIGHT_PROPERTY, + px(FloatingLayer.spaceAcross(bounds, placement, padding)), + ); + floating.style.setProperty( + vertical ? AVAILABLE_HEIGHT_PROPERTY : AVAILABLE_WIDTH_PROPERTY, + UNCAPPED, + ); + + const { width, height } = floating.getBoundingClientRect(); + + return { width, height }; +} + +export function floatingBounds(element: Element): FloatingLayer.Bounds { + const computed = getComputedStyle(element); + const visible = visibleViewportRect(); + const layoutHeight = document.documentElement.clientHeight; + + return { + top: Math.max(visible.top, lengthOf(computed, CEILING_PROPERTY)), + right: visible.right, + bottom: Math.min( + visible.bottom, + layoutHeight - lengthOf(computed, FLOOR_PROPERTY), + ), + left: visible.left, + }; +} + +function isAnchorDetached( + anchor: Element, + anchorRect: DOMRect, + computed: CSSStyleDeclaration, + viewportAnchored: boolean, +) { + const root = document.documentElement; + + let top = viewportAnchored ? 0 : lengthOf(computed, CEILING_PROPERTY); + let right = root.clientWidth; + let bottom = + root.clientHeight - + (viewportAnchored ? 0 : lengthOf(computed, FLOOR_PROPERTY)); + let left = 0; + + let element = anchor.parentElement; + while (element !== null && element !== document.body) { + if (getComputedStyle(element).overflow !== "visible") { + const rect = element.getBoundingClientRect(); + + top = Math.max(top, rect.top); + right = Math.min(right, rect.right); + bottom = Math.min(bottom, rect.bottom); + left = Math.max(left, rect.left); + } + element = element.parentElement; + } + + return ( + anchorRect.bottom <= top || + anchorRect.top >= bottom || + anchorRect.right <= left || + anchorRect.left >= right + ); +} + +function isViewportAnchored(anchor: Element) { + let element: Element | null = anchor; + while (element !== null && element !== document.body) { + const position = getComputedStyle(element).position; + if (position === "fixed" || position === "sticky") return true; + + element = element.parentElement; + } + + return false; +} + +function sameBounds(a: FloatingLayer.Bounds, b: FloatingLayer.Bounds | null) { + return ( + b !== null && + a.top === b.top && + a.right === b.right && + a.bottom === b.bottom && + a.left === b.left + ); +} + +function sameResolution( + a: FloatingLayer.Resolution, + b: FloatingLayer.Resolution | null, +) { + return ( + b !== null && + a.side === b.side && + a.align === b.align && + Math.floor(a.availableWidth) === Math.floor(b.availableWidth) && + Math.floor(a.availableHeight) === Math.floor(b.availableHeight) && + a.transformOrigin === b.transformOrigin + ); +} + +function lengthOf(computed: CSSStyleDeclaration, property: string) { + return Number.parseFloat(computed.getPropertyValue(property)) || 0; +} + +function roundToDevicePixels(value: number) { + const ratio = window.devicePixelRatio || 1; + return Math.round(value * ratio) / ratio; +} + +function px(value: number) { + return `${value}px`; +} diff --git a/app/components/elements/useScrollIntoView.browser.test.tsx b/app/components/elements/useScrollIntoView.browser.test.tsx new file mode 100644 index 000000000..efbc8fa27 --- /dev/null +++ b/app/components/elements/useScrollIntoView.browser.test.tsx @@ -0,0 +1,99 @@ +import * as React from "react"; +import { afterEach, describe, expect, test } from "vitest"; +import { render } from "vitest-browser-react"; +import { lockScroll } from "~/modules/scroll-lock/scroll-lock"; +import { invariant } from "~/utils/invariant"; +import { closeKeyboard, KEYBOARD_HEIGHT, openKeyboard } from "./tests/keyboard"; +import { useScrollIntoView } from "./useScrollIntoView"; + +const PAGE_HEIGHT = 5000; +const ANCHOR_HEIGHT = 40; + +afterEach(() => { + closeKeyboard(); + window.scrollTo(0, 0); +}); + +const settle = () => new Promise((resolve) => setTimeout(resolve, 100)); + +function Anchored({ top }: { top: number }) { + const ref = React.useRef(null); + useScrollIntoView(true, () => ref.current); + + return ( + <> +
+
+ + ); +} + +function anchorRect() { + const anchor = document.querySelector('[data-testid="anchor"]'); + invariant(anchor); + return anchor.getBoundingClientRect(); +} + +function visualViewportHeight() { + const viewport = window.visualViewport; + invariant(viewport); + return viewport.height; +} + +describe("useScrollIntoView", () => { + test("scrolls the page so the anchor sits above the keyboard opening over it", async () => { + const top = visualViewportHeight() - KEYBOARD_HEIGHT + 20; + await render(); + + openKeyboard(); + + await expect + .poll(() => anchorRect().bottom) + .toBeLessThanOrEqual(visualViewportHeight()); + expect(window.scrollY).toBeGreaterThan(0); + }); + + test("leaves the page alone when the anchor is above the keyboard already", async () => { + await render(); + + openKeyboard(); + await settle(); + + expect(window.scrollY).toBe(0); + }); + + test("leaves the page alone when the viewport did not shrink to a keyboard", async () => { + const top = visualViewportHeight() - KEYBOARD_HEIGHT + 20; + await render(); + + window.visualViewport?.dispatchEvent(new Event("resize")); + await settle(); + + expect(window.scrollY).toBe(0); + }); + + test("does not scroll a scroll locked page", async () => { + const top = visualViewportHeight() - KEYBOARD_HEIGHT + 20; + await render(); + const release = lockScroll(); + + try { + openKeyboard(); + await settle(); + + expect(window.scrollY).toBe(0); + } finally { + release(); + } + }); +}); diff --git a/app/components/elements/useScrollIntoView.ts b/app/components/elements/useScrollIntoView.ts new file mode 100644 index 000000000..571c51691 --- /dev/null +++ b/app/components/elements/useScrollIntoView.ts @@ -0,0 +1,51 @@ +import * as React from "react"; +import { isScrollLocked } from "~/modules/scroll-lock/scroll-lock"; +import { keyboardIsOpen } from "~/utils/visual-viewport"; +import { floatingBounds } from "./useFloatingLayer"; + +const PADDING_PROPERTY = "--floating-viewport-padding"; + +// Brings the anchor of an open popover back on screen if the mobile keyboard opens over it +export function useScrollIntoView( + isOpen: boolean, + getAnchor: () => Element | null, +) { + const getAnchorRef = React.useRef(getAnchor); + getAnchorRef.current = getAnchor; + + React.useEffect(() => { + const viewport = window.visualViewport; + if (!isOpen || !viewport) return; + + const onResize = () => { + if (!keyboardIsOpen()) return; + const anchor = getAnchorRef.current(); + if (anchor) { + revealAboveKeyboard(anchor); + } + }; + + viewport.addEventListener("resize", onResize); + return () => viewport.removeEventListener("resize", onResize); + }, [isOpen]); +} + +function revealAboveKeyboard(anchor: Element) { + anchor.scrollIntoView({ block: "nearest", inline: "nearest" }); + if (isScrollLocked()) return; + + const padding = + Number.parseFloat( + getComputedStyle(anchor).getPropertyValue(PADDING_PROPERTY), + ) || 0; + const bounds = floatingBounds(anchor); + const rect = anchor.getBoundingClientRect(); + const below = rect.bottom - (bounds.bottom - padding); + const above = bounds.top + padding - rect.top; + + if (below > 0) { + window.scrollBy({ top: below }); + } else if (above > 0) { + window.scrollBy({ top: -above }); + } +} diff --git a/app/components/layout/GlobalSearch.browser.test.tsx b/app/components/layout/GlobalSearch.browser.test.tsx new file mode 100644 index 000000000..0b9fcc945 --- /dev/null +++ b/app/components/layout/GlobalSearch.browser.test.tsx @@ -0,0 +1,37 @@ +import { createMemoryRouter, RouterProvider } from "react-router"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { page } from "vitest/browser"; +import { render } from "vitest-browser-react"; +import { GlobalSearch } from "./GlobalSearch"; + +let hrefBefore = window.location.href; + +beforeEach(() => { + hrefBefore = window.location.href; +}); + +afterEach(() => { + window.history.replaceState(null, "", hrefBefore); +}); + +function pushSearchParamOpen() { + const url = new URL(window.location.href); + url.searchParams.set("search", "open"); + window.history.pushState(null, "", url); +} + +describe("GlobalSearch", () => { + test("opens from the search param and closes when navigating back pops it", async () => { + pushSearchParamOpen(); + + const router = createMemoryRouter([ + { path: "*", element: }, + ]); + await render(); + await expect.element(page.getByRole("dialog")).toBeVisible(); + + window.history.back(); + + await expect.element(page.getByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/app/components/layout/GlobalSearch.module.css b/app/components/layout/GlobalSearch.module.css index 09ef28db2..484379722 100644 --- a/app/components/layout/GlobalSearch.module.css +++ b/app/components/layout/GlobalSearch.module.css @@ -44,7 +44,8 @@ } @container (width < 620px) { - .searchLabel { + .searchLabel, + .searchPlaceholder { display: none; } @@ -77,7 +78,20 @@ .modal { width: calc(100% - 2 * var(--layout-main-padding)); max-width: 36rem; - margin: 15vh auto auto; + inset-block-start: var(--visual-viewport-offset-top, 0px); + inset-block-end: calc( + 100% - + var(--visual-viewport-offset-top, 0px) - + var(--visual-viewport-height, 100%) + ); + max-height: calc( + var(--visual-viewport-height, 100dvh) - + 2 * + var(--modal-margin-block) + ); + margin: var(--modal-margin-block) auto auto; + display: flex; + flex-direction: column; padding: 0; border: 1px solid var(--color-border); background-color: var(--color-bg); @@ -97,6 +111,12 @@ } } +.content { + display: flex; + flex-direction: column; + min-height: 0; +} + .inputContainer { display: flex; align-items: center; diff --git a/app/components/layout/GlobalSearch.tsx b/app/components/layout/GlobalSearch.tsx index b59f33c90..8fe861640 100644 --- a/app/components/layout/GlobalSearch.tsx +++ b/app/components/layout/GlobalSearch.tsx @@ -83,8 +83,8 @@ export function GlobalSearch() { const [isOpen, setIsOpen] = React.useState(searchParamOpen); const prevSearchParamOpen = React.useRef(searchParamOpen); - if (searchParamOpen && !prevSearchParamOpen.current) { - setIsOpen(true); + if (searchParamOpen !== prevSearchParamOpen.current) { + setIsOpen(searchParamOpen); } prevSearchParamOpen.current = searchParamOpen; @@ -310,7 +310,7 @@ function GlobalSearchContent({ if (searchType === "weapons" && selectedWeapon) { return ( -
+
+

{`${SEARCH_TYPE_TO_PREFIX[searchType]}.`} diff --git a/app/components/layout/SearchResults.module.css b/app/components/layout/SearchResults.module.css index 5428cefca..04e57d572 100644 --- a/app/components/layout/SearchResults.module.css +++ b/app/components/layout/SearchResults.module.css @@ -1,5 +1,5 @@ .listBox { - max-height: 325px; + min-height: 0; overflow-y: auto; padding: var(--s-2); outline: none; diff --git a/app/components/layout/TopNavMenus.browser.test.tsx b/app/components/layout/TopNavMenus.browser.test.tsx new file mode 100644 index 000000000..8a8f38a52 --- /dev/null +++ b/app/components/layout/TopNavMenus.browser.test.tsx @@ -0,0 +1,57 @@ +import type * as React from "react"; +import { createMemoryRouter, RouterProvider } from "react-router"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { page, userEvent } from "vitest/browser"; +import { render } from "vitest-browser-react"; +import { TopNavMenus } from "./TopNavMenus"; + +const viewportBefore = { width: window.innerWidth, height: window.innerHeight }; + +// the top nav is the desktop navigation, shown from the tablet breakpoint up +beforeEach(async () => { + await page.viewport(1280, 720); +}); + +afterEach(async () => { + await page.viewport(viewportBefore.width, viewportBefore.height); +}); + +function withRouter(element: React.ReactElement) { + const router = createMemoryRouter([{ path: "*", element }], { + initialEntries: ["/"], + }); + return ; +} + +function openPopovers() { + return [...document.querySelectorAll("[popover]:popover-open")]; +} + +describe("TopNavMenus", () => { + test("hovering another item while a menu is open moves the open menu there", async () => { + const screen = await render(withRouter()); + + await screen.getByRole("button", { name: "Play" }).click(); + await vi.waitFor(() => { + expect(openPopovers()).toHaveLength(1); + expect(openPopovers()[0].querySelector('a[href="/q"]')).not.toBeNull(); + }); + + await userEvent.hover(screen.getByRole("button", { name: "Tools" })); + + await vi.waitFor(() => { + const open = openPopovers(); + expect(open).toHaveLength(1); + expect(open[0].querySelector('a[href="/analyzer"]')).not.toBeNull(); + }); + }); + + test("hovering an item with no menu open leaves every menu closed", async () => { + const screen = await render(withRouter()); + + await userEvent.hover(screen.getByRole("button", { name: "Tools" })); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(openPopovers()).toHaveLength(0); + }); +}); diff --git a/app/components/layout/TopNavMenus.module.css b/app/components/layout/TopNavMenus.module.css index e98c5da14..2cefb1e12 100644 --- a/app/components/layout/TopNavMenus.module.css +++ b/app/components/layout/TopNavMenus.module.css @@ -88,7 +88,7 @@ .preview { position: absolute; - top: calc(100% + var(--s-1)); + top: calc(100% + var(--floating-gap)); left: 0; z-index: 1; display: grid; @@ -121,7 +121,7 @@ content: ""; position: absolute; z-index: -1; - inset: calc(-1 * (var(--s-1) + var(--border-width))); + inset: calc(-1 * (var(--floating-gap) + var(--border-width))); } } diff --git a/app/components/layout/TopNavMenus.tsx b/app/components/layout/TopNavMenus.tsx index 953ac938f..37a7bb1a9 100644 --- a/app/components/layout/TopNavMenus.tsx +++ b/app/components/layout/TopNavMenus.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import { ChevronDown } from "lucide-react"; -import { useState } from "react"; +import { type PointerEvent, useState } from "react"; import { useTranslation } from "react-i18next"; import { Form, Link, useLocation } from "react-router"; import { Config } from "~/config"; @@ -74,19 +74,48 @@ const NAV_CATEGORIES = [ }, ] as const; +interface MenuOpenState { + isOpen: boolean; + anotherIsOpen: boolean; + onOpenChange: (open: boolean) => void; +} + export function TopNavMenus() { + const [openMenu, setOpenMenu] = useState(null); + + const openStateOf = (name: string): MenuOpenState => ({ + isOpen: openMenu === name, + anotherIsOpen: openMenu !== null && openMenu !== name, + onOpenChange: (open) => + setOpenMenu((current) => { + if (open) return name; + return current === name ? null : current; + }), + }); + return (

); } -function DevMenu() { - const [isOpen, setIsOpen] = useState(false); +function takeOverOnHover(openState: MenuOpenState, event: PointerEvent) { + if (openState.anotherIsOpen && event.pointerType !== "touch") { + openState.onOpenChange(true); + } +} + +function DevMenu({ openState }: { openState: MenuOpenState }) { const [isPreviewSuppressed, setIsPreviewSuppressed] = useState(false); const location = useLocation(); const returnTo = `${location.pathname}${location.search}`; @@ -98,7 +127,10 @@ function DevMenu() { } diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index 8027885d5..f15c1d91b 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -9,7 +9,13 @@ import { ActionButton } from "~/components/ActionButton"; import { Avatar } from "~/components/Avatar"; import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; -import { Image, ModeImage, TierImage, WeaponImage } from "~/components/Image"; +import { + Image, + ModeImage, + SpecialWeaponImage, + TierImage, + WeaponImage, +} from "~/components/Image"; import { NoteAvatar } from "~/components/NoteAvatar"; import { useUser } from "~/features/auth/core/user"; import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants"; @@ -24,12 +30,7 @@ import { languagesUnified } from "~/modules/i18n/config"; import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids"; import { nullFilledArray } from "~/utils/arrays"; import { inGameNameWithoutDiscriminator } from "~/utils/strings"; -import { - SENDOUQ_LOOKING_PAGE, - specialWeaponImageUrl, - TIERS_PAGE, - tierImageUrl, -} from "~/utils/urls"; +import { SENDOUQ_LOOKING_PAGE, TIERS_PAGE, tierImageUrl } from "~/utils/urls"; import { finishUpdateIfUnmoved, usePageViewTransitionClass, @@ -144,11 +145,9 @@ export function GroupCard({
{group.noScreen ? (
- {`weapons:SPECIAL_${SPLATTERCOLOR_SCREEN_ID}`}
) : null} diff --git a/app/form/fields/SubSpecialSelectFormField.tsx b/app/form/fields/SubSpecialSelectFormField.tsx index 8263199ed..7e2fdfdde 100644 --- a/app/form/fields/SubSpecialSelectFormField.tsx +++ b/app/form/fields/SubSpecialSelectFormField.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import { SendouSelect, SendouSelectItem } from "~/components/elements/Select"; -import { Image } from "~/components/Image"; +import { SpecialWeaponImage, SubWeaponImage } from "~/components/Image"; import type { SpecialWeaponId, SubWeaponId, @@ -9,7 +9,6 @@ import { specialWeaponIds, subWeaponIds, } from "~/modules/in-game-lists/weapon-ids"; -import { specialWeaponImageUrl, subWeaponImageUrl } from "~/utils/urls"; import type { FormFieldProps } from "../types"; import { FormFieldMessages, useTranslatedTexts } from "./FormFieldWrapper"; import styles from "./SubSpecialSelectFormField.module.css"; @@ -38,17 +37,12 @@ export function SubSpecialSelectFormField({ const { t } = useTranslation(["weapons"]); const { translatedLabel } = useTranslatedTexts({ label }); - const options: Array<{ id: number; name: string; imgPath: string }> = + const options: Array<{ id: number; name: string }> = weaponType === "SUB" - ? subWeaponIds.map((id) => ({ - id, - name: t(`weapons:SUB_${id}`), - imgPath: subWeaponImageUrl(id), - })) + ? subWeaponIds.map((id) => ({ id, name: t(`weapons:SUB_${id}`) })) : specialWeaponIds.map((id) => ({ id, name: t(`weapons:SPECIAL_${id}`), - imgPath: specialWeaponImageUrl(id), })); return ( @@ -73,7 +67,17 @@ export function SubSpecialSelectFormField({ textValue={option.name} > - + {weaponType === "SUB" ? ( + + ) : ( + + )} {option.name} diff --git a/app/utils/urls.ts b/app/utils/urls.ts index a030a6726..01c0e52df 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -398,6 +398,23 @@ export const subWeaponImageUrl = (subWeaponSplId: SubWeaponId) => `${STATIC_ASSETS_URL}/img/sub-weapons/${subWeaponSplId}`; export const specialWeaponImageUrl = (specialWeaponSplId: SpecialWeaponId) => `${STATIC_ASSETS_URL}/img/special-weapons/${specialWeaponSplId}`; +/** + * Teal parts of a sub weapon icon, as an overlay laid on top of the accent + * colored silhouette. Generated by `scripts/create-ink-detail-images.ts`. + */ +export const subWeaponDetailImageUrl = (subWeaponSplId: SubWeaponId) => + `${STATIC_ASSETS_URL}/img/sub-weapons-detail/${subWeaponSplId}`; +/** Special weapon counterpart of {@link subWeaponDetailImageUrl}. */ +export const specialWeaponDetailImageUrl = ( + specialWeaponSplId: SpecialWeaponId, +) => `${STATIC_ASSETS_URL}/img/special-weapons-detail/${specialWeaponSplId}`; +/** White parts of a sub weapon icon, as a mask so they can be painted separately. */ +export const subWeaponHighlightImageUrl = (subWeaponSplId: SubWeaponId) => + `${STATIC_ASSETS_URL}/img/sub-weapons-highlight/${subWeaponSplId}`; +/** Special weapon counterpart of {@link subWeaponHighlightImageUrl}. */ +export const specialWeaponHighlightImageUrl = ( + specialWeaponSplId: SpecialWeaponId, +) => `${STATIC_ASSETS_URL}/img/special-weapons-highlight/${specialWeaponSplId}`; export const specialWeaponVariantImageUrl = ( specialWeaponSplId: SpecialWeaponId, variant: "weakpoints", diff --git a/changelog/2026-09-21-ink-colored-weapon-icons.md b/changelog/2026-09-21-ink-colored-weapon-icons.md new file mode 100644 index 000000000..39d5b970e --- /dev/null +++ b/changelog/2026-09-21-ink-colored-weapon-icons.md @@ -0,0 +1,4 @@ +--- +type: feature +--- +Sub and special weapon icons now follow theme colors diff --git a/package.json b/package.json index 9953badc8..9d7d5e2b2 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "setup": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/setup.ts", "og:generate": "node scripts/generate-og-images.ts", "changelog:image": "node scripts/generate-changelog-image.ts", + "ink-detail:generate": "node scripts/create-ink-detail-images.ts", "notification:test": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/send-test-notification.ts", "i18n:sync": "node scripts/collapse-single-plural-keys.ts && i18next-locales-sync -e true -p en -s da de es-ES es-US fr-CA fr-EU he it ja ko nl pl pt-BR ru zh -l locales && pnpm run biome:fix", "knip": "knip" diff --git a/scripts/create-ink-detail-images.ts b/scripts/create-ink-detail-images.ts new file mode 100644 index 000000000..0ee0f1898 --- /dev/null +++ b/scripts/create-ink-detail-images.ts @@ -0,0 +1,235 @@ +/** biome-ignore-all lint/suspicious/noConsole: CLI script output */ + +// Splits sub & special weapon icons into the layers that let them take the +// user's accent color as their ink color, writing them into the assets repo, +// see docs/dev/how-to.md +// +// The source icons are painted in exactly three tones (ink purple, teal, +// white), so each one becomes: +// +// detail the teal tone, keeping its color, as an overlay +// highlight the white tone as a mask, so the app can paint it on top of the +// recolored ink without a seam +// +// The ink tone needs no file of its own: the app fills the source icon's own +// alpha channel with the accent color and lays these two on top. See +// app/components/Image.tsx. + +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { range } from "remeda"; +import sharp from "sharp"; + +const DEFAULT_ASSETS_IMG_DIR = fileURLToPath( + new URL("../../assets/assets/img", import.meta.url), +); + +const SUB_WEAPON_ID_COUNT = 14; +const SPECIAL_WEAPON_ID_COUNT = 19; + +/** The three tones every sub & special weapon icon is painted in. */ +const INK = [65, 59, 185]; +const TEAL = [53, 191, 193]; +const WHITE = [255, 255, 255]; +const TONES = [INK, TEAL, WHITE]; +const TONE_PAIRS = [ + [0, 1], + [0, 2], + [1, 2], +]; + +/** How teal a pixel has to be to vouch for the teal around it, see `clearStrayTeal`. */ +const TEAL_SEED_WEIGHT = 0.7; +const TEAL_SEED_MIN_ALPHA = 0.5; + +/** 4:4:4 because the chroma subsampling of the source icons is what smears the tone boundaries in the first place. */ +const AVIF_OPTIONS = { + quality: 90, + effort: 6, + chromaSubsampling: "4:4:4", +} as const; + +const GROUPS = [ + { + name: "sub-weapons", + ids: range(0, SUB_WEAPON_ID_COUNT), + }, + { + name: "special-weapons", + ids: range(1, SPECIAL_WEAPON_ID_COUNT + 1), + }, +]; + +async function main() { + const imgDir = path.resolve(process.argv[2] ?? DEFAULT_ASSETS_IMG_DIR); + + for (const group of GROUPS) { + const dirs = { + detail: path.join(imgDir, `${group.name}-detail`), + highlight: path.join(imgDir, `${group.name}-highlight`), + }; + for (const dir of Object.values(dirs)) { + await fs.mkdir(dir, { recursive: true }); + } + + for (const id of group.ids) { + const source = await fs.readFile( + path.join(imgDir, group.name, `${id}.avif`), + ); + const layers = await toInkLayers(source); + + await fs.writeFile(path.join(dirs.detail, `${id}.avif`), layers.detail); + await fs.writeFile( + path.join(dirs.highlight, `${id}.avif`), + layers.highlight, + ); + } + + console.log(`${group.name}: wrote ${group.ids.length} icons`); + } + + console.log(`\nOutput in ${imgDir}, commit & push it in the assets repo.`); +} + +async function toInkLayers(source: Buffer) { + const { data, info } = await sharp(source) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + const pixelCount = info.width * info.height; + const alphas = new Float32Array(pixelCount); + const tealWeights = new Float32Array(pixelCount); + const whiteWeights = new Float32Array(pixelCount); + + for (let p = 0; p < pixelCount; p++) { + const i = p * 4; + alphas[p] = data[i + 3] / 255; + if (alphas[p] === 0) continue; + + const [, teal, white] = toneWeightsOf([data[i], data[i + 1], data[i + 2]]); + tealWeights[p] = teal; + whiteWeights[p] = white; + } + + clearStrayTeal({ + tealWeights, + alphas, + width: info.width, + height: info.height, + }); + + const detail = Buffer.alloc(data.length); + const highlight = Buffer.alloc(data.length); + for (let p = 0; p < pixelCount; p++) { + const i = p * 4; + const alpha = alphas[p]; + if (alpha === 0) continue; + + // the layers stack detail over ink and highlight over detail, so each one + // only has to cover what the layers under it still show through + const highlightAlpha = alpha * whiteWeights[p]; + const covered = 1 - highlightAlpha; + const detailAlpha = covered === 0 ? 0 : (alpha * tealWeights[p]) / covered; + + for (const channel of [0, 1, 2]) { + detail[i + channel] = TEAL[channel]; + highlight[i + channel] = WHITE[channel]; + } + detail[i + 3] = toByte(detailAlpha); + highlight[i + 3] = toByte(highlightAlpha); + } + + const toAvif = (buffer: Buffer) => + sharp(buffer, { + raw: { width: info.width, height: info.height, channels: 4 }, + }) + .avif(AVIF_OPTIONS) + .toBuffer(); + + return { detail: await toAvif(detail), highlight: await toAvif(highlight) }; +} + +/** + * Zeroes teal coverage no teal region can account for. A pixel is only part + * teal because a teal region overlaps it, so it has to touch a pixel that is + * confidently teal. Anything else is the lossy source's chroma noise read as a + * trace of teal, which would speckle the recolored icon. + */ +function clearStrayTeal({ + tealWeights, + alphas, + width, + height, +}: { + tealWeights: Float32Array; + alphas: Float32Array; + width: number; + height: number; +}) { + const nearTeal = new Uint8Array(tealWeights.length); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const p = y * width + x; + if ( + tealWeights[p] < TEAL_SEED_WEIGHT || + alphas[p] < TEAL_SEED_MIN_ALPHA + ) { + continue; + } + + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const ny = y + dy; + const nx = x + dx; + if (ny < 0 || ny >= height || nx < 0 || nx >= width) continue; + nearTeal[ny * width + nx] = 1; + } + } + } + } + + for (let p = 0; p < tealWeights.length; p++) { + if (!nearTeal[p]) tealWeights[p] = 0; + } +} + +function toByte(value: number) { + return Math.round(Math.max(0, Math.min(1, value)) * 255); +} + +/** + * How much of each tone a pixel is made of. A pixel sits inside one flat region + * or on the boundary between two, so the best explanation is a blend of exactly + * two tones. Fitting all three at once instead lets the lossy source's chroma + * noise turn an ink/white edge into teal. + */ +function toneWeightsOf(color: number[]) { + const dot = (a: number[], b: number[]) => + a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + + let best = { from: 0, to: 0, blend: 0, residual: Number.POSITIVE_INFINITY }; + for (const [from, to] of TONE_PAIRS) { + const toward = TONES[to].map((c, i) => c - TONES[from][i]); + const offset = color.map((c, i) => c - TONES[from][i]); + const blend = Math.max( + 0, + Math.min(1, dot(offset, toward) / dot(toward, toward)), + ); + const residual = Math.hypot( + ...color.map((c, i) => c - (TONES[from][i] + blend * toward[i])), + ); + + if (residual < best.residual) best = { from, to, blend, residual }; + } + + const weights = [0, 0, 0]; + weights[best.from] = 1 - best.blend; + weights[best.to] = best.blend; + + return weights; +} + +await main(); From bd305605c6b5a398e410eea1263a46d392e8ed79 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:28:38 +0300 Subject: [PATCH 39/50] Make site logo a square --- app/components/layout/index.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/layout/index.module.css b/app/components/layout/index.module.css index 441eeb38a..a8c756bd0 100644 --- a/app/components/layout/index.module.css +++ b/app/components/layout/index.module.css @@ -46,7 +46,7 @@ flex-direction: row; align-items: center; justify-content: center; - width: 40px; + width: 36px; height: 36px; background-color: var(--color-text-accent); border-radius: var(--radius-field); From 36234587446f6a1149595755ca851086dbe2b337 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:58:42 +0300 Subject: [PATCH 40/50] Tweak no screen group card styles --- .../sendouq/components/GroupCard.module.css | 37 ++++++++++--- app/features/sendouq/components/GroupCard.tsx | 53 +++++++++---------- locales/da/q.json | 1 + locales/de/q.json | 1 + locales/en/q.json | 1 + locales/es-ES/q.json | 1 + locales/es-US/q.json | 1 + locales/fr-CA/q.json | 1 + locales/fr-EU/q.json | 1 + locales/he/q.json | 1 + locales/it/q.json | 1 + locales/ja/q.json | 1 + locales/ko/q.json | 1 + locales/nl/q.json | 1 + locales/pl/q.json | 1 + locales/pt-BR/q.json | 1 + locales/ru/q.json | 1 + locales/zh/q.json | 1 + 18 files changed, 71 insertions(+), 35 deletions(-) diff --git a/app/features/sendouq/components/GroupCard.module.css b/app/features/sendouq/components/GroupCard.module.css index 922623b36..40db02713 100644 --- a/app/features/sendouq/components/GroupCard.module.css +++ b/app/features/sendouq/components/GroupCard.module.css @@ -16,14 +16,37 @@ overflow-anchor: none; } +.futureMatchModes { + display: flex; + flex-wrap: wrap; + gap: var(--s-2); + align-items: center; + justify-content: center; +} + .noScreen { - background-color: var(--color-error-low); - border-radius: 100%; - padding: var(--s-1); - width: 30px; - height: 30px; - display: grid; - place-items: center; + display: flex; + align-items: center; + justify-content: center; + gap: var(--s-1); + margin-block-start: calc(-1 * var(--s-3)); + margin-inline: calc(-1 * var(--s-3)); + margin-block-end: calc(-1 * var(--s-2)); + padding-block: var(--s-0-5); + border-start-start-radius: var(--radius-box); + border-start-end-radius: var(--radius-box); + background-color: var(--color-bg-higher); + color: var(--color-text-high); + font-size: var(--font-3xs); + font-weight: var(--weight-bold); + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.noScreenCross { + color: var(--color-error); + margin-inline-start: calc(-1 * var(--s-0-5)); + margin-inline-end: var(--s-0-5); } .member { diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index f15c1d91b..1b5e657d0 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import type { SqlBool } from "kysely"; -import { Check, Hourglass, Mic, Volume2, VolumeX } from "lucide-react"; +import { Check, Hourglass, Mic, Volume2, VolumeX, X } from "lucide-react"; import * as React from "react"; import { ViewTransition } from "react"; import { Trans, useTranslation } from "react-i18next"; @@ -100,6 +100,8 @@ export function GroupCard({ // while previewing the queue the viewer has no group of their own to act with const actionToShow = ownGroup ? action : undefined; + const futureMatchModesToShow = group.members ? null : futureMatchModes; + return (
) : null} - {futureMatchModes && !group.members ? ( -
+ + + {t("q:looking.noScreen")} +
+ ) : null} + {futureMatchModesToShow ? ( +
+ {futureMatchModesToShow.map((mode) => { + return ( +
+ +
+ ); })} - > -
- {futureMatchModes.map((mode) => { - return ( -
- -
- ); - })} -
- {group.noScreen ? ( -
- -
- ) : null}
) : null} {group.tier && diff --git a/locales/da/q.json b/locales/da/q.json index 88cac963a..c1d0dfc7c 100644 --- a/locales/da/q.json +++ b/locales/da/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "", "looking.groups.missedReadyCheck": "", "looking.replay": "", + "looking.noScreen": "", "looking.sp.calculating": "", "looking.rankCalculating": "", "looking.allTiers": "", diff --git a/locales/de/q.json b/locales/de/q.json index 82a29e692..681fb9525 100644 --- a/locales/de/q.json +++ b/locales/de/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "", "looking.groups.missedReadyCheck": "", "looking.replay": "", + "looking.noScreen": "", "looking.sp.calculating": "", "looking.rankCalculating": "", "looking.allTiers": "", diff --git a/locales/en/q.json b/locales/en/q.json index 44265a46c..7c5ce53a9 100644 --- a/locales/en/q.json +++ b/locales/en/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "Add note", "looking.groups.missedReadyCheck": "Didn't ready up", "looking.replay": "Replay", + "looking.noScreen": "No Screen", "looking.sp.calculating": "Calculating...", "looking.rankCalculating": "Less than {{count}} sets played. Rank is still calculating...", "looking.allTiers": "All tiers", diff --git a/locales/es-ES/q.json b/locales/es-ES/q.json index 46a5c1a7f..133034bf7 100644 --- a/locales/es-ES/q.json +++ b/locales/es-ES/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "Añadir nota", "looking.groups.missedReadyCheck": "", "looking.replay": "Repetir", + "looking.noScreen": "", "looking.sp.calculating": "Calculando...", "looking.rankCalculating": "Menos de {{count}} sets jugados. El rango aún se está calculando...", "looking.allTiers": "Todos los niveles", diff --git a/locales/es-US/q.json b/locales/es-US/q.json index 4473de20b..14ef09983 100644 --- a/locales/es-US/q.json +++ b/locales/es-US/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "Añadir nota", "looking.groups.missedReadyCheck": "", "looking.replay": "Repetir", + "looking.noScreen": "", "looking.sp.calculating": "Calculando...", "looking.rankCalculating": "Menos de {{count}} sets jugados. El rango aún se está calculando...", "looking.allTiers": "Todos los niveles", diff --git a/locales/fr-CA/q.json b/locales/fr-CA/q.json index 65300be73..cca72cc66 100644 --- a/locales/fr-CA/q.json +++ b/locales/fr-CA/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "", "looking.groups.missedReadyCheck": "", "looking.replay": "", + "looking.noScreen": "", "looking.sp.calculating": "", "looking.rankCalculating": "", "looking.allTiers": "", diff --git a/locales/fr-EU/q.json b/locales/fr-EU/q.json index 7a7830f4d..003fbb4a6 100644 --- a/locales/fr-EU/q.json +++ b/locales/fr-EU/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "Ajouter la note", "looking.groups.missedReadyCheck": "", "looking.replay": "Rejouer", + "looking.noScreen": "", "looking.sp.calculating": "Calcule...", "looking.rankCalculating": "Moins de {{count}} sets jouer. Le rank est toujours en calcul...", "looking.allTiers": "Tout les ranks", diff --git a/locales/he/q.json b/locales/he/q.json index 412896c8b..94a0f0c5b 100644 --- a/locales/he/q.json +++ b/locales/he/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "", "looking.groups.missedReadyCheck": "", "looking.replay": "", + "looking.noScreen": "", "looking.sp.calculating": "", "looking.rankCalculating": "", "looking.allTiers": "", diff --git a/locales/it/q.json b/locales/it/q.json index 0d3bd8add..aad5b878f 100644 --- a/locales/it/q.json +++ b/locales/it/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "Aggiungi nota", "looking.groups.missedReadyCheck": "", "looking.replay": "Replay", + "looking.noScreen": "", "looking.sp.calculating": "In calcolo...", "looking.rankCalculating": "Meno di {{count}} sets giocati. Il rango sta ancora venendo calcolato...", "looking.allTiers": "Tutti i tier", diff --git a/locales/ja/q.json b/locales/ja/q.json index d4905ab24..b01d8d8f2 100644 --- a/locales/ja/q.json +++ b/locales/ja/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "メモを追加する", "looking.groups.missedReadyCheck": "", "looking.replay": "リプレイ", + "looking.noScreen": "", "looking.sp.calculating": "計算中...", "looking.rankCalculating": "遊んだセットが{{count}}個以下です。ランクはまだ計算中です...", "looking.allTiers": "全てのティア", diff --git a/locales/ko/q.json b/locales/ko/q.json index 82a29e692..681fb9525 100644 --- a/locales/ko/q.json +++ b/locales/ko/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "", "looking.groups.missedReadyCheck": "", "looking.replay": "", + "looking.noScreen": "", "looking.sp.calculating": "", "looking.rankCalculating": "", "looking.allTiers": "", diff --git a/locales/nl/q.json b/locales/nl/q.json index 82a29e692..681fb9525 100644 --- a/locales/nl/q.json +++ b/locales/nl/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "", "looking.groups.missedReadyCheck": "", "looking.replay": "", + "looking.noScreen": "", "looking.sp.calculating": "", "looking.rankCalculating": "", "looking.allTiers": "", diff --git a/locales/pl/q.json b/locales/pl/q.json index 82a29e692..681fb9525 100644 --- a/locales/pl/q.json +++ b/locales/pl/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "", "looking.groups.missedReadyCheck": "", "looking.replay": "", + "looking.noScreen": "", "looking.sp.calculating": "", "looking.rankCalculating": "", "looking.allTiers": "", diff --git a/locales/pt-BR/q.json b/locales/pt-BR/q.json index 557492df3..5af9d1d5d 100644 --- a/locales/pt-BR/q.json +++ b/locales/pt-BR/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "Adicionar nota", "looking.groups.missedReadyCheck": "", "looking.replay": "Replay", + "looking.noScreen": "", "looking.sp.calculating": "Calculando...", "looking.rankCalculating": "Menos que {{count}} sets jogados. O rank ainda está sendo calculado...", "looking.allTiers": "Todas as tiers", diff --git a/locales/ru/q.json b/locales/ru/q.json index 09351e25d..5e6f62024 100644 --- a/locales/ru/q.json +++ b/locales/ru/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "Добавить заметку", "looking.groups.missedReadyCheck": "", "looking.replay": "Повтор", + "looking.noScreen": "", "looking.sp.calculating": "Идёт рассчет...", "looking.rankCalculating": "Менее чем {{count}} матчей сыграно. Ранг ещё рассчитывается...", "looking.allTiers": "Все ранги", diff --git a/locales/zh/q.json b/locales/zh/q.json index ee864a049..3656b2397 100644 --- a/locales/zh/q.json +++ b/locales/zh/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "添加备注", "looking.groups.missedReadyCheck": "", "looking.replay": "重赛", + "looking.noScreen": "", "looking.sp.calculating": "正在计算...", "looking.rankCalculating": "正在计算段位。共需完成 {{count}} 组比赛才能完成段位计算。", "looking.allTiers": "所有段位", From b08916a05aac63361616afde4945c852edfb0d42 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:53:54 +0300 Subject: [PATCH 41/50] Fix weapons duplicating in comp analyzer --- .../components/RangeVisualization.tsx | 24 +++---- .../SelectedWeapons.browser.test.tsx | 31 +++++++++ .../components/SelectedWeapons.tsx | 68 +++++++++++++++---- .../comp-analyzer/core/weapon-range.test.ts | 8 +++ .../comp-analyzer/core/weapon-range.ts | 5 +- .../img-export/components/CompGraphic.tsx | 7 +- ...6-09-22-comp-analyzer-duplicate-weapons.md | 5 ++ 7 files changed, 113 insertions(+), 35 deletions(-) create mode 100644 changelog/2026-09-22-comp-analyzer-duplicate-weapons.md diff --git a/app/features/comp-analyzer/components/RangeVisualization.tsx b/app/features/comp-analyzer/components/RangeVisualization.tsx index b77b18c72..e92711d13 100644 --- a/app/features/comp-analyzer/components/RangeVisualization.tsx +++ b/app/features/comp-analyzer/components/RangeVisualization.tsx @@ -80,7 +80,6 @@ export function RangeVisualization({ weaponIds }: RangeVisualizationProps) { maxRange={maxRange} minY={minY} maxY={maxY} - weaponIds={weaponIds} />
) : null} @@ -98,7 +97,6 @@ interface TrajectoryChartProps { maxRange: number; minY: number; maxY: number; - weaponIds: MainWeaponId[]; } function TrajectoryChart({ @@ -107,7 +105,6 @@ function TrajectoryChart({ maxRange, minY, maxY, - weaponIds, }: TrajectoryChartProps) { const chartWidth = 600; const chartHeight = 200; @@ -180,12 +177,11 @@ function TrajectoryChart({ return (
- {weapons.map((weapon, index) => { - const slotIndex = weaponIds.indexOf(weapon.weaponId); - const color = SLOT_COLORS[slotIndex % SLOT_COLORS.length]; + {weapons.map((weapon) => { + const color = SLOT_COLORS[weapon.slot % SLOT_COLORS.length]; return (
{ + {weapons.map((weapon) => { if (!weapon.trajectory) return null; - const slotIndex = weaponIds.indexOf(weapon.weaponId); - const color = SLOT_COLORS[slotIndex % SLOT_COLORS.length]; + const color = SLOT_COLORS[weapon.slot % SLOT_COLORS.length]; return ( { + {weapons.map((weapon) => { if (!weapon.blastRadius || !weapon.trajectory) return null; const groundPoint = getGroundIntersection(weapon.trajectory); if (!groundPoint) return null; - const slotIndex = weaponIds.indexOf(weapon.weaponId); - const color = SLOT_COLORS[slotIndex % SLOT_COLORS.length]; + const color = SLOT_COLORS[weapon.slot % SLOT_COLORS.length]; const radiusPixels = xScale(weapon.blastRadius); return ( { expect(kitIcons.length).toBeGreaterThan(0); }); + test("renders a row per copy when the same weapon is picked twice", async () => { + const screen = await renderSelectedWeapons({ + selectedWeaponIds: [0, 0], + }); + + const rows = screen.container.querySelectorAll( + '[data-testid^="selected-weapon-"]', + ); + expect(rows.length).toBe(2); + + const emptySlots = screen.container.querySelectorAll( + '[class*="weaponNameEmpty"]', + ); + expect(emptySlots.length).toBe(MAX_WEAPONS - 2); + }); + test("shows empty slots for remaining positions", async () => { const screen = await renderSelectedWeapons({ selectedWeaponIds: [0], @@ -135,6 +151,21 @@ describe("SelectedWeapons", () => { expect(onRemove).toHaveBeenCalledWith(1); }); + test("calls onRemove with the clicked index when the same weapon is picked twice", async () => { + const onRemove = vi.fn(); + const screen = await renderSelectedWeapons({ + selectedWeaponIds: [0, 0], + onRemove, + }); + + const removeButtons = screen.container.querySelectorAll( + '[class*="removeButton"]', + ); + (removeButtons[1] as HTMLElement).click(); + + expect(onRemove).toHaveBeenCalledWith(1); + }); + test("does not render remove button for empty slots", async () => { const screen = await renderSelectedWeapons({ selectedWeaponIds: [], diff --git a/app/features/comp-analyzer/components/SelectedWeapons.tsx b/app/features/comp-analyzer/components/SelectedWeapons.tsx index a30619d2e..e1f39728c 100644 --- a/app/features/comp-analyzer/components/SelectedWeapons.tsx +++ b/app/features/comp-analyzer/components/SelectedWeapons.tsx @@ -8,6 +8,7 @@ import { useSensors, } from "@dnd-kit/core"; import { + arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, @@ -15,6 +16,8 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import clsx from "clsx"; +import { nanoid } from "nanoid"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Image, @@ -40,6 +43,14 @@ export function SelectedWeapons({ onReorder, }: SelectedWeaponsProps) { const { t } = useTranslation(["weapons", "analyzer"]); + const [rowsState, setRows] = useState(() => + reconcileRows([], selectedWeaponIds), + ); + + const rows = reconcileRows(rowsState, selectedWeaponIds); + if (rows !== rowsState) { + setRows(rows); + } const sensors = useSensors( useSensor(PointerSensor), @@ -51,16 +62,16 @@ export function SelectedWeapons({ const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; - if (over && active.id !== over.id) { - const oldIndex = selectedWeaponIds.indexOf(active.id as MainWeaponId); - const newIndex = selectedWeaponIds.indexOf(over.id as MainWeaponId); + if (!over || active.id === over.id) return; - const newIds = [...selectedWeaponIds]; - const [removed] = newIds.splice(oldIndex, 1); - newIds.splice(newIndex, 0, removed); + const oldIndex = rows.findIndex((row) => row.id === active.id); + const newIndex = rows.findIndex((row) => row.id === over.id); - onReorder(newIds); - } + if (oldIndex === -1 || newIndex === -1) return; + + const newRows = arrayMove(rows, oldIndex, newIndex); + setRows(newRows); + onReorder(newRows.map((row) => row.weaponId)); }; const emptySlotCount = MAX_WEAPONS - selectedWeaponIds.length; @@ -74,13 +85,14 @@ export function SelectedWeapons({ onDragEnd={handleDragEnd} > row.id)} strategy={verticalListSortingStrategy} > - {selectedWeaponIds.map((weaponId, index) => ( + {rows.map((row, index) => ( row.weaponId === weaponIds[index]); + if (alreadyMatching) return rows; + + const unclaimed = [...rows]; + + return weaponIds.map((weaponId) => { + const matchIndex = unclaimed.findIndex((row) => row.weaponId === weaponId); + if (matchIndex === -1) { + return { id: nanoid(), weaponId }; + } + + return unclaimed.splice(matchIndex, 1)[0]; + }); +} + interface SortableWeaponRowProps { + rowId: string; weaponId: MainWeaponId; index: number; onRemove: (index: number) => void; @@ -113,6 +154,7 @@ interface SortableWeaponRowProps { } function SortableWeaponRow({ + rowId, weaponId, index, onRemove, @@ -126,7 +168,7 @@ function SortableWeaponRow({ transform, transition, isDragging, - } = useSortable({ id: weaponId }); + } = useSortable({ id: rowId }); const style = { transform: CSS.Transform.toString(transform), diff --git a/app/features/comp-analyzer/core/weapon-range.test.ts b/app/features/comp-analyzer/core/weapon-range.test.ts index df7298485..58ef07c1d 100644 --- a/app/features/comp-analyzer/core/weapon-range.test.ts +++ b/app/features/comp-analyzer/core/weapon-range.test.ts @@ -11,6 +11,14 @@ const RANGE_COMPARISONS: [MainWeaponId, MainWeaponId][] = [ [2070, 7010], // Snipewriter 5H > Tri-Stringer ]; +describe("getWeaponsWithRange", () => { + test("keeps a separate slot for each copy of a duplicated weapon", () => { + const weapons = getWeaponsWithRange([40, 40, 70]); + + expect(weapons.map((weapon) => weapon.slot)).toEqual([0, 1, 2]); + }); +}); + describe("weapon range comparisons", () => { test.each(RANGE_COMPARISONS)( "weapon %i has more range than weapon %i", diff --git a/app/features/comp-analyzer/core/weapon-range.ts b/app/features/comp-analyzer/core/weapon-range.ts index 8e3827b99..0dc5764e7 100644 --- a/app/features/comp-analyzer/core/weapon-range.ts +++ b/app/features/comp-analyzer/core/weapon-range.ts @@ -217,6 +217,8 @@ export function getWeaponRange(weaponId: MainWeaponId): WeaponRangeResult { export interface WeaponWithRange { weaponId: MainWeaponId; + /** Position in the comp, kept through the filtering so duplicates stay apart */ + slot: number; range: number; blastRadius?: number; rangeType: "calculated" | "direct" | "unsupported"; @@ -227,10 +229,11 @@ export function getWeaponsWithRange( weaponIds: MainWeaponId[], ): WeaponWithRange[] { return weaponIds - .map((weaponId) => { + .map((weaponId, slot) => { const result = getWeaponRange(weaponId); return { weaponId, + slot, ...result, }; }) diff --git a/app/features/img-export/components/CompGraphic.tsx b/app/features/img-export/components/CompGraphic.tsx index 0f0d6fc4a..f449dae93 100644 --- a/app/features/img-export/components/CompGraphic.tsx +++ b/app/features/img-export/components/CompGraphic.tsx @@ -83,12 +83,7 @@ export function CompGraphic({ {t("analyzer:comp.weaponRanges")} - ({ - ...weapon, - slot: weaponIds.indexOf(weapon.weaponId), - }))} - /> + ) : null} {showCombos && topCombos.length > 0 ? ( diff --git a/changelog/2026-09-22-comp-analyzer-duplicate-weapons.md b/changelog/2026-09-22-comp-analyzer-duplicate-weapons.md new file mode 100644 index 000000000..e24a83272 --- /dev/null +++ b/changelog/2026-09-22-comp-analyzer-duplicate-weapons.md @@ -0,0 +1,5 @@ +--- +navItem: comp-analyzer +type: bug +--- +Picking the same weapon into several slots no longer breaks the comp analyzer's weapon list, reordering and range chart From 7e322c3c1f772c58ea63d2a713bac625cf923e2b Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:05:49 +0300 Subject: [PATCH 42/50] Fix gap between tier list maker weapons --- .../components/DraggableItem.module.css | 11 +++++++++++ .../tier-list-maker/components/DraggableItem.tsx | 7 ++++++- .../tier-list-maker/components/ItemPool.module.css | 10 +++++++++- .../tier-list-maker/components/TierRow.module.css | 3 ++- changelog/2026-09-22-tier-list-maker-dead-space.md | 5 +++++ 5 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 changelog/2026-09-22-tier-list-maker-dead-space.md diff --git a/app/features/tier-list-maker/components/DraggableItem.module.css b/app/features/tier-list-maker/components/DraggableItem.module.css index e22f2d388..8c5f21acd 100644 --- a/app/features/tier-list-maker/components/DraggableItem.module.css +++ b/app/features/tier-list-maker/components/DraggableItem.module.css @@ -1,7 +1,18 @@ .item { + display: grid; min-height: 50px; cursor: move; touch-action: none; user-select: none; -webkit-user-select: none; } + +.hitArea { + position: relative; + + &::after { + content: ""; + position: absolute; + inset: calc(var(--tier-list-item-gap) / -2); + } +} diff --git a/app/features/tier-list-maker/components/DraggableItem.tsx b/app/features/tier-list-maker/components/DraggableItem.tsx index 24122ba9f..d32aa6b50 100644 --- a/app/features/tier-list-maker/components/DraggableItem.tsx +++ b/app/features/tier-list-maker/components/DraggableItem.tsx @@ -31,7 +31,12 @@ export function DraggableItem({ item }: DraggableItemProps) { return (
-
+
diff --git a/app/features/tier-list-maker/components/ItemPool.module.css b/app/features/tier-list-maker/components/ItemPool.module.css index e3c887004..b13ef228f 100644 --- a/app/features/tier-list-maker/components/ItemPool.module.css +++ b/app/features/tier-list-maker/components/ItemPool.module.css @@ -1,11 +1,13 @@ .pool { + --tier-list-item-gap: var(--s-3); display: flex; flex-wrap: wrap; - gap: var(--s-3); + gap: var(--tier-list-item-gap); min-height: 50px; } .clickableItem { + position: relative; min-height: 50px; padding: 0; border: none; @@ -13,6 +15,12 @@ cursor: pointer; transition: transform 0.1s; + &::after { + content: ""; + position: absolute; + inset: calc(var(--tier-list-item-gap) / -2); + } + &:hover:not(:disabled) { transform: scale(1.1); } diff --git a/app/features/tier-list-maker/components/TierRow.module.css b/app/features/tier-list-maker/components/TierRow.module.css index 0c22e8791..2f119d797 100644 --- a/app/features/tier-list-maker/components/TierRow.module.css +++ b/app/features/tier-list-maker/components/TierRow.module.css @@ -48,10 +48,11 @@ } .targetZone { + --tier-list-item-gap: var(--s-1-5); grid-column: 2; display: flex; flex-wrap: wrap; - gap: var(--s-1-5); + gap: var(--tier-list-item-gap); padding: var(--s-2); background: var(--color-bg-high); border-radius: var(--radius-field) 0 0 var(--radius-field); diff --git a/changelog/2026-09-22-tier-list-maker-dead-space.md b/changelog/2026-09-22-tier-list-maker-dead-space.md new file mode 100644 index 000000000..6121d5774 --- /dev/null +++ b/changelog/2026-09-22-tier-list-maker-dead-space.md @@ -0,0 +1,5 @@ +--- +navItem: tier-list-maker +type: bug +--- +Clicking between two items in the tier list maker no longer misses both of them From d0d8b893db9f5e09af069116d4e66ce40b7936ec Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:11:09 +0300 Subject: [PATCH 43/50] Tier list maker default to drag & drop and persist the choice --- .../tier-list-maker/hooks/useTierList.ts | 17 ++++++++++++++--- .../tier-list-maker/routes/tier-list-maker.tsx | 2 +- .../2026-09-22-tier-list-maker-drag-default.md | 5 +++++ 3 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 changelog/2026-09-22-tier-list-maker-drag-default.md diff --git a/app/features/tier-list-maker/hooks/useTierList.ts b/app/features/tier-list-maker/hooks/useTierList.ts index 5f450c9e2..655a0cb43 100644 --- a/app/features/tier-list-maker/hooks/useTierList.ts +++ b/app/features/tier-list-maker/hooks/useTierList.ts @@ -5,6 +5,7 @@ import type { } from "@dnd-kit/core"; import { arrayMove } from "@dnd-kit/sortable"; import * as React from "react"; +import * as v from "valibot"; import { abilitiesShort } from "~/modules/in-game-lists/abilities"; import { modesShort } from "~/modules/in-game-lists/modes"; import { stageIds } from "~/modules/in-game-lists/stage-ids"; @@ -14,6 +15,8 @@ import { subWeaponIds, weaponIdToType, } from "~/modules/in-game-lists/weapon-ids"; +import { usePersistedState } from "~/modules/persisted-state/hooks"; +import * as PersistedState from "~/modules/persisted-state/persisted-state"; import { useSearchParam } from "~/modules/search-params/hooks"; import { assertUnreachable } from "~/utils/types"; import { DEFAULT_TIERS } from "../tier-list-maker-constants"; @@ -27,6 +30,13 @@ import { addItemToTier, getNextNthForItem } from "../tier-list-maker-utils"; export type TierListPlacementMode = "track" | "click"; +const placementModePersisted = PersistedState.define({ + key: "tier-list-maker__placement-mode", + storage: "local", + schema: v.picklist(["track", "click"]), + default: "track" as TierListPlacementMode, +}); + export function useTierList() { const [itemType, setItemType] = useSearchParam( tierListMakerSearchParams, @@ -37,10 +47,11 @@ export function useTierList() { useSearchParamTiersState(); const [activeItem, setActiveItem] = React.useState(null); - const [placementMode, setPlacementMode] = - React.useState("click"); + const [placementMode, setPlacementMode] = usePersistedState( + placementModePersisted, + ); const [selectedTierId, setSelectedTierId] = React.useState( - () => tiers.tiers[0]?.id ?? null, + () => (placementMode === "click" ? (tiers.tiers[0]?.id ?? null) : null), ); const handleChangePlacementMode = (mode: TierListPlacementMode) => { diff --git a/app/features/tier-list-maker/routes/tier-list-maker.tsx b/app/features/tier-list-maker/routes/tier-list-maker.tsx index fe8e11a2f..a3fde3891 100644 --- a/app/features/tier-list-maker/routes/tier-list-maker.tsx +++ b/app/features/tier-list-maker/routes/tier-list-maker.tsx @@ -50,7 +50,7 @@ import type { TierListItem } from "../tier-list-maker-schemas"; import { tierListMakerPathWithState } from "../tier-list-maker-utils"; import styles from "./tier-list-maker.module.css"; -const PLACEMENT_MODES: TierListPlacementMode[] = ["click", "track"]; +const PLACEMENT_MODES: TierListPlacementMode[] = ["track", "click"]; export const meta: MetaFunction = (args) => { return metaTags({ diff --git a/changelog/2026-09-22-tier-list-maker-drag-default.md b/changelog/2026-09-22-tier-list-maker-drag-default.md new file mode 100644 index 000000000..ecef7044f --- /dev/null +++ b/changelog/2026-09-22-tier-list-maker-drag-default.md @@ -0,0 +1,5 @@ +--- +navItem: tier-list-maker +type: feature +--- +Tier list maker defaults to drag & drop and remembers your placement mode choice From 6b40e59df6e253fa9d65a078e2b709a0a6c3acc0 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:34:25 +0300 Subject: [PATCH 44/50] Draggable tiers --- .../components/TierRow.module.css | 41 ++++----- .../tier-list-maker/components/TierRow.tsx | 61 ++++++------- .../tier-list-maker/hooks/useTierList.ts | 86 ++++++++++--------- .../routes/tier-list-maker.tsx | 59 +++++++++++-- .../tier-list-maker/tier-list-maker-utils.ts | 14 +++ ...2026-09-22-tier-list-maker-tier-reorder.md | 5 ++ e2e/helpers/playwright.ts | 23 +++++ .../tier-list-maker/tier-list-maker-page.ts | 38 +++++--- e2e/tier-list-maker.spec.ts | 35 +++++++- 9 files changed, 242 insertions(+), 120 deletions(-) create mode 100644 changelog/2026-09-22-tier-list-maker-tier-reorder.md diff --git a/app/features/tier-list-maker/components/TierRow.module.css b/app/features/tier-list-maker/components/TierRow.module.css index 2f119d797..16690127e 100644 --- a/app/features/tier-list-maker/components/TierRow.module.css +++ b/app/features/tier-list-maker/components/TierRow.module.css @@ -5,6 +5,12 @@ min-height: 68px; } +.containerReordering { + position: relative; + z-index: 1; + opacity: 0.75; +} + .tierLabel { grid-column: 1; display: flex; @@ -93,14 +99,8 @@ justify-content: center; } -.arrowControls { +.dragHandle { grid-column: 3; - display: grid; - grid-template-rows: 1fr 1fr; - gap: var(--s-1); -} - -.arrowButton { display: flex; align-items: center; justify-content: center; @@ -109,31 +109,24 @@ padding: var(--s-1); background: var(--color-bg-high); border: none; - cursor: pointer; - transition: opacity 0.2s; + border-radius: 0 var(--radius-field) var(--radius-field) 0; + color: var(--color-text-high); + cursor: grab; + touch-action: none; + transition: color 0.2s; - &:hover:not(:disabled) { - opacity: 0.8; + &:hover { + color: var(--color-text); } - &:disabled { - opacity: 0.3; - cursor: not-allowed; + &:active { + cursor: grabbing; } } -.arrowButtonUpper { - border-radius: 0 var(--radius-field) 0 0; -} - -.arrowButtonLower { - border-radius: 0 0 var(--radius-field) 0; -} - -.arrowIcon { +.dragHandleIcon { width: 20px; height: 20px; - color: var(--color-text); } .colorGrid { diff --git a/app/features/tier-list-maker/components/TierRow.tsx b/app/features/tier-list-maker/components/TierRow.tsx index 34a19f768..912a310a7 100644 --- a/app/features/tier-list-maker/components/TierRow.tsx +++ b/app/features/tier-list-maker/components/TierRow.tsx @@ -2,9 +2,11 @@ import { useDroppable } from "@dnd-kit/core"; import { horizontalListSortingStrategy, SortableContext, + useSortable, } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import clsx from "clsx"; -import { ChevronDown, ChevronUp, Plus, Trash } from "lucide-react"; +import { GripVertical, Plus, Trash } from "lucide-react"; import type { KeyboardEvent } from "react"; import { useLayoutEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; @@ -20,6 +22,7 @@ import { isLightColor, tierListItemId, tierNameFontSize, + tierSortableId, tierTextColor, } from "../tier-list-maker-utils"; import { DraggableItem } from "./DraggableItem"; @@ -31,14 +34,11 @@ interface TierRowProps { export function TierRow({ tier }: TierRowProps) { const { - state, activeItem, getItemsInTier, handleRemoveTier, handleRenameTier, handleChangeTierColor, - handleMoveTierUp, - handleMoveTierDown, showTierHeaders, placementMode, selectedTierId, @@ -56,11 +56,15 @@ export function TierRow({ tier }: TierRowProps) { isDragging: activeItem !== null, }); - const tierIndex = state.tiers.findIndex( - (candidate) => candidate.id === tier.id, - ); - const isFirstTier = tierIndex === 0; - const isLastTier = tierIndex === state.tiers.length - 1; + const { + attributes, + listeners, + setNodeRef: setSortableNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging: isReordering, + } = useSortable({ id: tierSortableId(tier.id) }); const isClickMode = placementMode === "click"; const isSelected = isClickMode && selectedTierId === tier.id; @@ -82,7 +86,14 @@ export function TierRow({ tier }: TierRowProps) { : {}; return ( -
+
{showTierHeaders ? ( -
- - -
+
); } diff --git a/app/features/tier-list-maker/hooks/useTierList.ts b/app/features/tier-list-maker/hooks/useTierList.ts index 655a0cb43..783685aac 100644 --- a/app/features/tier-list-maker/hooks/useTierList.ts +++ b/app/features/tier-list-maker/hooks/useTierList.ts @@ -26,7 +26,11 @@ import type { TierListState, } from "../tier-list-maker-schemas"; import { tierListMakerSearchParams } from "../tier-list-maker-search-params"; -import { addItemToTier, getNextNthForItem } from "../tier-list-maker-utils"; +import { + addItemToTier, + getNextNthForItem, + tierIdFromSortableId, +} from "../tier-list-maker-utils"; export type TierListPlacementMode = "track" | "click"; @@ -46,6 +50,7 @@ export function useTierList() { const { tiers, setTiers, persistTiersStateToParams } = useSearchParamTiersState(); const [activeItem, setActiveItem] = React.useState(null); + const [isReorderingTiers, setIsReorderingTiers] = React.useState(false); const [placementMode, setPlacementMode] = usePersistedState( placementModePersisted, @@ -119,6 +124,10 @@ export function useTierList() { }; const handleDragStart = (event: DragStartEvent) => { + const isTierDrag = tierIdFromSortableId(String(event.active.id)) !== null; + setIsReorderingTiers(isTierDrag); + if (isTierDrag) return; + const item = parseItemFromId(String(event.active.id)); if (item) { setActiveItem(item); @@ -128,7 +137,7 @@ export function useTierList() { const handleDragOver = (event: DragOverEvent) => { const { active, over } = event; - if (!over) { + if (!over || tierIdFromSortableId(String(active.id))) { return; } @@ -196,6 +205,13 @@ export function useTierList() { const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; setActiveItem(null); + setIsReorderingTiers(false); + + const draggedTierId = tierIdFromSortableId(String(active.id)); + if (draggedTierId) { + handleReorderTiers(draggedTierId, over?.id); + return; + } if (!over) { persistTiersStateToParams(tiers); @@ -275,6 +291,30 @@ export function useTierList() { persistTiersStateToParams(tiers); }; + const handleDragCancel = () => { + setActiveItem(null); + setIsReorderingTiers(false); + }; + + const handleReorderTiers = ( + draggedTierId: string, + overId: string | number | undefined, + ) => { + const overTierId = overId ? tierIdFromSortableId(String(overId)) : null; + if (!overTierId || overTierId === draggedTierId) return; + + const oldIndex = tiers.tiers.findIndex((tier) => tier.id === draggedTierId); + const newIndex = tiers.tiers.findIndex((tier) => tier.id === overTierId); + if (oldIndex === -1 || newIndex === -1) return; + + const newState = { + ...tiers, + tiers: arrayMove(tiers.tiers, oldIndex, newIndex), + }; + setTiers(newState); + persistTiersStateToParams(newState); + }; + const handleAddItemToTier = (item: TierListItem, tierId: string) => { const newState = addItemToTier(tiers, tierId, item); if (newState === tiers) return; @@ -408,44 +448,6 @@ export function useTierList() { }); }; - const handleMoveTierUp = (tierId: string) => { - const currentIndex = tiers.tiers.findIndex((tier) => tier.id === tierId); - if (currentIndex <= 0) return; - - const newTiers = [...tiers.tiers]; - [newTiers[currentIndex - 1], newTiers[currentIndex]] = [ - newTiers[currentIndex], - newTiers[currentIndex - 1], - ]; - - const newState = { - ...tiers, - tiers: newTiers, - }; - setTiers(newState); - persistTiersStateToParams(newState); - }; - - const handleMoveTierDown = (tierId: string) => { - const currentIndex = tiers.tiers.findIndex((tier) => tier.id === tierId); - if (currentIndex === -1 || currentIndex >= tiers.tiers.length - 1) { - return; - } - - const newTiers = [...tiers.tiers]; - [newTiers[currentIndex], newTiers[currentIndex + 1]] = [ - newTiers[currentIndex + 1], - newTiers[currentIndex], - ]; - - const newState = { - ...tiers, - tiers: newTiers, - }; - setTiers(newState); - persistTiersStateToParams(newState); - }; - const handleReset = () => { const newState = { tiers: DEFAULT_TIERS, @@ -460,16 +462,16 @@ export function useTierList() { setItemType, state: tiers, activeItem, + isReorderingTiers, handleDragStart, handleDragOver, handleDragEnd, + handleDragCancel, handleAddTier, handleAddItemToTier, handleRemoveTier, handleRenameTier, handleChangeTierColor, - handleMoveTierUp, - handleMoveTierDown, handleReset, getItemsInTier, availableItems: getAvailableItems(), diff --git a/app/features/tier-list-maker/routes/tier-list-maker.tsx b/app/features/tier-list-maker/routes/tier-list-maker.tsx index a3fde3891..21a2df76a 100644 --- a/app/features/tier-list-maker/routes/tier-list-maker.tsx +++ b/app/features/tier-list-maker/routes/tier-list-maker.tsx @@ -1,4 +1,6 @@ +import type { CollisionDetection, Modifier } from "@dnd-kit/core"; import { + closestCenter, DndContext, DragOverlay, KeyboardSensor, @@ -8,7 +10,12 @@ import { useSensor, useSensors, } from "@dnd-kit/core"; -import { sortableKeyboardCoordinates } from "@dnd-kit/sortable"; +import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; import clsx from "clsx"; import { HardDriveDownload, Plus, RefreshCcw } from "lucide-react"; import { useState } from "react"; @@ -47,11 +54,33 @@ import { } from "../contexts/TierListContext"; import type { TierListPlacementMode } from "../hooks/useTierList"; import type { TierListItem } from "../tier-list-maker-schemas"; -import { tierListMakerPathWithState } from "../tier-list-maker-utils"; +import { + tierIdFromSortableId, + tierListMakerPathWithState, + tierSortableId, +} from "../tier-list-maker-utils"; import styles from "./tier-list-maker.module.css"; const PLACEMENT_MODES: TierListPlacementMode[] = ["track", "click"]; +/** Tier rows and items share one context, so each drag only collides with its own kind of target. */ +const tierAwareCollisionDetection: CollisionDetection = (args) => { + const isTierDrag = tierIdFromSortableId(String(args.active.id)) !== null; + const droppableContainers = args.droppableContainers.filter( + (container) => + (tierIdFromSortableId(String(container.id)) !== null) === isTierDrag, + ); + + return isTierDrag + ? closestCenter({ ...args, droppableContainers }) + : pointerWithin({ ...args, droppableContainers }); +}; + +const restrictTierDragToVerticalAxis: Modifier = (args) => + args.active && tierIdFromSortableId(String(args.active.id)) !== null + ? restrictToVerticalAxis(args) + : args.transform; + export const meta: MetaFunction = (args) => { return metaTags({ title: "Tier List Maker", @@ -97,9 +126,11 @@ function TierListMakerContent() { setItemType, state, activeItem, + isReorderingTiers, handleDragStart, handleDragOver, handleDragEnd, + handleDragCancel, handleAddTier, handleReset, hideAltKits, @@ -144,16 +175,23 @@ function TierListMakerContent() {
- {state.tiers.map((tier) => ( - - ))} + tierSortableId(tier.id))} + strategy={verticalListSortingStrategy} + > + {state.tiers.map((tier) => ( + + ))} +
@@ -280,9 +318,12 @@ function TierListMakerContent() { - - {activeItem ? : null} - + {/* an empty overlay would hide the dropped tier row during its drop animation */} + {isReorderingTiers ? null : ( + + {activeItem ? : null} + + )} ); diff --git a/app/features/tier-list-maker/tier-list-maker-utils.ts b/app/features/tier-list-maker/tier-list-maker-utils.ts index 9d2d1f391..ff3ae83bc 100644 --- a/app/features/tier-list-maker/tier-list-maker-utils.ts +++ b/app/features/tier-list-maker/tier-list-maker-utils.ts @@ -10,6 +10,20 @@ export function tierListItemId(item: TierListItem) { return `${item.type}:${item.id}${item.nth ? `:${item.nth}` : ""}`; } +const TIER_SORTABLE_ID_PREFIX = "tier-sortable:"; + +/** Id of the tier row as a sortable, kept apart from the tier's own item drop zone id. */ +export function tierSortableId(tierId: string) { + return `${TIER_SORTABLE_ID_PREFIX}${tierId}`; +} + +/** Tier id behind a sortable id, or `null` if the id belongs to something else being dragged. */ +export function tierIdFromSortableId(id: string) { + return id.startsWith(TIER_SORTABLE_ID_PREFIX) + ? id.slice(TIER_SORTABLE_ID_PREFIX.length) + : null; +} + /** Path that reopens the given tier list, used by the exported image's QR code. */ export function tierListMakerPathWithState({ state, diff --git a/changelog/2026-09-22-tier-list-maker-tier-reorder.md b/changelog/2026-09-22-tier-list-maker-tier-reorder.md new file mode 100644 index 000000000..976af2f3b --- /dev/null +++ b/changelog/2026-09-22-tier-list-maker-tier-reorder.md @@ -0,0 +1,5 @@ +--- +navItem: tier-list-maker +type: feature +--- +Tiers can be reordered by dragging the handle on the right side of the row diff --git a/e2e/helpers/playwright.ts b/e2e/helpers/playwright.ts index 6ae95a8b6..369c883fb 100644 --- a/e2e/helpers/playwright.ts +++ b/e2e/helpers/playwright.ts @@ -504,6 +504,29 @@ export async function waitForDropToSettle(page: Page) { await page.waitForTimeout(2 * DND_KIT_CLICK_SUPPRESSION_MS); } +/** Drags `from` onto the center of `to` with a dnd-kit compatible stepped pointer move, then waits for the drop to settle. */ +export async function dragAndDrop( + page: Page, + { from, to }: { from: Locator; to: Locator }, +) { + await from.hover(); + await page.mouse.down(); + + const targetBox = await to.boundingBox(); + if (!targetBox) { + throw new Error("The drop target has no bounding box"); + } + // the drag & drop library only registers the drop when moved in steps + await page.mouse.move( + targetBox.x + targetBox.width / 2, + targetBox.y + targetBox.height / 2, + { steps: 10 }, + ); + await page.mouse.up(); + + await waitForDropToSettle(page); +} + /** * The scroll position the page was at when it was last pressed, for asserting that * an action did not move the viewer. Playwright scrolls a click target into view diff --git a/e2e/pages/tier-list-maker/tier-list-maker-page.ts b/e2e/pages/tier-list-maker/tier-list-maker-page.ts index a1687b5ec..33376f243 100644 --- a/e2e/pages/tier-list-maker/tier-list-maker-page.ts +++ b/e2e/pages/tier-list-maker/tier-list-maker-page.ts @@ -1,11 +1,10 @@ import type { Page } from "@playwright/test"; -import { invariant } from "~/utils/invariant"; import { TIER_LIST_MAKER_URL } from "~/utils/urls"; import { + dragAndDrop, expect, expectIsHydrated, navigate, - waitForDropToSettle, } from "../../helpers/playwright"; type ItemType = @@ -49,6 +48,7 @@ export class TierListMakerPage { this.locators = { emptyTiersDragMode: page.getByText("Drop items here"), emptyTiersClickMode: page.getByText("Click items to add here"), + tierDragHandles: page.getByRole("button", { name: "Reorder tier" }), }; } @@ -88,20 +88,30 @@ export class TierListMakerPage { const emptyTiers = this.locators.emptyTiersDragMode; const emptyCountBefore = await emptyTiers.count(); - await this.poolItems(type).first().hover(); - await this.page.mouse.down(); - - const tierBox = await emptyTiers.last().boundingBox(); - invariant(tierBox, "The tier dropped on has no bounding box"); - await this.page.mouse.move( - tierBox.x + tierBox.width / 2, - tierBox.y + tierBox.height / 2, - { steps: 10 }, - ); - await this.page.mouse.up(); + await dragAndDrop(this.page, { + from: this.poolItems(type).first(), + to: emptyTiers.last(), + }); await expect(emptyTiers).toHaveCount(emptyCountBefore - 1); - await waitForDropToSettle(this.page); + } + + /** Ids of the tier rows, top to bottom. */ + async tierIds() { + return this.page + .locator("[data-tier-id]") + .evaluateAll((rows) => + rows.map((row) => (row as HTMLElement).dataset.tierId), + ); + } + + async dragTier({ from, to }: { from: number; to: number }) { + const handles = this.locators.tierDragHandles; + + await dragAndDrop(this.page, { + from: handles.nth(from), + to: handles.nth(to), + }); } async clickFirstItem(type: ItemType) { diff --git a/e2e/tier-list-maker.spec.ts b/e2e/tier-list-maker.spec.ts index 51ffd82b5..0ad348b50 100644 --- a/e2e/tier-list-maker.spec.ts +++ b/e2e/tier-list-maker.spec.ts @@ -39,7 +39,7 @@ test.describe("Tier List Maker", () => { const tierList = new TierListMakerPage(page); await tierList.goto(); - // click to place is the default mode + await tierList.setPlacementMode("click"); await expect(tierList.locators.emptyTiersClickMode).toHaveCount(5); // the first tier is selected by default @@ -50,4 +50,37 @@ test.describe("Tier List Maker", () => { await tierList.clickFirstItem("main-weapon"); await expect(tierList.locators.emptyTiersClickMode).toHaveCount(3); }); + + test("tiers are reordered by dragging their handle", async ({ page }) => { + const tierList = new TierListMakerPage(page); + await tierList.goto(); + + expect(await tierList.tierIds()).toEqual([ + "tier-x", + "tier-s", + "tier-a", + "tier-b", + "tier-c", + ]); + + await tierList.dragTier({ from: 0, to: 2 }); + + expect(await tierList.tierIds()).toEqual([ + "tier-s", + "tier-a", + "tier-x", + "tier-b", + "tier-c", + ]); + + await tierList.reload(); + + expect(await tierList.tierIds()).toEqual([ + "tier-s", + "tier-a", + "tier-x", + "tier-b", + "tier-c", + ]); + }); }); From 621555f2ea6d9e53cf64c7606323d26d21156ae2 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:54:04 +0300 Subject: [PATCH 45/50] Fix drag n drop jank in tier list maker --- .../components/DraggableItem.tsx | 2 +- .../tier-list-maker/components/TierRow.tsx | 12 +++--- .../routes/tier-list-maker.tsx | 41 +++++++++++++++++-- ...2026-09-22-tier-list-maker-drag-preview.md | 5 +++ 4 files changed, 49 insertions(+), 11 deletions(-) create mode 100644 changelog/2026-09-22-tier-list-maker-drag-preview.md diff --git a/app/features/tier-list-maker/components/DraggableItem.tsx b/app/features/tier-list-maker/components/DraggableItem.tsx index d32aa6b50..93c81bace 100644 --- a/app/features/tier-list-maker/components/DraggableItem.tsx +++ b/app/features/tier-list-maker/components/DraggableItem.tsx @@ -24,7 +24,7 @@ export function DraggableItem({ item }: DraggableItemProps) { }); const style = { - transform: CSS.Transform.toString(transform), + transform: CSS.Translate.toString(transform), transition, opacity: isDragging ? 0.3 : 1, }; diff --git a/app/features/tier-list-maker/components/TierRow.tsx b/app/features/tier-list-maker/components/TierRow.tsx index 912a310a7..991ba8906 100644 --- a/app/features/tier-list-maker/components/TierRow.tsx +++ b/app/features/tier-list-maker/components/TierRow.tsx @@ -1,6 +1,6 @@ import { useDroppable } from "@dnd-kit/core"; import { - horizontalListSortingStrategy, + rectSortingStrategy, SortableContext, useSortable, } from "@dnd-kit/sortable"; @@ -47,9 +47,12 @@ export function TierRow({ tier }: TierRowProps) { const items = getItemsInTier(tier.id); const { t } = useTranslation(["tier-list-maker", "common"]); - const { setNodeRef, isOver } = useDroppable({ + const { setNodeRef, over } = useDroppable({ id: tier.id, }); + const itemIds = items.map(tierListItemId); + const isOver = + over !== null && (over.id === tier.id || itemIds.includes(String(over.id))); const combinedRef = useLockedHeightWhileDragging({ setNodeRef, @@ -193,10 +196,7 @@ export function TierRow({ tier }: TierRowProps) { : t("tier-list-maker:dropItems")}
) : items.length > 0 ? ( - + {items.map((item) => ( ))} diff --git a/app/features/tier-list-maker/routes/tier-list-maker.tsx b/app/features/tier-list-maker/routes/tier-list-maker.tsx index 21a2df76a..e61a3c970 100644 --- a/app/features/tier-list-maker/routes/tier-list-maker.tsx +++ b/app/features/tier-list-maker/routes/tier-list-maker.tsx @@ -1,4 +1,4 @@ -import type { CollisionDetection, Modifier } from "@dnd-kit/core"; +import type { ClientRect, CollisionDetection, Modifier } from "@dnd-kit/core"; import { closestCenter, DndContext, @@ -71,9 +71,30 @@ const tierAwareCollisionDetection: CollisionDetection = (args) => { (tierIdFromSortableId(String(container.id)) !== null) === isTierDrag, ); - return isTierDrag - ? closestCenter({ ...args, droppableContainers }) - : pointerWithin({ ...args, droppableContainers }); + if (isTierDrag) return closestCenter({ ...args, droppableContainers }); + + const pointerCollisions = pointerWithin({ ...args, droppableContainers }); + + // gaps between items would otherwise resolve to the tier itself, snapping the sort preview back + const tierZoneCollision = pointerCollisions.find((collision) => + String(collision.id).startsWith("tier-"), + ); + const tierZoneRect = tierZoneCollision + ? args.droppableRects.get(tierZoneCollision.id) + : undefined; + if (!tierZoneCollision || !tierZoneRect) return pointerCollisions; + + const itemsInTierZone = droppableContainers.filter((container) => { + const rect = args.droppableRects.get(container.id); + return ( + container.id !== tierZoneCollision.id && + rect !== undefined && + isRectCenterWithin(rect, tierZoneRect) + ); + }); + if (itemsInTierZone.length === 0) return [tierZoneCollision]; + + return closestCenter({ ...args, droppableContainers: itemsInTierZone }); }; const restrictTierDragToVerticalAxis: Modifier = (args) => @@ -412,3 +433,15 @@ function ResetPopover({ handleReset }: { handleReset: () => void }) { ); } + +function isRectCenterWithin(rect: ClientRect, container: ClientRect) { + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + return ( + centerX >= container.left && + centerX <= container.right && + centerY >= container.top && + centerY <= container.bottom + ); +} diff --git a/changelog/2026-09-22-tier-list-maker-drag-preview.md b/changelog/2026-09-22-tier-list-maker-drag-preview.md new file mode 100644 index 000000000..c6c63479f --- /dev/null +++ b/changelog/2026-09-22-tier-list-maker-drag-preview.md @@ -0,0 +1,5 @@ +--- +navItem: tier-list-maker +type: bug +--- +Dragging items in tier list maker tiers with multiple rows no longer jumps around or leaves gaps in the preview From d630e667adb674af71f690cbd1ed90b273ae35db Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:21:43 +0300 Subject: [PATCH 46/50] Division logos visual tweaks --- .../components/DivisionImage.module.css | 12 +++++ .../top-search/components/DivisionImage.tsx | 47 +++++++++++++++++++ .../top-search/components/Placements.tsx | 17 ++----- app/features/top-search/routes/xsearch.tsx | 19 ++++---- app/features/user-page/components/Widget.tsx | 16 ++----- changelog/2026-09-22-division-logos.md | 5 ++ 6 files changed, 84 insertions(+), 32 deletions(-) create mode 100644 app/features/top-search/components/DivisionImage.module.css create mode 100644 app/features/top-search/components/DivisionImage.tsx create mode 100644 changelog/2026-09-22-division-logos.md diff --git a/app/features/top-search/components/DivisionImage.module.css b/app/features/top-search/components/DivisionImage.module.css new file mode 100644 index 000000000..6b70585d9 --- /dev/null +++ b/app/features/top-search/components/DivisionImage.module.css @@ -0,0 +1,12 @@ +.lightThemeOnly { + :is(html:global(.dark), [data-theme="dark"]) &:not([data-theme="light"] *) { + display: none; + } +} + +.darkThemeOnly { + html:not(:global(.dark)) &:not([data-theme="dark"] *), + [data-theme="light"] & { + display: none; + } +} diff --git a/app/features/top-search/components/DivisionImage.tsx b/app/features/top-search/components/DivisionImage.tsx new file mode 100644 index 000000000..c2f5a04b9 --- /dev/null +++ b/app/features/top-search/components/DivisionImage.tsx @@ -0,0 +1,47 @@ +import { Image } from "~/components/Image"; +import { brandImageUrl } from "~/utils/urls"; +import type { XRankPlacementRegion } from "../top-search-types"; +import styles from "./DivisionImage.module.css"; + +const TENTATEK_BRAND_ID = "B10"; +const TAKOROKA_BRAND_ID = "B11"; + +/** X Rank division logo. Takoroka has a lighter variant for dark theme, `inverted` flips it for backgrounds contrasting the theme (e.g. a selected chip). */ +export function DivisionImage({ + region, + size, + alt, + inverted = false, +}: { + region: XRankPlacementRegion; + size: number; + alt: string; + inverted?: boolean; +}) { + if (region === "WEST") { + return ( + {alt} + ); + } + + return ( + <> + {alt} + {alt} + + ); +} diff --git a/app/features/top-search/components/Placements.tsx b/app/features/top-search/components/Placements.tsx index 673e34284..3c931d7cc 100644 --- a/app/features/top-search/components/Placements.tsx +++ b/app/features/top-search/components/Placements.tsx @@ -11,9 +11,10 @@ import { topSearchPage, topSearchPlayerPage, } from "~/features/top-search/top-search-urls"; -import { brandImageUrl, modeImageUrl } from "~/utils/urls"; +import { modeImageUrl } from "~/utils/urls"; import { monthYearToSpan } from "../top-search-utils"; import type * as XRankPlacementRepository from "../XRankPlacementRepository.server"; +import { DivisionImage } from "./DivisionImage"; import styles from "./Placements.module.css"; interface PlacementsTableProps { @@ -21,9 +22,6 @@ interface PlacementsTableProps { type?: "PLAYER_NAME" | "MODE_INFO"; } -const TENTATEK_BRAND_ID = "B10"; -const TAKOROKA_BRAND_ID = "B11"; - export function PlacementsTable({ placements, type = "PLAYER_NAME", @@ -47,19 +45,14 @@ export function PlacementsTable({ {type === "MODE_INFO" ? ( <>
- {
diff --git a/app/features/top-search/routes/xsearch.tsx b/app/features/top-search/routes/xsearch.tsx index 602b36865..48fa78ae9 100644 --- a/app/features/top-search/routes/xsearch.tsx +++ b/app/features/top-search/routes/xsearch.tsx @@ -10,7 +10,7 @@ import { SendouSelectItem, SendouSelectItemSection, } from "~/components/elements/Select"; -import { Image, ModeImage } from "~/components/Image"; +import { ModeImage } from "~/components/Image"; import { LocaleTimeRange } from "~/components/LocaleTimeRange"; import { Main } from "~/components/Main"; import { topSearchPage } from "~/features/top-search/top-search-urls"; @@ -18,7 +18,8 @@ import { rankedModesShort } from "~/modules/in-game-lists/modes"; import { useSearchParamsTyped } from "~/modules/search-params/hooks"; import { metaTags, ogPageImage } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; -import { brandImageUrl, navIconUrl } from "~/utils/urls"; +import { navIconUrl } from "~/utils/urls"; +import { DivisionImage } from "../components/DivisionImage"; import { PlacementsTable } from "../components/Placements"; import { loader } from "../loaders/xsearch.server"; import { topSearchSearchParams } from "../top-search-search-params"; @@ -26,10 +27,7 @@ import { type MonthYear, monthYearToSpan } from "../top-search-utils"; export { loader }; -const DIVISIONS = [ - { region: "WEST", brandId: "B10" }, - { region: "JPN", brandId: "B11" }, -] as const; +const DIVISIONS = ["WEST", "JPN"] as const; export const handle: SendouRouteHandle = { breadcrumb: () => ({ @@ -148,7 +146,7 @@ function DivisionFilter() { return ( - {DIVISIONS.map(({ region, brandId }) => ( + {DIVISIONS.map((region) => ( setParams({ region })} > - + {t(`common:divisions.${region}`)} diff --git a/app/features/user-page/components/Widget.tsx b/app/features/user-page/components/Widget.tsx index 410bbf06f..5f9eed831 100644 --- a/app/features/user-page/components/Widget.tsx +++ b/app/features/user-page/components/Widget.tsx @@ -29,6 +29,7 @@ import { previewUrl } from "~/features/art/art-utils"; import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay"; import { lfgSearchParams } from "~/features/lfg/lfg-search-params"; import { tierListMakerSearchParams } from "~/features/tier-list-maker/tier-list-maker-search-params"; +import { DivisionImage } from "~/features/top-search/components/DivisionImage"; import { topSearchPlayerPage } from "~/features/top-search/top-search-urls"; import { tournamentBracketsPage } from "~/features/tournament-bracket/tournament-bracket-urls"; import { tournamentOrganizationPage } from "~/features/tournament-organization/tournament-organization-urls"; @@ -50,7 +51,6 @@ import type { SerializeFrom } from "~/utils/remix"; import { rawSensToString } from "~/utils/strings"; import { assertUnreachable } from "~/utils/types"; import { - brandImageUrl, calendarEventPage, controllerImageUrl, gameBadgeUrl, @@ -563,9 +563,6 @@ function LFGPosts({ ); } -const TENTATEK_BRAND_ID = "B10"; -const TAKOROKA_BRAND_ID = "B11"; - function XRankPeaks({ peaks, }: { @@ -583,15 +580,10 @@ function XRankPeaks({ height={24} />
- {peak.region
diff --git a/changelog/2026-09-22-division-logos.md b/changelog/2026-09-22-division-logos.md new file mode 100644 index 000000000..517524a54 --- /dev/null +++ b/changelog/2026-09-22-division-logos.md @@ -0,0 +1,5 @@ +--- +navItem: xsearch +type: bug +--- +Takoroka division logo is now visible in dark mode and the Tentatek logo is better centered From 4c3c4d7b7329f213ae40b36d84f7b4e5db544aea Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:31:04 +0300 Subject: [PATCH 47/50] Bump dependencies - @aws-sdk/client-s3 & @aws-sdk/lib-storage 3.1132.0 - lucide-react 1.46.0 - mediabunny 1.56.2 - remeda 2.50.0 - magic-string 1.4.1 - @biomejs/biome 2.5.13 (add sort compare functions for new useArraySortCompare lint) --- app/features/art/ArtRepository.server.test.ts | 8 +- .../AvailabilityRepository.server.test.ts | 6 +- .../sendouq/core/ready-check.server.test.ts | 4 +- ...amentOrganizationRepository.server.test.ts | 4 +- ...ournamentAuditLogRepository.server.test.ts | 4 +- app/routines/syncLiveStreams.test.ts | 9 +- package.json | 14 +- pnpm-lock.yaml | 138 +++++++++--------- 8 files changed, 94 insertions(+), 93 deletions(-) diff --git a/app/features/art/ArtRepository.server.test.ts b/app/features/art/ArtRepository.server.test.ts index e1f11ed48..276fef505 100644 --- a/app/features/art/ArtRepository.server.test.ts +++ b/app/features/art/ArtRepository.server.test.ts @@ -76,11 +76,9 @@ describe("findAllTags", () => { const result = await ArtRepository.findAllTags(); expect(result).toHaveLength(3); - expect(result.map((t) => t.name).sort()).toEqual([ - "Character", - "Landscape", - "Weapon", - ]); + expect( + result.map((t) => t.name).sort((a, b) => a.localeCompare(b)), + ).toEqual(["Character", "Landscape", "Weapon"]); }); test("returns empty array when no tags exist", async () => { diff --git a/app/features/availability/AvailabilityRepository.server.test.ts b/app/features/availability/AvailabilityRepository.server.test.ts index 8fc508153..26052d18e 100644 --- a/app/features/availability/AvailabilityRepository.server.test.ts +++ b/app/features/availability/AvailabilityRepository.server.test.ts @@ -174,8 +174,8 @@ describe("AvailabilityRepository.upsertOwnWeek", () => { ...WINDOW, }); - expect(weeks.map((week) => week.userId).sort()).toEqual( - [users.id(1), users.id(2)].sort(), + expect(weeks.map((week) => week.userId).sort((a, b) => a - b)).toEqual( + [users.id(1), users.id(2)].sort((a, b) => a - b), ); }); }); @@ -469,7 +469,7 @@ describe("AvailabilityRepository.findAllTeamEventsByUserIds", () => { events .filter((event) => event.userId === users.id(2)) .map((e) => e.name) - .sort(), + .sort((a, b) => a.localeCompare(b)), ).toEqual(["Selected only", "Whole team"]); }); }); diff --git a/app/features/sendouq/core/ready-check.server.test.ts b/app/features/sendouq/core/ready-check.server.test.ts index 8fd903cc5..1700a8834 100644 --- a/app/features/sendouq/core/ready-check.server.test.ts +++ b/app/features/sendouq/core/ready-check.server.test.ts @@ -230,8 +230,8 @@ describe("SendouQ ready check", () => { ); // the two who confirmed are not kickable, the two who didn't are - expect(kickable.sort()).toEqual( - [groups.ownMembers[2].id, groups.ownMembers[3].id].sort(), + expect(kickable.sort((a, b) => a - b)).toEqual( + [groups.ownMembers[2].id, groups.ownMembers[3].id].sort((a, b) => a - b), ); }); diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts index 0d65ffb75..1146156c5 100644 --- a/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts +++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts @@ -30,8 +30,8 @@ describe("findByUserId", () => { ); expect(result).toHaveLength(2); - expect(result.map((org) => org.id).sort()).toEqual( - [org1.id, org2.id].sort(), + expect(result.map((org) => org.id).sort((a, b) => a - b)).toEqual( + [org1.id, org2.id].sort((a, b) => a - b), ); }); diff --git a/app/features/tournament/TournamentAuditLogRepository.server.test.ts b/app/features/tournament/TournamentAuditLogRepository.server.test.ts index a346539b8..b75f8fb72 100644 --- a/app/features/tournament/TournamentAuditLogRepository.server.test.ts +++ b/app/features/tournament/TournamentAuditLogRepository.server.test.ts @@ -124,7 +124,9 @@ describe("TournamentAuditLogRepository", () => { tournament.id, ); expect(teams).toHaveLength(2); - expect(teams.map((team) => team.name).sort()).toEqual(["Team A", "Team B"]); + expect( + teams.map((team) => team.name).sort((a, b) => a.localeCompare(b)), + ).toEqual(["Team A", "Team B"]); const events = await TournamentAuditLogRepository.findByTournamentId({ tournamentId: tournament.id, diff --git a/app/routines/syncLiveStreams.test.ts b/app/routines/syncLiveStreams.test.ts index b9ccbde98..c6e7cefb4 100644 --- a/app/routines/syncLiveStreams.test.ts +++ b/app/routines/syncLiveStreams.test.ts @@ -114,10 +114,11 @@ describe("syncLiveStreams tournament streamers", () => { const rows = await findAllLiveStreams(); expect(rows).toHaveLength(2); - expect(rows.map((r) => r.twitch).sort()).toEqual([ - "streamer_one", - "streamer_two", - ]); + expect( + rows + .map((r) => r.twitch) + .sort((a, b) => (a ?? "").localeCompare(b ?? "")), + ).toEqual(["streamer_one", "streamer_two"]); expect(rows[0].viewerCount).toBe(100); }); diff --git a/package.json b/package.json index 9d7d5e2b2..94777e1f3 100644 --- a/package.json +++ b/package.json @@ -55,8 +55,8 @@ "knip": "knip" }, "dependencies": { - "@aws-sdk/client-s3": "3.1130.0", - "@aws-sdk/lib-storage": "3.1130.0", + "@aws-sdk/client-s3": "3.1132.0", + "@aws-sdk/lib-storage": "3.1132.0", "@date-fns/tz": "1.5.0", "@dnd-kit/core": "6.3.1", "@dnd-kit/modifiers": "9.0.0", @@ -81,9 +81,9 @@ "ics": "3.12.0", "isbot": "5.2.2", "kysely": "0.29.0", - "lucide-react": "1.44.0", + "lucide-react": "1.46.0", "markdown-to-jsx": "9.10.2", - "mediabunny": "1.56.1", + "mediabunny": "1.56.2", "nanoid": "6.0.1", "node-cron": "4.6.0", "nprogress": "0.2.0", @@ -97,7 +97,7 @@ "react-error-boundary": "6.1.5", "react-i18next": "17.0.11", "react-router": "8.3.1", - "remeda": "2.48.0", + "remeda": "2.50.0", "remix-auth": "4.2.0", "remix-auth-oauth2": "3.4.1", "remix-i18next": "8.0.0", @@ -109,7 +109,7 @@ }, "devDependencies": { "@babel/preset-typescript": "7.29.7", - "@biomejs/biome": "2.5.12", + "@biomejs/biome": "2.5.13", "@faker-js/faker": "10.6.0", "@napi-rs/canvas": "1.0.9", "@playwright/test": "1.63.0", @@ -125,7 +125,7 @@ "cross-env": "10.1.0", "i18next-locales-sync": "2.1.1", "knip": "6.35.1", - "magic-string": "1.3.1", + "magic-string": "1.4.1", "sharp": "^0.35.4", "sql-formatter": "15.8.2", "typescript": "7.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dafc4e24..85311bbb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,11 +16,11 @@ importers: .: dependencies: '@aws-sdk/client-s3': - specifier: 3.1130.0 - version: 3.1130.0 + specifier: 3.1132.0 + version: 3.1132.0 '@aws-sdk/lib-storage': - specifier: 3.1130.0 - version: 3.1130.0(@aws-sdk/client-s3@3.1130.0) + specifier: 3.1132.0 + version: 3.1132.0(@aws-sdk/client-s3@3.1132.0) '@date-fns/tz': specifier: 1.5.0 version: 1.5.0 @@ -94,14 +94,14 @@ importers: specifier: 0.29.0 version: 0.29.0(patch_hash=a3e94339939b1be5b70610601e96fff19ed5678aab525de24b52dfc5212c686a) lucide-react: - specifier: 1.44.0 - version: 1.44.0(react@19.3.0) + specifier: 1.46.0 + version: 1.46.0(react@19.3.0) markdown-to-jsx: specifier: 9.10.2 version: 9.10.2(react@19.3.0) mediabunny: - specifier: 1.56.1 - version: 1.56.1 + specifier: 1.56.2 + version: 1.56.2 nanoid: specifier: 6.0.1 version: 6.0.1 @@ -142,8 +142,8 @@ importers: specifier: 8.3.1 version: 8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) remeda: - specifier: 2.48.0 - version: 2.48.0 + specifier: 2.50.0 + version: 2.50.0 remix-auth: specifier: 4.2.0 version: 4.2.0 @@ -173,8 +173,8 @@ importers: specifier: 7.29.7 version: 7.29.7(@babel/core@7.29.7) '@biomejs/biome': - specifier: 2.5.12 - version: 2.5.12 + specifier: 2.5.13 + version: 2.5.13 '@faker-js/faker': specifier: 10.6.0 version: 10.6.0 @@ -221,8 +221,8 @@ importers: specifier: 6.35.1 version: 6.35.1 magic-string: - specifier: 1.3.1 - version: 1.3.1 + specifier: 1.4.1 + version: 1.4.1 sharp: specifier: ^0.35.4 version: 0.35.4(@types/node@26.5.1) @@ -254,8 +254,8 @@ packages: resolution: {integrity: sha512-6uTniZc87q+B5eXouGTl+7Tmc482rEeCcvxpsvREP8EfF0gvloRZ41UOA9sbSJlyy8TbqIBXb3kKfKarEArUQA==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.1130.0': - resolution: {integrity: sha512-TUe0hgQi3RtuccmTgpUsRIuPk07ZtJE6s3vpOeWCM8sibLNXPVKweemNEz5gwaRYTor8MAGgjlyVZfKGuu1ZOQ==} + '@aws-sdk/client-s3@3.1132.0': + resolution: {integrity: sha512-ccS2utR3YpKE1KEkAlo1Oq9kUhzy/IOIbpwHAcENn/JzpzCj9l79I2o6stTzXGOKbL8+XEdH2P7c2Znp8PbBlQ==} engines: {node: '>=20.0.0'} '@aws-sdk/core@3.978.0': @@ -294,11 +294,11 @@ packages: resolution: {integrity: sha512-uylIQSUWpfLuH2LovxEEfwzJGM/SabLOfLMg6YXu/E8jJEKUdpdILCVCQCdFvHyu/7dLJOHPMfrSwduxO56NkQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/lib-storage@3.1130.0': - resolution: {integrity: sha512-e9sb+Utkn6LiYrBhLyHl7k3V/0TtIetyRgVyJ4j67rSA4zmcgcrt6YJ6qopg1k3JXVXahcUhF7Imnd62d4TfqA==} + '@aws-sdk/lib-storage@3.1132.0': + resolution: {integrity: sha512-yKiaw9V22XRdqVGxp9+jbnDADIHsdr4WGwOqKVOEClFkl5NQPSWl6RyiVcG+8/DsXz7nsmWFJI2WiG8vLwc6bg==} engines: {node: '>=20.0.0'} peerDependencies: - '@aws-sdk/client-s3': ^3.1130.0 + '@aws-sdk/client-s3': ^3.1132.0 '@aws-sdk/middleware-sdk-s3@3.972.76': resolution: {integrity: sha512-NfnTkVUTBKTBuBgqaapFK9r3YdkKt1b2oRvgLzZq91bwNKh6ZS0S7sEcheguttREaL4iyfs/xQnqD7Z7AsWSsA==} @@ -477,59 +477,59 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.5.12': - resolution: {integrity: sha512-Lw4VHZRebrReBBnlHa12JQjnIBm3JJAA55PDB9LbBVBF0q4RYphm6KfmIjqtPhf61MxZ5Q9KoK8R8x+7per5Aw==} + '@biomejs/biome@2.5.13': + resolution: {integrity: sha512-+SEC/mFk1a+5mvUANZgbZTaiZXs1nj4iMhL/PHiqDT5TPUEPFIlliEtkKKZB4N862yylHC3UI+/Sj2I0HJEqhA==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.5.12': - resolution: {integrity: sha512-lCRY1rwgNeWNgTr4DI/u6ZwXTRwRLHAvbaio1YLLGS+4r1nhvB2ssyPqIpfUSmRveNfv0fn/N58C7CAdK2XVrg==} + '@biomejs/cli-darwin-arm64@2.5.13': + resolution: {integrity: sha512-nYSuDJ6zgVqZUkAkJkvhXxsF2PYIrUk/g638K4voCXz7foI9f7b6C2/7+oAihsHQlivZNMUrm/y8ywlcHQtZOw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.5.12': - resolution: {integrity: sha512-vhPgwnh+6tN3ArdAXuET99xaNbFt7CG82Bqn+omHVLC5xdVx45JsYjGPmUIGNzjDek5XdNCP1HKksK7fn8+3bQ==} + '@biomejs/cli-darwin-x64@2.5.13': + resolution: {integrity: sha512-KVy1ceEDuJ3AzFxjT9kkxbVy+UANw1pjEMUS6lvKfxjJ+fmkRvc7sQn1Xo6ETgseEI7wUQoV03KSga3XfE8YRQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.5.12': - resolution: {integrity: sha512-couHYjFLL5uuI8ne6zhT7KwEsXo5YP7ry/2xmEqah7qanu0YmfDi3mwJg47YXSuv/NpZj22CZzcRH/5c4gjPSQ==} + '@biomejs/cli-linux-arm64-musl@2.5.13': + resolution: {integrity: sha512-CH32xpep3dNS5EVJpAHlYchkBYznxyVZQrx0b6YYYlPL9u9ZeD2NtqiK/6s64+HFS2a7hGnryLlqqZAOh8ax5g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.5.12': - resolution: {integrity: sha512-2gp8aVwXYKdAtmBfRFCUuyDMcfN1ahHqUkGfLYrZlNRFmryMATLVvJgWKvyA8wu4Rwn5OSxM1UcUmOuOFNGeBQ==} + '@biomejs/cli-linux-arm64@2.5.13': + resolution: {integrity: sha512-VlNMtoxOqs0dUR6drxxHr18SNUvI7xxAuHZlH4s/dstYSf3g6RaqVgo8JRnhcOhKz9afWrvtJTboNG1JuYlIDQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.5.12': - resolution: {integrity: sha512-8A0oDW58/w9f/PQNYuq0sGUZtGtGrkNF4Z6n0PUoXpLCshi85vtKTv1XSznQawhdE4MXJ8ufpzHXyLFe87M/+w==} + '@biomejs/cli-linux-x64-musl@2.5.13': + resolution: {integrity: sha512-F3pmwl+VHoUuVJN/tbNLKeLt0SVK3EIyN1jXlMcNyQGYRQQTVqXF+GgkP0/CjGXFN87e/mv0sBfUnWqWza5PKQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.5.12': - resolution: {integrity: sha512-SnvOs3TSTiuia4SQOUNe1aWC9RT4+YkjcKnOhL/nsKOV0k5ycgBkDzF0lUxKn1V7Q8CLTRq6iV23ZAivHomRoA==} + '@biomejs/cli-linux-x64@2.5.13': + resolution: {integrity: sha512-Fi6gIxbUaJ3ZCIXeG3ggIBPF76O2DjDN67WrPX/ODRv54GeXPm2gbEPC96Yb0yrJoiH0Eax1JLx1Pi0XbsNFZQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.5.12': - resolution: {integrity: sha512-b9vtoZFsuZt1pdjNwJvXl0f+BpayRzV008uS2+JpmwIKdSE2qdu4A/l04FESwLoou5g2E/Qlec0xwJydZplH+A==} + '@biomejs/cli-win32-arm64@2.5.13': + resolution: {integrity: sha512-+WD13qshXrr0Icv4BfsAdzm8Fs3TL+nZ59zEQxsYNd8lcgZJ0A5+OrlwsL1PipVCmWeRpxgIPD6DAcEd7smkCQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.5.12': - resolution: {integrity: sha512-B1R/l+CwEpKFSuqiwePzPNRk1EiJN8kc0UhdafNz6MZN9v5OFP9HYP1irptvWzHrwVI4blVNGMbxc5zt70m3IA==} + '@biomejs/cli-win32-x64@2.5.13': + resolution: {integrity: sha512-VOofU/nW761XWzUeUNE8zzYNyPrxuMuNFMizL0qz7J75yeV8N7WFheUlk1/K6dOkaFpH9wJ+lDKlwjAbw0346w==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -3512,8 +3512,8 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@1.44.0: - resolution: {integrity: sha512-2egNApH4hX4j/qdCgRublh88+9u3mEhz9iSlW5ckm4kaQEqZbXbMr0l5u5JZLy8nmWRx2dbHGQkEDYz6C9aCgw==} + lucide-react@1.46.0: + resolution: {integrity: sha512-Bv+FZXgZPrxc/NCl1e7JJVQFLdiCxYgxNVhqoV7X0p6I8ADJo8DxBnK1auH0fZz4AmqOJ3jgneL4f1i8LJQRAA==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -3524,8 +3524,8 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magic-string@1.3.1: - resolution: {integrity: sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==} + magic-string@1.4.1: + resolution: {integrity: sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==} markdown-it@14.1.1: resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} @@ -3560,8 +3560,8 @@ packages: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} - mediabunny@1.56.1: - resolution: {integrity: sha512-DNOm0bRBmeOxJgCK/g3LxNWbxY5morTauNdlychujGmJ9L37ezVezkNoI1WtO2Xdb4WlWWJlYfIKnOEzd5phRw==} + mediabunny@1.56.2: + resolution: {integrity: sha512-KrrL2Hr47q+IXS19BVWJyYMw1Wb8gz8FkeKP/+ui7q8tPt0lfaio2d9CaGkEWFghaD02wNae/HgYBZTcg5RyZg==} merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} @@ -3932,8 +3932,8 @@ packages: resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} engines: {node: '>= 20.19.0'} - remeda@2.48.0: - resolution: {integrity: sha512-+tf74WyTpQT1lHI/zJeXOkMBqb6R9gNcd3T4DpXzjs26PdwEARCPKOjLAwJaQYrvSXS+nxsXMhEZD4NS40QuSg==} + remeda@2.50.0: + resolution: {integrity: sha512-pzljnP7Gnl/lLz5WcOSkxEGCjcK4dteQihQjRrtKCGqn8J6jD0BHwfU6pRrXjvQV2EWe+HdgtfZW7rDdmKn8uQ==} engines: {node: '>=18.0.0'} remix-auth-oauth2@3.4.1: @@ -4477,7 +4477,7 @@ snapshots: '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/client-s3@3.1130.0': + '@aws-sdk/client-s3@3.1132.0': dependencies: '@aws-sdk/checksums': 3.1001.0 '@aws-sdk/core': 3.978.0 @@ -4586,9 +4586,9 @@ snapshots: '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/lib-storage@3.1130.0(@aws-sdk/client-s3@3.1130.0)': + '@aws-sdk/lib-storage@3.1132.0(@aws-sdk/client-s3@3.1132.0)': dependencies: - '@aws-sdk/client-s3': 3.1130.0 + '@aws-sdk/client-s3': 3.1132.0 '@smithy/core': 3.34.1 '@smithy/types': 4.18.0 buffer: 5.6.0 @@ -4843,39 +4843,39 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@biomejs/biome@2.5.12': + '@biomejs/biome@2.5.13': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.5.12 - '@biomejs/cli-darwin-x64': 2.5.12 - '@biomejs/cli-linux-arm64': 2.5.12 - '@biomejs/cli-linux-arm64-musl': 2.5.12 - '@biomejs/cli-linux-x64': 2.5.12 - '@biomejs/cli-linux-x64-musl': 2.5.12 - '@biomejs/cli-win32-arm64': 2.5.12 - '@biomejs/cli-win32-x64': 2.5.12 + '@biomejs/cli-darwin-arm64': 2.5.13 + '@biomejs/cli-darwin-x64': 2.5.13 + '@biomejs/cli-linux-arm64': 2.5.13 + '@biomejs/cli-linux-arm64-musl': 2.5.13 + '@biomejs/cli-linux-x64': 2.5.13 + '@biomejs/cli-linux-x64-musl': 2.5.13 + '@biomejs/cli-win32-arm64': 2.5.13 + '@biomejs/cli-win32-x64': 2.5.13 - '@biomejs/cli-darwin-arm64@2.5.12': + '@biomejs/cli-darwin-arm64@2.5.13': optional: true - '@biomejs/cli-darwin-x64@2.5.12': + '@biomejs/cli-darwin-x64@2.5.13': optional: true - '@biomejs/cli-linux-arm64-musl@2.5.12': + '@biomejs/cli-linux-arm64-musl@2.5.13': optional: true - '@biomejs/cli-linux-arm64@2.5.12': + '@biomejs/cli-linux-arm64@2.5.13': optional: true - '@biomejs/cli-linux-x64-musl@2.5.12': + '@biomejs/cli-linux-x64-musl@2.5.13': optional: true - '@biomejs/cli-linux-x64@2.5.12': + '@biomejs/cli-linux-x64@2.5.13': optional: true - '@biomejs/cli-win32-arm64@2.5.12': + '@biomejs/cli-win32-arm64@2.5.13': optional: true - '@biomejs/cli-win32-x64@2.5.12': + '@biomejs/cli-win32-x64@2.5.13': optional: true '@blazediff/core@1.9.1': {} @@ -7720,7 +7720,7 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@1.44.0(react@19.3.0): + lucide-react@1.46.0(react@19.3.0): dependencies: react: 19.3.0 @@ -7730,7 +7730,7 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 - magic-string@1.3.1: + magic-string@1.4.1: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -7753,7 +7753,7 @@ snapshots: media-typer@1.1.0: {} - mediabunny@1.56.1: + mediabunny@1.56.2: dependencies: '@types/dom-mediacapture-transform': 0.1.12 '@types/dom-webcodecs': 0.1.13 @@ -8154,7 +8154,7 @@ snapshots: readdirp@5.1.1: {} - remeda@2.48.0: {} + remeda@2.50.0: {} remix-auth-oauth2@3.4.1(remix-auth@4.2.0): dependencies: From 23875c7c678aac12899ac43ee9786bb20ff53b36 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:43:48 +0300 Subject: [PATCH 48/50] Custom theme improvements (#3410) Co-authored-by: hfcRed --- app/components/Chart.tsx | 10 +- app/components/CustomThemeSelector.module.css | 2 +- app/components/CustomThemeSelector.tsx | 124 +- app/components/Divider.module.css | 2 +- app/components/DotPagination.module.css | 2 +- app/components/MapPoolSelector.module.css | 2 +- app/components/MobileNav.module.css | 8 +- app/components/NotificationDot.module.css | 4 +- app/components/SideNav.module.css | 7 +- app/components/SubNav.module.css | 2 +- app/components/elements/Button.module.css | 14 +- app/components/elements/Calendar.module.css | 6 +- app/components/elements/ChipRadio.module.css | 4 +- app/components/elements/Menu.module.css | 2 +- app/components/elements/Select.module.css | 4 +- app/components/elements/Switch.module.css | 6 +- app/components/elements/Tabs.module.css | 8 +- .../filter-bar/FilterBar.module.css | 2 +- app/components/layout/ChatSidebar.module.css | 8 +- app/components/layout/Footer.module.css | 2 +- app/components/layout/GlobalSearch.module.css | 6 +- .../layout/TopRightButtons.module.css | 4 +- app/components/layout/index.module.css | 12 +- .../MatchActionPickBanTab.module.css | 4 +- .../match-page/MatchActionTab.module.css | 8 +- .../match-page/MatchRosterTab.module.css | 4 +- .../art/components/ArtGrid.module.css | 3 +- app/features/articles/routes/a.module.css | 2 +- .../RegistrationAvailabilityPanel.module.css | 2 +- .../components/ScheduleDayCell.module.css | 2 +- .../components/ScheduleHeatmap.module.css | 4 +- .../components/ScheduleNudge.module.css | 2 +- .../components/ScheduleTracks.module.css | 2 +- .../WeekAvailabilityEditor.module.css | 2 +- .../routes/t.$customUrl.schedule.module.css | 2 +- .../badges/components/BadgeDisplay.module.css | 2 +- .../badges/routes/badges.$id.module.css | 4 +- .../components/PerInkTankGrid.module.css | 2 +- .../build-analyzer/routes/analyzer.module.css | 8 +- .../routes/builds.$slug.stats.module.css | 2 +- .../components/TournamentCard.module.css | 2 +- .../components/ChangelogGraphic.module.css | 4 +- app/features/chat/components/Chat.module.css | 14 +- .../components/DamageComboBar.module.css | 4 +- .../components/WeaponGrid.module.css | 2 +- .../routes/components.colors.module.css | 351 ++++++ .../routes/components.colors.tsx | 1052 +++++++++++++++++ .../components-showcase/routes/components.tsx | 2 + .../GlobalStatusIndicator.module.css | 4 +- .../img-export/components/Graphic.module.css | 4 +- app/features/info/routes/support.module.css | 2 +- app/features/info/routes/welcome.module.css | 2 +- .../map-planner/components/Planner.module.css | 4 +- app/features/map-planner/plans-global.css | 4 +- .../components/NotificationList.module.css | 4 +- .../object-damage-calculator.module.css | 6 +- .../ParamComparisonDialog.module.css | 4 +- .../components/WeaponParamsTable.module.css | 6 +- .../plus-voting-results.module.css | 6 +- .../components/EventsSummary.module.css | 2 +- .../scanner/components/LivePage.module.css | 2 +- .../scanner/components/MetaChips.module.css | 2 +- .../scanner/components/ScannerApp.module.css | 8 +- .../components/ScannerChrome.module.css | 6 +- .../scanner/components/VodPage.module.css | 6 +- .../scrims/components/ScrimCard.module.css | 4 +- app/features/sendouq/routes/q.info.module.css | 2 +- .../sendouq/routes/q.looking.module.css | 2 +- .../settings/actions/settings.server.ts | 4 +- app/features/settings/components/ThemeTab.tsx | 2 +- .../actions/t.$customUrl.edit.server.test.ts | 37 +- .../team/actions/t.$customUrl.edit.server.ts | 4 +- .../team/routes/t.$customUrl.edit.tsx | 2 +- .../team/routes/t.$customUrl.module.css | 6 +- app/features/theme/core/ThemePalette.test.ts | 355 ++++++ app/features/theme/core/ThemePalette.ts | 631 ++++++++++ app/features/theme/theme-constants.ts | 41 +- .../routes/to.$id.admin.seeds.module.css | 8 +- .../components/Bracket/Match.module.css | 2 +- .../BracketMapListDialog.module.css | 6 +- .../components/BracketMapListDialog.tsx | 4 +- .../components/CustomFlowBuilder.module.css | 14 +- .../routes/to.$id.divisions.module.css | 4 +- .../components/LFGGroupCard.module.css | 2 +- .../routes/to.$id.looking.module.css | 2 +- .../components/BannedPlayersList.module.css | 2 +- .../components/EventCalendar.module.css | 2 +- .../routes/org.$slug.module.css | 2 +- .../routes/org.$slug.stats.module.css | 4 +- .../components/TeamWithRoster.module.css | 4 +- .../components/TournamentNav.module.css | 4 +- .../routes/to.$id.register.module.css | 6 +- .../routes/to.$id.results.module.css | 2 +- .../routes/to.$id.teams.$tid.module.css | 2 +- .../TournamentSummaryRow.module.css | 4 +- .../trophies/routes/trophies.new.module.css | 4 +- .../components/ParticipationPill.module.css | 2 +- .../components/UserPageIconNav.module.css | 4 +- .../user-page/components/Widget.module.css | 6 +- .../custom-theme-json.server.test.ts | 5 +- .../u.$identifier.edit-widgets.module.css | 4 +- .../u.$identifier.seasons.index.module.css | 2 +- .../u.$identifier.seasons.stats.module.css | 2 +- .../components/ReportsBarChart.tsx | 2 +- .../fields/WeaponPoolFormField.module.css | 4 +- app/routes.ts | 4 + app/styles/common.css | 44 +- app/styles/front.module.css | 2 +- app/styles/utils.css | 8 +- app/styles/vars.css | 139 ++- app/utils/oklch-gamut.ts | 174 +-- app/utils/schema.ts | 23 +- changelog/2026-09-22-custom-theme-colors.md | 10 + docs/styles.md | 72 +- locales/da/common.json | 1 + locales/de/common.json | 1 + locales/en/common.json | 1 + locales/es-ES/common.json | 1 + locales/es-US/common.json | 1 + locales/fr-CA/common.json | 1 + locales/fr-EU/common.json | 1 + locales/he/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/nl/common.json | 1 + locales/pl/common.json | 1 + locales/pt-BR/common.json | 1 + locales/ru/common.json | 1 + locales/zh/common.json | 1 + ...0260922181213-custom-theme-palette-vars.ts | 61 + 131 files changed, 3028 insertions(+), 523 deletions(-) create mode 100644 app/features/components-showcase/routes/components.colors.module.css create mode 100644 app/features/components-showcase/routes/components.colors.tsx create mode 100644 app/features/theme/core/ThemePalette.test.ts create mode 100644 app/features/theme/core/ThemePalette.ts create mode 100644 changelog/2026-09-22-custom-theme-colors.md create mode 100644 migrations/20260922181213-custom-theme-palette-vars.ts diff --git a/app/components/Chart.tsx b/app/components/Chart.tsx index 2d2325447..b1499b71c 100644 --- a/app/components/Chart.tsx +++ b/app/components/Chart.tsx @@ -87,12 +87,12 @@ export function Chart({ const colors = useThemeColors({ // "high" variants for the curve lines so they stay legible on the dark chart - accentHigh: "--color-text-accent", + accentHigh: "--color-fg-accent", infoHigh: "--color-info-high", - secondHigh: "--color-second-high", - // low variants for the highlight marker fills (paired with a light border) - accentLow: "--color-accent-low", - secondLow: "--color-second-low", + secondHigh: "--color-fg-second", + // tinted surfaces for the highlight marker fills (paired with a light border) + accentLow: "--color-bg-accent", + secondLow: "--color-bg-second", border: "--color-border", borderHigh: "--color-border-high", text: "--color-text-high", diff --git a/app/components/CustomThemeSelector.module.css b/app/components/CustomThemeSelector.module.css index 786419eef..539a181b5 100644 --- a/app/components/CustomThemeSelector.module.css +++ b/app/components/CustomThemeSelector.module.css @@ -84,7 +84,7 @@ } .chatColorPreview { - color: oklch(from var(--color-text-accent) l c var(--_chat-h)); + color: oklch(from var(--color-fg-accent) l c var(--_chat-h)); } .themeShare { diff --git a/app/components/CustomThemeSelector.tsx b/app/components/CustomThemeSelector.tsx index a896e6162..ad4720db2 100644 --- a/app/components/CustomThemeSelector.tsx +++ b/app/components/CustomThemeSelector.tsx @@ -1,17 +1,11 @@ import { Check, Clipboard, PencilLine } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import * as v from "valibot"; import type { CustomTheme } from "~/db/tables-json"; +import * as ThemePalette from "~/features/theme/core/ThemePalette"; import { CUSTOM_THEME_VARS } from "~/features/theme/theme-constants"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { - ACCENT_CHROMA_MULTIPLIERS, - BASE_CHROMA_MULTIPLIERS, - clampThemeToGamut, - type ThemeInput, -} from "~/utils/oklch-gamut"; -import { THEME_INPUT_LIMITS, themeInputSchema } from "~/utils/schema"; +import { THEME_INPUT_LIMITS } from "~/utils/schema"; import styles from "./CustomThemeSelector.module.css"; import { Divider } from "./Divider"; import { LinkButton, SendouButton } from "./elements/Button"; @@ -55,6 +49,15 @@ const COLOR_SLIDERS = [ labelKey: "accentChroma", isHue: false, }, + { + id: "bg-lightness", + inputKey: "bgLightness", + min: THEME_INPUT_LIMITS.BG_LIGHTNESS_MIN, + max: THEME_INPUT_LIMITS.BG_LIGHTNESS_MAX, + step: THEME_INPUT_LIMITS.BG_LIGHTNESS_STEP, + labelKey: "bgLightness", + isHue: false, + }, ] as const; const RADIUS_SLIDERS = [ @@ -129,91 +132,10 @@ type ThemeInputKey = | (typeof SIZE_SLIDERS)[number]["inputKey"] | "chatHue"; -const THEME_STRING_KEYS: readonly ThemeInputKey[] = [ - ...COLOR_SLIDERS.map((s) => s.inputKey), - ...RADIUS_SLIDERS.map((s) => s.inputKey), - ...BORDER_SLIDERS.map((s) => s.inputKey), - ...SIZE_SLIDERS.map((s) => s.inputKey), - "chatHue", -]; +function applyThemeInput(input: ThemePalette.ThemeInput) { + const theme = ThemePalette.build(input); -function themeInputToString(input: ThemeInput): string { - return THEME_STRING_KEYS.map((key) => { - const value = input[key]; - return value === null ? "_" : String(value); - }).join(";"); -} - -function themeInputFromString(str: string): ThemeInput | null { - const parts = str.split(";"); - if (parts.length !== THEME_STRING_KEYS.length) return null; - - const raw: Record = {}; - for (let i = 0; i < THEME_STRING_KEYS.length; i++) { - const key = THEME_STRING_KEYS[i]; - const part = parts[i].trim(); - - if (key === "chatHue" && part === "_") { - raw[key] = null; - continue; - } - - const num = Number(part); - if (Number.isNaN(num)) return null; - raw[key] = num; - } - - const parsed = v.safeParse(themeInputSchema, raw); - return parsed.success ? parsed.output : null; -} - -const DEFAULT_THEME_INPUT: ThemeInput = { - baseHue: 268, - baseChroma: 0.05, - accentHue: 253, - accentChroma: 0.24, - chatHue: null, - radiusBox: 3, - radiusField: 2, - radiusSelector: 2, - borderWidth: 2, - sizeField: 1, - sizeSelector: 1, - sizeSpacing: 1, -}; - -function themeInputFromCustomTheme(customTheme: CustomTheme): ThemeInput { - return { - baseHue: customTheme["--_base-h"] ?? DEFAULT_THEME_INPUT.baseHue, - baseChroma: - typeof customTheme["--_base-c-2"] === "number" - ? customTheme["--_base-c-2"] / BASE_CHROMA_MULTIPLIERS[2] - : DEFAULT_THEME_INPUT.baseChroma, - accentHue: customTheme["--_acc-h"] ?? DEFAULT_THEME_INPUT.accentHue, - accentChroma: - typeof customTheme["--_acc-c-2"] === "number" - ? customTheme["--_acc-c-2"] / ACCENT_CHROMA_MULTIPLIERS[2] - : DEFAULT_THEME_INPUT.accentChroma, - chatHue: customTheme["--_chat-h"], - radiusBox: customTheme["--_radius-box"] ?? DEFAULT_THEME_INPUT.radiusBox, - radiusField: - customTheme["--_radius-field"] ?? DEFAULT_THEME_INPUT.radiusField, - radiusSelector: - customTheme["--_radius-selector"] ?? DEFAULT_THEME_INPUT.radiusSelector, - borderWidth: - customTheme["--_border-width"] ?? DEFAULT_THEME_INPUT.borderWidth, - sizeField: customTheme["--_size-field"] ?? DEFAULT_THEME_INPUT.sizeField, - sizeSelector: - customTheme["--_size-selector"] ?? DEFAULT_THEME_INPUT.sizeSelector, - sizeSpacing: - customTheme["--_size-spacing"] ?? DEFAULT_THEME_INPUT.sizeSpacing, - }; -} - -function applyThemeInput(input: ThemeInput) { - const clampedTheme = clampThemeToGamut(input); - - for (const [key, value] of Object.entries(clampedTheme)) { + for (const [key, value] of Object.entries(theme)) { document.documentElement.style.setProperty(key, String(value)); } } @@ -268,7 +190,7 @@ export function CustomThemeSelector({ initialTheme: CustomTheme | null | undefined; isSupporter: boolean; isPersonalTheme: boolean; - onSave: (themeInput: ThemeInput) => void; + onSave: (themeInput: ThemePalette.ThemeInput) => void; onReset: () => void; hidePatreonInfo?: boolean; fetcherState?: "idle" | "submitting" | "loading"; @@ -276,11 +198,11 @@ export function CustomThemeSelector({ const { t } = useTranslation(["common"]); const initialThemeInput = initialTheme - ? themeInputFromCustomTheme(initialTheme) - : DEFAULT_THEME_INPUT; + ? ThemePalette.toThemeInput(initialTheme) + : ThemePalette.DEFAULT_THEME_INPUT; const [themeInput, setThemeInput] = - React.useState(initialThemeInput); + React.useState(initialThemeInput); const handleSliderChange = (inputKey: ThemeInputKey, value: number) => { const updatedInput = { ...themeInput, [inputKey]: value }; @@ -304,7 +226,7 @@ export function CustomThemeSelector({ }; const handleReset = () => { - setThemeInput(DEFAULT_THEME_INPUT); + setThemeInput(ThemePalette.DEFAULT_THEME_INPUT); for (const varDef of CUSTOM_THEME_VARS) { document.documentElement.style.removeProperty(varDef); } @@ -464,17 +386,17 @@ function ThemeShareInput({ themeInput, onImport, }: { - themeInput: ThemeInput; - onImport: (input: ThemeInput) => void; + themeInput: ThemePalette.ThemeInput; + onImport: (input: ThemePalette.ThemeInput) => void; }) { const { t } = useTranslation(["common"]); const { copyToClipboard, copySuccess } = useCopyToClipboard(); - const themeString = themeInputToString(themeInput); + const themeString = ThemePalette.toShareCode(themeInput); const handlePaste = async () => { const text = await navigator.clipboard.readText(); - const parsed = themeInputFromString(text); + const parsed = ThemePalette.fromShareCode(text); if (parsed) onImport(parsed); }; diff --git a/app/components/Divider.module.css b/app/components/Divider.module.css index 8633ebfbb..2c3a9271b 100644 --- a/app/components/Divider.module.css +++ b/app/components/Divider.module.css @@ -2,7 +2,7 @@ display: flex; width: 100%; align-items: center; - color: var(--color-text-accent); + color: var(--color-fg-accent); font-size: var(--font-lg); text-align: center; diff --git a/app/components/DotPagination.module.css b/app/components/DotPagination.module.css index b07544875..b7ed1bfc6 100644 --- a/app/components/DotPagination.module.css +++ b/app/components/DotPagination.module.css @@ -21,7 +21,7 @@ } .buttonActive { - color: var(--color-text-accent); + color: var(--color-fg-accent); background-color: var(--color-bg-high); border-color: var(--color-border-high); } diff --git a/app/components/MapPoolSelector.module.css b/app/components/MapPoolSelector.module.css index 958061795..31e693e9e 100644 --- a/app/components/MapPoolSelector.module.css +++ b/app/components/MapPoolSelector.module.css @@ -49,7 +49,7 @@ border: var(--border-style); border-radius: var(--radius-full); background-color: var(--color-bg); - color: var(--color-accent); + color: var(--color-fg-accent); opacity: 1 !important; outline: initial; diff --git a/app/components/MobileNav.module.css b/app/components/MobileNav.module.css index 3f480267f..8425dcb3a 100644 --- a/app/components/MobileNav.module.css +++ b/app/components/MobileNav.module.css @@ -62,7 +62,7 @@ &:hover, &[data-active="true"] { - color: var(--color-text-accent); + color: var(--color-fg-accent); } } @@ -282,7 +282,7 @@ text-align: center; &:hover { - color: var(--color-text-accent); + color: var(--color-fg-accent); } } @@ -380,8 +380,8 @@ right: -8px; font-size: var(--font-2xs); font-weight: var(--weight-bold); - color: var(--color-text-inverse); - background-color: var(--color-text-accent); + color: var(--color-fg-on-accent); + background-color: var(--color-fill-accent); min-width: 16px; height: 16px; padding: 0 var(--s-0-5); diff --git a/app/components/NotificationDot.module.css b/app/components/NotificationDot.module.css index 72d9b3592..eb986c1ac 100644 --- a/app/components/NotificationDot.module.css +++ b/app/components/NotificationDot.module.css @@ -12,7 +12,7 @@ width: 100%; height: 100%; position: absolute; - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); border-radius: 100%; outline: 2px solid var(--color-bg); } @@ -25,7 +25,7 @@ top: -10px; left: -10px; border-radius: 100%; - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); animation: pulse 2s infinite; opacity: 0; } diff --git a/app/components/SideNav.module.css b/app/components/SideNav.module.css index cf0b2cf18..e8fc7507c 100644 --- a/app/components/SideNav.module.css +++ b/app/components/SideNav.module.css @@ -167,8 +167,8 @@ margin-left: auto; font-size: var(--font-2xs); font-weight: var(--weight-semi); - color: var(--color-text-inverse); - background-color: var(--color-text-accent); + color: var(--color-fg-on-accent); + background-color: var(--color-fill-accent); padding: 0 var(--s-1); border-radius: var(--radius-selector); height: var(--selector-size-xs); @@ -178,7 +178,8 @@ } .listLinkBadgeWarning { - background-color: var(--color-text-second); + color: var(--color-fg-on-second); + background-color: var(--color-fill-second); } .sideNavHeader { diff --git a/app/components/SubNav.module.css b/app/components/SubNav.module.css index 25182e85a..35ee4c541 100644 --- a/app/components/SubNav.module.css +++ b/app/components/SubNav.module.css @@ -21,7 +21,7 @@ gap: var(--s-1-5); &.active { - color: var(--color-text-accent); + color: var(--color-fg-accent); } } diff --git a/app/components/elements/Button.module.css b/app/components/elements/Button.module.css index 9b9196da7..a2eaa9130 100644 --- a/app/components/elements/Button.module.css +++ b/app/components/elements/Button.module.css @@ -3,17 +3,17 @@ width: auto; align-items: center; justify-content: center; - border: var(--border-style-accent); + border: var(--border-width) solid var(--color-fill-accent); border-radius: var(--radius-field); appearance: none; - background: var(--color-text-accent); - color: var(--color-text-inverse); + background: var(--color-fill-accent); + color: var(--color-fg-on-accent); cursor: pointer; font-size: var(--font-sm); font-weight: var(--weight-bold); padding: 0 var(--field-padding); user-select: none; - outline-color: var(--color-text-accent); + outline-color: var(--color-fg-accent); height: var(--field-size); white-space: nowrap; @@ -36,8 +36,9 @@ } .outlined { + border-color: var(--color-fg-accent); background-color: transparent; - color: var(--color-text-accent); + color: var(--color-fg-accent); } .outlinedSuccess { @@ -84,7 +85,7 @@ padding: 0; border: none; background-color: transparent; - color: var(--color-text-accent); + color: var(--color-fg-accent); outline: initial; &:focus-visible { @@ -103,6 +104,7 @@ .success { border-color: var(--color-success); background-color: var(--color-success); + color: var(--color-text-inverse); outline-color: var(--color-success); } diff --git a/app/components/elements/Calendar.module.css b/app/components/elements/Calendar.module.css index 586112629..3ea5805f1 100644 --- a/app/components/elements/Calendar.module.css +++ b/app/components/elements/Calendar.module.css @@ -22,7 +22,7 @@ .navButton { background-color: transparent; - color: var(--color-text-accent); + color: var(--color-fg-accent); border: none; padding: 0; border-radius: 100%; @@ -54,7 +54,7 @@ width: 35px; height: 35px; border-radius: var(--radius-field); - outline-color: var(--color-accent); + outline-color: var(--color-fg-accent); border: none; background: transparent; color: var(--color-text); @@ -68,7 +68,7 @@ &[data-selected] { background-color: var(--color-bg-high); - color: var(--color-text-accent); + color: var(--color-fg-accent); } &:hover { diff --git a/app/components/elements/ChipRadio.module.css b/app/components/elements/ChipRadio.module.css index 069b3aa13..b56637834 100644 --- a/app/components/elements/ChipRadio.module.css +++ b/app/components/elements/ChipRadio.module.css @@ -46,7 +46,7 @@ } &:checked + .label { - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); } } diff --git a/app/components/elements/Menu.module.css b/app/components/elements/Menu.module.css index d383e1897..878749b13 100644 --- a/app/components/elements/Menu.module.css +++ b/app/components/elements/Menu.module.css @@ -62,7 +62,7 @@ } .itemActive { - color: var(--color-text-accent); + color: var(--color-fg-accent); } .itemDestructive { diff --git a/app/components/elements/Select.module.css b/app/components/elements/Select.module.css index bce5375a5..9efa381e9 100644 --- a/app/components/elements/Select.module.css +++ b/app/components/elements/Select.module.css @@ -129,7 +129,7 @@ } .itemSelected { - color: var(--color-text-accent); + color: var(--color-fg-accent); font-weight: var(--weight-bold); } @@ -140,7 +140,7 @@ border-bottom: 1px solid var(--color-border); border-radius: 0; - accent-color: var(--color-accent); + accent-color: var(--color-fg-accent); color: var(--color-text); outline: none; padding: var(--s-1-5) var(--s-1-5) calc(var(--s-0-5) + var(--s-2)) diff --git a/app/components/elements/Switch.module.css b/app/components/elements/Switch.module.css index abd257faa..0bb5c982b 100644 --- a/app/components/elements/Switch.module.css +++ b/app/components/elements/Switch.module.css @@ -36,12 +36,12 @@ margin-block-end: 0; &:has(> .input:checked) .indicator { - background: var(--color-text-accent); - border-color: var(--color-text-accent); + background: var(--color-fill-accent); + border-color: var(--color-fill-accent); grid-template-columns: 1fr 1fr 0fr; &:before { - background: var(--color-text-inverse); + background: var(--color-fg-on-accent); } } diff --git a/app/components/elements/Tabs.module.css b/app/components/elements/Tabs.module.css index 69b04c441..86afd6913 100644 --- a/app/components/elements/Tabs.module.css +++ b/app/components/elements/Tabs.module.css @@ -88,12 +88,12 @@ } &[data-selected] .tabButton { - border-color: var(--color-text-accent); + border-color: var(--color-fg-accent); color: var(--color-text); } &:focus-visible .tabButton { - color: var(--color-text-accent) !important; + color: var(--color-fg-accent) !important; outline: none; } } @@ -141,7 +141,7 @@ } .tabNumber { - color: var(--color-text-accent); + color: var(--color-fg-accent); margin-inline-start: var(--s-2); } @@ -186,7 +186,7 @@ &[data-selected] .tabButton { border-bottom-color: transparent; - border-inline-end-color: var(--color-text-accent); + border-inline-end-color: var(--color-fg-accent); } } diff --git a/app/components/filter-bar/FilterBar.module.css b/app/components/filter-bar/FilterBar.module.css index 66f042b2f..02e987e8c 100644 --- a/app/components/filter-bar/FilterBar.module.css +++ b/app/components/filter-bar/FilterBar.module.css @@ -128,7 +128,7 @@ } .value { - color: var(--color-text-accent); + color: var(--color-fg-accent); } .chevron, diff --git a/app/components/layout/ChatSidebar.module.css b/app/components/layout/ChatSidebar.module.css index 83b0e854e..77850aa11 100644 --- a/app/components/layout/ChatSidebar.module.css +++ b/app/components/layout/ChatSidebar.module.css @@ -101,8 +101,8 @@ .unreadBadge { font-size: var(--font-2xs); font-weight: var(--weight-bold); - color: var(--color-text-inverse); - background-color: var(--color-text-accent); + color: var(--color-fg-on-accent); + background-color: var(--color-fill-accent); min-width: 18px; height: 18px; padding: 0 var(--s-1); @@ -146,8 +146,8 @@ right: -2px; font-size: var(--font-3xs); font-weight: var(--weight-bold); - color: var(--color-text-inverse); - background-color: var(--color-text-accent); + color: var(--color-fg-on-accent); + background-color: var(--color-fill-accent); min-width: 14px; height: 14px; padding: 0 3px; diff --git a/app/components/layout/Footer.module.css b/app/components/layout/Footer.module.css index b0e7cb34d..e941db273 100644 --- a/app/components/layout/Footer.module.css +++ b/app/components/layout/Footer.module.css @@ -122,7 +122,7 @@ padding: var(--s-1) var(--s-2-5); border-radius: var(--radius-full); background-color: var(--color-bg-higher); - color: var(--color-text-accent); + color: var(--color-fg-accent); } .cta { diff --git a/app/components/layout/GlobalSearch.module.css b/app/components/layout/GlobalSearch.module.css index 484379722..9e3f214be 100644 --- a/app/components/layout/GlobalSearch.module.css +++ b/app/components/layout/GlobalSearch.module.css @@ -123,7 +123,7 @@ border-bottom: 1px solid var(--color-border); &:focus-within { - border-color: var(--color-text-accent); + border-color: var(--color-fg-accent); } } @@ -201,8 +201,8 @@ } .searchTypeRadioSelected { - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); border-color: transparent; } diff --git a/app/components/layout/TopRightButtons.module.css b/app/components/layout/TopRightButtons.module.css index 44e075861..6e3a8274a 100644 --- a/app/components/layout/TopRightButtons.module.css +++ b/app/components/layout/TopRightButtons.module.css @@ -93,8 +93,8 @@ right: -6px; font-size: var(--font-2xs); font-weight: var(--weight-bold); - color: var(--color-text-inverse); - background-color: var(--color-text-accent); + color: var(--color-fg-on-accent); + background-color: var(--color-fill-accent); min-width: 18px; height: 18px; padding: 0 var(--s-1); diff --git a/app/components/layout/index.module.css b/app/components/layout/index.module.css index a8c756bd0..425f1bf2b 100644 --- a/app/components/layout/index.module.css +++ b/app/components/layout/index.module.css @@ -48,10 +48,10 @@ justify-content: center; width: 36px; height: 36px; - background-color: var(--color-text-accent); + background-color: var(--color-fill-accent); border-radius: var(--radius-field); font-weight: var(--weight-bold); - color: var(--color-text-inverse); + color: var(--color-fg-on-accent); text-decoration: none; flex-shrink: 0; transition: background-color 0.2s; @@ -178,8 +178,8 @@ .friendRequestsBadge { font-size: var(--font-2xs); font-weight: var(--weight-bold); - color: var(--color-text-inverse); - background-color: var(--color-text-accent); + color: var(--color-fg-on-accent); + background-color: var(--color-fill-accent); min-width: 18px; height: 18px; padding: 0 var(--s-1); @@ -196,8 +196,8 @@ inset-inline-end: -5px; font-size: var(--font-2xs); font-weight: var(--weight-bold); - color: var(--color-text-inverse); - background-color: var(--color-text-accent); + color: var(--color-fg-on-accent); + background-color: var(--color-fill-accent); min-width: 16px; height: 16px; padding: 0 var(--s-0-5); diff --git a/app/components/match-page/MatchActionPickBanTab.module.css b/app/components/match-page/MatchActionPickBanTab.module.css index bcf773d08..799ed7f2b 100644 --- a/app/components/match-page/MatchActionPickBanTab.module.css +++ b/app/components/match-page/MatchActionPickBanTab.module.css @@ -175,13 +175,13 @@ .tileNumber { position: absolute; - background-color: var(--color-text-accent); + background-color: var(--color-fill-accent); border-radius: 100%; width: 18px; height: 18px; display: grid; place-items: center; - color: var(--color-text-inverse); + color: var(--color-fg-on-accent); font-size: var(--font-2xs); font-weight: var(--weight-semi); top: -5px; diff --git a/app/components/match-page/MatchActionTab.module.css b/app/components/match-page/MatchActionTab.module.css index 870054a45..d14ace46c 100644 --- a/app/components/match-page/MatchActionTab.module.css +++ b/app/components/match-page/MatchActionTab.module.css @@ -175,7 +175,7 @@ height: 100%; &:hover .checkCircle { - border-color: var(--color-accent-high); + border-color: var(--color-fill-accent); } @container (max-width: 599px) { @@ -199,9 +199,9 @@ } .checkCircleSelected { - background-color: var(--color-accent-high); - border-color: var(--color-accent-high); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + border-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); & svg { stroke-width: 3px; diff --git a/app/components/match-page/MatchRosterTab.module.css b/app/components/match-page/MatchRosterTab.module.css index dbc9e7f92..a5f5c99d3 100644 --- a/app/components/match-page/MatchRosterTab.module.css +++ b/app/components/match-page/MatchRosterTab.module.css @@ -216,10 +216,10 @@ flex-shrink: 0; &[data-side="alpha"] { - background-color: var(--color-accent); + background-color: var(--color-fg-accent); } &[data-side="bravo"] { - background-color: var(--color-second); + background-color: var(--color-fg-second); } } diff --git a/app/features/art/components/ArtGrid.module.css b/app/features/art/components/ArtGrid.module.css index 0393bf380..9b4924431 100644 --- a/app/features/art/components/ArtGrid.module.css +++ b/app/features/art/components/ArtGrid.module.css @@ -172,7 +172,8 @@ } .dialogTagUser { - background-color: var(--color-accent); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); } @keyframes lightbox-zoom-in { diff --git a/app/features/articles/routes/a.module.css b/app/features/articles/routes/a.module.css index 899727371..0f479fd24 100644 --- a/app/features/articles/routes/a.module.css +++ b/app/features/articles/routes/a.module.css @@ -7,6 +7,6 @@ } .title { - color: var(--color-text-accent); + color: var(--color-fg-accent); font-size: var(--font-md); } diff --git a/app/features/availability/components/RegistrationAvailabilityPanel.module.css b/app/features/availability/components/RegistrationAvailabilityPanel.module.css index 9df9945fe..c3231a116 100644 --- a/app/features/availability/components/RegistrationAvailabilityPanel.module.css +++ b/app/features/availability/components/RegistrationAvailabilityPanel.module.css @@ -122,7 +122,7 @@ } .noteFlag { - color: var(--color-text-accent); + color: var(--color-fg-accent); flex-shrink: 0; } diff --git a/app/features/availability/components/ScheduleDayCell.module.css b/app/features/availability/components/ScheduleDayCell.module.css index a6aa02830..b79992c1d 100644 --- a/app/features/availability/components/ScheduleDayCell.module.css +++ b/app/features/availability/components/ScheduleDayCell.module.css @@ -50,5 +50,5 @@ } .noteFlag { - color: var(--color-text-accent); + color: var(--color-fg-accent); } diff --git a/app/features/availability/components/ScheduleHeatmap.module.css b/app/features/availability/components/ScheduleHeatmap.module.css index cffe88bb5..2d29edabd 100644 --- a/app/features/availability/components/ScheduleHeatmap.module.css +++ b/app/features/availability/components/ScheduleHeatmap.module.css @@ -46,8 +46,8 @@ transition: background-color 0.15s; &[aria-pressed="true"] { - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); } &:focus-visible { diff --git a/app/features/availability/components/ScheduleNudge.module.css b/app/features/availability/components/ScheduleNudge.module.css index 7ed4c490d..13397cd91 100644 --- a/app/features/availability/components/ScheduleNudge.module.css +++ b/app/features/availability/components/ScheduleNudge.module.css @@ -24,7 +24,7 @@ gap: var(--s-2); font-size: var(--font-2xs); font-weight: var(--weight-bold); - color: var(--color-text-accent); + color: var(--color-fg-accent); &:hover { text-decoration: underline; diff --git a/app/features/availability/components/ScheduleTracks.module.css b/app/features/availability/components/ScheduleTracks.module.css index 64f74a5c7..6fb9036e5 100644 --- a/app/features/availability/components/ScheduleTracks.module.css +++ b/app/features/availability/components/ScheduleTracks.module.css @@ -90,7 +90,7 @@ } .noteFlag { - color: var(--color-text-accent); + color: var(--color-fg-accent); flex-shrink: 0; } diff --git a/app/features/availability/components/WeekAvailabilityEditor.module.css b/app/features/availability/components/WeekAvailabilityEditor.module.css index 923b5afd3..67040c080 100644 --- a/app/features/availability/components/WeekAvailabilityEditor.module.css +++ b/app/features/availability/components/WeekAvailabilityEditor.module.css @@ -147,7 +147,7 @@ border: none; border-radius: var(--radius-full); font-size: var(--font-xs); - color: var(--color-text-accent); + color: var(--color-fg-accent); cursor: pointer; &:focus-visible { diff --git a/app/features/availability/routes/t.$customUrl.schedule.module.css b/app/features/availability/routes/t.$customUrl.schedule.module.css index c4809e01e..d3427f30b 100644 --- a/app/features/availability/routes/t.$customUrl.schedule.module.css +++ b/app/features/availability/routes/t.$customUrl.schedule.module.css @@ -81,7 +81,7 @@ } .noteFlag { - color: var(--color-text-accent); + color: var(--color-fg-accent); } .dayDot { diff --git a/app/features/badges/components/BadgeDisplay.module.css b/app/features/badges/components/BadgeDisplay.module.css index dd4b95e82..cdf758876 100644 --- a/app/features/badges/components/BadgeDisplay.module.css +++ b/app/features/badges/components/BadgeDisplay.module.css @@ -48,7 +48,7 @@ margin-top: -8px; margin-right: auto; margin-left: auto; - color: var(--color-accent-high); + color: var(--color-fg-accent); font-size: var(--font-2xs); font-weight: var(--weight-bold); } diff --git a/app/features/badges/routes/badges.$id.module.css b/app/features/badges/routes/badges.$id.module.css index 731ed3d7e..024a61b15 100644 --- a/app/features/badges/routes/badges.$id.module.css +++ b/app/features/badges/routes/badges.$id.module.css @@ -1,5 +1,5 @@ .explanation { - color: var(--color-text-accent); + color: var(--color-fg-accent); font-weight: var(--weight-semi); text-align: center; } @@ -33,6 +33,6 @@ } .count { - color: var(--color-accent-high); + color: var(--color-fg-accent); font-size: var(--font-xs); } diff --git a/app/features/build-analyzer/components/PerInkTankGrid.module.css b/app/features/build-analyzer/components/PerInkTankGrid.module.css index 78952f850..2a00a7595 100644 --- a/app/features/build-analyzer/components/PerInkTankGrid.module.css +++ b/app/features/build-analyzer/components/PerInkTankGrid.module.css @@ -23,7 +23,7 @@ } .inkGridApFocused { - color: var(--color-accent); + color: var(--color-fg-accent); font-weight: var(--weight-bold); text-decoration: underline; } diff --git a/app/features/build-analyzer/routes/analyzer.module.css b/app/features/build-analyzer/routes/analyzer.module.css index 9151f9998..521106a6c 100644 --- a/app/features/build-analyzer/routes/analyzer.module.css +++ b/app/features/build-analyzer/routes/analyzer.module.css @@ -75,7 +75,7 @@ width: 100%; align-items: center; border-radius: var(--radius-box); - background-color: var(--color-accent-low); + background-color: var(--color-bg-accent); font-size: var(--font-2xs); font-weight: var(--weight-semi); gap: var(--s-2); @@ -179,7 +179,7 @@ padding: 0; border: none; background-color: transparent; - color: var(--color-accent); + color: var(--color-fg-accent); font-size: var(--font-md); font-weight: var(--weight-bold); outline: initial; @@ -245,8 +245,8 @@ .patch { border-radius: var(--radius-selector); - background-color: var(--color-accent-low); - color: var(--color-accent-high); + background-color: var(--color-bg-accent); + color: var(--color-fg-accent); font-size: var(--font-2xs); font-weight: var(--weight-bold); padding-inline: var(--s-2); diff --git a/app/features/build-stats/routes/builds.$slug.stats.module.css b/app/features/build-stats/routes/builds.$slug.stats.module.css index 03f0e1679..536f8f196 100644 --- a/app/features/build-stats/routes/builds.$slug.stats.module.css +++ b/app/features/build-stats/routes/builds.$slug.stats.module.css @@ -15,6 +15,6 @@ } .bar { - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); height: 100%; } diff --git a/app/features/calendar/components/TournamentCard.module.css b/app/features/calendar/components/TournamentCard.module.css index e365a83c8..e9894bc54 100644 --- a/app/features/calendar/components/TournamentCard.module.css +++ b/app/features/calendar/components/TournamentCard.module.css @@ -31,7 +31,7 @@ } .cardRanked { - box-shadow: inset 0 -3px 0 var(--color-accent); + box-shadow: inset 0 -3px 0 var(--color-fg-accent); } .imgContainer { diff --git a/app/features/changelog/components/ChangelogGraphic.module.css b/app/features/changelog/components/ChangelogGraphic.module.css index 82002562c..5507f2531 100644 --- a/app/features/changelog/components/ChangelogGraphic.module.css +++ b/app/features/changelog/components/ChangelogGraphic.module.css @@ -16,10 +16,10 @@ avatar slot down to the entry icons */ justify-content: center; width: 2.5em; height: 2.25em; - background-color: var(--color-text-accent); + background-color: var(--color-fill-accent); border-radius: var(--radius-field); font-weight: var(--weight-bold); - color: var(--color-text-inverse); + color: var(--color-fg-on-accent); line-height: 1; } diff --git a/app/features/chat/components/Chat.module.css b/app/features/chat/components/Chat.module.css index 5e97da44d..093d6996d 100644 --- a/app/features/chat/components/Chat.module.css +++ b/app/features/chat/components/Chat.module.css @@ -47,8 +47,8 @@ top: 28px; left: 50%; transform: translateX(-50%); - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); font-size: var(--font-2xs); font-weight: var(--weight-bold); padding: 1px 4px; @@ -58,7 +58,7 @@ .pronounsTag { background-color: var(--color-bg-higher); - color: var(--color-text-accent); + color: var(--color-fg-accent); font-size: var(--font-2xs); font-weight: var(--weight-semi); padding: 1px 5px; @@ -69,7 +69,7 @@ .messageUser { font-weight: var(--weight-semi); font-size: var(--font-sm); - color: oklch(from var(--color-text-accent) l c var(--chat-hue)); + color: oklch(from var(--color-fg-accent) l c var(--chat-hue)); max-width: 110px; overflow: hidden; text-overflow: ellipsis; @@ -88,8 +88,8 @@ .sendButton.sendButton { border-radius: var(--radius-full); - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); flex-shrink: 0; &:disabled { @@ -121,7 +121,7 @@ } .roomLink { - color: var(--color-text-accent); + color: var(--color-fg-accent); text-decoration: underline; word-break: break-all; } diff --git a/app/features/comp-analyzer/components/DamageComboBar.module.css b/app/features/comp-analyzer/components/DamageComboBar.module.css index 279ce9a69..23faab933 100644 --- a/app/features/comp-analyzer/components/DamageComboBar.module.css +++ b/app/features/comp-analyzer/components/DamageComboBar.module.css @@ -73,7 +73,7 @@ gap: var(--s-1); height: 36px; padding: 0 var(--s-2); - color: var(--color-text-inverse); + color: var(--color-text-on-light); font-weight: var(--weight-semi); &[data-slot-color="yellow"] { @@ -331,7 +331,7 @@ .inkTimeLabel { font-size: var(--font-2xs); - color: var(--color-text-second); + color: var(--color-fg-second); text-align: center; white-space: nowrap; padding: var(--s-0-5) var(--s-0-5); diff --git a/app/features/comp-analyzer/components/WeaponGrid.module.css b/app/features/comp-analyzer/components/WeaponGrid.module.css index 95a9f594b..2f16b30b8 100644 --- a/app/features/comp-analyzer/components/WeaponGrid.module.css +++ b/app/features/comp-analyzer/components/WeaponGrid.module.css @@ -85,7 +85,7 @@ transition: border-color 0.1s; &:hover { - border-color: var(--color-text-accent); + border-color: var(--color-fg-accent); } &:disabled { diff --git a/app/features/components-showcase/routes/components.colors.module.css b/app/features/components-showcase/routes/components.colors.module.css new file mode 100644 index 000000000..12be72808 --- /dev/null +++ b/app/features/components-showcase/routes/components.colors.module.css @@ -0,0 +1,351 @@ +.preview { + padding: var(--s-4); + border-radius: var(--radius-box); + background-color: var(--color-bg); + color: var(--color-text); +} + +.sectionTitle { + font-size: var(--font-xl); + border-bottom: 2px solid var(--color-border); +} + +.tokenName { + display: inline-flex; + align-items: center; + gap: var(--s-1-5); + font-size: var(--font-xs); + word-break: break-all; +} + +.swatch { + flex-shrink: 0; + width: 1rem; + height: 1rem; + border: 1px solid var(--color-border-high); + border-radius: var(--radius-selector); +} + +.strikethrough { + text-decoration: line-through; +} + +.annotated { + container-type: inline-size; + position: relative; +} + +.annotatedGrid { + display: grid; + gap: var(--s-6); + + @container (width > 44rem) { + grid-template-columns: minmax(0, 30rem) 18rem; + justify-content: space-between; + gap: var(--s-24); + align-items: center; + } +} + +.stage { + padding: var(--s-6); + border: var(--border-style); + border-radius: var(--radius-box); + background-color: var(--color-bg); +} + +.notes { + display: flex; + flex-direction: column; + gap: var(--s-3); + padding: 0; + margin: 0; + list-style: none; +} + +.note { + display: flex; + gap: var(--s-2); + padding: var(--s-1-5); + border-radius: var(--radius-field); + cursor: default; + + &[data-active="true"] { + background-color: var(--color-bg-high); + } +} + +.marker, +.pin { + display: grid; + flex-shrink: 0; + place-items: center; + width: 1.25rem; + height: 1.25rem; + border-radius: var(--radius-full); + background-color: var(--color-text); + color: var(--color-text-inverse); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); +} + +.pin { + position: absolute; + translate: -50% -50%; + pointer-events: none; +} + +.arrows { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} + +.arrow { + fill: none; + stroke: var(--color-text-high); + stroke-width: 1.5; + opacity: 0.6; + + &[data-active="true"] { + stroke: var(--color-text); + stroke-width: 2; + opacity: 1; + } +} + +.arrowHead { + fill: var(--color-text-high); + + &[data-active="true"] { + fill: var(--color-text); + } +} + +.targetHighlight { + position: absolute; + outline: 2px dashed var(--color-text); + outline-offset: 3px; + border-radius: var(--radius-field); + pointer-events: none; +} + +.noteTarget { + display: inline-block; + width: fit-content; +} + +.mockCard { + padding: var(--s-4); + border: var(--border-style); + border-radius: var(--radius-box); + background-color: var(--color-bg-high); +} + +.mockTitle { + width: fit-content; + color: var(--color-text); + font-size: var(--font-md); + font-weight: var(--weight-bold); +} + +.mockMeta { + width: fit-content; + color: var(--color-text-high); + font-size: var(--font-sm); +} + +.mockDivider { + width: 100%; + margin: 0; + border: none; + border-top: var(--border-style); +} + +.mockNeutralChip { + padding: var(--s-1) var(--s-2-5); + border-radius: var(--radius-selector); + background-color: var(--color-bg-higher); + color: var(--color-text); + font-size: var(--font-xs); + font-weight: var(--weight-semi); +} + +.mockAccentChip { + display: inline-flex; + align-items: center; + gap: var(--s-1); + padding: var(--s-1) var(--s-2-5); + border-radius: var(--radius-selector); + background-color: var(--color-bg-accent); + color: var(--color-fg-accent); + font-size: var(--font-xs); + font-weight: var(--weight-semi); +} + +.mockTabs { + display: flex; + gap: var(--s-4); + border-bottom: var(--border-style); +} + +.mockTab { + padding-block: var(--s-1-5); + margin-bottom: calc(var(--border-width) * -1); + border-bottom: var(--border-width) solid transparent; + color: var(--color-text-high); + font-size: var(--font-sm); + font-weight: var(--weight-semi); + + &[data-active] { + border-color: var(--color-fg-accent); + color: var(--color-fg-accent); + } +} + +.mockRow, +.mockRowHighlighted { + display: flex; + justify-content: space-between; + padding: var(--s-1-5) var(--s-2); + border-radius: var(--radius-field); + font-size: var(--font-sm); +} + +.mockRowHighlighted { + background-color: var(--color-bg-accent); + color: var(--color-text); + font-weight: var(--weight-semi); +} + +.mockLink { + width: fit-content; + color: var(--color-fg-accent); + font-size: var(--font-sm); + font-weight: var(--weight-semi); + text-decoration: underline; +} + +.mockFocused { + width: fit-content; + padding: var(--s-1) var(--s-2); + border-radius: var(--radius-field); + outline: var(--focus-ring); + outline-offset: 2px; + font-size: var(--font-sm); +} + +.mockSecondBadge { + padding: 0 var(--s-1-5); + border-radius: var(--radius-selector); + background-color: var(--color-fill-second); + color: var(--color-fg-on-second); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); +} + +.mockStat { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: var(--s-2); + font-size: var(--font-sm); +} + +.mockTrack { + height: 0.5rem; + border-radius: var(--radius-full); + background-color: var(--color-bg-higher); +} + +.mockBar { + height: 100%; + border-radius: var(--radius-full); + background-color: var(--color-fg-second); +} + +.mockIconText { + display: inline-flex; + align-items: center; + gap: var(--s-1); + width: fit-content; + font-size: var(--font-sm); + font-weight: var(--weight-semi); +} + +.mockSecondTint { + padding: var(--s-2) var(--s-3); + border-radius: var(--radius-field); + background-color: var(--color-bg-second); + color: var(--color-text); + font-size: var(--font-sm); +} + +.mockErrorInput { + display: flex; + align-items: center; + justify-content: space-between; + max-width: 16rem; + height: var(--field-size); + padding-inline: var(--field-padding); + border: var(--border-width) solid var(--color-error); + border-radius: var(--radius-field); + background-color: var(--color-bg); + font-size: var(--font-sm); + + & svg { + color: var(--color-error); + } +} + +.pairings { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: var(--s-3); +} + +.pairing { + display: flex; + flex-direction: column; + gap: var(--s-2); + padding: var(--s-2); + border: var(--border-style); + border-radius: var(--radius-box); + background-color: var(--color-bg-high); +} + +.pairingSample { + padding: var(--s-4) var(--s-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-field); + font-size: var(--font-lg); + font-weight: var(--weight-bold); +} + +.verdict, +.contrastBadge { + padding: 0 var(--s-1-5); + border-radius: var(--radius-selector); + font-size: var(--font-xs); + font-weight: var(--weight-bold); +} + +.verdict[data-verdict="do"], +.contrastBadge[data-level="pass"] { + background-color: var(--color-success-low); + color: var(--color-success-high); +} + +.verdict[data-verdict="dont"], +.contrastBadge[data-level="fail"] { + background-color: var(--color-error-low); + color: var(--color-error-high); +} + +.contrastBadge[data-level="large"] { + background-color: var(--color-warning-low); + color: var(--color-warning-high); +} diff --git a/app/features/components-showcase/routes/components.colors.tsx b/app/features/components-showcase/routes/components.colors.tsx new file mode 100644 index 000000000..282efca09 --- /dev/null +++ b/app/features/components-showcase/routes/components.colors.tsx @@ -0,0 +1,1052 @@ +import clsx from "clsx"; +import { Check, CircleAlert, LogOut, Star } from "lucide-react"; +import * as React from "react"; +import { Link } from "react-router"; +import { isDeepEqual } from "remeda"; +import { Alert } from "~/components/Alert"; +import { SendouButton } from "~/components/elements/Button"; +import { + SendouChipRadio, + SendouChipRadioGroup, +} from "~/components/elements/ChipRadio"; +import { SendouSwitch } from "~/components/elements/Switch"; +import { Main } from "~/components/Main"; +import { Table } from "~/components/Table"; +import styles from "./components.colors.module.css"; + +type Scheme = "current" | "light" | "dark"; + +type AnchorSide = "right" | "top" | "bottom"; + +interface Note { + id: string; + tokens: string[]; + text: React.ReactNode; + anchor?: AnchorSide; +} + +const MIN_AA_CONTRAST = 4.5; +const MIN_LARGE_TEXT_CONTRAST = 3; + +const FAMILIES = [ + { + pattern: "--color-bg-x", + role: "Surfaces", + use: "Page, cards, tinted areas like a highlighted row or a status strip", + }, + { + pattern: "--color-fg-x", + role: "Foreground on surfaces", + use: "Text, icons, borders, outlines and indicators (dots, bars, checkmarks)", + }, + { + pattern: "--color-fill-x", + role: "Fills that carry content", + use: "Button, badge and pill backgrounds", + }, + { + pattern: "--color-fg-on-x", + role: "Foreground on a fill", + use: "The label or icon inside a --color-fill-x. Never anywhere else", + }, +] as const; + +const OVERVIEW_NOTES: Note[] = [ + { + id: "bg", + tokens: ["--color-bg-accent"], + text: "bg: a tinted surface. Any foreground token (here --color-fg-accent) sits on it.", + anchor: "top", + }, + { + id: "fg", + tokens: ["--color-fg-accent"], + text: "fg: the text and border of an outlined button, readable on every surface.", + anchor: "bottom", + }, + { + id: "fill", + tokens: ["--color-fill-accent"], + text: "fill: a solid background that holds content.", + anchor: "top", + }, + { + id: "fg-on", + tokens: ["--color-fg-on-accent"], + text: "fg-on: the label on that fill. Dark on bright custom accents, light otherwise, so never hardcode white.", + anchor: "bottom", + }, +]; + +const NEUTRAL_NOTES: Note[] = [ + { + id: "page", + tokens: ["--color-bg"], + text: "The page. Default surface everything else sits on.", + }, + { + id: "card", + tokens: ["--color-bg-high"], + text: "One step up: cards, sections, dialogs, popovers.", + anchor: "top", + }, + { + id: "chip", + tokens: ["--color-bg-higher"], + text: "Raised inside a card: chips, inputs, hovered rows, progress tracks.", + }, + { + id: "title", + tokens: ["--color-text"], + text: "Primary text. Readable on every neutral surface and on --color-bg-accent / --color-bg-second.", + }, + { + id: "meta", + tokens: ["--color-text-high"], + text: "Secondary text: metadata, hints, labels. 'high' is the scale step, not more emphasis.", + }, + { + id: "divider", + tokens: ["--color-border", "--color-border-high"], + text: "Dividers and card outlines. The -high variant for borders that need to stand out, like inputs.", + }, +]; + +const ACCENT_NOTES: Note[] = [ + { + id: "tab", + tokens: ["--color-fg-accent"], + text: "Active tab text and its underline: foreground on a surface.", + anchor: "top", + }, + { + id: "highlight", + tokens: ["--color-bg-accent"], + text: "Highlights 'you' or 'selected'. Text on it stays --color-text, accents on it --color-fg-accent.", + }, + { + id: "link", + tokens: ["--color-fg-accent"], + text: "Links and accent icons.", + }, + { + id: "button", + tokens: ["--color-fill-accent", "--color-fg-on-accent"], + text: "Primary button: always pair the fill with its fg-on.", + anchor: "bottom", + }, + { + id: "switch", + tokens: ["--color-fill-accent", "--color-fg-on-accent"], + text: "Selected switches and chips use the same fill pair: fill for the track, fg-on for the thumb.", + }, + { + id: "focus", + tokens: ["--color-fg-accent"], + text: "Focus rings (--focus-ring) and accent borders (--border-style-accent) are foreground too.", + }, +]; + +const SECOND_NOTES: Note[] = [ + { + id: "badge", + tokens: ["--color-fill-second", "--color-fg-on-second"], + text: "Badges that call attention: fill with its fg-on.", + }, + { + id: "bar", + tokens: ["--color-fg-second"], + text: "Indicators (bars, dots, checkmarks) are foreground, not fill: they need contrast against the surface, not a label on top.", + }, + { + id: "icon", + tokens: ["--color-fg-second"], + text: "Secondary text and icons, also available as the .text-theme-secondary utility.", + }, + { + id: "tint", + tokens: ["--color-bg-second"], + text: "Tinted secondary surface, with --color-text or --color-fg-second on it.", + }, +]; + +const STATUS_NOTES: Note[] = [ + { + id: "alert", + tokens: ["--color-info-low", "--color-info-high"], + text: "Status surface: -low background with -high text. Same for success, warning and error.", + }, + { + id: "success", + tokens: ["--color-success", "--color-text-inverse"], + text: "Solid status fill with inverse text.", + anchor: "bottom", + }, + { + id: "error-border", + tokens: ["--color-error"], + text: "The plain status color is for borders and icons.", + }, +]; + +const PAIRINGS = [ + { + fg: "--color-text", + bg: "--color-bg", + verdict: "do", + why: "Body text", + }, + { + fg: "--color-text-high", + bg: "--color-bg-high", + verdict: "do", + why: "Secondary text in a card", + }, + { + fg: "--color-fg-accent", + bg: "--color-bg-high", + verdict: "do", + why: "Accent link or icon in a card", + }, + { + fg: "--color-fg-accent", + bg: "--color-bg-accent", + verdict: "do", + why: "Accent text on a tinted surface", + }, + { + fg: "--color-text", + bg: "--color-bg-accent", + verdict: "do", + why: "Normal text on a tinted surface", + }, + { + fg: "--color-fg-on-accent", + bg: "--color-fill-accent", + verdict: "do", + why: "Button label", + }, + { + fg: "--color-fg-second", + bg: "--color-bg-second", + verdict: "do", + why: "Secondary text on its tint", + }, + { + fg: "--color-fg-on-second", + bg: "--color-fill-second", + verdict: "do", + why: "Badge label", + }, + { + fg: "--color-fg-on-accent", + bg: "--color-bg", + verdict: "dont", + why: "fg-on only belongs on its fill. In dark mode it is the page background color itself.", + }, + { + fg: "--color-fg-accent", + bg: "--color-fill-accent", + verdict: "dont", + why: "fg and fill are the same color in dark mode, use --color-fg-on-accent.", + }, + { + fg: "--color-text-inverse", + bg: "--color-fill-accent", + verdict: "dont", + why: "Works with the default theme but a bright custom accent (yellow, cyan...) gets a light fill that needs dark text. Use --color-fg-on-accent.", + }, + { + fg: "--color-fill-accent", + bg: "--color-bg", + verdict: "dont", + why: "Fills are not text colors, a bright light mode fill can vanish on the page. Use --color-fg-accent.", + }, +] as const; + +const REPLACEMENTS = [ + { legacy: "--color-accent-low", use: "--color-bg-accent" }, + { + legacy: "--color-accent", + use: "--color-fill-accent (fills) or --color-fg-accent (text, borders)", + }, + { legacy: "--color-accent-high", use: "--color-fg-accent" }, + { legacy: "--color-text-accent", use: "--color-fg-accent (renamed)" }, + { + legacy: "--color-second-low / --color-second / --color-second-high", + use: "the matching --color-*-second token", + }, + { + legacy: "--color-base-0 ... --color-base-7", + use: "--color-bg-*, --color-text-* or --color-border-*", + }, + { legacy: "--_*", use: "nothing, these are custom theme inputs" }, +] as const; + +const REFERENCE = [ + { tokens: ["--color-bg"], use: "Page background" }, + { tokens: ["--color-bg-high"], use: "Cards, sections, dialogs" }, + { tokens: ["--color-bg-higher"], use: "Raised elements inside a card" }, + { tokens: ["--color-bg-nav"], use: "Top nav, side nav and mobile nav" }, + { + tokens: ["--color-bg-accent", "--color-bg-second"], + use: "Tinted surfaces", + }, + { + tokens: ["--color-fg-accent", "--color-fg-second"], + use: "Text, icons, borders, outlines and indicators on any surface", + }, + { + tokens: ["--color-fill-accent", "--color-fill-second"], + use: "Button, badge and pill backgrounds", + }, + { + tokens: ["--color-fg-on-accent", "--color-fg-on-second"], + use: "Content on the matching fill, nowhere else", + }, + { tokens: ["--color-text"], use: "Primary text" }, + { tokens: ["--color-text-high"], use: "Secondary text" }, + { + tokens: ["--color-text-inverse"], + use: "Text on solid status fills (--color-success, --color-error)", + }, + { + tokens: ["--color-text-on-light", "--color-text-on-dark"], + use: "Text on colors that don't follow the scheme, like user picked tier list and tag colors. Fixed in both modes", + }, + { + tokens: ["--color-border", "--color-border-high"], + use: "Dividers and outlines, -high for inputs and emphasis", + }, + { + tokens: [ + "--color-info-low", + "--color-success-low", + "--color-warning-low", + "--color-error-low", + ], + use: "Status surfaces", + }, + { + tokens: [ + "--color-info", + "--color-success", + "--color-warning", + "--color-error", + ], + use: "Status borders, icons and solid fills (with --color-text-inverse)", + }, + { + tokens: [ + "--color-info-high", + "--color-success-high", + "--color-warning-high", + "--color-error-high", + ], + use: "Text on the matching -low surface", + }, + { + tokens: ["--color-bg-badge", "--color-bg-ability"], + use: "Fixed backdrops of badge and ability images", + }, + { + tokens: ["--color-chart-alpha", "--color-chart-bravo"], + use: "The two sides in charts and timelines", + }, +] as const; + +export default function ComponentsColorsPage() { + const [scheme, setScheme] = React.useState("current"); + + return ( +
+
+ + ← Components + +

Colors

+

+ Live documentation of the color tokens in{" "} + app/styles/vars.css. Every example below is rendered with + the real tokens, so it follows your custom theme. Hover a note to + highlight what it points at. +

+
+ +
+ Preview in + + {(["current", "light", "dark"] as const).map((option) => ( + setScheme(option)} + > + {option === "current" ? "Current scheme" : option} + + ))} + +
+ +
+ + + + + + + + +
+
+ ); +} + +function OverviewSection() { + return ( + +
+

+ Accent and secondary colors are consumed through four kinds of token. + Pick the surface or fill first, then the foreground that belongs to + it. Every fg/bg combination below is contrast checked for any custom + theme, other combinations are not. +

+ + + + + + + + + + {FAMILIES.map((family) => ( + + + + + + ))} + +
TokenRoleUse for
+ {family.pattern} + {family.role}{family.use}
+ +
+
+ In The Zone 42 + + Registered + +
+ Saturday 20:00 · 48 teams +
+ + }> + Leave + + + + + Check in + + +
+
+
+
+
+ ); +} + +function NeutralSection() { + return ( + + +
+
+ + Swim or Sink #17 + + + Starts in 2 hours · 32 teams + +
+
+
+ + Splat Zones + + Tower Control +
+
+
+
+ ); +} + +function AccentSection() { + return ( + + +
+
+ + Standings + + Bracket + Teams +
+
+
+ Olive Branch + 3-1 +
+
+ Your team + 2-2 +
+
+ Inkling Squad + 1-3 +
+
+ + View full standings + +
+ + Report score + + + Notify me + +
+ + Focused element + +
+
+
+ ); +} + +function SecondSection() { + return ( + + +
+
+ Season stats + + NEW + +
+
+ Win rate +
+
+
+ 64% +
+ + Top 500 + +
+ Season 8 ends in 3 days +
+
+ + + ); +} + +function StatusSection() { + return ( + + +
+
+ Check-in opens in 30 minutes +
+ Your roster is not full + Registration complete + Match reported incorrectly +
+ + Confirm + + Delete +
+
+ abc + +
+
+
+
+ ); +} + +function PairingsSection() { + return ( + +
+

+ Contrast ratios are measured live for the current preview. A + "don't" can pass here and still fail for another scheme + or custom theme, which is why the pairing matters and not the number + you happen to see. +

+
+ {PAIRINGS.map((pairing) => ( + + ))} +
+
+
+ ); +} + +function ReplacementsSection() { + return ( + +
+

+ These are the building blocks behind the tokens above. They change + meaning between light and dark mode and custom themes can push them to + values that only work in their intended role. +

+ + + + + + + + + {REPLACEMENTS.map((replacement) => ( + + + + + ))} + +
Instead ofUse
+ + {replacement.legacy} + + {replacement.use}
+
+
+ ); +} + +function ReferenceSection() { + return ( + + + + + + + + + + {REFERENCE.map((row) => ( + + + + + ))} + +
TokenUse for
+
+ {row.tokens.map((token) => ( + + ))} +
+
{row.use}
+
+ ); +} + +function DocSection({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function TokenName({ token }: { token: string }) { + return ( + + + {token} + + ); +} + +function ContrastSample({ + fg, + bg, + verdict, + why, +}: { + fg: string; + bg: string; + verdict: "do" | "dont"; + why: string; +}) { + const sampleRef = React.useRef(null); + const [ratio, setRatio] = React.useState(null); + + React.useLayoutEffect(() => { + if (!sampleRef.current) return; + + const style = getComputedStyle(sampleRef.current); + setRatio(contrastRatio(style.color, style.backgroundColor)); + }, []); + + return ( +
+
+ Aa Check in +
+
+
+ + {verdict === "do" ? "Do" : "Don't"} + + {ratio !== null ? : null} +
+ + on + + {why} +
+
+ ); +} + +function ContrastBadge({ ratio }: { ratio: number }) { + const level = + ratio >= MIN_AA_CONTRAST + ? "pass" + : ratio >= MIN_LARGE_TEXT_CONTRAST + ? "large" + : "fail"; + + return ( + + {ratio.toFixed(1)}:1{" "} + {level === "pass" ? "AA" : level === "large" ? "AA large" : "fail"} + + ); +} + +interface Point { + x: number; + y: number; +} + +interface Rect extends Point { + width: number; + height: number; +} + +interface NoteGeometry { + id: string; + side: AnchorSide; + target: Rect; + anchor: Point; + start: Point; +} + +interface AnnotationGeometry { + isSideLayout: boolean; + gutterX: number; + notes: NoteGeometry[]; +} + +function Annotated({ + notes, + stageNote, + children, +}: { + notes: Note[]; + /** note id pointing at the stage itself */ + stageNote?: string; + children: React.ReactNode; +}) { + const markerId = React.useId(); + const containerRef = React.useRef(null); + const stageRef = React.useRef(null); + const labelsRef = React.useRef(null); + const [geometry, setGeometry] = React.useState( + null, + ); + const [activeNoteId, setActiveNoteId] = React.useState(null); + + React.useLayoutEffect(() => { + const container = containerRef.current; + const stage = stageRef.current; + const labels = labelsRef.current; + if (!container || !stage || !labels) return; + + const measure = () => { + const next = measureAnnotations({ container, stage, labels }); + setGeometry((previous) => + previous && isDeepEqual(previous, next) ? previous : next, + ); + }; + + const observer = new ResizeObserver(measure); + observer.observe(container); + for (const target of stage.querySelectorAll("[data-note]")) { + observer.observe(target); + } + + return () => observer.disconnect(); + }, []); + + const activeTarget = geometry?.notes.find( + (note) => note.id === activeNoteId, + )?.target; + + return ( +
+
+
+ {children} +
+
    + {notes.map((note, index) => ( +
  1. setActiveNoteId(note.id)} + onMouseLeave={() => setActiveNoteId(null)} + > + + {index + 1} + +
    + {note.tokens.map((token) => ( + + ))} + {note.text} +
    +
  2. + ))} +
+
+ + {activeTarget ? ( +
+ ) : null} + + {geometry?.isSideLayout ? ( + + ) : ( + geometry?.notes.map((note) => ( + + )) + )} +
+ ); +} + +function measureAnnotations({ + container, + stage, + labels, +}: { + container: HTMLElement; + stage: HTMLElement; + labels: HTMLElement; +}): AnnotationGeometry { + const origin = container.getBoundingClientRect(); + const relative = (element: Element): Rect => { + const rect = element.getBoundingClientRect(); + return { + x: Math.round(rect.left - origin.left), + y: Math.round(rect.top - origin.top), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; + }; + + const notes: NoteGeometry[] = []; + for (const label of labels.querySelectorAll( + "[data-note-label]", + )) { + const id = label.dataset.noteLabel; + const target = + stage.dataset.note === id + ? stage + : stage.querySelector(`[data-note="${id}"]`); + const marker = label.querySelector("[data-note-marker]"); + if (!id || !target || !marker) continue; + + const side = (label.dataset.noteAnchor ?? "right") as AnchorSide; + const targetRect = relative(target); + const markerRect = relative(marker); + + notes.push({ + id, + side, + target: targetRect, + anchor: anchorPoint(targetRect, side), + start: { x: markerRect.x - 4, y: markerRect.y + markerRect.height / 2 }, + }); + } + + const stageRight = stage.getBoundingClientRect().right; + const labelsLeft = labels.getBoundingClientRect().left; + + return { + isSideLayout: labelsLeft >= stageRight, + gutterX: Math.round( + stageRight - origin.left + (labelsLeft - stageRight) / 4, + ), + notes, + }; +} + +function anchorPoint(rect: Rect, side: AnchorSide): Point { + switch (side) { + case "top": + return { x: rect.x + rect.width / 2, y: rect.y }; + case "bottom": + return { x: rect.x + rect.width / 2, y: rect.y + rect.height }; + case "right": + return { x: rect.x + rect.width, y: rect.y + rect.height / 2 }; + } +} + +/** S-curves through the gap between stage and notes, entering a right anchor horizontally so the arrow doesn't cross content */ +function arrowPath({ start, anchor, side }: NoteGeometry, gutterX: number) { + const midX = (start.x + gutterX) / 2; + const verticalBend = 36; + + if (side === "right") { + return `M ${start.x} ${start.y} C ${midX} ${start.y}, ${midX} ${anchor.y}, ${gutterX} ${anchor.y} L ${anchor.x} ${anchor.y}`; + } + + const controlY = + side === "top" ? anchor.y - verticalBend : anchor.y + verticalBend; + + return `M ${start.x} ${start.y} C ${midX} ${start.y}, ${anchor.x} ${controlY}, ${anchor.x} ${anchor.y}`; +} + +let colorCanvasContext: CanvasRenderingContext2D | null = null; + +/** WCAG 2 contrast ratio between two CSS colors, resolved by drawing them to a canvas */ +function contrastRatio(first: string, second: string) { + const [lighter, darker] = [ + relativeLuminance(first), + relativeLuminance(second), + ].sort((a, b) => b - a); + + return (lighter! + 0.05) / (darker! + 0.05); +} + +function relativeLuminance(color: string) { + colorCanvasContext ??= document + .createElement("canvas") + .getContext("2d", { willReadFrequently: true }); + if (!colorCanvasContext) return 0; + + colorCanvasContext.clearRect(0, 0, 1, 1); + colorCanvasContext.fillStyle = color; + colorCanvasContext.fillRect(0, 0, 1, 1); + const [r, g, b] = colorCanvasContext.getImageData(0, 0, 1, 1).data; + + const linear = (channel = 0) => { + const value = channel / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }; + + return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b); +} diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx index da2eed9ee..1c14ea5c9 100644 --- a/app/features/components-showcase/routes/components.tsx +++ b/app/features/components-showcase/routes/components.tsx @@ -1,5 +1,6 @@ import { Check, Plus, RotateCcw, Search, SquarePen, Trash } from "lucide-react"; import { useState } from "react"; +import { Link } from "react-router"; import { Ability } from "~/components/Ability"; import { Alert } from "~/components/Alert"; import { Avatar } from "~/components/Avatar"; @@ -202,6 +203,7 @@ export default function ComponentsShowcasePage() { return (

Components

+ Color tokens → {SECTIONS.map(({ id, component: Component }) => ( ))} diff --git a/app/features/global-status/components/GlobalStatusIndicator.module.css b/app/features/global-status/components/GlobalStatusIndicator.module.css index 8b832abc1..5e65434d5 100644 --- a/app/features/global-status/components/GlobalStatusIndicator.module.css +++ b/app/features/global-status/components/GlobalStatusIndicator.module.css @@ -55,8 +55,8 @@ } .countBadgeAction { - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); } .alertBadge { diff --git a/app/features/img-export/components/Graphic.module.css b/app/features/img-export/components/Graphic.module.css index a07193a1f..77c65dda4 100644 --- a/app/features/img-export/components/Graphic.module.css +++ b/app/features/img-export/components/Graphic.module.css @@ -2,7 +2,7 @@ --graphic-row-bg: var(--color-bg-high); --graphic-row-border: color-mix(in oklch, var(--color-text) 8%, transparent); --graphic-text-dim: var(--color-text-high); - --graphic-accent: var(--color-text-accent); + --graphic-accent: var(--color-fg-accent); --graphic-first: oklch(87% 0.13 85); --graphic-second: oklch(83% 0.015 268); --graphic-third: oklch(74% 0.09 55); @@ -17,7 +17,7 @@ background: radial-gradient( ellipse 90% 45% at 50% -5%, - color-mix(in oklch, var(--color-accent) 25%, transparent), + color-mix(in oklch, var(--color-fg-accent) 25%, transparent), transparent 70% ), var(--color-bg); diff --git a/app/features/info/routes/support.module.css b/app/features/info/routes/support.module.css index 9b4eaf54b..58427ce4d 100644 --- a/app/features/info/routes/support.module.css +++ b/app/features/info/routes/support.module.css @@ -17,7 +17,7 @@ padding: 0; border: none; background-color: transparent; - color: var(--color-text-accent); + color: var(--color-fg-accent); font-size: var(--font-md); font-weight: var(--weight-bold); outline: initial; diff --git a/app/features/info/routes/welcome.module.css b/app/features/info/routes/welcome.module.css index 5580551f8..25686d695 100644 --- a/app/features/info/routes/welcome.module.css +++ b/app/features/info/routes/welcome.module.css @@ -18,7 +18,7 @@ content: ""; position: absolute; inset: 0; - background-color: var(--color-second); + background-color: var(--color-fg-second); opacity: 0.3; pointer-events: none; } diff --git a/app/features/map-planner/components/Planner.module.css b/app/features/map-planner/components/Planner.module.css index 07edab0cb..d367624ea 100644 --- a/app/features/map-planner/components/Planner.module.css +++ b/app/features/map-planner/components/Planner.module.css @@ -77,8 +77,8 @@ } .outlineToggleButtonOutlined { - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); } .weaponsWrapper { diff --git a/app/features/map-planner/plans-global.css b/app/features/map-planner/plans-global.css index ec49a19ce..01c934b16 100644 --- a/app/features/map-planner/plans-global.css +++ b/app/features/map-planner/plans-global.css @@ -65,8 +65,8 @@ body:has(.planner) footer { .tl-theme__light.tl-theme__light { --color-panel: var(--color-bg); --color-divider: var(--color-border); - --color-selected: var(--color-text-accent); - --color-selected-contrast: var(--color-text-inverse); + --color-selected: var(--color-fill-accent); + --color-selected-contrast: var(--color-fg-on-accent); --color-hint: var(--color-bg-high); --color-muted-2: var(--color-bg-high); --color-background: var(--color-bg); diff --git a/app/features/notifications/components/NotificationList.module.css b/app/features/notifications/components/NotificationList.module.css index f0b26b59a..5c55a9cd9 100644 --- a/app/features/notifications/components/NotificationList.module.css +++ b/app/features/notifications/components/NotificationList.module.css @@ -30,12 +30,12 @@ } &:focus-within .imageContainer { - outline: 3px solid var(--color-accent-low); + outline: 3px solid var(--color-bg-accent); } } .unseenDot { - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); border-radius: 100%; width: 8px; height: 8px; diff --git a/app/features/object-damage-calculator/routes/object-damage-calculator.module.css b/app/features/object-damage-calculator/routes/object-damage-calculator.module.css index 3b1d42183..427e0fedf 100644 --- a/app/features/object-damage-calculator/routes/object-damage-calculator.module.css +++ b/app/features/object-damage-calculator/routes/object-damage-calculator.module.css @@ -112,7 +112,7 @@ } .multiplier { - color: var(--color-text-accent); + color: var(--color-fg-accent); font-size: var(--font-2xs); font-weight: var(--weight-bold); letter-spacing: 0.5px; @@ -128,8 +128,8 @@ .patch { border-radius: var(--radius-selector); - background-color: var(--color-accent-low); - color: var(--color-accent-high); + background-color: var(--color-bg-accent); + color: var(--color-fg-accent); font-size: var(--font-2xs); font-weight: var(--weight-bold); padding-inline: var(--s-2); diff --git a/app/features/params/components/ParamComparisonDialog.module.css b/app/features/params/components/ParamComparisonDialog.module.css index 6cb222282..b4886ac09 100644 --- a/app/features/params/components/ParamComparisonDialog.module.css +++ b/app/features/params/components/ParamComparisonDialog.module.css @@ -38,11 +38,11 @@ height: 18px; min-width: 2px; border-radius: var(--radius-field); - background-color: var(--color-accent); + background-color: var(--color-fg-accent); } .barFillCurrent { - background-color: var(--color-second); + background-color: var(--color-fg-second); } .value { diff --git a/app/features/params/components/WeaponParamsTable.module.css b/app/features/params/components/WeaponParamsTable.module.css index b1c3e22e4..5e27442d2 100644 --- a/app/features/params/components/WeaponParamsTable.module.css +++ b/app/features/params/components/WeaponParamsTable.module.css @@ -145,7 +145,7 @@ display: flex; align-items: center; flex-shrink: 0; - color: var(--color-accent); + color: var(--color-fg-accent); } .paramCell { @@ -185,8 +185,8 @@ height: 16px; padding-inline: var(--s-0-5); border-radius: var(--radius-full); - background-color: var(--color-accent-low); - color: var(--color-accent); + background-color: var(--color-bg-accent); + color: var(--color-fg-accent); font-size: var(--font-xs); font-weight: var(--weight-bold); } diff --git a/app/features/plus-voting/plus-voting-results.module.css b/app/features/plus-voting/plus-voting-results.module.css index d00d1404a..ed4de56c5 100644 --- a/app/features/plus-voting/plus-voting-results.module.css +++ b/app/features/plus-voting/plus-voting-results.module.css @@ -21,7 +21,7 @@ .tierHeader { display: flex; flex-direction: row; - color: var(--color-text-accent); + color: var(--color-fg-accent); &::before, &::after { @@ -54,8 +54,8 @@ .userStatus { border-radius: var(--radius-selector); height: var(--selector-size-sm); - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); font-size: var(--font-xs); font-weight: var(--weight-semi); padding-inline: var(--s-1-5); diff --git a/app/features/scanner/components/EventsSummary.module.css b/app/features/scanner/components/EventsSummary.module.css index 5e1b1cfef..a1b7d4d69 100644 --- a/app/features/scanner/components/EventsSummary.module.css +++ b/app/features/scanner/components/EventsSummary.module.css @@ -30,7 +30,7 @@ button.toggle { border: none; background: transparent; - color: var(--color-text-accent); + color: var(--color-fg-accent); height: auto; padding: 0; font-size: var(--font-2xs); diff --git a/app/features/scanner/components/LivePage.module.css b/app/features/scanner/components/LivePage.module.css index 12ceb84e4..6c1b8e543 100644 --- a/app/features/scanner/components/LivePage.module.css +++ b/app/features/scanner/components/LivePage.module.css @@ -50,5 +50,5 @@ button.outlined { background: transparent; - color: var(--color-text-accent); + color: var(--color-fg-accent); } diff --git a/app/features/scanner/components/MetaChips.module.css b/app/features/scanner/components/MetaChips.module.css index ce8fabf81..b764bcaa7 100644 --- a/app/features/scanner/components/MetaChips.module.css +++ b/app/features/scanner/components/MetaChips.module.css @@ -22,6 +22,6 @@ font-weight: var(--weight-bold); & svg { - color: var(--color-text-accent); + color: var(--color-fg-accent); } } diff --git a/app/features/scanner/components/ScannerApp.module.css b/app/features/scanner/components/ScannerApp.module.css index db78a0520..afdcdb4fc 100644 --- a/app/features/scanner/components/ScannerApp.module.css +++ b/app/features/scanner/components/ScannerApp.module.css @@ -14,11 +14,11 @@ from sendou.ink's app/styles/vars.css. align-items: center; justify-content: center; gap: var(--s-1-5); - border: var(--border-style-accent); + border: var(--border-width) solid var(--color-fill-accent); border-radius: var(--radius-field); appearance: none; - background: var(--color-text-accent); - color: var(--color-text-inverse); + background: var(--color-fill-accent); + color: var(--color-fg-on-accent); cursor: pointer; font-family: inherit; font-size: var(--font-xs); @@ -80,7 +80,7 @@ from sendou.ink's app/styles/vars.css. } &.active { - color: var(--color-text-accent); + color: var(--color-fg-accent); background: var(--color-bg-high); } } diff --git a/app/features/scanner/components/ScannerChrome.module.css b/app/features/scanner/components/ScannerChrome.module.css index eefbf2edc..b48569ed4 100644 --- a/app/features/scanner/components/ScannerChrome.module.css +++ b/app/features/scanner/components/ScannerChrome.module.css @@ -11,7 +11,7 @@ button.iconMenu { color: var(--color-text); &:hover { - color: var(--color-text-accent); + color: var(--color-fg-accent); } & > svg { @@ -101,8 +101,8 @@ button.iconMenu { } .over { - border-color: var(--color-text-accent); - color: var(--color-text-accent); + border-color: var(--color-fg-accent); + color: var(--color-fg-accent); } /* a 640px wide preview would starve the feed next to it: split evenly instead */ diff --git a/app/features/scanner/components/VodPage.module.css b/app/features/scanner/components/VodPage.module.css index 12cc92a8b..9d97e2874 100644 --- a/app/features/scanner/components/VodPage.module.css +++ b/app/features/scanner/components/VodPage.module.css @@ -6,10 +6,10 @@ gap: var(--s-1-5); padding: 0 var(--field-padding); height: var(--field-size-sm); - border: var(--border-style-accent); + border: var(--border-width) solid var(--color-fill-accent); border-radius: var(--radius-field); - background: var(--color-text-accent); - color: var(--color-text-inverse); + background: var(--color-fill-accent); + color: var(--color-fg-on-accent); font-size: var(--font-xs); font-weight: var(--weight-bold); white-space: nowrap; diff --git a/app/features/scrims/components/ScrimCard.module.css b/app/features/scrims/components/ScrimCard.module.css index c3cd176d2..4a7900cf9 100644 --- a/app/features/scrims/components/ScrimCard.module.css +++ b/app/features/scrims/components/ScrimCard.module.css @@ -102,7 +102,7 @@ .expandButton { background: none; border: none; - color: var(--color-text-accent); + color: var(--color-fg-accent); cursor: pointer; font-size: var(--font-2xs); font-weight: var(--weight-semi); @@ -138,7 +138,7 @@ } .filteredFooter { - background-color: var(--color-accent-low); + background-color: var(--color-bg-accent); } .canceledContainer { diff --git a/app/features/sendouq/routes/q.info.module.css b/app/features/sendouq/routes/q.info.module.css index 5aee43d83..20d40ff41 100644 --- a/app/features/sendouq/routes/q.info.module.css +++ b/app/features/sendouq/routes/q.info.module.css @@ -7,7 +7,7 @@ & h2 { font-size: var(--font-lg); margin-block-end: var(--s-2); - color: var(--color-text-accent); + color: var(--color-fg-accent); } & h3 { diff --git a/app/features/sendouq/routes/q.looking.module.css b/app/features/sendouq/routes/q.looking.module.css index 85ee0afa1..50868d86c 100644 --- a/app/features/sendouq/routes/q.looking.module.css +++ b/app/features/sendouq/routes/q.looking.module.css @@ -20,7 +20,7 @@ font-size: var(--font-2xs); font-weight: var(--weight-semi); text-transform: uppercase; - color: var(--color-text-accent); + color: var(--color-fg-accent); display: flex; align-items: center; diff --git a/app/features/settings/actions/settings.server.ts b/app/features/settings/actions/settings.server.ts index 051542e81..6dc852148 100644 --- a/app/features/settings/actions/settings.server.ts +++ b/app/features/settings/actions/settings.server.ts @@ -11,10 +11,10 @@ import { SENDOUQ_LOOKING_CHANNEL, sqGroupChannel, } from "~/features/sendouq/q-constants"; +import * as ThemePalette from "~/features/theme/core/ThemePalette"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { parseFormData } from "~/form/parse.server"; import { isSupporter } from "~/modules/permissions/utils"; -import { clampThemeToGamut } from "~/utils/oklch-gamut"; import { errorToast } from "~/utils/remix.server"; import { toDBBoolean } from "~/utils/sql"; import { assertUnreachable } from "~/utils/types"; @@ -40,7 +40,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { } const clampedTheme = data.newValue - ? clampThemeToGamut(data.newValue) + ? ThemePalette.build(data.newValue) : null; await UserRepository.updateOwnCustomTheme(clampedTheme); diff --git a/app/features/settings/components/ThemeTab.tsx b/app/features/settings/components/ThemeTab.tsx index 1b7dc8560..237173c48 100644 --- a/app/features/settings/components/ThemeTab.tsx +++ b/app/features/settings/components/ThemeTab.tsx @@ -3,11 +3,11 @@ import { useMatches } from "react-router"; import { CustomThemeSelector } from "~/components/CustomThemeSelector"; import { FormMessage } from "~/components/FormMessage"; import { Theme, useTheme } from "~/features/theme/core/provider"; +import type { ThemeInput } from "~/features/theme/core/ThemePalette"; import { SelectFormField } from "~/form/fields/SelectFormField"; import { useActionSubmit } from "~/hooks/useActionSubmit"; import { useHasRole } from "~/modules/permissions/hooks"; import type { RootLoaderData } from "~/root"; -import type { ThemeInput } from "~/utils/oklch-gamut"; import { customThemeSchema } from "../settings-schemas"; export function ThemeTab() { diff --git a/app/features/team/actions/t.$customUrl.edit.server.test.ts b/app/features/team/actions/t.$customUrl.edit.server.test.ts index 8add65bfb..12791d735 100644 --- a/app/features/team/actions/t.$customUrl.edit.server.test.ts +++ b/app/features/team/actions/t.$customUrl.edit.server.test.ts @@ -5,8 +5,8 @@ import * as TeamFactory from "~/db/seed/factories/TeamFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import * as ImageRepository from "~/features/img-upload/ImageRepository.server"; import * as TeamRepository from "~/features/team/TeamRepository.server"; +import * as ThemePalette from "~/features/theme/core/ThemePalette"; import { invariant } from "~/utils/invariant"; -import { clampThemeToGamut } from "~/utils/oklch-gamut"; import { assertResponseErrored, wrappedAction } from "~/utils/Test"; import type { editTeamActionSchema } from "../team-schemas"; import { action as _editTeamProfileAction } from "./t.$customUrl.edit.server"; @@ -31,6 +31,7 @@ const VALID_CUSTOM_THEME = { baseChroma: 0.05, accentHue: 200, accentChroma: 0.1, + bgLightness: 0.17, chatHue: null, radiusBox: 3, radiusField: 2, @@ -42,7 +43,7 @@ const VALID_CUSTOM_THEME = { } as const; const expectedStoredTheme = () => - JSON.parse(JSON.stringify(clampThemeToGamut(VALID_CUSTOM_THEME))); + JSON.parse(JSON.stringify(ThemePalette.build(VALID_CUSTOM_THEME))); const users = UserFactory.pool(); const victimId = () => users.id(1); @@ -131,20 +132,28 @@ describe("team page editing", () => { expect((await teamRow()).customTheme).toBeNull(); }); - test("prevents setting an invalid custom theme", async () => { - const response = await editTeamProfileAction( - { - _action: "UPDATE_CUSTOM_THEME", - newValue: { - ...VALID_CUSTOM_THEME, - baseHue: 500, // Invalid: max is 360 + test.each([ + { why: "base hue above max", field: "baseHue", value: 500 }, + { why: "bg lightness below min", field: "bgLightness", value: 0.05 }, + { why: "bg lightness above max", field: "bgLightness", value: 0.18 }, + { why: "bg lightness off step", field: "bgLightness", value: 0.125 }, + ])( + "prevents setting an invalid custom theme ($why)", + async ({ field, value }) => { + const response = await editTeamProfileAction( + { + _action: "UPDATE_CUSTOM_THEME", + newValue: { + ...VALID_CUSTOM_THEME, + [field]: value, + }, }, - }, - { user: "regular", params: { customUrl } }, - ); + { user: "regular", params: { customUrl } }, + ); - expect(response.fieldErrors["newValue.baseHue"]).toBeTruthy(); - }); + expect(response.fieldErrors[`newValue.${field}`]).toBeTruthy(); + }, + ); test("preserves an existing custom theme when editing the team profile", async () => { await editTeamProfileAction( diff --git a/app/features/team/actions/t.$customUrl.edit.server.ts b/app/features/team/actions/t.$customUrl.edit.server.ts index ba23f930f..0b84fe660 100644 --- a/app/features/team/actions/t.$customUrl.edit.server.ts +++ b/app/features/team/actions/t.$customUrl.edit.server.ts @@ -2,9 +2,9 @@ import type { ActionFunction } from "react-router"; import { redirect } from "react-router"; import * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; +import * as ThemePalette from "~/features/theme/core/ThemePalette"; import { parseFormDataWithImages } from "~/form/parse.server"; import { requirePermission } from "~/modules/permissions/guards.server"; -import { clampThemeToGamut } from "~/utils/oklch-gamut"; import { errorToastIfFalsy, notFoundIfNullish } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; import { mySlugify, teamPage } from "~/utils/urls"; @@ -45,7 +45,7 @@ export const action: ActionFunction = async ({ request, params }) => { await TeamRepository.updateCustomTheme({ id: team.id, - customTheme: data.newValue ? clampThemeToGamut(data.newValue) : null, + customTheme: data.newValue ? ThemePalette.build(data.newValue) : null, }); return { ok: true }; diff --git a/app/features/team/routes/t.$customUrl.edit.tsx b/app/features/team/routes/t.$customUrl.edit.tsx index 3e1d98e55..2e349e675 100644 --- a/app/features/team/routes/t.$customUrl.edit.tsx +++ b/app/features/team/routes/t.$customUrl.edit.tsx @@ -12,10 +12,10 @@ import { preferencesFromRaw, } from "~/features/settings/components/MapModePreferencesField"; import { TeamGoBackButton } from "~/features/team/components/TeamGoBackButton"; +import type { ThemeInput } from "~/features/theme/core/ThemePalette"; import { existingImage } from "~/form/image-field"; import { SendouForm } from "~/form/SendouForm"; import { useActionSubmit } from "~/hooks/useActionSubmit"; -import type { ThemeInput } from "~/utils/oklch-gamut"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { action } from "../actions/t.$customUrl.edit.server"; diff --git a/app/features/team/routes/t.$customUrl.module.css b/app/features/team/routes/t.$customUrl.module.css index 20efdc73b..f5d94f62e 100644 --- a/app/features/team/routes/t.$customUrl.module.css +++ b/app/features/team/routes/t.$customUrl.module.css @@ -18,7 +18,7 @@ .bannerPlaceholder { height: 6rem; - background-color: var(--color-accent-low); + background-color: var(--color-bg-accent); } .bannerFlags { @@ -104,8 +104,8 @@ .bannerTag { font-size: var(--font-sm); - background-color: var(--color-accent-low); - color: var(--color-accent); + background-color: var(--color-bg-accent); + color: var(--color-fg-accent); padding: var(--s-1) var(--s-1-5); border-radius: var(--radius-box); } diff --git a/app/features/theme/core/ThemePalette.test.ts b/app/features/theme/core/ThemePalette.test.ts new file mode 100644 index 000000000..73c239841 --- /dev/null +++ b/app/features/theme/core/ThemePalette.test.ts @@ -0,0 +1,355 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; +import type { CustomTheme } from "~/db/tables-json"; +import { isInSrgbGamut, type Oklch } from "~/utils/oklch-gamut"; +import { THEME_INPUT_LIMITS } from "~/utils/schema"; +import * as ThemePalette from "./ThemePalette"; + +const input = (overrides: Partial = {}) => ({ + ...ThemePalette.DEFAULT_THEME_INPUT, + ...overrides, +}); + +const built = (overrides: Partial = {}) => + ThemePalette.build(input(overrides)); + +describe("ThemePalette.build", () => { + test("reproduces the defaults of vars.css for the default input", () => { + const cssDefaults = varsCssThemeDefaults(); + const theme = built(); + + expect(Object.keys(cssDefaults).length).toBeGreaterThan(50); + for (const [key, value] of Object.entries(cssDefaults)) { + expect(theme[key as keyof CustomTheme], key).toBeCloseTo(value, 10); + } + }); + + test("vars.css resolves every color to the same value as resolveColors", () => { + const css = varsCssColors(); + const { base, dark, light } = ThemePalette.resolveColors(built()); + const kebab = (key: string) => + key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); + const expectColor = (label: string, actual: Oklch, expected: Oklch) => { + expect(actual.l, `${label} lightness`).toBeCloseTo(expected.l, 6); + expect(actual.c, `${label} chroma`).toBeCloseTo(expected.c, 6); + expect(actual.h, `${label} hue`).toBeCloseTo(expected.h, 6); + }; + + for (const [mode, colors] of [ + ["dark", dark], + ["light", light], + ] as const) { + for (const [key, color] of Object.entries(colors)) { + const name = `--color-${kebab(key)}`; + expectColor(`${mode} ${name}`, css.resolve(name, mode), color); + } + } + for (const [index, color] of base.entries()) { + if (index < 5) { + expectColor( + `dark base-${index}`, + css.resolve(`--color-base-${index}`, "dark"), + color, + ); + } + expectColor( + `light base-${7 - index}`, + css.resolve(`--color-base-${7 - index}`, "light"), + color, + ); + } + }); + + test("every text color has at least WCAG AA contrast for any input", () => { + const failures: string[] = []; + + for (const sample of sampledInputs()) { + for (const pair of ThemePalette.textContrastPairs(built(sample))) { + if (pair.contrast >= 4.5) continue; + + failures.push( + `${pair.name} ${pair.contrast.toFixed(2)} ${JSON.stringify(sample)}`, + ); + } + } + + expect(failures).toEqual([]); + }); + + test("every color is inside the sRGB gamut for any input", () => { + const failures: string[] = []; + + for (const sample of sampledInputs()) { + const { base, dark, light } = ThemePalette.resolveColors(built(sample)); + const colors = [ + ...base.map((color, index) => [`base-${index}`, color] as const), + ...Object.entries(dark).map( + ([name, color]) => [`dark ${name}`, color] as const, + ), + ...Object.entries(light).map( + ([name, color]) => [`light ${name}`, color] as const, + ), + ]; + + for (const [name, color] of colors) { + if (isInSrgbGamut(color)) continue; + + failures.push( + `${name} ${JSON.stringify(color)} ${JSON.stringify(sample)}`, + ); + } + } + + expect(failures).toEqual([]); + }); + + test("gives a yellow accent a bright light mode fill with dark text on it", () => { + const theme = built({ accentHue: 100, accentChroma: 0.3 }); + + expect(theme["--_acc-fill-dark-text"]).toBe(1); + expect(theme["--_acc-l-6"]).toBeGreaterThan(0.8); + }); + + test("keeps the default accent's light mode fill as its text color with white text on it", () => { + const theme = built(); + + expect(theme["--_acc-fill-dark-text"]).toBe(0); + expect(theme["--_acc-l-6"]).toBe(theme["--_acc-l-4"]); + }); + + test("gives the default theme's amber secondary a bright light mode fill with dark text on it", () => { + const theme = built(); + + expect(theme["--_second-fill-dark-text"]).toBe(1); + expect(theme["--_second-l-6"]).toBeGreaterThan(0.8); + }); + + test("keeps a yellow accent's blue secondary fill as its text color with white text on it", () => { + const theme = built({ accentHue: 100 }); + + expect(theme["--_second-fill-dark-text"]).toBe(0); + expect(theme["--_second-l-6"]).toBe(theme["--_second-l-4"]); + }); + + test("raises a yellow dark mode accent to where yellow is at its most vivid", () => { + const theme = built({ accentHue: 100 }); + + expect(theme["--_acc-l-2"]).toBeGreaterThan(0.88); + expect(theme["--_acc-c-2"]).toBeGreaterThan(0.15); + }); + + test("rotates dark shades of yellow toward amber", () => { + const theme = built({ accentHue: 100 }); + + expect(theme["--_acc-h-4"]).toBeLessThan(85); + expect(theme["--_acc-h-2"]).toBe(100); + }); +}); + +describe("ThemePalette.toThemeInput", () => { + test.each([ + { why: "default", overrides: {} }, + { + why: "yellow", + overrides: { accentHue: 100, accentChroma: 0.3, bgLightness: 0.08 }, + }, + { + why: "gray", + overrides: { baseChroma: 0, accentChroma: 0, bgLightness: 0.12 }, + }, + ])("recovers the input of a $why theme", ({ overrides }) => { + const recovered = ThemePalette.toThemeInput(built(overrides)); + + for (const [key, value] of Object.entries(input(overrides))) { + if (typeof value === "number") { + expect( + recovered[key as keyof ThemePalette.ThemeInput], + key, + ).toBeCloseTo(value, 6); + } else { + expect(recovered[key as keyof ThemePalette.ThemeInput], key).toBe( + value, + ); + } + } + }); +}); + +describe("ThemePalette.fromShareCode", () => { + test.each([ + { why: "default", overrides: {} }, + { + why: "custom", + overrides: { accentHue: 100, bgLightness: 0.08, chatHue: 30 }, + }, + ])("round-trips a $why theme through toShareCode", ({ overrides }) => { + const code = ThemePalette.toShareCode(input(overrides)); + + expect(ThemePalette.fromShareCode(code)).toEqual(input(overrides)); + }); + + test("decodes a legacy code without background lightness", () => { + expect( + ThemePalette.fromShareCode("180;0.05;200;0.1;3;2;2;2;1;1;1;_"), + ).toEqual( + input({ + baseHue: 180, + accentHue: 200, + accentChroma: 0.1, + }), + ); + }); + + test.each([ + { why: "too few parts", code: "180;0.05;200" }, + { why: "not a number", code: "180;0.05;200;x;3;2;2;2;1;1;1;_;0.17" }, + { + why: "out of range value", + code: "180;0.05;200;0.1;3;2;2;2;1;1;1;_;0.5", + }, + ])("returns null for $why", ({ code }) => { + expect(ThemePalette.fromShareCode(code)).toBeNull(); + }); +}); + +function* sampledInputs() { + const { BG_LIGHTNESS_MIN, BG_LIGHTNESS_MAX } = THEME_INPUT_LIMITS; + + for (let baseHue = 0; baseHue < 360; baseHue += 30) { + for (const baseChroma of [0, 0.025, 0.05, 0.075, 0.1]) { + for (let accentHue = 0; accentHue < 360; accentHue += 5) { + for (const accentChroma of [0, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5]) { + for (const bgLightness of [ + BG_LIGHTNESS_MIN, + (BG_LIGHTNESS_MIN + BG_LIGHTNESS_MAX) / 2, + BG_LIGHTNESS_MAX, + ]) { + yield { baseHue, baseChroma, accentHue, accentChroma, bgLightness }; + } + } + } + } + } +} + +const VARS_CSS = readFileSync( + new URL("../../../styles/vars.css", import.meta.url), + "utf8", +).replace(/\r\n/g, "\n"); + +type VarsBlock = "defaults" | "dark" | "light" | "semantic"; + +/** Custom property declarations of the top level blocks of vars.css, keyed by what the block is for */ +function varsCssBlocks() { + const blocks = new Map>(); + const blockFor = (selector: string): VarsBlock | null => { + const lastLine = selector.trim().split("\n").at(-1)!; + if (lastLine.includes("[data-default-theme]")) return "defaults"; + if (lastLine.includes('[data-theme="dark"]')) return "dark"; + if (lastLine.includes('[data-theme="light"]')) return "light"; + if (lastLine === "[data-theme]") return "semantic"; + return null; + }; + + for (const [, selector, body] of VARS_CSS.matchAll( + /^([^{}]+?)\{\n([^{}]*)\n\}/gm, + )) { + const block = blockFor(selector); + if (!block) continue; + + const declarations = new Map(); + for (const [, key, value] of body.matchAll(/(--[\w-]+):\s*([^;]+);/g)) { + declarations.set(key, value.replace(/\s+/g, " ").trim()); + } + blocks.set(block, declarations); + } + + return blocks; +} + +/** Theme variable defaults from vars.css, `var()` references resolved */ +function varsCssThemeDefaults() { + const raw = varsCssBlocks().get("defaults")!; + + const resolve = (value: string): number => { + const reference = value.match(/^var\((--_[\w-]+)\)$/); + if (reference) return resolve(raw.get(reference[1])!); + + return Number(value); + }; + + return Object.fromEntries( + [...raw.entries()].map(([key, value]) => [key, resolve(value)]), + ); +} + +/** Evaluates the oklch() colors of vars.css for a color scheme the way the browser would */ +function varsCssColors() { + const blocks = varsCssBlocks(); + + const lookup = (name: string, mode: "dark" | "light"): string => { + const value = + blocks.get(mode)?.get(name) ?? + blocks.get("semantic")?.get(name) ?? + blocks.get("defaults")?.get(name); + if (value === undefined) + throw new Error(`${name} is not defined for ${mode}`); + + return value; + }; + + const substituteVars = (value: string, mode: "dark" | "light"): string => { + let result = value; + while (result.includes("var(")) { + result = result.replace(/var\((--[\w-]+)\)/g, (_, name) => + lookup(name, mode), + ); + } + + return result; + }; + + const evaluate = (expression: string): number => { + const arithmetic = expression + .replaceAll("calc", "") + .replace(/(\d+(?:\.\d+)?)%/g, "($1/100)"); + if (!/^[\d\s.+\-*/()]+$/.test(arithmetic)) { + throw new Error(`cannot evaluate ${expression}`); + } + + return new Function(`return ${arithmetic}`)(); + }; + + const splitTopLevel = (value: string) => { + const parts: string[] = []; + let depth = 0; + let current = ""; + for (const char of value) { + if (char === "(") depth++; + if (char === ")") depth--; + if (char === " " && depth === 0) { + if (current) parts.push(current); + current = ""; + } else { + current += char; + } + } + if (current) parts.push(current); + + return parts; + }; + + const resolve = (name: string, mode: "dark" | "light"): Oklch => { + const value = substituteVars(lookup(name, mode), mode); + const inner = value.match(/^oklch\((.*)\)$/s)?.[1]; + if (!inner) + throw new Error( + `${name} does not resolve to oklch() for ${mode}: ${value}`, + ); + const [l, c, h] = splitTopLevel(inner.trim()).map(evaluate); + + return { l, c, h }; + }; + + return { resolve }; +} diff --git a/app/features/theme/core/ThemePalette.ts b/app/features/theme/core/ThemePalette.ts new file mode 100644 index 000000000..aa1e06099 --- /dev/null +++ b/app/features/theme/core/ThemePalette.ts @@ -0,0 +1,631 @@ +import * as v from "valibot"; +import type { CustomTheme } from "~/db/tables-json"; +import { + contrastRatio, + cuspLightness, + maxChroma, + type Oklch, +} from "~/utils/oklch-gamut"; +import { THEME_INPUT_LIMITS, themeInputSchema } from "~/utils/schema"; + +export type ThemeInput = v.InferOutput; + +export const DEFAULT_THEME_INPUT: ThemeInput = { + baseHue: 268, + baseChroma: 0.05, + accentHue: 253, + accentChroma: 0.24, + bgLightness: THEME_INPUT_LIMITS.BG_LIGHTNESS_DEFAULT, + chatHue: null, + radiusBox: 3, + radiusField: 2, + radiusSelector: 2, + borderWidth: 2, + sizeField: 1, + sizeSelector: 1, + sizeSpacing: 1, +}; + +/** Keys added after share codes were introduced go last, codes made before them just omit them */ +const LEGACY_SHARE_CODE_KEYS: ReadonlyArray = [ + "baseHue", + "baseChroma", + "accentHue", + "accentChroma", + "radiusBox", + "radiusField", + "radiusSelector", + "borderWidth", + "sizeField", + "sizeSelector", + "sizeSpacing", + "chatHue", +]; +const SHARE_CODE_KEYS: ReadonlyArray = [ + ...LEGACY_SHARE_CODE_KEYS, + "bgLightness", +]; + +/** WCAG AA for normal sized text */ +const MIN_TEXT_CONTRAST = 4.5; +const LIGHTNESS_SEARCH_STEP = 0.005; + +// Any changes to the lightness values or offsets NEED to be reflected in vars.css as well + +const BASE_LIGHTNESS_VALUES = [ + 1.0, // --_base-c-0 + 0.95, // --_base-c-1 + 0.9, // --_base-c-2 + 0.64, // --_base-c-3 + 0.46, // --_base-c-4 + 0.32, // --_base-c-5 + 0.25, // --_base-c-6 + 0.17, // --_base-c-7 +] as const; + +const BASE_CHROMA_MULTIPLIERS = [ + 0.01, 0.49, 0.62, 1.4, 1.29, 1.36, 1.29, 0.67, +] as const; + +/** In dark mode --color-base-5...7 sit this much above the background lightness (`--_base-l`) */ +const DARK_SURFACE_OFFSETS: Partial> = { + 5: 0.15, + 6: 0.08, + 7: 0, +}; + +/** Lightness of text drawn on top of accent fills when the fill is too light for white text */ +const DARK_TEXT_LIGHTNESS = BASE_LIGHTNESS_VALUES[7]; + +interface AccentSlot { + /** Lightness the slot is designed around, the solver only moves away from it when needed */ + lightness: number; + chromaMultiplier: number; + /** Hues that are at their most colorful above `lightness` (yellow, cyan...) are raised toward that point, at most up to this value, so they stay clean tints instead of muddy shades */ + maxCuspLift?: number; +} + +const ACCENT_SLOTS = [ + { lightness: 0.26, chromaMultiplier: 0.38 }, // dark --color-accent-low + { lightness: 0.52, chromaMultiplier: 1.11 }, // dark --color-accent + { lightness: 0.83, chromaMultiplier: 0.34, maxCuspLift: 0.92 }, // dark --color-accent-high + { lightness: 0.88, chromaMultiplier: 0.25, maxCuspLift: 0.92 }, // light --color-accent-low + { lightness: 0.53, chromaMultiplier: 1.09 }, // light --color-accent + { lightness: 0.32, chromaMultiplier: 0.56 }, // light --color-accent-high +] as const satisfies ReadonlyArray; + +/** Light mode --color-fill-accent when it is swapped to a bright fill with dark text */ +const BRIGHT_FILL = { + minLightness: 0.8, + maxLightness: 0.92, + chromaMultiplier: 1.09, + /** How much more chroma the bright fill has to reach before it is preferred over the dark one */ + minChromaGain: 1.25, +}; + +/** + * Hues with a larger gamut than the default accent hue get proportionally more chroma + * so e.g. yellow can be as vivid as blue is at the same slider value. + */ +const GAMUT_BOOST = { + referenceHue: DEFAULT_THEME_INPUT.accentHue, + max: 2, +}; + +/** Dark yellows look olive, so shades of them are rotated toward amber (like e.g. Tailwind's yellow palette does) */ +const SHADE_HUE_SHIFT = { + targetHue: 70, + fullWeightHues: [85, 115], + zeroWeightHues: [75, 135], + strength: 0.85, + /** How far below the hue's cusp lightness the full shift is applied */ + fullShiftDepth: 0.4, +}; + +/** + * Expands the supporter's slider values into the CSS variables of a custom theme. + * Every text color is solved to have at least WCAG AA contrast against the surfaces it's shown on. + */ +export function build(input: ThemeInput): CustomTheme { + const bgLightness = input.bgLightness; + const baseChromas = BASE_LIGHTNESS_VALUES.map((lightness, index) => { + const desiredChroma = input.baseChroma * BASE_CHROMA_MULTIPLIERS[index]; + const darkSurfaceOffset = DARK_SURFACE_OFFSETS[index]; + const lightnesses = + darkSurfaceOffset === undefined + ? [lightness] + : [lightness, bgLightness + darkSurfaceOffset]; + + return Math.min( + desiredChroma, + ...lightnesses.map((l) => maxChroma(l, input.baseHue)), + ); + }); + + const surfaces: Surfaces = { + dark: { + bgHigher: { + l: bgLightness + DARK_SURFACE_OFFSETS[5]!, + c: baseChromas[5], + h: input.baseHue, + }, + }, + light: { + bg: { l: BASE_LIGHTNESS_VALUES[0], c: baseChromas[0], h: input.baseHue }, + bgHigh: { + l: BASE_LIGHTNESS_VALUES[1], + c: baseChromas[1], + h: input.baseHue, + }, + darkText: { + l: DARK_TEXT_LIGHTNESS, + c: baseChromas[7], + h: input.baseHue, + }, + }, + }; + + const accent = buildPalette({ + hue: input.accentHue, + chroma: input.accentChroma, + boostChroma: true, + surfaces, + }); + + const secondaryHue = (input.accentHue + 180) % 360; + const secondary = buildPalette({ + hue: secondaryHue, + chroma: input.accentChroma, + boostChroma: false, + surfaces, + }); + + const lightFill = buildLightFill({ + hue: input.accentHue, + chroma: input.accentChroma, + boostChroma: true, + darkFill: accent[4], + darkText: surfaces.light.darkText, + }); + const secondaryLightFill = buildLightFill({ + hue: secondaryHue, + chroma: input.accentChroma, + boostChroma: false, + darkFill: secondary[4], + darkText: surfaces.light.darkText, + }); + + return { + "--_base-h": input.baseHue, + "--_base-c-0": baseChromas[0], + "--_base-c-1": baseChromas[1], + "--_base-c-2": baseChromas[2], + "--_base-c-3": baseChromas[3], + "--_base-c-4": baseChromas[4], + "--_base-c-5": baseChromas[5], + "--_base-c-6": baseChromas[6], + "--_base-c-7": baseChromas[7], + "--_base-l": bgLightness, + "--_acc-h": input.accentHue, + "--_acc-c": input.accentChroma, + ...slotVars("acc", accent), + "--_acc-l-6": lightFill.color.l, + "--_acc-c-6": lightFill.color.c, + "--_acc-h-6": lightFill.color.h, + "--_acc-fill-dark-text": lightFill.hasDarkText ? 1 : 0, + ...slotVars("second", secondary), + "--_second-l-6": secondaryLightFill.color.l, + "--_second-c-6": secondaryLightFill.color.c, + "--_second-h-6": secondaryLightFill.color.h, + "--_second-fill-dark-text": secondaryLightFill.hasDarkText ? 1 : 0, + "--_chat-h": input.chatHue, + "--_radius-box": input.radiusBox, + "--_radius-field": input.radiusField, + "--_radius-selector": input.radiusSelector, + "--_border-width": input.borderWidth, + "--_size-field": input.sizeField, + "--_size-selector": input.sizeSelector, + "--_size-spacing": input.sizeSpacing, + }; +} + +/** Recovers the slider values a stored custom theme was built from. */ +export function toThemeInput(theme: CustomTheme): ThemeInput { + return { + baseHue: theme["--_base-h"] ?? DEFAULT_THEME_INPUT.baseHue, + baseChroma: + typeof theme["--_base-c-2"] === "number" + ? theme["--_base-c-2"] / BASE_CHROMA_MULTIPLIERS[2] + : DEFAULT_THEME_INPUT.baseChroma, + accentHue: theme["--_acc-h"] ?? DEFAULT_THEME_INPUT.accentHue, + accentChroma: theme["--_acc-c"] ?? DEFAULT_THEME_INPUT.accentChroma, + bgLightness: theme["--_base-l"] ?? DEFAULT_THEME_INPUT.bgLightness, + chatHue: theme["--_chat-h"], + radiusBox: theme["--_radius-box"] ?? DEFAULT_THEME_INPUT.radiusBox, + radiusField: theme["--_radius-field"] ?? DEFAULT_THEME_INPUT.radiusField, + radiusSelector: + theme["--_radius-selector"] ?? DEFAULT_THEME_INPUT.radiusSelector, + borderWidth: theme["--_border-width"] ?? DEFAULT_THEME_INPUT.borderWidth, + sizeField: theme["--_size-field"] ?? DEFAULT_THEME_INPUT.sizeField, + sizeSelector: theme["--_size-selector"] ?? DEFAULT_THEME_INPUT.sizeSelector, + sizeSpacing: theme["--_size-spacing"] ?? DEFAULT_THEME_INPUT.sizeSpacing, + }; +} + +/** Serializes the slider values into a share code other users can paste into their theme selector. */ +export function toShareCode(input: ThemeInput): string { + return SHARE_CODE_KEYS.map((key) => { + const value = input[key]; + return value === null ? "_" : String(value); + }).join(";"); +} + +/** Parses a share code made by `toShareCode()` (also older ones without the later added keys), returns null if it is not a valid theme. */ +export function fromShareCode(code: string): ThemeInput | null { + const parts = code.split(";"); + if ( + parts.length !== SHARE_CODE_KEYS.length && + parts.length !== LEGACY_SHARE_CODE_KEYS.length + ) { + return null; + } + + const raw: Record = {}; + for (let i = 0; i < parts.length; i++) { + const key = SHARE_CODE_KEYS[i]; + const part = parts[i].trim(); + + if (key === "chatHue" && part === "_") { + raw[key] = null; + continue; + } + + const num = Number(part); + if (Number.isNaN(num)) return null; + raw[key] = num; + } + + const parsed = v.safeParse(themeInputSchema, raw); + return parsed.success ? parsed.output : null; +} + +/** + * Every color of a built theme as vars.css resolves them, for verifying the accessibility and gamut guarantees. + */ +export function resolveColors(theme: CustomTheme) { + const base = (lightness: number, chromaIndex: number): Oklch => ({ + l: lightness, + c: theme[`--_base-c-${chromaIndex}` as "--_base-c-0"], + h: theme["--_base-h"], + }); + const slot = (prefix: "acc" | "second", index: number): Oklch => ({ + l: theme[`--_${prefix}-l-${index}` as "--_acc-l-0"], + c: theme[`--_${prefix}-c-${index}` as "--_acc-c-0"], + h: theme[`--_${prefix}-h-${index}` as "--_acc-h-0"], + }); + const lightBase = BASE_LIGHTNESS_VALUES.map((lightness, index) => + base(lightness, index), + ); + + const darkBg = base(theme["--_base-l"], 7); + + return { + /** light mode lightnesses, also used as dark mode text colors */ + base: lightBase, + dark: { + bg: darkBg, + bgHigh: base(theme["--_base-l"] + DARK_SURFACE_OFFSETS[6]!, 6), + bgHigher: base(theme["--_base-l"] + DARK_SURFACE_OFFSETS[5]!, 5), + text: lightBase[0], + textHigh: lightBase[3], + accentLow: slot("acc", 0), + accent: slot("acc", 1), + accentHigh: slot("acc", 2), + secondLow: slot("second", 0), + second: slot("second", 1), + secondHigh: slot("second", 2), + bgAccent: slot("acc", 0), + fgAccent: slot("acc", 2), + fillAccent: slot("acc", 2), + fgOnAccent: darkBg, + bgSecond: slot("second", 0), + fgSecond: slot("second", 2), + fillSecond: slot("second", 2), + fgOnSecond: darkBg, + }, + light: { + bg: lightBase[0], + bgHigh: lightBase[1], + bgHigher: lightBase[2], + textHigh: lightBase[4], + text: lightBase[7], + accentLow: slot("acc", 3), + accent: slot("acc", 4), + accentHigh: slot("acc", 5), + secondLow: slot("second", 3), + second: slot("second", 4), + secondHigh: slot("second", 5), + bgAccent: slot("acc", 3), + fgAccent: slot("acc", 4), + fillAccent: slot("acc", 6), + fgOnAccent: + theme["--_acc-fill-dark-text"] === 1 ? lightBase[7] : lightBase[0], + bgSecond: slot("second", 3), + fgSecond: slot("second", 4), + fillSecond: slot("second", 6), + fgOnSecond: + theme["--_second-fill-dark-text"] === 1 ? lightBase[7] : lightBase[0], + }, + }; +} + +/** + * Text/background color pairs of a built theme as vars.css resolves them, for verifying the accessibility guarantee. + */ +export function textContrastPairs(theme: CustomTheme) { + const { dark, light } = resolveColors(theme); + + return [ + { name: "dark text", fg: dark.text, bg: dark.bgHigher }, + { name: "dark text-high", fg: dark.textHigh, bg: dark.bgHigh }, + { name: "dark fg-accent", fg: dark.fgAccent, bg: dark.bgHigher }, + { + name: "dark fg-accent on bg-accent", + fg: dark.fgAccent, + bg: dark.bgAccent, + }, + { name: "dark fg-on-accent", fg: dark.fgOnAccent, bg: dark.fillAccent }, + { name: "dark text on bg-accent", fg: dark.text, bg: dark.bgAccent }, + { name: "dark fg-second", fg: dark.fgSecond, bg: dark.bgHigher }, + { + name: "dark fg-second on bg-second", + fg: dark.fgSecond, + bg: dark.bgSecond, + }, + { name: "dark fg-on-second", fg: dark.fgOnSecond, bg: dark.fillSecond }, + { name: "dark text on bg-second", fg: dark.text, bg: dark.bgSecond }, + { name: "light text", fg: light.text, bg: light.bgHigh }, + { name: "light text-high", fg: light.textHigh, bg: light.bg }, + { name: "light fg-accent", fg: light.fgAccent, bg: light.bgHigh }, + { + name: "light accent-high on low", + fg: light.accentHigh, + bg: light.accentLow, + }, + { name: "light fg-on-accent", fg: light.fgOnAccent, bg: light.fillAccent }, + { + name: "light fg-accent on bg-accent", + fg: light.fgAccent, + bg: light.bgAccent, + }, + { name: "light text on bg-accent", fg: light.text, bg: light.bgAccent }, + { name: "light fg-second", fg: light.fgSecond, bg: light.bgHigh }, + { + name: "light second-high on low", + fg: light.secondHigh, + bg: light.secondLow, + }, + { name: "light fg-on-second", fg: light.fgOnSecond, bg: light.fillSecond }, + { + name: "light fg-second on bg-second", + fg: light.fgSecond, + bg: light.bgSecond, + }, + { name: "light text on bg-second", fg: light.text, bg: light.bgSecond }, + ].map((pair) => ({ ...pair, contrast: contrastRatio(pair.fg, pair.bg) })); +} + +interface Surfaces { + dark: { bgHigher: Oklch }; + light: { bg: Oklch; bgHigh: Oklch; darkText: Oklch }; +} + +function buildPalette({ + hue, + chroma, + boostChroma, + surfaces, +}: { + hue: number; + chroma: number; + boostChroma: boolean; + surfaces: Surfaces; +}): Oklch[] { + const colorAt = (slot: AccentSlot) => (lightness: number) => + slotColor({ + lightness, + hue, + desiredChroma: chroma * slot.chromaMultiplier, + boostChroma, + }); + const startLightness = (slot: AccentSlot) => + slot.maxCuspLift + ? clamp(cuspLightness(hue), slot.lightness, slot.maxCuspLift) + : slot.lightness; + + const [darkLow, darkMid, darkHigh, lightLow, lightMid, lightHigh] = + ACCENT_SLOTS.map((slot) => colorAt(slot)(startLightness(slot))); + + return [ + darkLow, + darkMid, + ensureContrast({ + colorAt: colorAt(ACCENT_SLOTS[2]), + start: darkHigh, + against: [surfaces.dark.bgHigher, darkLow], + direction: "lighter", + }), + lightLow, + ensureContrast({ + colorAt: colorAt(ACCENT_SLOTS[4]), + start: lightMid, + against: [surfaces.light.bg, surfaces.light.bgHigh, lightLow], + direction: "darker", + }), + ensureContrast({ + colorAt: colorAt(ACCENT_SLOTS[5]), + start: lightHigh, + against: [lightLow], + direction: "darker", + }), + ]; +} + +/** Light mode fill (buttons, badges...), a bright fill with dark text is used when the hue can't be vivid while dark */ +function buildLightFill({ + hue, + chroma, + boostChroma, + darkFill, + darkText, +}: { + hue: number; + chroma: number; + boostChroma: boolean; + darkFill: Oklch; + darkText: Oklch; +}) { + const colorAt = (lightness: number) => + slotColor({ + lightness, + hue, + desiredChroma: chroma * BRIGHT_FILL.chromaMultiplier, + boostChroma, + }); + const brightFill = ensureContrast({ + colorAt, + start: colorAt( + clamp( + cuspLightness(hue), + BRIGHT_FILL.minLightness, + BRIGHT_FILL.maxLightness, + ), + ), + against: [darkText], + direction: "lighter", + }); + + if (brightFill.c > darkFill.c * BRIGHT_FILL.minChromaGain) { + return { color: brightFill, hasDarkText: true }; + } + + return { color: darkFill, hasDarkText: false }; +} + +function slotColor({ + lightness, + hue, + desiredChroma, + boostChroma, +}: { + lightness: number; + hue: number; + desiredChroma: number; + boostChroma: boolean; +}): Oklch { + // rounded before the gamut is computed so the stored values are exactly the checked ones + const roundedLightness = round(lightness); + const shiftedHue = round(shadeShiftedHue(hue, roundedLightness)); + const gamut = maxChroma(roundedLightness, shiftedHue); + const boost = boostChroma ? gamutBoost(roundedLightness, gamut) : 1; + + return { + l: roundedLightness, + c: Math.min(desiredChroma * boost, gamut), + h: shiftedHue, + }; +} + +function gamutBoost(lightness: number, gamut: number) { + const referenceGamut = maxChroma(lightness, GAMUT_BOOST.referenceHue); + if (referenceGamut <= 0) return 1; + + return clamp(gamut / referenceGamut, 1, GAMUT_BOOST.max); +} + +function shadeShiftedHue(hue: number, lightness: number) { + const weight = yellowWeight(hue); + if (weight === 0) return hue; + + const depth = clamp( + (cuspLightness(hue) - lightness) / SHADE_HUE_SHIFT.fullShiftDepth, + 0, + 1, + ); + + return ( + hue + + (SHADE_HUE_SHIFT.targetHue - hue) * + weight * + depth * + SHADE_HUE_SHIFT.strength + ); +} + +function yellowWeight(hue: number) { + const [fullStart, fullEnd] = SHADE_HUE_SHIFT.fullWeightHues; + const [zeroStart, zeroEnd] = SHADE_HUE_SHIFT.zeroWeightHues; + + if (hue <= zeroStart || hue >= zeroEnd) return 0; + if (hue < fullStart) return (hue - zeroStart) / (fullStart - zeroStart); + if (hue > fullEnd) return (zeroEnd - hue) / (zeroEnd - fullEnd); + return 1; +} + +/** Moves the color's lightness until it has enough contrast against every given color */ +function ensureContrast({ + colorAt, + start, + against, + direction, +}: { + colorAt: (lightness: number) => Oklch; + start: Oklch; + against: Oklch[]; + direction: "lighter" | "darker"; +}): Oklch { + const step = + direction === "lighter" ? LIGHTNESS_SEARCH_STEP : -LIGHTNESS_SEARCH_STEP; + const hasContrast = (candidate: Oklch) => + against.every( + (other) => contrastRatio(candidate, other) >= MIN_TEXT_CONTRAST, + ); + + let color = start; + while (!hasContrast(color)) { + const nextLightness = color.l + step; + if (nextLightness < 0 || nextLightness > 1) { + return colorAt(direction === "lighter" ? 1 : 0); + } + color = colorAt(nextLightness); + } + + return color; +} + +type SlotVars = Record< + `--_${Prefix}-${"l" | "c" | "h"}-${0 | 1 | 2 | 3 | 4 | 5}`, + number +>; + +function slotVars( + prefix: Prefix, + slots: Oklch[], +): SlotVars { + const result: Record = {}; + for (const [index, color] of slots.entries()) { + result[`--_${prefix}-l-${index}`] = color.l; + result[`--_${prefix}-c-${index}`] = color.c; + result[`--_${prefix}-h-${index}`] = color.h; + } + + return result as SlotVars; +} + +function round(value: number) { + return Math.round(value * 10_000) / 10_000; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} diff --git a/app/features/theme/theme-constants.ts b/app/features/theme/theme-constants.ts index 31548c359..7d0c45adf 100644 --- a/app/features/theme/theme-constants.ts +++ b/app/features/theme/theme-constants.ts @@ -16,7 +16,6 @@ export const CUSTOM_THEME_VARS = [ "--_acc-c-3", "--_acc-c-4", "--_acc-c-5", - "--_second-h", "--_second-c-0", "--_second-c-1", "--_second-c-2", @@ -31,6 +30,41 @@ export const CUSTOM_THEME_VARS = [ "--_size-field", "--_size-selector", "--_size-spacing", + + "--_base-l", + "--_acc-c", + "--_acc-l-0", + "--_acc-l-1", + "--_acc-l-2", + "--_acc-l-3", + "--_acc-l-4", + "--_acc-l-5", + "--_acc-l-6", + "--_acc-c-6", + "--_acc-h-0", + "--_acc-h-1", + "--_acc-h-2", + "--_acc-h-3", + "--_acc-h-4", + "--_acc-h-5", + "--_acc-h-6", + "--_acc-fill-dark-text", + "--_second-l-0", + "--_second-l-1", + "--_second-l-2", + "--_second-l-3", + "--_second-l-4", + "--_second-l-5", + "--_second-h-0", + "--_second-h-1", + "--_second-h-2", + "--_second-h-3", + "--_second-h-4", + "--_second-h-5", + "--_second-l-6", + "--_second-c-6", + "--_second-h-6", + "--_second-fill-dark-text", ] as const; export type CustomThemeVar = (typeof CUSTOM_THEME_VARS)[number]; @@ -40,7 +74,12 @@ export const PATRON_CHIP_THEME_VARS = [ "--_base-h", "--_base-c-2", "--_base-c-5", + "--_base-l", "--_acc-h", "--_acc-c-2", "--_acc-c-4", + "--_acc-l-2", + "--_acc-l-4", + "--_acc-h-2", + "--_acc-h-4", ] as const satisfies ReadonlyArray; diff --git a/app/features/tournament-admin/routes/to.$id.admin.seeds.module.css b/app/features/tournament-admin/routes/to.$id.admin.seeds.module.css index 414b7d321..57cbbc005 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.seeds.module.css +++ b/app/features/tournament-admin/routes/to.$id.admin.seeds.module.css @@ -135,8 +135,8 @@ .newBadge { flex-shrink: 0; - background-color: var(--color-second); - color: var(--color-text-inverse); + background-color: var(--color-fill-second); + color: var(--color-fg-on-second); font-size: var(--font-2xs); font-weight: var(--weight-bold); padding: 1px 4px; @@ -166,8 +166,8 @@ } .playerNewBadge { - background-color: var(--color-second); - color: var(--color-text-inverse); + background-color: var(--color-fill-second); + color: var(--color-fg-on-second); font-size: var(--font-2xs); font-weight: var(--weight-bold); padding: 1px 3px; diff --git a/app/features/tournament-bracket/components/Bracket/Match.module.css b/app/features/tournament-bracket/components/Bracket/Match.module.css index eadef155a..fb5eb716d 100644 --- a/app/features/tournament-bracket/components/Bracket/Match.module.css +++ b/app/features/tournament-bracket/components/Bracket/Match.module.css @@ -109,7 +109,7 @@ a.match:hover { } .matchSeed { - color: var(--color-text-accent); + color: var(--color-fg-accent); margin-inline-end: var(--s-0-5); min-width: 15px; max-width: 15px; diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.module.css b/app/features/tournament-bracket/components/BracketMapListDialog.module.css index 015e294fc..da1a2e8d8 100644 --- a/app/features/tournament-bracket/components/BracketMapListDialog.module.css +++ b/app/features/tournament-bracket/components/BracketMapListDialog.module.css @@ -58,9 +58,9 @@ } .roundButtonActive { - background-color: var(--color-text-accent); - color: var(--color-text-inverse); - border-color: var(--color-text-accent); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); + border-color: var(--color-fg-accent); } .roundButtonNumber { diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.tsx b/app/features/tournament-bracket/components/BracketMapListDialog.tsx index dcd9613b8..721d2ff90 100644 --- a/app/features/tournament-bracket/components/BracketMapListDialog.tsx +++ b/app/features/tournament-bracket/components/BracketMapListDialog.tsx @@ -1294,7 +1294,7 @@ function ModeListRow({
?
- + {isCounterpicks ? t("tournament:pickInfo.counterpick") : t("tournament:mapList.teamsPick")} @@ -1322,7 +1322,7 @@ function MysteryRow({
  • {number}. diff --git a/app/features/tournament-bracket/components/CustomFlowBuilder.module.css b/app/features/tournament-bracket/components/CustomFlowBuilder.module.css index 00c1ff33a..8ba9e8d3f 100644 --- a/app/features/tournament-bracket/components/CustomFlowBuilder.module.css +++ b/app/features/tournament-bracket/components/CustomFlowBuilder.module.css @@ -24,7 +24,7 @@ .paletteLabel { font-size: var(--font-xs); font-weight: var(--weight-semi); - color: var(--color-text-second); + color: var(--color-fg-second); min-width: 3rem; } @@ -96,7 +96,7 @@ .dragHandle { cursor: grab; - color: var(--color-text-second); + color: var(--color-fg-second); display: flex; align-items: center; touch-action: none; @@ -131,12 +131,8 @@ } .dropZoneOver { - border-color: var(--color-text-accent); - background-color: color-mix( - in srgb, - var(--color-text-accent) 10%, - transparent - ); + border-color: var(--color-fg-accent); + background-color: color-mix(in srgb, var(--color-fg-accent) 10%, transparent); } .dropZoneInvalid { @@ -154,7 +150,7 @@ .removeButton { background: none; border: none; - color: var(--color-text-second); + color: var(--color-fg-second); cursor: pointer; padding: 2px; display: flex; diff --git a/app/features/tournament-bracket/routes/to.$id.divisions.module.css b/app/features/tournament-bracket/routes/to.$id.divisions.module.css index f739e1d3e..bd7ab120f 100644 --- a/app/features/tournament-bracket/routes/to.$id.divisions.module.css +++ b/app/features/tournament-bracket/routes/to.$id.divisions.module.css @@ -15,7 +15,7 @@ border: var(--border-style); &:focus-visible { - outline: 3px solid var(--color-accent); + outline: 3px solid var(--color-fg-accent); outline-offset: 3px; } } @@ -25,7 +25,7 @@ outline-offset: 3px; & svg { - fill: var(--color-accent); + fill: var(--color-fg-accent); } } diff --git a/app/features/tournament-lfg/components/LFGGroupCard.module.css b/app/features/tournament-lfg/components/LFGGroupCard.module.css index 150c0ac1a..321e13ae1 100644 --- a/app/features/tournament-lfg/components/LFGGroupCard.module.css +++ b/app/features/tournament-lfg/components/LFGGroupCard.module.css @@ -51,7 +51,7 @@ .star { min-width: 18px; max-width: 18px; - color: var(--color-text-second); + color: var(--color-fg-second); stroke-width: 2; } diff --git a/app/features/tournament-lfg/routes/to.$id.looking.module.css b/app/features/tournament-lfg/routes/to.$id.looking.module.css index 2413ff38c..b988951a6 100644 --- a/app/features/tournament-lfg/routes/to.$id.looking.module.css +++ b/app/features/tournament-lfg/routes/to.$id.looking.module.css @@ -20,7 +20,7 @@ font-size: var(--font-2xs); font-weight: var(--weight-semi); text-transform: uppercase; - color: var(--color-text-accent); + color: var(--color-fg-accent); display: flex; align-items: center; diff --git a/app/features/tournament-organization/components/BannedPlayersList.module.css b/app/features/tournament-organization/components/BannedPlayersList.module.css index 38d5f1692..48417307e 100644 --- a/app/features/tournament-organization/components/BannedPlayersList.module.css +++ b/app/features/tournament-organization/components/BannedPlayersList.module.css @@ -20,7 +20,7 @@ .expandButton { background: none; border: none; - color: var(--color-accent); + color: var(--color-fg-accent); cursor: pointer; font-size: var(--font-2xs); padding: 0; diff --git a/app/features/tournament-organization/components/EventCalendar.module.css b/app/features/tournament-organization/components/EventCalendar.module.css index ccdf9cd1f..19f7e5c1e 100644 --- a/app/features/tournament-organization/components/EventCalendar.module.css +++ b/app/features/tournament-organization/components/EventCalendar.module.css @@ -68,7 +68,7 @@ } .calendarDayToday { - color: var(--color-text-accent); + color: var(--color-fg-accent); font-weight: var(--weight-bold); } diff --git a/app/features/tournament-organization/routes/org.$slug.module.css b/app/features/tournament-organization/routes/org.$slug.module.css index b970f4b28..6f4a727c1 100644 --- a/app/features/tournament-organization/routes/org.$slug.module.css +++ b/app/features/tournament-organization/routes/org.$slug.module.css @@ -50,7 +50,7 @@ & li::marker { font-size: var(--font-lg); font-weight: var(--weight-bold); - color: var(--color-accent); + color: var(--color-fg-accent); padding-inline-end: var(--s-2); } } diff --git a/app/features/tournament-organization/routes/org.$slug.stats.module.css b/app/features/tournament-organization/routes/org.$slug.stats.module.css index db0f9c5e8..93e62d8f3 100644 --- a/app/features/tournament-organization/routes/org.$slug.stats.module.css +++ b/app/features/tournament-organization/routes/org.$slug.stats.module.css @@ -27,7 +27,7 @@ .progressBar { height: 100%; border-radius: var(--radius-full); - background-color: var(--color-accent); + background-color: var(--color-fg-accent); transition: width 0.3s ease; } @@ -64,7 +64,7 @@ .breakdownBar { height: 100%; border-radius: var(--radius-full); - background-color: var(--color-accent); + background-color: var(--color-fg-accent); } .breakdownCount { diff --git a/app/features/tournament/components/TeamWithRoster.module.css b/app/features/tournament/components/TeamWithRoster.module.css index 78ec8ffb1..39ab0c981 100644 --- a/app/features/tournament/components/TeamWithRoster.module.css +++ b/app/features/tournament/components/TeamWithRoster.module.css @@ -97,8 +97,8 @@ .teamMemberNameRole { position: absolute; - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); width: 12px; height: 12px; border-radius: var(--radius-full); diff --git a/app/features/tournament/components/TournamentNav.module.css b/app/features/tournament/components/TournamentNav.module.css index 3a2d974ee..0f57cf928 100644 --- a/app/features/tournament/components/TournamentNav.module.css +++ b/app/features/tournament/components/TournamentNav.module.css @@ -98,7 +98,7 @@ } .linkActive { - color: var(--color-text-accent); + color: var(--color-fg-accent); background-color: var(--color-bg-high); } @@ -153,5 +153,5 @@ } .overflowLink.linkActive { - color: var(--color-text-accent); + color: var(--color-fg-accent); } diff --git a/app/features/tournament/routes/to.$id.register.module.css b/app/features/tournament/routes/to.$id.register.module.css index 58ae5bf10..3217828ae 100644 --- a/app/features/tournament/routes/to.$id.register.module.css +++ b/app/features/tournament/routes/to.$id.register.module.css @@ -55,7 +55,7 @@ gap: var(--s-1-5); font-size: var(--font-xs); font-weight: var(--weight-semi); - color: var(--color-text-accent); + color: var(--color-fg-accent); } .emptySlotRowOptional { @@ -70,11 +70,11 @@ place-items: center; border-radius: var(--radius-full); border: var(--border-style-accent); - color: var(--color-text-accent); + color: var(--color-fg-accent); } .emptySlotCircleOptional { - border: var(--border-width) dashed var(--color-text-accent); + border: var(--border-width) dashed var(--color-fg-accent); } .addMembers { diff --git a/app/features/tournament/routes/to.$id.results.module.css b/app/features/tournament/routes/to.$id.results.module.css index d7150c4a6..0eee54bbc 100644 --- a/app/features/tournament/routes/to.$id.results.module.css +++ b/app/features/tournament/routes/to.$id.results.module.css @@ -5,7 +5,7 @@ place-items: center; font-size: var(--font-xs); font-weight: var(--weight-semi); - border: 3px solid var(--color-accent); + border: 3px solid var(--color-fg-accent); border-radius: var(--radius-field); color: var(--color-text); } diff --git a/app/features/tournament/routes/to.$id.teams.$tid.module.css b/app/features/tournament/routes/to.$id.teams.$tid.module.css index 94c957b72..41fc270c1 100644 --- a/app/features/tournament/routes/to.$id.teams.$tid.module.css +++ b/app/features/tournament/routes/to.$id.teams.$tid.module.css @@ -147,7 +147,7 @@ display: flex; width: 100%; align-items: center; - color: var(--color-accent); + color: var(--color-fg-accent); font-size: var(--font-lg); &::before, diff --git a/app/features/trophies/components/TournamentSummaryRow.module.css b/app/features/trophies/components/TournamentSummaryRow.module.css index 3f5708a44..8fdf1ca36 100644 --- a/app/features/trophies/components/TournamentSummaryRow.module.css +++ b/app/features/trophies/components/TournamentSummaryRow.module.css @@ -30,8 +30,8 @@ height: var(--selector-size-xs); padding: 0 var(--s-1-5); border-radius: var(--radius-selector); - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); font-size: var(--font-2xs); font-weight: var(--weight-bold); } diff --git a/app/features/trophies/routes/trophies.new.module.css b/app/features/trophies/routes/trophies.new.module.css index 4310f22f2..64f423280 100644 --- a/app/features/trophies/routes/trophies.new.module.css +++ b/app/features/trophies/routes/trophies.new.module.css @@ -194,8 +194,8 @@ .editingBadge { padding: 0 var(--s-1-5); border-radius: var(--radius-selector); - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); font-size: var(--font-2xs); font-weight: var(--weight-bold); } diff --git a/app/features/user-page/components/ParticipationPill.module.css b/app/features/user-page/components/ParticipationPill.module.css index 9b1f2443b..91fd577d2 100644 --- a/app/features/user-page/components/ParticipationPill.module.css +++ b/app/features/user-page/components/ParticipationPill.module.css @@ -25,5 +25,5 @@ } .participating { - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); } diff --git a/app/features/user-page/components/UserPageIconNav.module.css b/app/features/user-page/components/UserPageIconNav.module.css index a73f3dd36..561fb6a3f 100644 --- a/app/features/user-page/components/UserPageIconNav.module.css +++ b/app/features/user-page/components/UserPageIconNav.module.css @@ -25,12 +25,12 @@ } &:focus-visible { - outline: 2px solid var(--color-text-second); + outline: 2px solid var(--color-fg-second); outline-offset: 2px; } &.active { - border-color: var(--color-text-second); + border-color: var(--color-fg-second); background-color: var(--color-bg-higher); } } diff --git a/app/features/user-page/components/Widget.module.css b/app/features/user-page/components/Widget.module.css index 596110979..eb7618341 100644 --- a/app/features/user-page/components/Widget.module.css +++ b/app/features/user-page/components/Widget.module.css @@ -33,7 +33,7 @@ font-weight: var(--weight-semi); text-transform: uppercase; letter-spacing: 0.05em; - color: var(--color-text-accent); + color: var(--color-fg-accent); } .content { @@ -468,8 +468,8 @@ height: var(--selector-size-xs); padding: 0 var(--s-1); border-radius: var(--radius-selector); - background-color: var(--color-text-second); - color: var(--color-text-inverse); + background-color: var(--color-fill-second); + color: var(--color-fg-on-second); font-size: var(--font-2xs); font-weight: var(--weight-semi); text-transform: uppercase; diff --git a/app/features/user-page/custom-theme-json.server.test.ts b/app/features/user-page/custom-theme-json.server.test.ts index 70afe1f7f..66753a1b0 100644 --- a/app/features/user-page/custom-theme-json.server.test.ts +++ b/app/features/user-page/custom-theme-json.server.test.ts @@ -1,13 +1,14 @@ import { describe, expect, test } from "vitest"; import * as UserFactory from "~/db/seed/factories/UserFactory"; -import { clampThemeToGamut } from "~/utils/oklch-gamut"; +import * as ThemePalette from "~/features/theme/core/ThemePalette"; import * as UserRepository from "./UserRepository.server"; -const CUSTOM_THEME = clampThemeToGamut({ +const CUSTOM_THEME = ThemePalette.build({ baseHue: 268, baseChroma: 0.05, accentHue: 253, accentChroma: 0.24, + bgLightness: 0.17, chatHue: null, radiusBox: 3, radiusField: 2, diff --git a/app/features/user-page/routes/u.$identifier.edit-widgets.module.css b/app/features/user-page/routes/u.$identifier.edit-widgets.module.css index f98c0f53e..8d6c7f8a6 100644 --- a/app/features/user-page/routes/u.$identifier.edit-widgets.module.css +++ b/app/features/user-page/routes/u.$identifier.edit-widgets.module.css @@ -247,12 +247,12 @@ .supporterMax { margin-left: var(--s-1); - color: var(--color-text-accent); + color: var(--color-fg-accent); font-weight: var(--weight-semi); } .supporterOnly { - color: var(--color-text-accent); + color: var(--color-fg-accent); font-size: var(--font-xs); font-weight: var(--weight-semi); white-space: nowrap; diff --git a/app/features/user-page/routes/u.$identifier.seasons.index.module.css b/app/features/user-page/routes/u.$identifier.seasons.index.module.css index f57c1c662..379e3f73a 100644 --- a/app/features/user-page/routes/u.$identifier.seasons.index.module.css +++ b/app/features/user-page/routes/u.$identifier.seasons.index.module.css @@ -67,7 +67,7 @@ place-items: center; font-size: var(--font-xs); font-weight: var(--weight-semi); - border: 3px solid var(--color-accent); + border: 3px solid var(--color-fg-accent); border-radius: var(--radius-field); color: var(--color-text); border-color: var(--color-error); diff --git a/app/features/user-page/routes/u.$identifier.seasons.stats.module.css b/app/features/user-page/routes/u.$identifier.seasons.stats.module.css index fcac860b0..7b7083ead 100644 --- a/app/features/user-page/routes/u.$identifier.seasons.stats.module.css +++ b/app/features/user-page/routes/u.$identifier.seasons.stats.module.css @@ -11,7 +11,7 @@ .seasonWeaponBorderOuter { --degree: 80deg; --smoothing: 0.5deg; - --color: var(--color-text-accent); + --color: var(--color-fg-accent); display: block; content: ""; diff --git a/app/features/user-report/components/ReportsBarChart.tsx b/app/features/user-report/components/ReportsBarChart.tsx index 0423e5ed1..9e22c5066 100644 --- a/app/features/user-report/components/ReportsBarChart.tsx +++ b/app/features/user-report/components/ReportsBarChart.tsx @@ -26,7 +26,7 @@ export function ReportsBarChart({ const isHydrated = useHydrated(); const colors = useThemeColors({ - bar: "--color-text-accent", + bar: "--color-fg-accent", border: "--color-border", borderHigh: "--color-border-high", text: "--color-text-high", diff --git a/app/form/fields/WeaponPoolFormField.module.css b/app/form/fields/WeaponPoolFormField.module.css index 72d13b81f..e6cc5a2bd 100644 --- a/app/form/fields/WeaponPoolFormField.module.css +++ b/app/form/fields/WeaponPoolFormField.module.css @@ -62,9 +62,9 @@ } .starIconOutlined { - color: var(--color-text-accent); + color: var(--color-fg-accent); } .starIconFilled { - fill: var(--color-text-accent); + fill: var(--color-fg-accent); } diff --git a/app/routes.ts b/app/routes.ts index 537659414..389bb3325 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -22,6 +22,10 @@ const devOnlyRoutes = "/components", "features/components-showcase/routes/components.tsx", ), + route( + "/components/colors", + "features/components-showcase/routes/components.colors.tsx", + ), route( "/comp-analyzer/all-ranges", "features/comp-analyzer/routes/comp-analyzer.all-ranges.tsx", diff --git a/app/styles/common.css b/app/styles/common.css index eea711042..7645502d7 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -8,7 +8,7 @@ color-scheme: light dark; /* only elements React names take part in view transitions, the rest of the page swaps instantly */ view-transition-name: none; - accent-color: var(--color-text-accent); + accent-color: var(--color-fg-accent); scroll-padding-top: var(--layout-sticky-top); } @@ -106,7 +106,7 @@ } a { - color: var(--color-text-accent); + color: var(--color-fg-accent); text-decoration: none; border-radius: var(--radius-field); @@ -216,6 +216,7 @@ input[type="checkbox"] { appearance: none; + position: relative; cursor: pointer; padding: 0; width: var(--selector-size-sm); @@ -235,19 +236,22 @@ &:checked, &:indeterminate { - border-color: var(--color-text-accent); - background-color: var(--color-text-accent); - background-repeat: no-repeat; - background-position: center; - background-size: var(--field-size-icon) auto; + border-color: var(--color-fill-accent); + background-color: var(--color-fill-accent); + + &::before { + content: ""; + position: absolute; + inset: 0; + background-color: var(--color-fg-on-accent); + mask: + var(--field-icon-checkbox) center / var(--field-size-icon) auto + no-repeat; + } } - &:checked { - background-image: var(--field-icon-checkbox); - } - - &:indeterminate { - background-image: var(--field-icon-minus); + &:indeterminate::before { + mask-image: var(--field-icon-minus); } /* Hacky selector to remove the margin bottom from the container from Label.tsx */ @@ -288,10 +292,10 @@ } &:checked { - border-color: var(--color-text-accent); + border-color: var(--color-fg-accent); &::after { - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); } } @@ -540,7 +544,7 @@ &:focus-within { --track-color: var(--color-bg-higher); - --thumb-color: var(--color-text-accent); + --thumb-color: var(--color-fg-accent); } &::-webkit-slider-runnable-track { @@ -603,7 +607,7 @@ border: none; border-radius: var(--radius-full); background-color: var(--color-bg-high); - color: var(--color-text-accent); + color: var(--color-fg-accent); vertical-align: baseline; &::-webkit-progress-bar { @@ -612,11 +616,11 @@ } &::-moz-progress-bar { - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); } &[value]::-webkit-progress-value { - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); transition: inline-size 0.2s ease; } } @@ -659,7 +663,7 @@ #nprogress { & .bar { - background: var(--color-text-accent) !important; + background: var(--color-fg-accent) !important; } & .spinner { diff --git a/app/styles/front.module.css b/app/styles/front.module.css index 8a2748c30..351a7d9f7 100644 --- a/app/styles/front.module.css +++ b/app/styles/front.module.css @@ -23,7 +23,7 @@ color: var(--color-text-high); &:hover { - color: var(--color-text-accent); + color: var(--color-fg-accent); } } diff --git a/app/styles/utils.css b/app/styles/utils.css index f7486f01d..541b171d9 100644 --- a/app/styles/utils.css +++ b/app/styles/utils.css @@ -43,8 +43,8 @@ color: var(--color-success); } - .text-accent-high { - color: var(--color-accent-high); + .text-accent { + color: var(--color-fg-accent); } .text-warning { @@ -52,11 +52,11 @@ } .text-theme { - color: var(--color-text-accent); + color: var(--color-fg-accent); } .text-theme-secondary { - color: var(--color-text-second); + color: var(--color-fg-second); } .text-uppercase { diff --git a/app/styles/vars.css b/app/styles/vars.css index 393fc096c..14df2d430 100644 --- a/app/styles/vars.css +++ b/app/styles/vars.css @@ -4,16 +4,17 @@ /* Tokens settable by custom themes and their defaults. Not to be consumed directly, only as a base -for other vars. Hue and chroma values are generated with oklch-gamut.ts, do not edit by hand. +for other vars. Values are generated with ThemePalette.ts, do not edit by hand. A subtree can force a color scheme with [data-theme="dark"|"light"] and opt out of an active custom theme with [data-default-theme] (e.g. image export previews) */ html, [data-default-theme] { + --_base-l: 0.17; --_base-h: 268; --_base-c-0: 0; - --_base-c-1: 0.02357547860966049; + --_base-c-1: 0.02356547860966049; --_base-c-2: 0.031; --_base-c-3: 0.06999999999999999; --_base-c-4: 0.0645; @@ -21,21 +22,53 @@ html, --_base-c-6: 0.0645; --_base-c-7: 0.0335; + --_acc-c: 0.24; --_acc-h: 253; - --_acc-c-0: 0.0801740871856143; - --_acc-c-1: 0.1603481743712286; + --_acc-l-0: 0.26; + --_acc-l-1: 0.52; + --_acc-l-2: 0.83; + --_acc-l-3: 0.88; + --_acc-l-4: 0.485; + --_acc-l-5: 0.32; + --_acc-l-6: 0.485; + --_acc-c-0: 0.08016408718561431; + --_acc-c-1: 0.1603381743712286; --_acc-c-2: 0.0816; --_acc-c-3: 0.06; - --_acc-c-4: 0.16343179310913686; - --_acc-c-5: 0.09867579961306376; + --_acc-c-4: 0.14954550878854975; + --_acc-c-5: 0.09866579961306376; + --_acc-c-6: 0.14954550878854975; + --_acc-h-0: 253; + --_acc-h-1: 253; + --_acc-h-2: 253; + --_acc-h-3: 253; + --_acc-h-4: 253; + --_acc-h-5: 253; + --_acc-h-6: 253; + --_acc-fill-dark-text: 0; - --_second-h: 73; + --_second-l-0: 0.26; + --_second-l-1: 0.52; + --_second-l-2: 0.83; + --_second-l-3: 0.88; + --_second-l-4: 0.485; + --_second-l-5: 0.32; --_second-c-0: 0.055379201356259726; --_second-c-1: 0.11075840271251945; --_second-c-2: 0.0816; --_second-c-3: 0.06; - --_second-c-4: 0.11288837199545253; + --_second-c-4: 0.10330351022225373; --_second-c-5: 0.06815901705385813; + --_second-h-0: 73; + --_second-h-1: 73; + --_second-h-2: 73; + --_second-h-3: 73; + --_second-h-4: 73; + --_second-h-5: 73; + --_second-l-6: 0.8007; + --_second-c-6: 0.1704874227120647; + --_second-h-6: 73; + --_second-fill-dark-text: 1; --_radius-box: 3; --_radius-field: 2; @@ -54,15 +87,25 @@ html, --color-base-x is rarely consumed directly, it is the base for vars like --color-text and --color-bg. ---color-text-accent and --color-text-second keep high contrast as a background or highlight in -both modes; pair with --color-text-inverse as the text color. +Accent and secondary colors come in four kinds of token: +- --color-bg-x: surfaces (tinted chips, highlighted rows...) +- --color-fg-x: foreground on surfaces: text, icons, borders, outlines and indicators (dots, + bars, checkboxes...). Readable on any --color-bg-x surface +- --color-fill-x: fills that carry content (buttons, badges...) +- --color-fg-on-x: foreground on a --color-fill-x fill. In light mode a yellow accent e.g. gets a + bright fill with dark text while --color-fg-accent stays dark enough to read +--color-accent-x and --color-second-x are the building blocks behind these and not meant to be +consumed directly. Info, success, warning, error: --color-x-high for text, --color-x-low for backgrounds, --color-x for borders and icons. -Field icons are defined per mode because SVGs cannot use currentColor inside data URLs. +The checkbox icons are masks colored by the checkbox. The select and date/time icons are background +images with a hardcoded color per mode, since those elements render no pseudo-element to mask and +SVGs cannot use currentColor inside data URLs. -Any changes here NEED to be reflected in oklch-gamut.ts as well +Custom theme lightness, chroma and hue values are generated by ThemePalette.ts which guarantees +their contrast. Any changes here NEED to be reflected in ThemePalette.ts as well */ html.dark, html.dark [data-custom-theme], @@ -72,26 +115,34 @@ html.dark [data-custom-theme], --color-base-2: oklch(90% var(--_base-c-2) var(--_base-h)); --color-base-3: oklch(64% var(--_base-c-3) var(--_base-h)); --color-base-4: oklch(46% var(--_base-c-4) var(--_base-h)); - --color-base-5: oklch(32% var(--_base-c-5) var(--_base-h)); - --color-base-6: oklch(25% var(--_base-c-6) var(--_base-h)); - --color-base-7: oklch(17% var(--_base-c-7) var(--_base-h)); + --color-base-5: oklch( + calc(var(--_base-l) + 0.15) var(--_base-c-5) var(--_base-h) + ); + --color-base-6: oklch( + calc(var(--_base-l) + 0.08) var(--_base-c-6) var(--_base-h) + ); + --color-base-7: oklch(var(--_base-l) var(--_base-c-7) var(--_base-h)); - --color-accent-low: oklch(26% var(--_acc-c-0) var(--_acc-h)); - --color-accent: oklch(52% var(--_acc-c-1) var(--_acc-h)); - --color-accent-high: oklch(83% var(--_acc-c-2) var(--_acc-h)); + --color-accent-low: oklch(var(--_acc-l-0) var(--_acc-c-0) var(--_acc-h-0)); + --color-accent: oklch(var(--_acc-l-1) var(--_acc-c-1) var(--_acc-h-1)); + --color-accent-high: oklch(var(--_acc-l-2) var(--_acc-c-2) var(--_acc-h-2)); --color-second-low: oklch( - from var(--color-accent-low) l var(--_second-c-0) var(--_second-h) + var(--_second-l-0) var(--_second-c-0) var(--_second-h-0) ); --color-second: oklch( - from var(--color-accent) l var(--_second-c-1) var(--_second-h) + var(--_second-l-1) var(--_second-c-1) var(--_second-h-1) ); --color-second-high: oklch( - from var(--color-accent-high) l var(--_second-c-2) var(--_second-h) + var(--_second-l-2) var(--_second-c-2) var(--_second-h-2) ); - --color-text-accent: var(--color-accent-high); - --color-text-second: var(--color-second-high); + --color-fill-accent: var(--color-accent-high); + --color-fill-second: var(--color-second-high); + --color-fg-accent: var(--color-accent-high); + --color-fg-second: var(--color-second-high); + --color-fg-on-accent: var(--color-base-7); + --color-fg-on-second: var(--color-base-7); --color-info-low: oklch(26% 0.09 275); --color-info: oklch(52% 0.27 275); @@ -112,8 +163,6 @@ html.dark [data-custom-theme], --color-chart-alpha: #da5b76; --color-chart-bravo: #6572e4; - --field-icon-checkbox: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(20, 20, 20)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); - --field-icon-minus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(20, 20, 20)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E"); /* inlined instead of var() because data URIs can't read custom properties: keep in sync with --color-text-high */ --field-icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(122, 139, 183)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); --field-icon-date: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(122, 139, 183)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E"); @@ -132,22 +181,34 @@ html.light [data-custom-theme], --color-base-6: oklch(95% var(--_base-c-1) var(--_base-h)); --color-base-7: oklch(100% var(--_base-c-0) var(--_base-h)); - --color-accent-low: oklch(88% var(--_acc-c-3) var(--_acc-h)); - --color-accent: oklch(53% var(--_acc-c-4) var(--_acc-h)); - --color-accent-high: oklch(32% var(--_acc-c-5) var(--_acc-h)); + --color-accent-low: oklch(var(--_acc-l-3) var(--_acc-c-3) var(--_acc-h-3)); + --color-accent: oklch(var(--_acc-l-4) var(--_acc-c-4) var(--_acc-h-4)); + --color-accent-high: oklch(var(--_acc-l-5) var(--_acc-c-5) var(--_acc-h-5)); --color-second-low: oklch( - from var(--color-accent-low) l var(--_second-c-0) var(--_second-h) + var(--_second-l-3) var(--_second-c-3) var(--_second-h-3) ); --color-second: oklch( - from var(--color-accent) l var(--_second-c-1) var(--_second-h) + var(--_second-l-4) var(--_second-c-4) var(--_second-h-4) ); --color-second-high: oklch( - from var(--color-accent-high) l var(--_second-c-2) var(--_second-h) + var(--_second-l-5) var(--_second-c-5) var(--_second-h-5) ); - --color-text-accent: var(--color-accent); - --color-text-second: var(--color-second); + --color-fill-accent: oklch(var(--_acc-l-6) var(--_acc-c-6) var(--_acc-h-6)); + --color-fill-second: oklch( + var(--_second-l-6) var(--_second-c-6) var(--_second-h-6) + ); + --color-fg-accent: var(--color-accent); + --color-fg-second: var(--color-second); + --color-fg-on-accent: oklch( + calc(1 - 0.83 * var(--_acc-fill-dark-text)) + calc(var(--_base-c-7) * var(--_acc-fill-dark-text)) var(--_base-h) + ); + --color-fg-on-second: oklch( + calc(1 - 0.83 * var(--_second-fill-dark-text)) + calc(var(--_base-c-7) * var(--_second-fill-dark-text)) var(--_base-h) + ); --color-info-low: oklch(87.897% 0.0555 279.573); --color-info: oklch(53.03% 0.24331 270.147); @@ -168,8 +229,6 @@ html.light [data-custom-theme], --color-chart-alpha: #b82851; --color-chart-bravo: #4246c2; - --field-icon-checkbox: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); - --field-icon-minus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E"); /* inlined instead of var() because data URIs can't read custom properties: keep in sync with --color-text-high */ --field-icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(72, 87, 124)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); --field-icon-date: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(72, 87, 124)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E"); @@ -196,15 +255,21 @@ html, ); --color-bg-ability: oklch(11.736% 0.00867 215.976); --color-bg-badge: oklch(0% 0 0); + --color-bg-accent: var(--color-accent-low); + --color-bg-second: var(--color-second-low); + + /* masks, so the color is set by the checkbox */ + --field-icon-checkbox: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); + --field-icon-minus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E"); --color-border: var(--color-base-5); --color-border-high: var(--color-base-4); --border-width: calc(var(--_border-width) * 1px); --border-style: var(--border-width) solid var(--color-border); --border-style-high: var(--border-width) solid var(--color-border-high); - --border-style-accent: var(--border-width) solid var(--color-text-accent); + --border-style-accent: var(--border-width) solid var(--color-fg-accent); - --focus-ring: 2px solid var(--color-text-accent); + --focus-ring: 2px solid var(--color-fg-accent); --focus-ring-error: 2px solid var(--color-error); --font-xl: 1.5rem; diff --git a/app/utils/oklch-gamut.ts b/app/utils/oklch-gamut.ts index 29683635b..95a71f35f 100644 --- a/app/utils/oklch-gamut.ts +++ b/app/utils/oklch-gamut.ts @@ -1,8 +1,10 @@ -import type * as v from "valibot"; -import type { CustomTheme } from "~/db/tables-json"; -import type { themeInputSchema } from "~/utils/schema"; - -export type ThemeInput = v.InferOutput; +export interface Oklch { + /** Lightness, 0-1 */ + l: number; + c: number; + /** Hue in degrees */ + h: number; +} interface Lab { L: number; @@ -224,117 +226,67 @@ function maximum_chroma_for_lh(L: number, h: number): number { return t; } -// These are the lightness values used in vars.css -// Any changes here NEED to be reflected in vars.css as well. +const GAMUT_TIGHTEN_STEP = 0.00001; -const BASE_LIGHTNESS_VALUES = [ - 1.0, // --base-c-0 - 0.95, // --base-c-1 - 0.9, // --base-c-2 - 0.64, // --base-c-3 - 0.46, // --base-c-4 - 0.32, // --base-c-5 - 0.25, // --base-c-6 - 0.17, // --base-c-7 -] as const; +/** Highest chroma that stays inside the sRGB gamut at the given lightness (0-1) and hue (degrees). */ +export function maxChroma(lightness: number, hueDegrees: number): number { + let chroma = Math.max( + 0, + maximum_chroma_for_lh(lightness, toRadians(hueDegrees)), + ); -const ACCENT_LIGHTNESS_VALUES = [ - 0.26, // --acc-c-0: dark mode low - 0.52, // --acc-c-1: dark mode mid - 0.83, // --acc-c-2: dark mode high - 0.88, // --acc-c-3: light mode low - 0.53, // --acc-c-4: light mode mid - 0.32, // --acc-c-5: light mode high -] as const; + // the estimate can land a hair outside the gamut + while ( + chroma > 0 && + !isInSrgbGamut({ l: lightness, c: chroma, h: hueDegrees }) + ) { + chroma = Math.max(0, chroma - GAMUT_TIGHTEN_STEP); + } -export const BASE_CHROMA_MULTIPLIERS = [ - 0.01, // --base-c-0 - 0.49, // --base-c-1 - 0.62, // --base-c-2 - 1.4, // --base-c-3 - 1.29, // --base-c-4 - 1.36, // --base-c-5 - 1.29, // --base-c-6 - 0.67, // --base-c-7 -] as const; - -export const ACCENT_CHROMA_MULTIPLIERS = [ - 0.38, // --acc-c-0 - 1.11, // --acc-c-1 - 0.34, // --acc-c-2 - 0.25, // --acc-c-3 - 1.09, // --acc-c-4 - 0.56, // --acc-c-5 -] as const; - -function clampChromaForColor( - lightness: number, - desiredChroma: number, - hueRadians: number, -): number { - const maxChroma = maximum_chroma_for_lh(lightness, hueRadians); - return Math.min(desiredChroma, maxChroma); + return chroma; } -export function clampThemeToGamut(input: ThemeInput): CustomTheme { - const baseHueRadians = input.baseHue * (Math.PI / 180); - const accentHueRadians = input.accentHue * (Math.PI / 180); +/** Whether the color can be displayed in sRGB without clipping. */ +export function isInSrgbGamut(color: Oklch): boolean { + const { r, g, b } = oklchToLinearSrgb(color); - const clampedBaseChromas = BASE_LIGHTNESS_VALUES.map((lightness, index) => { - const desiredChroma = input.baseChroma * BASE_CHROMA_MULTIPLIERS[index]; - return clampChromaForColor(lightness, desiredChroma, baseHueRadians); + return [r, g, b].every((channel) => channel >= 0 && channel <= 1); +} + +/** Lightness (0-1) at which the hue reaches its most saturated in-gamut color. */ +export function cuspLightness(hueDegrees: number): number { + const radians = toRadians(hueDegrees); + return find_cusp(Math.cos(radians), Math.sin(radians)).L; +} + +/** WCAG 2 contrast ratio (1-21) between two colors. */ +export function contrastRatio(first: Oklch, second: Oklch): number { + const [lighter, darker] = [ + relativeLuminance(first), + relativeLuminance(second), + ].sort((a, b) => b - a); + + return (lighter + 0.05) / (darker + 0.05); +} + +function relativeLuminance(color: Oklch): number { + const { r, g, b } = oklchToLinearSrgb(color); + + return ( + 0.2126 * clamp(r, 0, 1) + 0.7152 * clamp(g, 0, 1) + 0.0722 * clamp(b, 0, 1) + ); +} + +function oklchToLinearSrgb(color: Oklch) { + const radians = toRadians(color.h); + + return oklab_to_linear_srgb({ + L: color.l, + a: color.c * Math.cos(radians), + b: color.c * Math.sin(radians), }); - - const clampedAccentChromas = ACCENT_LIGHTNESS_VALUES.map( - (lightness, index) => { - const desiredChroma = - input.accentChroma * ACCENT_CHROMA_MULTIPLIERS[index]; - return clampChromaForColor(lightness, desiredChroma, accentHueRadians); - }, - ); - - const secondaryHue = (input.accentHue + 180) % 360; - const secondaryHueRadians = secondaryHue * (Math.PI / 180); - - const clampedSecondaryChromas = ACCENT_LIGHTNESS_VALUES.map( - (lightness, index) => { - const desiredChroma = - input.accentChroma * ACCENT_CHROMA_MULTIPLIERS[index]; - return clampChromaForColor(lightness, desiredChroma, secondaryHueRadians); - }, - ); - - return { - "--_base-h": input.baseHue, - "--_base-c-0": clampedBaseChromas[0], - "--_base-c-1": clampedBaseChromas[1], - "--_base-c-2": clampedBaseChromas[2], - "--_base-c-3": clampedBaseChromas[3], - "--_base-c-4": clampedBaseChromas[4], - "--_base-c-5": clampedBaseChromas[5], - "--_base-c-6": clampedBaseChromas[6], - "--_base-c-7": clampedBaseChromas[7], - "--_acc-h": input.accentHue, - "--_acc-c-0": clampedAccentChromas[0], - "--_acc-c-1": clampedAccentChromas[1], - "--_acc-c-2": clampedAccentChromas[2], - "--_acc-c-3": clampedAccentChromas[3], - "--_acc-c-4": clampedAccentChromas[4], - "--_acc-c-5": clampedAccentChromas[5], - "--_second-h": secondaryHue, - "--_second-c-0": clampedSecondaryChromas[0], - "--_second-c-1": clampedSecondaryChromas[1], - "--_second-c-2": clampedSecondaryChromas[2], - "--_second-c-3": clampedSecondaryChromas[3], - "--_second-c-4": clampedSecondaryChromas[4], - "--_second-c-5": clampedSecondaryChromas[5], - "--_chat-h": input.chatHue, - "--_radius-box": input.radiusBox, - "--_radius-field": input.radiusField, - "--_radius-selector": input.radiusSelector, - "--_border-width": input.borderWidth, - "--_size-field": input.sizeField, - "--_size-selector": input.sizeSelector, - "--_size-spacing": input.sizeSpacing, - }; +} + +function toRadians(degrees: number) { + return degrees * (Math.PI / 180); } diff --git a/app/utils/schema.ts b/app/utils/schema.ts index 1d63266ba..69fb50330 100644 --- a/app/utils/schema.ts +++ b/app/utils/schema.ts @@ -127,7 +127,11 @@ export const THEME_INPUT_LIMITS = { ACCENT_HUE_MIN: 0, ACCENT_HUE_MAX: 360, ACCENT_CHROMA_MIN: 0, - ACCENT_CHROMA_MAX: 0.3, + ACCENT_CHROMA_MAX: 0.5, + BG_LIGHTNESS_MIN: 0.06, + BG_LIGHTNESS_MAX: 0.17, + BG_LIGHTNESS_STEP: 0.01, + BG_LIGHTNESS_DEFAULT: 0.17, RADIUS_MIN: 0, RADIUS_MAX: 5, RADIUS_STEP: 1, @@ -166,6 +170,23 @@ export const themeInputSchema = v.object({ v.minValue(THEME_INPUT_LIMITS.ACCENT_CHROMA_MIN), v.maxValue(THEME_INPUT_LIMITS.ACCENT_CHROMA_MAX), ), + bgLightness: v.optional( + v.pipe( + v.number(), + v.minValue(THEME_INPUT_LIMITS.BG_LIGHTNESS_MIN), + v.maxValue(THEME_INPUT_LIMITS.BG_LIGHTNESS_MAX), + v.check( + (val) => + isValidStep( + val, + THEME_INPUT_LIMITS.BG_LIGHTNESS_MIN, + THEME_INPUT_LIMITS.BG_LIGHTNESS_STEP, + ), + "Must be a valid step increment", + ), + ), + THEME_INPUT_LIMITS.BG_LIGHTNESS_DEFAULT, + ), chatHue: v.nullable( v.pipe( v.number(), diff --git a/changelog/2026-09-22-custom-theme-colors.md b/changelog/2026-09-22-custom-theme-colors.md new file mode 100644 index 000000000..bd0f4aa25 --- /dev/null +++ b/changelog/2026-09-22-custom-theme-colors.md @@ -0,0 +1,10 @@ +--- +type: feature +--- +Custom theme improvements + +- Yellow and other light accent colors are now vivid instead of muddy +- In light mode buttons, toggles, checkboxes and badges with a light accent or secondary color are bright with dark text +- New slider to make the dark mode background darker +- Accent chroma slider goes higher so every hue can be as vivid as the screen allows +- Accent colored text on tinted backgrounds (chips, tags) is easier to read in light mode diff --git a/docs/styles.md b/docs/styles.md index 9d98a5217..2a9e40b82 100644 --- a/docs/styles.md +++ b/docs/styles.md @@ -1,54 +1,64 @@ # Custom Theme System -The custom theme system lets Patreon supporters customize the sites colors, border radii, sizes, and border widths. Custom themes are created using as a small set of inputs which are expanded into a full set of CSS properties via gamut clamping. +The custom theme system lets Patreon supporters customize the sites colors, border radii, sizes, and border widths. Custom themes are created using a small set of inputs (slider values) which are expanded into a full set of CSS properties by `ThemePalette.build()`. The original intention of the system is that no combination of inputs can produce inaccessible colors. ## Files | File | Purpose | | ------ | --------- | | `app/styles/vars.css` | Default CSS custom property values and semantic tokens | -| `app/utils/oklch-gamut.ts` | Gamut clamping math, lightness values, chroma multipliers | +| `app/features/theme/core/ThemePalette.ts` | Expands the inputs into theme variables, `DEFAULT_THEME_INPUT`, lightness values, chroma multipliers | +| `app/utils/oklch-gamut.ts` | Color math: sRGB gamut limits and WCAG contrast | | `app/utils/schema.ts` | `themeInputSchema` and `THEME_INPUT_LIMITS` for validation | -| `app/db/tables.ts` | `CustomTheme` type and `CUSTOM_THEME_VARS` list | -| `app/components/CustomThemeSelector.tsx` | UI component, `DEFAULT_THEME_INPUT` | +| `app/features/theme/theme-constants.ts` | `CUSTOM_THEME_VARS` list | +| `app/components/CustomThemeSelector.tsx` | UI component | | `app/root.tsx` | `useCustomThemeVars()` applies theme to `` element | +## Semantic tokens + +Feature code never uses the palette slots (`--color-accent`, `--color-accent-low`...) directly. Accent and secondary colors are consumed through four kinds of tokens: + +| Token | Role | Example | +| ------ | ----- | -------- | +| `--color-bg-x` | Tinted surface | Container background, highlighted row | +| `--color-fg-x` | Foreground on surfaces | Text, icons, borders, outlines, indicator dots and bars | +| `--color-fill-x` | Fill that holds content | Button, badge background | +| `--color-fg-on-x` | Foreground on a fill | Button label, badge text | + +`--color-fg-x` is readable on the page surfaces and on `--color-bg-x`. `--color-fg-on-x` is only readable on `--color-fill-x`, so always put a fill and its foreground together. + +## How colors are generated + +Every color slot has a designed lightness (e.g. dark mode `--color-accent-high` is 83%). `build()` then: + +1. **Lifts light slots toward the hue's cusp.** Hues like yellow are only vivid when very light, at 83% they'd be a muddy khaki. Slots with `maxCuspLift` are raised toward the lightness where the hue is at its most saturated. +2. **Rotates dark yellow shades toward amber.** Dark yellow reads as olive, so yellow hues get their hue shifted (`SHADE_HUE_SHIFT`) the further below their cusp they are. +3. **Boosts chroma for hues with a larger gamut.** The accent chroma is scaled by how much more chroma the hue can have than the default accent hue at that lightness (`GAMUT_BOOST`), so a yellow can be as vivid as the default blue. +4. **Solves for contrast.** Text colors are moved lighter/darker until they have at least 4.5:1 (WCAG AA) against every surface they are shown on, including the tinted `--color-bg-accent` / `--color-bg-second` surfaces. +5. **Picks the light mode fills.** `--color-fill-accent` and `--color-fill-second` (buttons, badges) are normally the same as the foreground color with white text on it. When a bright fill would be much more colorful (yellow, cyan...) it becomes a bright fill with dark text instead (`--_acc-fill-dark-text`, `--_second-fill-dark-text`). + +The dark mode background lightness (`--_base-l`) is a slider of its own. Dark mode surfaces (`--color-base-5...7`) keep their distance from it. + +`ThemePalette.test.ts` sweeps inputs, checks the contrast of every text/background pair (`textContrastPairs()`) and that every resolved color (`resolveColors()`) is inside the sRGB gamut. It also evaluates the `oklch()` and `calc()` expressions of `vars.css` for the default theme and checks that every `--color-x` resolves to the same value as the matching key of `resolveColors()`, so the lightness values, surface offsets and slot mapping cannot drift apart between the two files. + ## Changing Default Theme Values -The default theme is defined in **three places that must stay in sync**: - -### 1. `vars.css` — CSS defaults - -The `html { }` block at the beginning of `vars.css` contains the default values that apply when no custom theme is active. These are the output of `clampThemeToGamut(DEFAULT_THEME_INPUT)`. - -### 2. `oklch-gamut.ts` — Lightness and multiplier constants - -`BASE_LIGHTNESS_VALUES`, `ACCENT_LIGHTNESS_VALUES`, `BASE_CHROMA_MULTIPLIERS`, and `ACCENT_CHROMA_MULTIPLIERS` define how the input chroma/hue maps to each color step. The lightness values must match the percentages used in the `oklch()` calls in `vars.css`. - -### 3. `CustomThemeSelector.tsx` — `DEFAULT_THEME_INPUT` - -The `DEFAULT_THEME_INPUT` object defines the default slider positions (hue, chroma, radius, etc.). Running `clampThemeToGamut(DEFAULT_THEME_INPUT)` should produce values matching the CSS defaults in `vars.css`. +`ThemePalette.build(DEFAULT_THEME_INPUT)` must produce the values in the first block of `vars.css`. A unit test verifies this. ### Update procedure -When changing the default theme: - -1. Edit `DEFAULT_THEME_INPUT` in `CustomThemeSelector.tsx` with the new input values -2. Run `clampThemeToGamut(DEFAULT_THEME_INPUT)` to get the output CSS variable values -3. Update `vars.css` with the output values in the `html { }` block -4. Verify lightness values in `vars.css` `oklch()` calls still match `BASE_LIGHTNESS_VALUES` and `ACCENT_LIGHTNESS_VALUES` in `oklch-gamut.ts` - -When changing lightness values or chroma multipliers: - -1. Edit the constants in `oklch-gamut.ts` -2. Update the matching `oklch()` percentages in `vars.css` (e.g. if you change `BASE_LIGHTNESS_VALUES[1]` from `0.94873` you must also update `oklch(94.873% ...)` in `vars.css`) -3. Recompute and update the default chroma values in `vars.css` by running `clampThemeToGamut(DEFAULT_THEME_INPUT)` with the new multipliers +1. Edit `DEFAULT_THEME_INPUT` or the constants in `ThemePalette.ts` +2. Run `ThemePalette.build(DEFAULT_THEME_INPUT)` to get the output CSS variable values +3. Update `vars.css` with the output values +4. If lightness values or offsets changed, update the `oklch()` / `calc()` calls in `vars.css` to match (the sync test fails until they do) ## Gotchas -### Gamut clamping makes reverse engineering unreliable +### Adding theme variables -You cannot always recover the original `baseChroma` from a stored `CustomTheme`. At high lightness values, sRGB cannot display much chroma, so the clamping function reduces it. Dividing a clamped output by its multiplier gives a number **smaller** than the true input. Indices 2–7 are more reliable but recovery success depends on the specific hue. ``themeInputFromCustomTheme()`` uses the second indice for input recovery. +Stored themes are the output of `build()` at the time they were saved, and every stored theme is expected to have every variable in `CUSTOM_THEME_VARS`. When adding a variable, add a migration that backfills it into `User.customTheme` and `AllTeam.customTheme` (see `migrations/20260922181213-custom-theme-palette-vars.ts`). Backfill the value that reproduces how existing themes render, so they only change when re-saved. + +`toThemeInput()` recovers the slider values from a stored theme. The accent chroma is stored as is (`--_acc-c`) because the gamut boost makes it impossible to reverse from the output. ### Size and border vars are for users only diff --git a/locales/da/common.json b/locales/da/common.json index 25b40ab51..7acaf0d5c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/de/common.json b/locales/de/common.json index 560fb7633..8cdcc4ec2 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/en/common.json b/locales/en/common.json index 3725ae932..2126831b1 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "Base chroma", "settings.customTheme.accentHue": "Accent hue", "settings.customTheme.accentChroma": "Accent chroma", + "settings.customTheme.bgLightness": "Dark mode background brightness", "settings.customTheme.chatHueToggle": "Use custom chat name color", "settings.customTheme.chatHue": "Chat name color", "settings.customTheme.boxes": "Boxes", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 310a93927..15a995655 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "Croma base", "settings.customTheme.accentHue": "Tono de contraste", "settings.customTheme.accentChroma": "Croma de contraste", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "Usar color de nombre personalizado en el chat", "settings.customTheme.chatHue": "Color del nombre en el chat", "settings.customTheme.boxes": "Cajas", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index b3321022a..fb12eaf6b 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "Croma base", "settings.customTheme.accentHue": "Tono de contraste", "settings.customTheme.accentChroma": "Croma de contraste", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "Usar color de nombre personalizado en el chat", "settings.customTheme.chatHue": "Color del nombre en el chat", "settings.customTheme.boxes": "Cajas", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 1d760c4a3..68aef4638 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index 1f8d8b680..7cf0248a8 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/he/common.json b/locales/he/common.json index 49edd753b..6a4ecb14b 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/it/common.json b/locales/it/common.json index 54e2e3ce8..bceff16e7 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/ja/common.json b/locales/ja/common.json index 46a3474f7..9ba0ba8ad 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "彩度一色目", "settings.customTheme.accentHue": "色合い二色目", "settings.customTheme.accentChroma": "彩度二色目", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "カスタムカラーをチャットの名前に使用する", "settings.customTheme.chatHue": "チャットの名前の色", "settings.customTheme.boxes": "", diff --git a/locales/ko/common.json b/locales/ko/common.json index a2e2289e2..23add5c0a 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/nl/common.json b/locales/nl/common.json index 714f11d27..a892c1c0b 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/pl/common.json b/locales/pl/common.json index d69c02eb1..c8d3d7139 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index 244903da1..bcf1b821b 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/ru/common.json b/locales/ru/common.json index cb8d048bd..c1fe2df7f 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/zh/common.json b/locales/zh/common.json index 3f056ea26..5b5b6518e 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "基础色彩度", "settings.customTheme.accentHue": "强调色色调", "settings.customTheme.accentChroma": "强调色彩度", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "使用自定义聊天名称颜色", "settings.customTheme.chatHue": "聊天名称颜色", "settings.customTheme.boxes": "卡片", diff --git a/migrations/20260922181213-custom-theme-palette-vars.ts b/migrations/20260922181213-custom-theme-palette-vars.ts new file mode 100644 index 000000000..6381afd2b --- /dev/null +++ b/migrations/20260922181213-custom-theme-palette-vars.ts @@ -0,0 +1,61 @@ +import { type Kysely, sql } from "kysely"; + +/** + * Custom themes now store lightness and hue per color slot, the dark mode + * background lightness and the raw accent chroma. Existing themes get the + * values they were rendered with until now (fixed lightness, unshifted hue) + * so they look exactly the same until re-saved. Light mode secondary colors + * used the dark mode chroma slots, now they have their own. The secondary + * hue is always the accent's opposite so it is no longer stored. + */ +export async function up(db: Kysely): Promise { + await db.transaction().execute(async (trx) => { + for (const table of ["User", "AllTeam"]) { + await sql` + update ${sql.table(table)} + set "customTheme" = json_remove(json_set( + "customTheme", + '$."--_base-l"', 0.17, + '$."--_acc-c"', json_extract("customTheme", '$."--_acc-c-2"') / 0.34, + '$."--_acc-l-0"', 0.26, + '$."--_acc-l-1"', 0.52, + '$."--_acc-l-2"', 0.83, + '$."--_acc-l-3"', 0.88, + '$."--_acc-l-4"', 0.53, + '$."--_acc-l-5"', 0.32, + '$."--_acc-l-6"', 0.53, + '$."--_acc-c-6"', json_extract("customTheme", '$."--_acc-c-4"'), + '$."--_acc-h-0"', json_extract("customTheme", '$."--_acc-h"'), + '$."--_acc-h-1"', json_extract("customTheme", '$."--_acc-h"'), + '$."--_acc-h-2"', json_extract("customTheme", '$."--_acc-h"'), + '$."--_acc-h-3"', json_extract("customTheme", '$."--_acc-h"'), + '$."--_acc-h-4"', json_extract("customTheme", '$."--_acc-h"'), + '$."--_acc-h-5"', json_extract("customTheme", '$."--_acc-h"'), + '$."--_acc-h-6"', json_extract("customTheme", '$."--_acc-h"'), + '$."--_acc-fill-dark-text"', 0, + '$."--_second-l-0"', 0.26, + '$."--_second-l-1"', 0.52, + '$."--_second-l-2"', 0.83, + '$."--_second-l-3"', 0.88, + '$."--_second-l-4"', 0.53, + '$."--_second-l-5"', 0.32, + '$."--_second-h-0"', json_extract("customTheme", '$."--_second-h"'), + '$."--_second-h-1"', json_extract("customTheme", '$."--_second-h"'), + '$."--_second-h-2"', json_extract("customTheme", '$."--_second-h"'), + '$."--_second-h-3"', json_extract("customTheme", '$."--_second-h"'), + '$."--_second-h-4"', json_extract("customTheme", '$."--_second-h"'), + '$."--_second-h-5"', json_extract("customTheme", '$."--_second-h"'), + '$."--_second-c-3"', json_extract("customTheme", '$."--_second-c-0"'), + '$."--_second-c-4"', json_extract("customTheme", '$."--_second-c-1"'), + '$."--_second-c-5"', json_extract("customTheme", '$."--_second-c-2"'), + '$."--_second-l-6"', 0.53, + '$."--_second-c-6"', json_extract("customTheme", '$."--_second-c-1"'), + '$."--_second-h-6"', json_extract("customTheme", '$."--_second-h"'), + '$."--_second-fill-dark-text"', 0 + ), '$."--_second-h"') + where "customTheme" is not null + and json_extract("customTheme", '$."--_base-l"') is null + `.execute(trx); + } + }); +} From 359390292a7fa899a3f101bbaf28056cbec470e5 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:49:38 +0300 Subject: [PATCH 49/50] Fix global search flicker --- app/components/elements/Radio.tsx | 9 ++++++++- app/components/layout/GlobalSearch.tsx | 5 +++++ changelog/2026-09-23-global-search-tab-flicker.md | 4 ++++ 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 changelog/2026-09-23-global-search-tab-flicker.md diff --git a/app/components/elements/Radio.tsx b/app/components/elements/Radio.tsx index 7c74d6447..6b2b72e8d 100644 --- a/app/components/elements/Radio.tsx +++ b/app/components/elements/Radio.tsx @@ -18,6 +18,7 @@ export function SendouRadioGroup({ isDisabled, className, "aria-label": ariaLabel, + onMouseDown, children, }: { value: string | null; @@ -25,12 +26,18 @@ export function SendouRadioGroup({ isDisabled?: boolean; className?: string; "aria-label"?: string; + onMouseDown?: (e: React.MouseEvent) => void; children: React.ReactNode; }) { const name = React.useId(); return ( -
    +
    {children} diff --git a/app/components/layout/GlobalSearch.tsx b/app/components/layout/GlobalSearch.tsx index 8fe861640..5dd168e68 100644 --- a/app/components/layout/GlobalSearch.tsx +++ b/app/components/layout/GlobalSearch.tsx @@ -343,6 +343,7 @@ function GlobalSearchContent({ onChange={handleSearchTypeChange} aria-label="Search type" className={styles.searchTypeRadioGroup} + onMouseDown={preventFocusLeavingInput} > {SEARCH_TYPES.map((type) => ( ["results"][number]; function getResultKey(result: SearchResult): string { diff --git a/changelog/2026-09-23-global-search-tab-flicker.md b/changelog/2026-09-23-global-search-tab-flicker.md new file mode 100644 index 000000000..8571ea0e9 --- /dev/null +++ b/changelog/2026-09-23-global-search-tab-flicker.md @@ -0,0 +1,4 @@ +--- +type: bug +--- +Switching tabs in search no longer makes the search field flicker From b2efb696f16617c7b4edc218b5b34eecab9134d3 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:17:58 +0300 Subject: [PATCH 50/50] Fix tier list maker tab switch scrolling to the top The key was added in e1da2741d ("Fix switching tabs making the item pool unresponsive") as a workaround for an unknown root cause. The drag & drop code has changed a lot since then, and dragging with mouse or touch in any direction after switching tabs now works without the remount, so this removes it. --- app/features/tier-list-maker/routes/tier-list-maker.tsx | 1 - changelog/2026-09-23-tier-list-maker-tab-scroll.md | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 changelog/2026-09-23-tier-list-maker-tab-scroll.md diff --git a/app/features/tier-list-maker/routes/tier-list-maker.tsx b/app/features/tier-list-maker/routes/tier-list-maker.tsx index e61a3c970..2dba1b9e5 100644 --- a/app/features/tier-list-maker/routes/tier-list-maker.tsx +++ b/app/features/tier-list-maker/routes/tier-list-maker.tsx @@ -194,7 +194,6 @@ function TierListMakerContent() {