From da538ba4236ca9753ec4fabea9180078254e528d Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 08:13:35 +0000 Subject: [PATCH 01/11] dev dependencies for typed lint, the query lint plugin, knip and jscpd --- apps/community-mobile/package.json | 4 +- apps/community-web/package.json | 5 + package.json | 2 + packages/ui/package.json | 5 +- pnpm-lock.yaml | 1134 +++++++++++++++++++++++++++- 5 files changed, 1117 insertions(+), 33 deletions(-) diff --git a/apps/community-mobile/package.json b/apps/community-mobile/package.json index 4154f237..acb55113 100644 --- a/apps/community-mobile/package.json +++ b/apps/community-mobile/package.json @@ -80,10 +80,12 @@ "zustand": "^5.0.1" }, "devDependencies": { + "@tanstack/eslint-plugin-query": "5.103.2", "@types/react": "~19.1.0", "eslint": "^9.16.0", "eslint-config-expo": "~10.0.0", - "typescript": "~5.9.2" + "typescript": "~5.9.2", + "typescript-eslint": "^8.18.0" }, "expo": { "doctor": { diff --git a/apps/community-web/package.json b/apps/community-web/package.json index 6f20954b..4e9a293c 100644 --- a/apps/community-web/package.json +++ b/apps/community-web/package.json @@ -39,10 +39,13 @@ "zustand": "^5.0.2" }, "devDependencies": { + "@eslint/js": "^9.16.0", "@fontsource/baloo-2": "^5", "@fontsource/bricolage-grotesque": "^5", "@fontsource/jetbrains-mono": "^5", "@fontsource/manrope": "^5", + "@next/eslint-plugin-next": "15.5.25", + "@tanstack/eslint-plugin-query": "5.103.2", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", @@ -52,12 +55,14 @@ "autoprefixer": "^10.4.20", "eslint": "^9.17.0", "eslint-config-next": "15.5.25", + "eslint-plugin-react-hooks": "^5.2.0", "jsdom": "^30.0.1", "postcss": "^8.5.28", "rimraf": "^6.0.1", "tailwindcss": "^3.4.17", "tailwindcss-animate": "^1.0.7", "typescript": "^5.7.2", + "typescript-eslint": "^8.18.0", "vitest": "^3.2.7" } } diff --git a/package.json b/package.json index 580ac9c3..6a91441f 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ }, "devDependencies": { "@changesets/cli": "^2.27.0", + "jscpd": "4.3.0", + "knip": "5.88.1", "turbo": "^2.3.3" }, "pnpm": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 0efae787..9fbe4f64 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -93,10 +93,11 @@ }, "peerDependencies": { "@gorhom/bottom-sheet": "*", - "@react-native-community/datetimepicker": "*", "@maplibre/maplibre-react-native": "*", + "@react-native-community/datetimepicker": "*", "@shopify/react-native-skia": "*", "@tanstack/react-query": "^5.59.0", + "expo-blur": "*", "lucide-react-native": "*", "maplibre-gl": "*", "react": "*", @@ -106,7 +107,6 @@ "react-native-reanimated": "*", "react-native-safe-area-context": "*", "react-native-svg": "*", - "expo-blur": "*", "react-native-video": "*", "react-native-web": "*", "zustand": "^5.0.0" @@ -151,6 +151,7 @@ "@maplibre/maplibre-react-native": "^11.3.0", "@react-native-community/datetimepicker": "8.4.4", "@shopify/react-native-skia": "2.2.12", + "@tanstack/eslint-plugin-query": "5.103.2", "@tanstack/react-query": "^5.62.7", "@types/react": "~19.1.0", "@types/react-dom": "~19.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a17e811..c2f0a58b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,12 @@ importers: '@changesets/cli': specifier: ^2.27.0 version: 2.31.1(@types/node@22.20.1) + jscpd: + specifier: 4.3.0 + version: 4.3.0 + knip: + specifier: 5.88.1 + version: 5.88.1(@types/node@22.20.1)(typescript@5.9.3) turbo: specifier: ^2.3.3 version: 2.10.12 @@ -212,18 +218,24 @@ importers: specifier: ^5.0.1 version: 5.0.15(@types/react@19.1.17)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)) devDependencies: + '@tanstack/eslint-plugin-query': + specifier: 5.103.2 + version: 5.103.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) '@types/react': specifier: ~19.1.0 version: 19.1.17 eslint: specifier: ^9.16.0 - version: 9.39.5(jiti@1.21.7) + version: 9.39.5(jiti@2.7.0) eslint-config-expo: specifier: ~10.0.0 - version: 10.0.0(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3) + version: 10.0.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) typescript: specifier: ~5.9.2 version: 5.9.3 + typescript-eslint: + specifier: ^8.18.0 + version: 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) apps/community-web: dependencies: @@ -285,6 +297,9 @@ importers: specifier: ^5.0.2 version: 5.0.15(@types/react@19.1.17)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)) devDependencies: + '@eslint/js': + specifier: ^9.16.0 + version: 9.39.5 '@fontsource/baloo-2': specifier: ^5 version: 5.3.0 @@ -297,6 +312,12 @@ importers: '@fontsource/manrope': specifier: ^5 version: 5.3.0 + '@next/eslint-plugin-next': + specifier: 15.5.25 + version: 15.5.25 + '@tanstack/eslint-plugin-query': + specifier: 5.103.2 + version: 5.103.2(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -324,6 +345,9 @@ importers: eslint-config-next: specifier: 15.5.25 version: 15.5.25(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3) + eslint-plugin-react-hooks: + specifier: ^5.2.0 + version: 5.2.0(eslint@9.39.5(jiti@1.21.7)) jsdom: specifier: ^30.0.1 version: 30.0.1 @@ -342,6 +366,9 @@ importers: typescript: specifier: ^5.7.2 version: 5.9.3 + typescript-eslint: + specifier: ^8.18.0 + version: 8.70.0(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3) vitest: specifier: ^3.2.7 version: 3.2.7(@types/node@22.20.1)(jiti@1.21.7)(jsdom@30.0.1)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.0) @@ -360,13 +387,13 @@ importers: version: 22.20.1 '@typescript-eslint/eslint-plugin': specifier: ^8.18.0 - version: 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3) + version: 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/parser': specifier: ^8.18.0 - version: 8.70.0(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3) + version: 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) eslint: specifier: ^9.16.0 - version: 9.39.5(jiti@1.21.7) + version: 9.39.5(jiti@2.7.0) prettier: specifier: ^3.4.2 version: 3.9.6 @@ -375,16 +402,16 @@ importers: version: 6.1.3 tsup: specifier: ^8.3.5 - version: 8.5.1(jiti@1.21.7)(postcss@8.5.28)(typescript@5.9.3)(yaml@2.9.0) + version: 8.5.1(jiti@2.7.0)(postcss@8.5.28)(typescript@5.9.3)(yaml@2.9.0) typescript: specifier: ^5.6.3 version: 5.9.3 typescript-eslint: specifier: ^8.18.0 - version: 8.70.0(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3) + version: 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/node@22.20.1)(jiti@1.21.7)(jsdom@30.0.1)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.0) + version: 3.2.7(@types/node@22.20.1)(jiti@2.7.0)(jsdom@30.0.1)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.0) packages/ui: dependencies: @@ -437,6 +464,9 @@ importers: '@shopify/react-native-skia': specifier: 2.2.12 version: 2.2.12(react-native-reanimated@4.1.7(react-native-worklets@0.5.1(@babel/core@7.29.7)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@tanstack/eslint-plugin-query': + specifier: 5.103.2 + version: 5.103.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) '@tanstack/react-query': specifier: ^5.62.7 version: 5.102.8(react@19.1.0) @@ -451,16 +481,16 @@ importers: version: 7.1.3 '@typescript-eslint/eslint-plugin': specifier: ^8.18.0 - version: 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3) + version: 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/parser': specifier: ^8.18.0 - version: 8.70.0(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3) + version: 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) eslint: specifier: ^9.16.0 - version: 9.39.5(jiti@1.21.7) + version: 9.39.5(jiti@2.7.0) eslint-plugin-react-hooks: specifier: ^5.2.0 - version: 5.2.0(eslint@9.39.5(jiti@1.21.7)) + version: 5.2.0(eslint@9.39.5(jiti@2.7.0)) expo-blur: specifier: ~15.0.8 version: 15.0.8(expo@54.0.37)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -496,10 +526,10 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.18.0 - version: 8.70.0(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3) + version: 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/node@22.20.1)(jiti@1.21.7)(jsdom@30.0.1)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.0) + version: 3.2.7(@types/node@22.20.1)(jiti@2.7.0)(jsdom@30.0.1)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.0) zustand: specifier: ^5.0.2 version: 5.0.15(@types/react@19.1.17)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)) @@ -1088,6 +1118,10 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + '@csstools/color-helpers@6.1.1': resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} engines: {node: '>=20.19.0'} @@ -1131,15 +1165,24 @@ packages: '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -1755,6 +1798,21 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jscpd/badge-reporter@4.2.5': + resolution: {integrity: sha512-ktXrjPeRaRyUDktxTroSA2/w5sshXpQplWkUuq/e6XqEpKBSbGEnwZLIaegSijOrMwIcCXPQ9k4feXIz5eVJNA==} + + '@jscpd/core@4.2.5': + resolution: {integrity: sha512-Esf2deHxaoNEjePwf2jqP6Urzj+BAOsJVPFLbnnSsV+q7rLNMcn0UEEoKBXIOOt4qMkrkhl9DfwpMyPPOr6GkQ==} + + '@jscpd/finder@4.3.0': + resolution: {integrity: sha512-MnEUyier0D6P9zRIhAlBoyJUV8BYT6d5FDZuhR7X23FdZAgdMV8yaRRO1Q1EveK9/ReA6WVHT1HE8F15rij71A==} + + '@jscpd/html-reporter@4.2.5': + resolution: {integrity: sha512-zMMIKbvi43dMgeNeHXlHQy1ovf+KJrzNlUubaBvCAVatqP23ksW8d3fmsevIQG9mMMTH0D1xOz+SxUn1FREOPg==} + + '@jscpd/tokenizer@4.2.6': + resolution: {integrity: sha512-/eyFjINWLs2mrBTU4H681bs855r5oRyl1O3mZxcd7TpL1JIG85a7pps0RkRPAe9c/2KcjVCc82HbhOz86M0n5g==} + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -1899,6 +1957,101 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.24.2': + resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.24.2': + resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} + cpu: [ppc64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} + cpu: [s390x] + os: [linux] + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} + cpu: [x64] + os: [win32] + '@radix-ui/primitive@1.1.7': resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} @@ -2424,6 +2577,15 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@tanstack/eslint-plugin-query@5.103.2': + resolution: {integrity: sha512-qycCzS4j5p7lLT6NUDcUURv3csrYS2VfnaxMDUn/bBi4MPjU0z+tAGoRPwPS3V86Z9yp6PG4p30MtbTOXo80NQ==} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ^5.6.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@tanstack/query-core@5.102.8': resolution: {integrity: sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==} @@ -2582,6 +2744,9 @@ packages: '@types/react@19.1.17': resolution: {integrity: sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==} + '@types/sarif@2.1.7': + resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==} + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -2831,6 +2996,11 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn@7.4.1: + resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} + engines: {node: '>=0.4.0'} + hasBin: true + acorn@8.18.0: resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} @@ -2951,6 +3121,9 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assert-never@1.4.0: + resolution: {integrity: sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==} + assert@2.1.0: resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} @@ -3051,6 +3224,13 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + babel-walk@3.0.0-canary-5: + resolution: {integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==} + engines: {node: '>= 10.0.0'} + + badgen@3.3.2: + resolution: {integrity: sha512-fbQwK9norfdzbdsoPwbLIAmgBXDGEme3jeIyqPAH7o6vp9lmuLHS7uXULvOiQ6XnMLkYNG4gDjILf74hgtTAug==} + badgin@1.2.3: resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==} @@ -3088,6 +3268,10 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + blamer@1.0.7: + resolution: {integrity: sha512-GbBStl/EVlSWkiJQBZps3H1iARBrC7vt++Jb/TTmCNu/jZ04VW7tSN1nScbFXBUy1AN+jzeL7Zep9sbQxLhXKA==} + engines: {node: '>=8.9'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -3190,6 +3374,9 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + character-parser@2.2.0: + resolution: {integrity: sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==} + chardet@2.2.0: resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} @@ -3235,6 +3422,10 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -3270,10 +3461,18 @@ packages: resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} engines: {node: '>=12.5.0'} + colors@1.4.0: + resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} + engines: {node: '>=0.1.90'} + commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -3307,6 +3506,9 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + constantinople@4.0.1: + resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==} + content-type@2.1.0: resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} engines: {node: '>=18'} @@ -3469,6 +3671,9 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + doctypes@1.1.0: + resolution: {integrity: sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -3520,6 +3725,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -3750,6 +3958,13 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + execa@4.1.0: + resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} + engines: {node: '>=10'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -4066,6 +4281,9 @@ packages: fbjs@3.0.5: resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -4119,6 +4337,11 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + formatly@0.3.0: + resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} + engines: {node: '>=18.3.0'} + hasBin: true + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -4130,6 +4353,10 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + fs-extra@11.4.1: + resolution: {integrity: sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==} + engines: {node: '>=14.14'} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -4187,6 +4414,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -4323,6 +4554,10 @@ packages: resolution: {integrity: sha512-zPGsiS+dWoTZtZ4AtpA9Y+BdSFSNWvnouNlWNoUFyAM6xHOHmdCvqO3k8AIbdamCOv4gUFUVNPf6rJFfc4UiJw==} hasBin: true + human-signals@1.1.1: + resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==} + engines: {node: '>=8.12.0'} + hyphenate-style-name@1.1.0: resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} @@ -4441,6 +4676,9 @@ packages: resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} engines: {node: '>= 0.4'} + is-expression@4.0.0: + resolution: {integrity: sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -4484,6 +4722,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@2.2.2: + resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -4496,6 +4737,10 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -4597,10 +4842,17 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-stringify@1.0.2: + resolution: {integrity: sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4618,6 +4870,13 @@ packages: jsc-safe-url@0.2.4: resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} + jscpd-sarif-reporter@4.2.5: + resolution: {integrity: sha512-O8LcM9grAS5yO5x1Q0yegYaYcUX//IEBEyvzGFSYCeo1YzHbMnAI6EK7oTrwD+7Csjvfg9m8B8G7OOxzcSlr9w==} + + jscpd@4.3.0: + resolution: {integrity: sha512-yUqcHy/USHvzFamS6Loo49MCSW0Dc+4RL6ELH+Un9o2/jRSQbs9+a5hjfFceV2d3Zgv1opv5PXJ0eZ3fEQdjkg==} + hasBin: true + jsdom@30.0.1: resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} @@ -4659,6 +4918,12 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + jstransformer@1.0.0: + resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -4677,6 +4942,14 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + knip@5.88.1: + resolution: {integrity: sha512-tpy5o7zu1MjawVkLPuahymVJekYY3kYjvzcoInhIchgePxTlo+api90tBv2KfhAIe5uXh+mez1tAfmbv8/TiZg==} + engines: {node: '>=18.18.0'} + hasBin: true + peerDependencies: + '@types/node': '>=18' + typescript: '>=5.0.4 <7' + lan-network@0.2.1: resolution: {integrity: sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==} hasBin: true @@ -4847,6 +5120,9 @@ packages: resolution: {integrity: sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==} engines: {node: '>=16.14.0', npm: '>=8.1.0'} + markdown-table@2.0.0: + resolution: {integrity: sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==} + marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} @@ -5018,6 +5294,10 @@ packages: resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} engines: {node: '>=4'} + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -5137,6 +5417,10 @@ packages: resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} + node-sarif-builder@4.1.0: + resolution: {integrity: sha512-IWqZF6u0EI/07HTBm+zZ+MgXgWl09dnSJRGaDCPBSlOqilDcx6pj3Mpb3HvPN8V2Gr+ISw7ZrMsL7STWs1F++w==} + engines: {node: '>=20'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -5145,6 +5429,10 @@ packages: resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==} engines: {node: ^16.14.0 || >=18.0.0} + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -5218,6 +5506,10 @@ packages: resolution: {integrity: sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==} engines: {node: '>=4'} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + open@7.4.2: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} @@ -5241,6 +5533,9 @@ packages: resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} engines: {node: '>= 0.4'} + oxc-resolver@11.24.2: + resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + p-filter@2.1.0: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} @@ -5458,6 +5753,45 @@ packages: protocol-buffers-schema@3.6.1: resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==} + pug-attrs@3.0.0: + resolution: {integrity: sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==} + + pug-code-gen@3.0.4: + resolution: {integrity: sha512-6okWYIKdasTyXICyEtvobmTZAVX57JkzgzIi4iRJlin8kmhG+Xry2dsus+Mun/nGCn6F2U49haHI5mkELXB14g==} + + pug-error@2.1.0: + resolution: {integrity: sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==} + + pug-filters@4.0.0: + resolution: {integrity: sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==} + + pug-lexer@5.0.1: + resolution: {integrity: sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==} + + pug-linker@4.0.0: + resolution: {integrity: sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==} + + pug-load@3.0.0: + resolution: {integrity: sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==} + + pug-parser@6.0.0: + resolution: {integrity: sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==} + + pug-runtime@3.0.1: + resolution: {integrity: sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==} + + pug-strip-comments@2.0.0: + resolution: {integrity: sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==} + + pug-walk@2.0.0: + resolution: {integrity: sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==} + + pug@3.0.4: + resolution: {integrity: sha512-kFfq5mMzrS7+wrl5pLJzZEzemx34OQ0w4SARfhy/3yxTlhbstsudDwJzhf1hP02yHzbjoVMSXUj/Sz6RNfMyXg==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -5725,6 +6059,10 @@ packages: resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==} hasBin: true + repeat-string@1.6.1: + resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} + engines: {node: '>=0.10'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -5958,6 +6296,10 @@ packages: resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} engines: {node: '>=8.0.0'} + smol-toml@1.9.0: + resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -5977,6 +6319,9 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + spark-md5@3.0.2: + resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==} + spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} @@ -6071,6 +6416,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -6079,6 +6428,10 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} @@ -6219,6 +6572,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + token-stream@1.0.0: + resolution: {integrity: sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==} + tough-cookie@6.0.2: resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} engines: {node: '>=16'} @@ -6323,6 +6679,10 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + unbash@2.2.0: + resolution: {integrity: sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w==} + engines: {node: '>=14'} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -6354,6 +6714,10 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -6515,6 +6879,10 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -6588,6 +6956,10 @@ packages: engines: {node: '>=8'} hasBin: true + with@7.0.2: + resolution: {integrity: sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==} + engines: {node: '>= 10.0.0'} + wonka@6.3.6: resolution: {integrity: sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==} @@ -6695,6 +7067,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + zustand@5.0.15: resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==} engines: {node: '>=12.20.0'} @@ -7491,6 +7866,9 @@ snapshots: human-id: 4.2.1 prettier: 2.8.8 + '@colors/colors@1.5.0': + optional: true + '@csstools/color-helpers@6.1.1': {} '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -7525,11 +7903,22 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 @@ -7540,6 +7929,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.28.2': optional: true @@ -7623,6 +8017,11 @@ snapshots: eslint: 9.39.5(jiti@1.21.7) eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))': + dependencies: + eslint: 9.39.5(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + '@eslint-community/regexpp@4.12.2': {} '@eslint/config-array@0.21.2': @@ -8245,6 +8644,40 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@jscpd/badge-reporter@4.2.5': + dependencies: + badgen: 3.3.2 + colors: 1.4.0 + fs-extra: 11.4.1 + + '@jscpd/core@4.2.5': + dependencies: + eventemitter3: 5.0.4 + + '@jscpd/finder@4.3.0': + dependencies: + '@jscpd/core': 4.2.5 + '@jscpd/tokenizer': 4.2.6 + blamer: 1.0.7 + bytes: 3.1.2 + cli-table3: 0.6.5 + colors: 1.4.0 + fast-glob: 3.3.3 + fs-extra: 11.4.1 + markdown-table: 2.0.0 + pug: 3.0.4 + + '@jscpd/html-reporter@4.2.5': + dependencies: + colors: 1.4.0 + fs-extra: 11.4.1 + pug: 3.0.4 + + '@jscpd/tokenizer@4.2.6': + dependencies: + '@jscpd/core': 4.2.5 + spark-md5: 3.0.2 + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.29.7 @@ -8334,6 +8767,13 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + '@next/env@15.5.25': {} '@next/eslint-plugin-next@15.5.25': @@ -8378,6 +8818,67 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + optional: true + + '@oxc-resolver/binding-android-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + 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) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + optional: true + '@radix-ui/primitive@1.1.7': {} '@radix-ui/react-collection@1.1.15(@types/react-dom@19.1.11(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': @@ -8889,9 +9390,27 @@ snapshots: dependencies: tslib: 2.8.1 - '@tanstack/query-core@5.102.8': {} + '@tanstack/eslint-plugin-query@5.103.2(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.5(jiti@1.21.7) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color - '@tanstack/react-query@5.102.8(react@19.1.0)': + '@tanstack/eslint-plugin-query@5.103.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@tanstack/query-core@5.102.8': {} + + '@tanstack/react-query@5.102.8(react@19.1.0)': dependencies: '@tanstack/query-core': 5.102.8 react: 19.1.0 @@ -9067,6 +9586,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/sarif@2.1.7': {} + '@types/stack-utils@2.0.3': {} '@types/supercluster@7.1.3': @@ -9095,6 +9616,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/type-utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.70.0 + eslint: 9.39.5(jiti@2.7.0) + ignore: 7.0.9 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.70.0 @@ -9107,6 +9644,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.70.0 + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/project-service@8.70.0(typescript@5.9.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@5.9.3) @@ -9137,6 +9686,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/type-utils@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/types@8.70.0': {} '@typescript-eslint/typescript-estree@8.70.0(typescript@5.9.3)': @@ -9165,6 +9726,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.70.0': dependencies: '@typescript-eslint/types': 8.70.0 @@ -9270,6 +9842,14 @@ snapshots: optionalDependencies: vite: 7.3.6(@types/node@22.20.1)(jiti@1.21.7)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.0) + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.0) + '@vitest/pretty-format@3.2.7': dependencies: tinyrainbow: 2.0.0 @@ -9320,6 +9900,8 @@ snapshots: dependencies: acorn: 8.18.0 + acorn@7.4.1: {} + acorn@8.18.0: {} agent-base@7.1.4: {} @@ -9460,6 +10042,8 @@ snapshots: asap@2.0.6: {} + assert-never@1.4.0: {} + assert@2.1.0: dependencies: call-bind: 1.0.9 @@ -9620,6 +10204,12 @@ snapshots: babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + babel-walk@3.0.0-canary-5: + dependencies: + '@babel/types': 7.29.8 + + badgen@3.3.2: {} + badgin@1.2.3: {} balanced-match@1.0.2: {} @@ -9646,6 +10236,11 @@ snapshots: binary-extensions@2.3.0: {} + blamer@1.0.7: + dependencies: + execa: 4.1.0 + which: 2.0.2 + boolbase@1.0.0: {} bplist-creator@0.1.0: @@ -9755,6 +10350,10 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + character-parser@2.2.0: + dependencies: + is-regex: 1.2.1 + chardet@2.2.0: {} check-error@2.1.3: {} @@ -9811,6 +10410,12 @@ snapshots: cli-spinners@2.9.2: {} + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + client-only@0.0.1: {} cliui@8.0.1: @@ -9845,8 +10450,12 @@ snapshots: color-convert: 2.0.1 color-string: 1.9.1 + colors@1.4.0: {} + commander@12.1.0: {} + commander@15.0.0: {} + commander@2.20.3: {} commander@4.1.1: {} @@ -9884,6 +10493,11 @@ snapshots: consola@3.4.2: {} + constantinople@4.0.1: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + content-type@2.1.0: {} convert-source-map@2.0.0: {} @@ -10025,6 +10639,8 @@ snapshots: dependencies: esutils: 2.0.3 + doctypes@1.1.0: {} + dom-accessibility-api@0.5.16: {} dom-serializer@2.0.0: @@ -10071,6 +10687,10 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -10238,16 +10858,16 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-expo@10.0.0(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3): + eslint-config-expo@10.0.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3) - eslint: 9.39.5(jiti@1.21.7) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@1.21.7)) - eslint-plugin-expo: 1.1.0(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@1.21.7)) - eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@1.21.7)) - eslint-plugin-react-hooks: 5.2.0(eslint@9.39.5(jiti@1.21.7)) + '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-expo: 1.1.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-react-hooks: 5.2.0(eslint@9.39.5(jiti@2.7.0)) globals: 16.5.0 transitivePeerDependencies: - eslint-import-resolver-webpack @@ -10298,6 +10918,21 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) + get-tsconfig: 4.14.3 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.17 + unrs-resolver: 1.12.2 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + transitivePeerDependencies: + - supports-color + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@1.21.7)): dependencies: debug: 3.2.7 @@ -10309,11 +10944,22 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-expo@1.1.0(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0) + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) + transitivePeerDependencies: + - supports-color + + eslint-plugin-expo@1.1.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): dependencies: '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@1.21.7))(typescript@5.9.3) - eslint: 9.39.5(jiti@1.21.7) + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0) transitivePeerDependencies: - supports-color - typescript @@ -10347,6 +10993,35 @@ snapshots: - eslint-import-resolver-webpack - supports-color + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.5(jiti@2.7.0) + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + hasown: 2.0.4 + is-core-module: 2.16.2 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.10 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@1.21.7)): dependencies: aria-query: 5.3.2 @@ -10370,6 +11045,10 @@ snapshots: dependencies: eslint: 9.39.5(jiti@1.21.7) + eslint-plugin-react-hooks@5.2.0(eslint@9.39.5(jiti@2.7.0)): + dependencies: + eslint: 9.39.5(jiti@2.7.0) + eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@1.21.7)): dependencies: array-includes: 3.1.9 @@ -10392,6 +11071,28 @@ snapshots: string.prototype.matchall: 4.1.0 string.prototype.repeat: 1.0.0 + eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@2.7.0)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.4.0 + eslint: 9.39.5(jiti@2.7.0) + estraverse: 5.3.0 + hasown: 2.0.4 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.7 + semver: 6.3.1 + string.prototype.matchall: 4.1.0 + string.prototype.repeat: 1.0.0 + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 @@ -10444,6 +11145,47 @@ snapshots: transitivePeerDependencies: - supports-color + eslint@9.39.5(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.7 + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + espree@10.4.0: dependencies: acorn: 8.18.0 @@ -10472,6 +11214,20 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@5.0.4: {} + + execa@4.1.0: + dependencies: + cross-spawn: 7.0.6 + get-stream: 5.2.0 + human-signals: 1.1.1 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + expect-type@1.4.0: {} expo-apple-authentication@8.0.8(expo@54.0.37)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0)): @@ -10866,6 +11622,10 @@ snapshots: transitivePeerDependencies: - encoding + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -10923,12 +11683,22 @@ snapshots: dependencies: is-callable: 1.2.7 + formatly@0.3.0: + dependencies: + fd-package-json: 2.0.0 + fraction.js@5.3.4: {} freeport-async@2.0.0: {} fresh@0.5.2: {} + fs-extra@11.4.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -10992,6 +11762,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + get-stream@6.0.1: {} get-symbol-description@1.1.0: @@ -11136,6 +11910,8 @@ snapshots: human-id@4.2.1: {} + human-signals@1.1.1: {} + hyphenate-style-name@1.1.0: {} i18next@25.10.10(typescript@5.9.3): @@ -11251,6 +12027,11 @@ snapshots: dependencies: call-bound: 1.0.4 + is-expression@4.0.0: + dependencies: + acorn: 7.4.1 + object-assign: 4.1.1 + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -11289,6 +12070,8 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-promise@2.2.2: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -11302,6 +12085,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-stream@2.0.1: {} + is-string@1.1.1: dependencies: call-bound: 1.0.4 @@ -11441,8 +12226,12 @@ snapshots: jiti@1.21.7: {} + jiti@2.7.0: {} + joycon@3.1.1: {} + js-stringify@1.0.2: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -11458,6 +12247,24 @@ snapshots: jsc-safe-url@0.2.4: {} + jscpd-sarif-reporter@4.2.5: + dependencies: + colors: 1.4.0 + fs-extra: 11.4.1 + node-sarif-builder: 4.1.0 + + jscpd@4.3.0: + dependencies: + '@jscpd/badge-reporter': 4.2.5 + '@jscpd/core': 4.2.5 + '@jscpd/finder': 4.3.0 + '@jscpd/html-reporter': 4.2.5 + '@jscpd/tokenizer': 4.2.6 + colors: 1.4.0 + commander: 15.0.0 + fs-extra: 11.4.1 + jscpd-sarif-reporter: 4.2.5 + jsdom@30.0.1: dependencies: '@asamuzakjp/css-color': 6.0.7 @@ -11506,6 +12313,17 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jstransformer@1.0.0: + dependencies: + is-promise: 2.2.2 + promise: 7.3.1 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -11523,6 +12341,24 @@ snapshots: kleur@3.0.3: {} + knip@5.88.1(@types/node@22.20.1)(typescript@5.9.3): + dependencies: + '@nodelib/fs.walk': 1.2.8 + '@types/node': 22.20.1 + fast-glob: 3.3.3 + formatly: 0.3.0 + jiti: 2.7.0 + minimist: 1.2.8 + oxc-resolver: 11.24.2 + picocolors: 1.1.1 + picomatch: 4.0.7 + smol-toml: 1.9.0 + strip-json-comments: 5.0.3 + typescript: 5.9.3 + unbash: 2.2.0 + yaml: 2.9.0 + zod: 4.6.5 + lan-network@0.2.1: {} language-subtag-registry@0.3.23: {} @@ -11683,6 +12519,10 @@ snapshots: tinyqueue: 3.0.0 vt-pbf: 3.1.3 + markdown-table@2.0.0: + dependencies: + repeat-string: 1.6.1 + marky@1.3.0: {} math-intrinsics@1.1.0: {} @@ -12068,6 +12908,8 @@ snapshots: mimic-fn@1.2.0: {} + mimic-fn@2.1.0: {} + minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -12169,6 +13011,11 @@ snapshots: node-releases@2.0.54: {} + node-sarif-builder@4.1.0: + dependencies: + '@types/sarif': 2.1.7 + fs-extra: 11.4.1 + normalize-path@3.0.0: {} npm-package-arg@11.0.3: @@ -12178,6 +13025,10 @@ snapshots: semver: 7.8.5 validate-npm-package-name: 5.0.1 + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + nth-check@2.1.1: dependencies: boolbase: 1.0.0 @@ -12259,6 +13110,10 @@ snapshots: dependencies: mimic-fn: 1.2.0 + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + open@7.4.2: dependencies: is-docker: 2.2.1 @@ -12297,6 +13152,28 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + oxc-resolver@11.24.2: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.24.2 + '@oxc-resolver/binding-android-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-x64': 11.24.2 + '@oxc-resolver/binding-freebsd-x64': 11.24.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-musl': 11.24.2 + '@oxc-resolver/binding-openharmony-arm64': 11.24.2 + '@oxc-resolver/binding-wasm32-wasi': 11.24.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + p-filter@2.1.0: dependencies: p-map: 2.1.0 @@ -12411,6 +13288,14 @@ snapshots: postcss: 8.5.28 yaml: 2.9.0 + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.28)(yaml@2.9.0): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 2.7.0 + postcss: 8.5.28 + yaml: 2.9.0 + postcss-nested@6.2.0(postcss@8.5.28): dependencies: postcss: 8.5.28 @@ -12476,6 +13361,78 @@ snapshots: protocol-buffers-schema@3.6.1: {} + pug-attrs@3.0.0: + dependencies: + constantinople: 4.0.1 + js-stringify: 1.0.2 + pug-runtime: 3.0.1 + + pug-code-gen@3.0.4: + dependencies: + constantinople: 4.0.1 + doctypes: 1.1.0 + js-stringify: 1.0.2 + pug-attrs: 3.0.0 + pug-error: 2.1.0 + pug-runtime: 3.0.1 + void-elements: 3.1.0 + with: 7.0.2 + + pug-error@2.1.0: {} + + pug-filters@4.0.0: + dependencies: + constantinople: 4.0.1 + jstransformer: 1.0.0 + pug-error: 2.1.0 + pug-walk: 2.0.0 + resolve: 1.22.12 + + pug-lexer@5.0.1: + dependencies: + character-parser: 2.2.0 + is-expression: 4.0.0 + pug-error: 2.1.0 + + pug-linker@4.0.0: + dependencies: + pug-error: 2.1.0 + pug-walk: 2.0.0 + + pug-load@3.0.0: + dependencies: + object-assign: 4.1.1 + pug-walk: 2.0.0 + + pug-parser@6.0.0: + dependencies: + pug-error: 2.1.0 + token-stream: 1.0.0 + + pug-runtime@3.0.1: {} + + pug-strip-comments@2.0.0: + dependencies: + pug-error: 2.1.0 + + pug-walk@2.0.0: {} + + pug@3.0.4: + dependencies: + pug-code-gen: 3.0.4 + pug-filters: 4.0.0 + pug-lexer: 5.0.1 + pug-linker: 4.0.0 + pug-load: 3.0.0 + pug-parser: 6.0.0 + pug-runtime: 3.0.1 + pug-strip-comments: 2.0.0 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} qrcode-generator@2.0.4: {} @@ -12789,6 +13746,8 @@ snapshots: dependencies: jsesc: 3.1.0 + repeat-string@1.6.1: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -13083,6 +14042,8 @@ snapshots: slugify@1.6.9: {} + smol-toml@1.9.0: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -13096,6 +14057,8 @@ snapshots: source-map@0.7.6: {} + spark-md5@3.0.2: {} + spawndamnit@3.0.1: dependencies: cross-spawn: 7.0.6 @@ -13205,10 +14168,14 @@ snapshots: strip-bom@3.0.0: {} + strip-final-newline@2.0.0: {} + strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} + strip-json-comments@5.0.3: {} + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 @@ -13362,6 +14329,8 @@ snapshots: toidentifier@1.0.1: {} + token-stream@1.0.0: {} + tough-cookie@6.0.2: dependencies: tldts: 7.4.12 @@ -13389,7 +14358,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(jiti@1.21.7)(postcss@8.5.28)(typescript@5.9.3)(yaml@2.9.0): + tsup@8.5.1(jiti@2.7.0)(postcss@8.5.28)(typescript@5.9.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.28.2) cac: 6.7.14 @@ -13400,7 +14369,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.28)(yaml@2.9.0) + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.28)(yaml@2.9.0) resolve-from: 5.0.0 rollup: 4.63.1 source-map: 0.7.6 @@ -13480,12 +14449,25 @@ snapshots: transitivePeerDependencies: - supports-color + typescript-eslint@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} ua-parser-js@1.0.41: {} ufo@1.6.4: {} + unbash@2.2.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -13510,6 +14492,8 @@ snapshots: universalify@0.1.2: {} + universalify@2.0.1: {} + unpipe@1.0.0: {} unrs-resolver@1.12.2: @@ -13620,6 +14604,27 @@ snapshots: - tsx - yaml + vite-node@3.2.4(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite@7.3.6(@types/node@22.20.1)(jiti@1.21.7)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.0): dependencies: esbuild: 0.28.2 @@ -13636,6 +14641,22 @@ snapshots: terser: 5.51.2 yaml: 2.9.0 + vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.0): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.28 + rollup: 4.63.1 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.33.0 + terser: 5.51.2 + yaml: 2.9.0 + vitest@3.2.7(@types/node@22.20.1)(jiti@1.21.7)(jsdom@30.0.1)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 @@ -13678,6 +14699,48 @@ snapshots: - tsx - yaml + vitest@3.2.7(@types/node@22.20.1)(jiti@2.7.0)(jsdom@30.0.1)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + jsdom: 30.0.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vlq@1.0.1: {} void-elements@3.1.0: {} @@ -13692,6 +14755,8 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + walk-up-path@4.0.0: {} + walker@1.0.8: dependencies: makeerror: 1.0.12 @@ -13793,6 +14858,13 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + with@7.0.2: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + assert-never: 1.4.0 + babel-walk: 3.0.0-canary-5 + wonka@6.3.6: {} word-wrap@1.2.5: {} @@ -13860,6 +14932,8 @@ snapshots: zod@3.25.76: {} + zod@4.6.5: {} + zustand@5.0.15(@types/react@19.1.17)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)): optionalDependencies: '@types/react': 19.1.17 From e77dbc82fd5edfdb1e8e8bcd3697ad03500a6e6a Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 08:28:18 +0000 Subject: [PATCH 02/11] web lints with an ESLint 9 flat config and typed rules, and typechecks the functions tests --- apps/community-web/.eslintrc.json | 117 ------------ apps/community-web/eslint.config.mjs | 176 ++++++++++++++++++ apps/community-web/functions/_preview-core.ts | 2 +- apps/community-web/package.json | 4 +- apps/community-web/scripts/tsconfig.json | 15 ++ .../src/components/auth/auth-modal.tsx | 2 +- .../components/home/use-team-invite-accept.ts | 4 +- .../host/broadcasts/broadcast-composer.tsx | 1 + 8 files changed, 198 insertions(+), 123 deletions(-) delete mode 100644 apps/community-web/.eslintrc.json create mode 100644 apps/community-web/eslint.config.mjs create mode 100644 apps/community-web/scripts/tsconfig.json diff --git a/apps/community-web/.eslintrc.json b/apps/community-web/.eslintrc.json deleted file mode 100644 index 7a11a4dc..00000000 --- a/apps/community-web/.eslintrc.json +++ /dev/null @@ -1,117 +0,0 @@ -{ - "extends": [ - "next/core-web-vitals", - "next/typescript" - ], - "rules": { - "@typescript-eslint/consistent-type-imports": [ - "warn", - { - "prefer": "type-imports", - "fixStyle": "separate-type-imports" - } - ], - "@typescript-eslint/no-unused-vars": [ - "warn", - { - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_" - } - ] - }, - "overrides": [ - { - "files": [ - "src/features/host/**", - "src/components/console/**", - "src/app/manage/**" - ], - "rules": { - "no-restricted-imports": [ - "error", - { - "paths": [ - { - "name": "@civfix/ui", - "message": "The host console is plain DOM and may import ONLY @civfix/ui/data, @civfix/ui/i18n and (type-only) @civfix/ui/capabilities. Every other subpath reaches React Native." - }, - { - "name": "@civfix/ui/bodies", - "message": "The host console is plain DOM and may import ONLY @civfix/ui/data, @civfix/ui/i18n and (type-only) @civfix/ui/capabilities. Every other subpath reaches React Native." - }, - { - "name": "@civfix/ui/nav", - "message": "The host console is plain DOM and may import ONLY @civfix/ui/data, @civfix/ui/i18n and (type-only) @civfix/ui/capabilities. Every other subpath reaches React Native." - }, - { - "name": "@civfix/ui/theme", - "message": "@civfix/ui/theme's barrel imports react-native. Use @civfix/ui/theme/schemes (the RN-free subpath) for scheme names and palettes, and lib/color-scheme.ts to read the same `.dark` class the --console-* variables key off." - }, - { - "name": "@civfix/ui/surface", - "message": "The host console is plain DOM and may import ONLY @civfix/ui/data, @civfix/ui/i18n and (type-only) @civfix/ui/capabilities. Every other subpath reaches React Native." - }, - { - "name": "@civfix/ui/realtime", - "message": "The realtime channel is mounted once by components/providers.tsx." - }, - { - "name": "@civfix/ui/typography", - "message": "Console typography comes from the Tailwind token scale." - }, - { - "name": "@civfix/ui/capabilities", - "message": "Capabilities are React-Native-shaped. Type-only imports are allowed; use @/lib/web-camera for the console's own camera seam.", - "allowTypeImports": true - }, - { - "name": "next/navigation", - "message": "The console is one static shell served by the SPA fallback: navigate with components/console/url-state.ts, never a Next route transition." - }, - { - "name": "next/link", - "message": "Use layout/console-link.tsx: a Next transition would target a path the static manifest does not contain." - }, - { - "name": "next/router", - "message": "The console is App Router + its own history bridge." - }, - { - "name": "maplibre-gl", - "message": "The console renders no map." - } - ], - "patterns": [ - { - "group": [ - "react-native", - "react-native/*", - "react-native-*", - "react-native-*/**", - "@gorhom/*", - "@shopify/react-native-*" - ], - "message": "The host console is plain DOM: no React Native, ever." - }, - { - "group": [ - "@civfix/ui/*/*", - "@civfix/ui/*/**", - "!@civfix/ui/theme/schemes" - ], - "message": "The host console is plain DOM and may import ONLY @civfix/ui/data, @civfix/ui/i18n, @civfix/ui/theme/schemes and (type-only) @civfix/ui/capabilities. Every other subpath reaches React Native. Nested subpaths (e.g. surface/liquidGlass) are React-Native and Skia." - }, - { - "group": [ - "maplibre-gl/*", - "maplibre-gl/**" - ], - "message": "The console renders no map." - } - ] - } - ] - } - } - ] -} diff --git a/apps/community-web/eslint.config.mjs b/apps/community-web/eslint.config.mjs new file mode 100644 index 00000000..541c44a9 --- /dev/null +++ b/apps/community-web/eslint.config.mjs @@ -0,0 +1,176 @@ +import { createRequire } from "node:module" +import nextPlugin from "@next/eslint-plugin-next" +import pluginQuery from "@tanstack/eslint-plugin-query" +import reactHooks from "eslint-plugin-react-hooks" +import tseslint from "typescript-eslint" + +// The react, jsx-a11y and import plugins are the exact copies the legacy `next/core-web-vitals` +// preset ran, so they resolve through eslint-config-next rather than as undeclared app dependencies. +const requireFromNextPreset = createRequire(createRequire(import.meta.url).resolve("eslint-config-next")) +const reactPlugin = requireFromNextPreset("eslint-plugin-react") +const jsxA11yPlugin = requireFromNextPreset("eslint-plugin-jsx-a11y") +const importPlugin = requireFromNextPreset("eslint-plugin-import") + +const TS_FILES = ["**/*.ts", "**/*.tsx"] +const TYPED_FILES = [...TS_FILES, "scripts/**/*.mjs"] +const FUNCTIONS_TESTS = ["functions/**/*.test.ts"] + +const HOST_CONSOLE_FILES = ["src/features/host/**", "src/components/console/**", "src/app/manage/**"] +const HOST_CONSOLE_UI_MESSAGE = + "The host console is plain DOM and may import ONLY @civfix/ui/data, @civfix/ui/i18n and (type-only) @civfix/ui/capabilities. Every other subpath reaches React Native." + +export default tseslint.config( + { + ignores: [".next/**", "out/**", "public/**", "next-env.d.ts"], + }, + ...tseslint.configs.recommended, + { + name: "community-web/next-core-web-vitals", + plugins: { + react: reactPlugin, + "react-hooks": reactHooks, + "jsx-a11y": jsxA11yPlugin, + import: importPlugin, + "@next/next": nextPlugin, + }, + languageOptions: { + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + settings: { + react: { version: "detect" }, + }, + rules: { + ...reactPlugin.configs.flat.recommended.rules, + ...reactHooks.configs.recommended.rules, + ...nextPlugin.flatConfig.recommended.rules, + ...nextPlugin.flatConfig.coreWebVitals.rules, + "import/no-anonymous-default-export": "warn", + "react/no-unknown-property": "off", + "react/react-in-jsx-scope": "off", + "react/prop-types": "off", + "jsx-a11y/alt-text": ["warn", { elements: ["img"], img: ["Image"] }], + "jsx-a11y/aria-props": "warn", + "jsx-a11y/aria-proptypes": "warn", + "jsx-a11y/aria-unsupported-elements": "warn", + "jsx-a11y/role-has-required-aria-props": "warn", + "jsx-a11y/role-supports-aria-props": "warn", + "react/jsx-no-target-blank": "off", + "@typescript-eslint/no-unused-expressions": "warn", + "@typescript-eslint/consistent-type-imports": [ + "warn", + { prefer: "type-imports", fixStyle: "separate-type-imports" }, + ], + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], + }, + }, + { + name: "community-web/typed", + files: TYPED_FILES, + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + plugins: { "@tanstack/query": pluginQuery }, + rules: { + ...Object.assign({}, ...pluginQuery.configs["flat/recommended"].map((config) => config.rules ?? {})), + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: { attributes: true } }], + "@typescript-eslint/switch-exhaustiveness-check": ["error", { considerDefaultExhaustiveForUnions: true }], + "@typescript-eslint/no-unsafe-argument": "error", + "@typescript-eslint/no-unsafe-assignment": "error", + "@typescript-eslint/no-unsafe-call": "error", + "@typescript-eslint/no-unsafe-enum-comparison": "error", + "@typescript-eslint/no-unsafe-member-access": "error", + "@typescript-eslint/no-unsafe-return": "error", + "@typescript-eslint/no-unsafe-unary-minus": "error", + }, + }, + { + // Pages Functions tests import node:fs and vitest, which the Workers-only functions/tsconfig.json + // deliberately cannot see, so they get their own project. + name: "community-web/functions-tests", + files: FUNCTIONS_TESTS, + languageOptions: { + parserOptions: { + projectService: false, + project: "./functions/tsconfig.test.json", + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + name: "community-web/scripts", + files: ["scripts/**/*.mjs"], + rules: { + // The build scripts are untyped JavaScript: type-aware linting guards their promise handling, + // while every value TypeScript cannot infer is `any` by construction. + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-return": "off", + }, + }, + { + name: "community-web/host-console", + files: HOST_CONSOLE_FILES, + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { name: "@civfix/ui", message: HOST_CONSOLE_UI_MESSAGE }, + { name: "@civfix/ui/bodies", message: HOST_CONSOLE_UI_MESSAGE }, + { name: "@civfix/ui/nav", message: HOST_CONSOLE_UI_MESSAGE }, + { + name: "@civfix/ui/theme", + message: + "@civfix/ui/theme's barrel imports react-native. Use @civfix/ui/theme/schemes (the RN-free subpath) for scheme names and palettes, and lib/color-scheme.ts to read the same `.dark` class the --console-* variables key off.", + }, + { name: "@civfix/ui/surface", message: HOST_CONSOLE_UI_MESSAGE }, + { name: "@civfix/ui/realtime", message: "The realtime channel is mounted once by components/providers.tsx." }, + { name: "@civfix/ui/typography", message: "Console typography comes from the Tailwind token scale." }, + { + name: "@civfix/ui/capabilities", + message: + "Capabilities are React-Native-shaped. Type-only imports are allowed; use @/lib/web-camera for the console's own camera seam.", + allowTypeImports: true, + }, + { + name: "next/navigation", + message: + "The console is one static shell served by the SPA fallback: navigate with components/console/url-state.ts, never a Next route transition.", + }, + { + name: "next/link", + message: "Use layout/console-link.tsx: a Next transition would target a path the static manifest does not contain.", + }, + { name: "next/router", message: "The console is App Router + its own history bridge." }, + { name: "maplibre-gl", message: "The console renders no map." }, + ], + patterns: [ + { + group: [ + "react-native", + "react-native/*", + "react-native-*", + "react-native-*/**", + "@gorhom/*", + "@shopify/react-native-*", + ], + message: "The host console is plain DOM: no React Native, ever.", + }, + { + group: ["@civfix/ui/*/*", "@civfix/ui/*/**", "!@civfix/ui/theme/schemes"], + message: + "The host console is plain DOM and may import ONLY @civfix/ui/data, @civfix/ui/i18n, @civfix/ui/theme/schemes and (type-only) @civfix/ui/capabilities. Every other subpath reaches React Native. Nested subpaths (e.g. surface/liquidGlass) are React-Native and Skia.", + }, + { group: ["maplibre-gl/*", "maplibre-gl/**"], message: "The console renders no map." }, + ], + }, + ], + }, + }, +) diff --git a/apps/community-web/functions/_preview-core.ts b/apps/community-web/functions/_preview-core.ts index 919a7adc..0f8c0d03 100644 --- a/apps/community-web/functions/_preview-core.ts +++ b/apps/community-web/functions/_preview-core.ts @@ -327,7 +327,7 @@ async function loadPayload( if (hit) { const cached = negativeCacheOutcome(hit) if (cached) return { payload: null, outcome: cached } - const payload = await hit.json().catch(() => null) + const payload: unknown = await hit.json().catch(() => null) return { payload, outcome: payload === null ? "transient" : "found" } } } diff --git a/apps/community-web/package.json b/apps/community-web/package.json index 4e9a293c..2360ed6f 100644 --- a/apps/community-web/package.json +++ b/apps/community-web/package.json @@ -10,8 +10,8 @@ "prebuild": "node scripts/copy-contract-fonts.mjs", "predev": "node scripts/copy-contract-fonts.mjs", "build": "next build && node scripts/cf-pages-postbuild.mjs", - "lint": "next lint --max-warnings=0", - "typecheck": "tsc --noEmit && tsc --noEmit -p ./functions/tsconfig.json", + "lint": "eslint . --max-warnings=0", + "typecheck": "tsc --noEmit && tsc --noEmit -p ./functions/tsconfig.json && tsc --noEmit -p ./functions/tsconfig.test.json", "og-image": "node scripts/generate-og-image.mjs", "test": "vitest run", "clean": "rimraf .next out *.tsbuildinfo", diff --git a/apps/community-web/scripts/tsconfig.json b/apps/community-web/scripts/tsconfig.json new file mode 100644 index 00000000..0b4d548f --- /dev/null +++ b/apps/community-web/scripts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowJs": true, + "checkJs": false, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["*.mjs"] +} diff --git a/apps/community-web/src/components/auth/auth-modal.tsx b/apps/community-web/src/components/auth/auth-modal.tsx index 5eb67347..542bfd05 100644 --- a/apps/community-web/src/components/auth/auth-modal.tsx +++ b/apps/community-web/src/components/auth/auth-modal.tsx @@ -213,7 +213,7 @@ export function AuthModal({ oauthReturnPath = null }: AuthModalProps = {}) { onEmailChange={setEmail} submitting={submitting} error={error} - onSubmit={requestCode} + onSubmit={(event) => void requestCode(event)} /> )} diff --git a/apps/community-web/src/components/home/use-team-invite-accept.ts b/apps/community-web/src/components/home/use-team-invite-accept.ts index 829da807..035cfb54 100644 --- a/apps/community-web/src/components/home/use-team-invite-accept.ts +++ b/apps/community-web/src/components/home/use-team-invite-accept.ts @@ -14,14 +14,14 @@ import { type EventTeamInviteLink, } from "@/lib/team-invite" -const TERMINAL_CODES: readonly string[] = [ +const TERMINAL_CODES: readonly ErrorCode[] = [ ErrorCode.NOT_FOUND, ErrorCode.CONFLICT, ErrorCode.FORBIDDEN, ErrorCode.VALIDATION, ] -function problemKey(code: string): string { +function problemKey(code: ErrorCode): string { if (code === ErrorCode.NOT_FOUND) return "accept.error_invalid" if (code === ErrorCode.CONFLICT) return "accept.error_unavailable" if (code === ErrorCode.FORBIDDEN) return "accept.error_removed" diff --git a/apps/community-web/src/features/host/broadcasts/broadcast-composer.tsx b/apps/community-web/src/features/host/broadcasts/broadcast-composer.tsx index 92a77170..fd61d3bd 100644 --- a/apps/community-web/src/features/host/broadcasts/broadcast-composer.tsx +++ b/apps/community-web/src/features/host/broadcasts/broadcast-composer.tsx @@ -333,6 +333,7 @@ export function BroadcastComposer({ broadcast }: BroadcastComposerProps) { [...draft.channels].sort(), ]) + // eslint-disable-next-line @tanstack/query/exhaustive-deps -- previewFingerprint is the key's projection of draft and broadcast: keying on the raw objects would drop a fetched preview on edits that do not change what is sent const preview = useQuery({ queryKey: [...consoleKeys.broadcastPreview(eventId, broadcast?.id ?? "new"), previewFingerprint], enabled: false, From c1ab489b2dd33a3c19e2cfdd00640a69a0b7d1ad Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 09:30:53 +0000 Subject: [PATCH 03/11] test: web tests typed for the typed lint rules --- apps/community-web/functions/_preview.test.ts | 12 +++++------ .../functions/tsconfig.test.json | 13 ++++++++++++ .../src/app/appearance-script.test.ts | 7 ++++++- .../components/auth/auth-modal.dom.test.tsx | 4 ++-- .../components/console/use-draft.dom.test.tsx | 16 +++++++++++---- .../home/body-transition-mounts.dom.test.tsx | 4 ++-- .../home/use-web-nav-adapter.dom.test.tsx | 6 +++--- .../features/auth/first-run-gate.dom.test.tsx | 2 +- .../features/claim/claim-view.dom.test.tsx | 2 +- .../org/accept-invite-screen.dom.test.tsx | 2 +- .../host/org/members-screen.dom.test.tsx | 4 ++-- .../host/org/verification-screen.dom.test.tsx | 2 +- .../service-record-view.dom.test.tsx | 2 +- .../registration-widget.dom.test.tsx | 20 ++++++++++--------- .../signup-page/signup-view.dom.test.tsx | 2 +- .../unsubscribe/unsubscribe.dom.test.tsx | 2 +- .../src/hooks/use-auth.dom.test.tsx | 8 ++++---- .../community-web/src/lib/locale.dom.test.tsx | 2 +- .../src/lib/viewer-scope.test.ts | 4 ++-- 19 files changed, 71 insertions(+), 43 deletions(-) create mode 100644 apps/community-web/functions/tsconfig.test.json diff --git a/apps/community-web/functions/_preview.test.ts b/apps/community-web/functions/_preview.test.ts index 4a7123f6..6009559f 100644 --- a/apps/community-web/functions/_preview.test.ts +++ b/apps/community-web/functions/_preview.test.ts @@ -1,6 +1,6 @@ import { readdirSync } from "node:fs" import { fileURLToPath } from "node:url" -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest" import { PERVASIVE_HEADERS } from "../src/lib/edge-headers" import { @@ -68,9 +68,9 @@ function makeCache(): EdgeCache & { entries: Map } { interface Harness { context: PreviewContextArg - rewrite: ReturnType - fetchSpy: ReturnType - assets: ReturnType + rewrite: Mock<(shell: Response, preview: LinkPreview) => Response> + fetchSpy: Mock<(input: Request) => Promise> + assets: Mock waited: Promise[] } @@ -80,8 +80,8 @@ function harness(options: { env?: Partial upstream?: () => Promise } = {}): Harness { - const assets = vi.fn(async () => shellResponse()) - const fetchSpy = vi.fn(options.upstream ?? (async () => new Response(JSON.stringify(REPORT_PAYLOAD), { status: 200 }))) + const assets = vi.fn(async () => shellResponse()) + const fetchSpy = vi.fn<(input: Request) => Promise>(options.upstream ?? (async () => new Response(JSON.stringify(REPORT_PAYLOAD), { status: 200 }))) vi.stubGlobal("fetch", fetchSpy) const waited: Promise[] = [] const rewrite = vi.fn((shell: Response, preview: LinkPreview) => { diff --git a/apps/community-web/functions/tsconfig.test.json b/apps/community-web/functions/tsconfig.test.json new file mode 100644 index 00000000..ce169668 --- /dev/null +++ b/apps/community-web/functions/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": [ + "**/*.ts", + "../src/lib/link-preview.ts", + "../src/lib/site-meta.ts", + "../src/lib/edge-headers.ts" + ], + "exclude": [] +} diff --git a/apps/community-web/src/app/appearance-script.test.ts b/apps/community-web/src/app/appearance-script.test.ts index 6fb5c2f3..43c22c98 100644 --- a/apps/community-web/src/app/appearance-script.test.ts +++ b/apps/community-web/src/app/appearance-script.test.ts @@ -36,7 +36,12 @@ function runScript(stored: string | null, systemDark: boolean): PaintResult { style, }, } - new Function("localStorage", "window", "document", APPEARANCE_SCRIPT)(localStorage, window, document) + const paint = new Function("localStorage", "window", "document", APPEARANCE_SCRIPT) as ( + storage: typeof localStorage, + win: typeof window, + doc: typeof document, + ) => void + paint(localStorage, window, document) return { readKey, dark, colorScheme: style.colorScheme } } diff --git a/apps/community-web/src/components/auth/auth-modal.dom.test.tsx b/apps/community-web/src/components/auth/auth-modal.dom.test.tsx index 87f506ec..898c35d7 100644 --- a/apps/community-web/src/components/auth/auth-modal.dom.test.tsx +++ b/apps/community-web/src/components/auth/auth-modal.dom.test.tsx @@ -2,8 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import type * as ApiModule from "@/lib/api" import { act, cleanup, fireEvent, render, screen } from "@testing-library/react" -const otpRequest = vi.fn() -const otpVerify = vi.fn() +const otpRequest = vi.fn<(...args: unknown[]) => unknown>() +const otpVerify = vi.fn<(...args: unknown[]) => unknown>() vi.mock("@civfix/ui/i18n", async () => { const { makeI18nMock } = await import("@/components/console/__testing__/i18n-mock") diff --git a/apps/community-web/src/components/console/use-draft.dom.test.tsx b/apps/community-web/src/components/console/use-draft.dom.test.tsx index 04899a6d..52206afc 100644 --- a/apps/community-web/src/components/console/use-draft.dom.test.tsx +++ b/apps/community-web/src/components/console/use-draft.dom.test.tsx @@ -75,10 +75,14 @@ describe("useDraft write", () => { const { result } = renderHook(() => useDraft(KEY, { title: "" })) act(() => result.current.patch({ title: "H" })) act(() => result.current.patch({ title: "Hi" })) - act(() => vi.advanceTimersByTime(499)) + act(() => { + vi.advanceTimersByTime(499) + }) expect(window.localStorage.getItem(KEY)).toBeNull() - act(() => vi.advanceTimersByTime(1)) + act(() => { + vi.advanceTimersByTime(1) + }) const envelope = JSON.parse(window.localStorage.getItem(KEY) as string) as Record expect(envelope).toMatchObject({ version: "v1", owner: "viewer-1", value: { title: "Hi" } }) }) @@ -104,7 +108,9 @@ describe("useDraft write", () => { const { result, unmount } = renderHook(() => useDraft(KEY, { title: "" })) act(() => result.current.patch({ title: "Hi" })) act(() => result.current.clear()) - act(() => vi.advanceTimersByTime(1000)) + act(() => { + vi.advanceTimersByTime(1000) + }) unmount() expect(window.localStorage.getItem(KEY)).toBeNull() }) @@ -118,7 +124,9 @@ describe("useDraft write", () => { act(() => result.current.patch({ title: "First" })) rerender({ key: other }) act(() => result.current.patch({ title: "Second" })) - act(() => vi.advanceTimersByTime(500)) + act(() => { + vi.advanceTimersByTime(500) + }) expect(storedValue(KEY)).toEqual({ title: "First" }) expect(storedValue(other)).toEqual({ title: "Second" }) }) diff --git a/apps/community-web/src/components/home/body-transition-mounts.dom.test.tsx b/apps/community-web/src/components/home/body-transition-mounts.dom.test.tsx index 26111a72..95b9cb10 100644 --- a/apps/community-web/src/components/home/body-transition-mounts.dom.test.tsx +++ b/apps/community-web/src/components/home/body-transition-mounts.dom.test.tsx @@ -4,8 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" vi.mock("react-native", async () => { // @ts-expect-error react-native-web ships no type declarations; webpack aliases it for the real build - const rnw = await import("react-native-web") - return rnw as unknown as Record + const rnw: unknown = await import("react-native-web") + return rnw as Record }) import { BodyTransition } from "../../../../../packages/ui/src/shell/BodyTransition.web" diff --git a/apps/community-web/src/components/home/use-web-nav-adapter.dom.test.tsx b/apps/community-web/src/components/home/use-web-nav-adapter.dom.test.tsx index c8ae9ebb..94ec80a4 100644 --- a/apps/community-web/src/components/home/use-web-nav-adapter.dom.test.tsx +++ b/apps/community-web/src/components/home/use-web-nav-adapter.dom.test.tsx @@ -60,7 +60,7 @@ async function browserBack(): Promise { } async function reload(): Promise { - const state = window.history.state + const state: unknown = window.history.state const pathname = window.location.pathname cleanup() useNavStore.setState({ @@ -208,7 +208,7 @@ describe("mount", () => { it("restores the stamped snapshot instead of re-seeding, so a reload keeps the stack", async () => { mount() await drive(() => nav().push(PIN_A)) - const reloadedState = window.history.state + const reloadedState: unknown = window.history.state cleanup() useNavStore.setState({ view: "home", stack: [], active: null }) @@ -763,7 +763,7 @@ describe("a landing carries the live search text and the seq already spent", () expect(aheadSeq).toBeGreaterThan(0) await browserBack() - const rootState = window.history.state + const rootState: unknown = window.history.state cleanup() useNavStore.setState({ view: "home", stack: [], active: null }) window.history.replaceState(rootState, "", "/") diff --git a/apps/community-web/src/features/auth/first-run-gate.dom.test.tsx b/apps/community-web/src/features/auth/first-run-gate.dom.test.tsx index 62d34c85..cd96427f 100644 --- a/apps/community-web/src/features/auth/first-run-gate.dom.test.tsx +++ b/apps/community-web/src/features/auth/first-run-gate.dom.test.tsx @@ -7,7 +7,7 @@ import type { UserDTO } from "@civfix/shared" import type * as UiDataModule from "@civfix/ui/data" import type * as ApiModule from "@/lib/api" -const logout = vi.fn() +const logout = vi.fn<(...args: unknown[]) => unknown>() const mutate = vi.fn() vi.mock("@civfix/ui/i18n", async () => { diff --git a/apps/community-web/src/features/claim/claim-view.dom.test.tsx b/apps/community-web/src/features/claim/claim-view.dom.test.tsx index 1a16c05b..d011784e 100644 --- a/apps/community-web/src/features/claim/claim-view.dom.test.tsx +++ b/apps/community-web/src/features/claim/claim-view.dom.test.tsx @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import type * as ApiModule from "@/lib/api" import { AppError, ErrorCode } from "@civfix/shared" -const claimNudge = vi.fn() +const claimNudge = vi.fn<(...args: unknown[]) => unknown>() vi.mock("next/navigation", () => ({ useRouter: () => ({ back: () => {}, push: () => {} }), diff --git a/apps/community-web/src/features/host/org/accept-invite-screen.dom.test.tsx b/apps/community-web/src/features/host/org/accept-invite-screen.dom.test.tsx index f36a0ecb..a5a0f5ed 100644 --- a/apps/community-web/src/features/host/org/accept-invite-screen.dom.test.tsx +++ b/apps/community-web/src/features/host/org/accept-invite-screen.dom.test.tsx @@ -63,7 +63,7 @@ describe("AcceptInviteScreen", () => { await waitFor(() => expect(client.acceptOrganizationInvite).toHaveBeenCalledWith({ token: TOKEN }), ) - const sent = client.acceptOrganizationInvite.mock.calls[0]?.[0] + const sent: unknown = client.acceptOrganizationInvite.mock.calls[0]?.[0] expect(AcceptOrganizationInviteRequestSchema.safeParse(sent).success).toBe(true) await waitFor(() => expect(window.location.pathname).toBe(`/manage/orgs/${ORG_ID}/`)) expect(window.sessionStorage.getItem(ORG_INVITE_TOKEN_STASH_KEY)).toBeNull() diff --git a/apps/community-web/src/features/host/org/members-screen.dom.test.tsx b/apps/community-web/src/features/host/org/members-screen.dom.test.tsx index b46ee12b..b2a6188c 100644 --- a/apps/community-web/src/features/host/org/members-screen.dom.test.tsx +++ b/apps/community-web/src/features/host/org/members-screen.dom.test.tsx @@ -222,7 +222,7 @@ describe("MembersScreen invite drawer", () => { role: "admin", }), ) - const sent = client.inviteOrganizationMember.mock.calls[0]?.[0] + const sent: unknown = client.inviteOrganizationMember.mock.calls[0]?.[0] expect(InviteOrganizationMemberRequestSchema.safeParse(sent).success).toBe(true) await waitFor(() => expect(screen.queryByRole("dialog", { name: "invite.title" })).toBeNull()) }) @@ -333,7 +333,7 @@ describe("MembersScreen pending invites", () => { inviteId: "33333333-3333-4333-8333-333333333333", }), ) - const sent = client.revokeOrganizationInvite.mock.calls[0]?.[0] + const sent: unknown = client.revokeOrganizationInvite.mock.calls[0]?.[0] expect(RevokeOrganizationInviteRequestSchema.safeParse(sent).success).toBe(true) }) }) diff --git a/apps/community-web/src/features/host/org/verification-screen.dom.test.tsx b/apps/community-web/src/features/host/org/verification-screen.dom.test.tsx index 1adc3d8b..40f636e8 100644 --- a/apps/community-web/src/features/host/org/verification-screen.dom.test.tsx +++ b/apps/community-web/src/features/host/org/verification-screen.dom.test.tsx @@ -122,7 +122,7 @@ describe("VerificationScreen", () => { note: "Determination letter attached", }), ) - const sent = client.applyOrganizationVerification.mock.calls[0]?.[0] + const sent: unknown = client.applyOrganizationVerification.mock.calls[0]?.[0] expect(ApplyOrganizationVerificationRequestSchema.safeParse(sent).success).toBe(true) expect(await screen.findByText("verification.pending_title")).toBeTruthy() diff --git a/apps/community-web/src/features/service-record/service-record-view.dom.test.tsx b/apps/community-web/src/features/service-record/service-record-view.dom.test.tsx index 6431678d..b217054a 100644 --- a/apps/community-web/src/features/service-record/service-record-view.dom.test.tsx +++ b/apps/community-web/src/features/service-record/service-record-view.dom.test.tsx @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import type * as ApiModule from "@/lib/api" -const verifyServiceHoursCertificate = vi.fn() +const verifyServiceHoursCertificate = vi.fn<(...args: unknown[]) => unknown>() vi.mock("@civfix/ui/i18n", async () => { const { makeI18nMock } = await import("@/components/console/__testing__/i18n-mock") diff --git a/apps/community-web/src/features/signup-page/registration-widget.dom.test.tsx b/apps/community-web/src/features/signup-page/registration-widget.dom.test.tsx index 0c47d049..b6b6e451 100644 --- a/apps/community-web/src/features/signup-page/registration-widget.dom.test.tsx +++ b/apps/community-web/src/features/signup-page/registration-widget.dom.test.tsx @@ -5,21 +5,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { GUEST_RSVP_TURNSTILE_ACTION } from "@civfix/shared" import type { PublicEventPageDTO } from "@civfix/shared" +import type { api } from "@/lib/api" + import { signupPage, ticket } from "./__fixtures__" -const registerForEvent = vi.fn() -const guestRsvpRequest = vi.fn() -const guestRsvpVerify = vi.fn() -const joinEventWaitlist = vi.fn() -const runTurnstile = vi.fn() +const registerForEvent = vi.fn<(body: Parameters[0], ...rest: unknown[]) => unknown>() +const guestRsvpRequest = vi.fn<(...args: unknown[]) => unknown>() +const guestRsvpVerify = vi.fn<(body: Parameters[0], ...rest: unknown[]) => unknown>() +const joinEventWaitlist = vi.fn<(...args: unknown[]) => unknown>() +const runTurnstile = vi.fn<(...args: unknown[]) => unknown>() let authenticated = true vi.mock("@/lib/api", () => ({ api: { - registerForEvent: (...args: unknown[]) => registerForEvent(...args), + registerForEvent: (...args: Parameters) => registerForEvent(...args), guestRsvpRequest: (...args: unknown[]) => guestRsvpRequest(...args), - guestRsvpVerify: (...args: unknown[]) => guestRsvpVerify(...args), + guestRsvpVerify: (...args: Parameters) => guestRsvpVerify(...args), joinEventWaitlist: (...args: unknown[]) => joinEventWaitlist(...args), }, toAppError: (error: unknown) => error, @@ -106,12 +108,12 @@ describe("member one-tap registration", () => { await user.click(screen.getByRole("button", { name: /form\.count_me_in/ })) await screen.findByText("web-signup:outcome.registered") const body = registerForEvent.mock.calls[0]?.[0] - expect(body.consent).toMatchObject({ + expect(body?.consent).toMatchObject({ disclosureVersion: "2026-09-06", hostContactOptIn: false, surface: "web_register", }) - expect(body.idempotencyKey.length).toBeGreaterThan(7) + expect(body?.idempotencyKey.length).toBeGreaterThan(7) }) it("keeps ONE idempotency key across a repeated tap on the same attempt", async () => { diff --git a/apps/community-web/src/features/signup-page/signup-view.dom.test.tsx b/apps/community-web/src/features/signup-page/signup-view.dom.test.tsx index 2563892e..64a55c4f 100644 --- a/apps/community-web/src/features/signup-page/signup-view.dom.test.tsx +++ b/apps/community-web/src/features/signup-page/signup-view.dom.test.tsx @@ -4,7 +4,7 @@ import { act, cleanup, render, screen } from "@testing-library/react" import { AppError, ErrorCode } from "@civfix/shared" import type { PublicEventPageDTO } from "@civfix/shared" -const getPublicEventPage = vi.fn() +const getPublicEventPage = vi.fn<(...args: unknown[]) => unknown>() vi.mock("@civfix/ui/i18n", () => ({ useT: () => ({ t: (key: string) => key, i18n: { language: "en" } }), diff --git a/apps/community-web/src/features/unsubscribe/unsubscribe.dom.test.tsx b/apps/community-web/src/features/unsubscribe/unsubscribe.dom.test.tsx index e5ba90e7..56c60a94 100644 --- a/apps/community-web/src/features/unsubscribe/unsubscribe.dom.test.tsx +++ b/apps/community-web/src/features/unsubscribe/unsubscribe.dom.test.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { cleanup, render, screen } from "@testing-library/react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -const unsubscribeBroadcasts = vi.fn() +const unsubscribeBroadcasts = vi.fn<(...args: unknown[]) => unknown>() vi.mock("@civfix/ui/i18n", async () => { const { makeI18nMock } = await import("@/components/console/__testing__/i18n-mock") diff --git a/apps/community-web/src/hooks/use-auth.dom.test.tsx b/apps/community-web/src/hooks/use-auth.dom.test.tsx index d0d90d52..a6cdb462 100644 --- a/apps/community-web/src/hooks/use-auth.dom.test.tsx +++ b/apps/community-web/src/hooks/use-auth.dom.test.tsx @@ -7,13 +7,13 @@ import { AppError, ErrorCode, type UserDTO } from "@civfix/shared" import type * as ApiModule from "@/lib/api" import { queryKeys } from "@/lib/query" -const logout = vi.fn() -const session = vi.fn() +const logout = vi.fn<(options: { signal?: AbortSignal }) => Promise>() +const session = vi.fn<(...args: unknown[]) => unknown>() vi.mock("@/lib/api", async (importOriginal) => ({ ...(await importOriginal()), api: { - logout: (...args: unknown[]) => logout(...args), + logout: (...args: Parameters) => logout(...args), session: (...args: unknown[]) => session(...args), }, })) @@ -173,7 +173,7 @@ describe("useLogout", () => { useAuthStore.setState({ csrfToken: null, optimistic: true }) const tokensAtCall: (string | undefined)[] = [] logout.mockImplementation(async (...args: unknown[]) => { - expect(args).toEqual([{ signal: expect.any(AbortSignal) }]) + expect(args).toEqual([{ signal: expect.any(AbortSignal) as unknown }]) tokensAtCall.push(await resolveCsrfToken()) throw new TypeError("Failed to fetch") }) diff --git a/apps/community-web/src/lib/locale.dom.test.tsx b/apps/community-web/src/lib/locale.dom.test.tsx index 6f641272..7670e397 100644 --- a/apps/community-web/src/lib/locale.dom.test.tsx +++ b/apps/community-web/src/lib/locale.dom.test.tsx @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { act, renderHook } from "@testing-library/react" import type { UserDTO } from "@civfix/shared" -const updateSettings = vi.fn() +const updateSettings = vi.fn<(...args: unknown[]) => unknown>() vi.mock("@/lib/api", () => ({ api: { updateSettings: (...args: unknown[]) => updateSettings(...args) }, diff --git a/apps/community-web/src/lib/viewer-scope.test.ts b/apps/community-web/src/lib/viewer-scope.test.ts index d0858592..12864b6a 100644 --- a/apps/community-web/src/lib/viewer-scope.test.ts +++ b/apps/community-web/src/lib/viewer-scope.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" import { QueryClient } from "@tanstack/react-query" import type { UserDTO } from "@civfix/shared" -const adoptViewer = vi.fn() -const discardViewerDrafts = vi.fn() +const adoptViewer = vi.fn<(...args: unknown[]) => unknown>() +const discardViewerDrafts = vi.fn<(...args: unknown[]) => unknown>() vi.mock("@civfix/ui", () => ({ adoptViewer: (viewerId: string | null) => adoptViewer(viewerId), From 2af7be5ea1790fada9d22dc170f7e2c40eb02fab Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 08:49:39 +0000 Subject: [PATCH 04/11] typed lint rules, exhaustive hook deps and the query plugin in shared, ui and mobile, with mobile tests typechecked --- apps/community-mobile/app/auth/otp.tsx | 8 ++-- apps/community-mobile/eslint.config.js | 45 +++++++++++++++++++ apps/community-mobile/package.json | 2 +- .../src/components/AuthOptions.tsx | 8 ++-- .../src/components/BootConnectivity.tsx | 2 +- .../components/onboarding/stages/MapStill.tsx | 12 ++--- .../components/report/ReportViewfinder.tsx | 6 +-- .../src/lib/nativeBetaInstall.ts | 3 +- .../src/query/mmkvPersister.ts | 5 ++- apps/community-mobile/tsconfig.json | 4 +- packages/shared/eslint.config.js | 24 ++++++++++ packages/shared/package.json | 2 +- packages/shared/src/client/client.ts | 2 +- packages/ui/eslint.config.js | 37 +++++++++++++++ packages/ui/package.json | 2 +- .../conversation/ConversationComposer.tsx | 4 +- .../bodies/host/checkin/useCheckinRoster.ts | 8 +--- .../host/dashboard/HostedEventsSection.tsx | 3 +- .../ui/src/bodies/reportFlow/CaptureStep.tsx | 7 +-- packages/ui/src/data/hooks/chat.ts | 12 ++++- packages/ui/src/data/hooks/chatAround.ts | 4 +- packages/ui/src/data/hooks/chatHistory.ts | 4 +- packages/ui/src/data/hooks/chatInbound.ts | 11 ++++- packages/ui/src/data/hooks/chatOutbox.ts | 8 ++-- packages/ui/src/data/hooks/chatRoomSocket.ts | 5 +++ packages/ui/src/data/hooks/chatTyping.ts | 2 +- packages/ui/src/data/hooks/feed.ts | 3 ++ packages/ui/src/data/hooks/resolveAddress.ts | 3 +- packages/ui/src/map/Map.native.tsx | 2 +- packages/ui/src/map/Map.web.tsx | 2 +- packages/ui/src/map/ReportPickMap.native.tsx | 2 +- packages/ui/src/map/ReportPickMap.web.tsx | 2 +- packages/ui/src/map/homeMapCamera.web.ts | 4 +- packages/ui/src/map/homeMapOverlays.web.tsx | 14 +++--- packages/ui/src/shell/BodyTransition.web.tsx | 10 ++--- packages/ui/src/shell/CompactShell.native.tsx | 4 +- .../ui/src/shell/ContentBottomReserve.tsx | 4 +- .../src/shell/KeyboardAwareScroll.native.tsx | 19 +++++--- .../ui/src/shell/KeyboardAwareScroll.web.tsx | 21 ++++++--- .../src/shell/MinimizeAwareScroll.native.tsx | 4 +- packages/ui/src/shell/PageStack.web.tsx | 4 +- packages/ui/src/shell/ScrollHost.tsx | 21 ++++++++- .../ui/src/shell/SearchBodyReveal.native.tsx | 10 ++++- .../src/shell/SheetHandoffScroll.native.tsx | 8 ++-- packages/ui/src/shell/useFlipPhase.ts | 7 ++- 45 files changed, 279 insertions(+), 95 deletions(-) diff --git a/apps/community-mobile/app/auth/otp.tsx b/apps/community-mobile/app/auth/otp.tsx index 6af0c9ab..97e36651 100644 --- a/apps/community-mobile/app/auth/otp.tsx +++ b/apps/community-mobile/app/auth/otp.tsx @@ -182,7 +182,7 @@ export default function OtpScreen() { spellCheck={false} maxLength={REVIEWER_OTP_CODE_MAX_LENGTH} returnKeyType="go" - onSubmitEditing={() => onVerify(code)} + onSubmitEditing={() => void onVerify(code)} editable={!locked && !verifying} autoFocus /> @@ -195,7 +195,7 @@ export default function OtpScreen() { setCode(next) }} length={EMAIL_OTP_CODE_LENGTH} - onComplete={onVerify} + onComplete={(value) => void onVerify(value)} editable={!locked && !verifying} /> )} @@ -218,7 +218,7 @@ export default function OtpScreen() { label={t("verify.label")} loading={verifying} disabled={locked || isCodeIncomplete(code, reviewer)} - onPress={() => onVerify(code)} + onPress={() => void onVerify(code)} style={styles.verifyBtn} /> @@ -227,7 +227,7 @@ export default function OtpScreen() { {t("resend.prompt")} void onResend()} disabled={cooldown > 0 || resending} accessibilityRole="button" accessibilityLabel={t("resend.action")} diff --git a/apps/community-mobile/eslint.config.js b/apps/community-mobile/eslint.config.js index fcd049ee..c39a8cd0 100644 --- a/apps/community-mobile/eslint.config.js +++ b/apps/community-mobile/eslint.config.js @@ -1,5 +1,32 @@ const { defineConfig } = require("eslint/config") const expoConfig = require("eslint-config-expo/flat") +const pluginQuery = require("@tanstack/eslint-plugin-query") + +const TYPED_RULES = { + // node:test's test()/describe() return promises the runner itself awaits. + "@typescript-eslint/no-floating-promises": [ + "error", + { + allowForKnownSafeCalls: [ + { + from: "package", + package: "node:test", + name: ["test", "describe", "it", "suite", "before", "after", "beforeEach", "afterEach"], + }, + ], + }, + ], + "@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: { attributes: true } }], + // A `default` branch is a deliberate catch-all; the rule only has to catch a switch that silently + // falls through to undefined when a union grows. + "@typescript-eslint/switch-exhaustiveness-check": ["error", { considerDefaultExhaustiveForUnions: true }], + "@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports", fixStyle: "separate-type-imports" }], + "@typescript-eslint/no-unsafe-argument": "error", + "@typescript-eslint/no-unsafe-assignment": "error", + "@typescript-eslint/no-unsafe-call": "error", + "@typescript-eslint/no-unsafe-member-access": "error", + "@typescript-eslint/no-unsafe-return": "error", +} module.exports = defineConfig([ expoConfig, @@ -7,4 +34,22 @@ module.exports = defineConfig([ // `scripts/*` are Node ESM dev tools, not RN app source. ignores: ["dist/*", ".expo/*", "node_modules/*", "expo-env.d.ts", "scripts/*"], }, + { + files: ["eslint.config.js"], + languageOptions: { globals: { __dirname: "readonly" } }, + }, + { + files: ["**/*.ts", "**/*.tsx"], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: __dirname, + }, + }, + rules: TYPED_RULES, + }, + { + files: ["**/*.ts", "**/*.tsx"], + extends: [pluginQuery.configs["flat/recommended"]], + }, ]) diff --git a/apps/community-mobile/package.json b/apps/community-mobile/package.json index acb55113..2c6b0be2 100644 --- a/apps/community-mobile/package.json +++ b/apps/community-mobile/package.json @@ -12,7 +12,7 @@ "postinstall": "pnpm --filter @civfix/shared build", "test": "node --experimental-strip-types --test tests/*.test.ts src/api/*.test.ts src/components/*.test.ts src/lib/*.test.ts src/query/*.test.ts src/theme/*.test.ts", "typecheck": "tsc --noEmit", - "lint": "eslint .", + "lint": "eslint . --max-warnings=0", "doctor": "expo-doctor", "export": "expo export", "build:testflight": "scripts/store-build.sh testflight", diff --git a/apps/community-mobile/src/components/AuthOptions.tsx b/apps/community-mobile/src/components/AuthOptions.tsx index 508734ab..450bf822 100644 --- a/apps/community-mobile/src/components/AuthOptions.tsx +++ b/apps/community-mobile/src/components/AuthOptions.tsx @@ -180,7 +180,7 @@ export function AuthOptions({ icon={Apple} loading={busy === "apple"} disabled={busy !== null && busy !== "apple"} - onPress={onApple} + onPress={() => void onApple()} /> ) : null} @@ -191,7 +191,7 @@ export function AuthOptions({ icon={Globe} loading={busy === "google"} disabled={busy !== null && busy !== "google"} - onPress={onGoogle} + onPress={() => void onGoogle()} /> ) : null} @@ -220,12 +220,12 @@ export function AuthOptions({ keyboardType="email-address" returnKeyType="go" autoFocus - onSubmitEditing={onEmail} + onSubmitEditing={() => void onEmail()} /> void onEmail()} style={styles.sendBtn} /> diff --git a/apps/community-mobile/src/components/BootConnectivity.tsx b/apps/community-mobile/src/components/BootConnectivity.tsx index 0d220774..789494b4 100644 --- a/apps/community-mobile/src/components/BootConnectivity.tsx +++ b/apps/community-mobile/src/components/BootConnectivity.tsx @@ -53,7 +53,7 @@ export function BootOfflineGate() { accessibilityLabel={t("boot.sign_out")} accessibilityState={{ disabled: signingOut, busy: signingOut }} disabled={signingOut} - onPress={onSignOut} + onPress={() => void onSignOut()} {...focusRingProps} style={({ pressed }) => [styles.secondary, pressed ? styles.pressed : null]} > diff --git a/apps/community-mobile/src/components/onboarding/stages/MapStill.tsx b/apps/community-mobile/src/components/onboarding/stages/MapStill.tsx index 23f66338..e1a90d7f 100644 --- a/apps/community-mobile/src/components/onboarding/stages/MapStill.tsx +++ b/apps/community-mobile/src/components/onboarding/stages/MapStill.tsx @@ -22,16 +22,16 @@ import { const STILLS: Readonly>>> = { report: { - light: require("../../../../assets/onboarding/report-light.png"), - dark: require("../../../../assets/onboarding/report-dark.png"), + light: require("../../../../assets/onboarding/report-light.png") as ImageRequireSource, + dark: require("../../../../assets/onboarding/report-dark.png") as ImageRequireSource, }, track: { - light: require("../../../../assets/onboarding/track-light.png"), - dark: require("../../../../assets/onboarding/track-dark.png"), + light: require("../../../../assets/onboarding/track-light.png") as ImageRequireSource, + dark: require("../../../../assets/onboarding/track-dark.png") as ImageRequireSource, }, together: { - light: require("../../../../assets/onboarding/together-light.png"), - dark: require("../../../../assets/onboarding/together-dark.png"), + light: require("../../../../assets/onboarding/together-light.png") as ImageRequireSource, + dark: require("../../../../assets/onboarding/together-dark.png") as ImageRequireSource, }, } diff --git a/apps/community-mobile/src/components/report/ReportViewfinder.tsx b/apps/community-mobile/src/components/report/ReportViewfinder.tsx index 996339fd..262d22dd 100644 --- a/apps/community-mobile/src/components/report/ReportViewfinder.tsx +++ b/apps/community-mobile/src/components/report/ReportViewfinder.tsx @@ -339,7 +339,7 @@ export function ReportViewfinder({ void onPickFromLibrary()} style={styles.gateBtnSecondary} /> } @@ -374,7 +374,7 @@ export function ReportViewfinder({ void onPickFromLibrary()} disabled={busy || recording} accessibilityRole="button" accessibilityLabel={t("gate.choose_library")} @@ -385,7 +385,7 @@ export function ReportViewfinder({ void (mode === "photo" ? onTakePhoto() : onToggleRecord())} disabled={busy && !recording} accessibilityRole="button" accessibilityState={{ disabled: busy && !recording, busy }} diff --git a/apps/community-mobile/src/lib/nativeBetaInstall.ts b/apps/community-mobile/src/lib/nativeBetaInstall.ts index 00b2c95e..0993db7f 100644 --- a/apps/community-mobile/src/lib/nativeBetaInstall.ts +++ b/apps/community-mobile/src/lib/nativeBetaInstall.ts @@ -1,3 +1,4 @@ +import type * as ExpoFileSystem from "expo-file-system" import { Platform } from "react-native" import { APP_STORE_RECEIPT, @@ -25,7 +26,7 @@ function statReceipt(file: { exists: boolean; modificationTime: number | null }) function probeStoreKit(): StoreKitProbe { try { // eslint-disable-next-line @typescript-eslint/no-require-imports -- loaded inside the guard so a resolution failure cannot break boot - const { File, Paths } = require("expo-file-system") as typeof import("expo-file-system") + const { File, Paths } = require("expo-file-system") as typeof ExpoFileSystem const container = Paths.document.parentDirectory return { dir: container.uri, diff --git a/apps/community-mobile/src/query/mmkvPersister.ts b/apps/community-mobile/src/query/mmkvPersister.ts index 0d7e8be5..fcc5bb9a 100644 --- a/apps/community-mobile/src/query/mmkvPersister.ts +++ b/apps/community-mobile/src/query/mmkvPersister.ts @@ -1,4 +1,5 @@ -import { QueryClient, dehydrate, hydrate } from "@tanstack/react-query" +import { dehydrate, hydrate } from "@tanstack/react-query" +import type { QueryClient, QueryKey } from "@tanstack/react-query" import Constants from "expo-constants" import { storage } from "@/lib/mmkv" import { CACHED_USER_KEY } from "@/lib/mmkvKeys" @@ -71,7 +72,7 @@ export function installCachePersistence(queryClient: QueryClient): () => void { const unsubscribe = queryClient.getQueryCache().subscribe((event) => { if (event.type !== "added" && event.type !== "updated" && event.type !== "removed") return - if (!isPersistedQueryKey(event.query.queryKey)) return + if (!isPersistedQueryKey(event.query.queryKey as QueryKey)) return active.schedule() }) diff --git a/apps/community-mobile/tsconfig.json b/apps/community-mobile/tsconfig.json index 98d6e15f..c9d3b2c1 100644 --- a/apps/community-mobile/tsconfig.json +++ b/apps/community-mobile/tsconfig.json @@ -2,6 +2,7 @@ "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, + "allowImportingTsExtensions": true, "baseUrl": ".", "paths": { "@/*": [ @@ -14,7 +15,6 @@ "**/*.tsx" ], "exclude": [ - "node_modules", - "**/*.test.ts" + "node_modules" ] } diff --git a/packages/shared/eslint.config.js b/packages/shared/eslint.config.js index eb059b08..f80a9526 100644 --- a/packages/shared/eslint.config.js +++ b/packages/shared/eslint.config.js @@ -28,6 +28,20 @@ const FRAMEWORK_FREE_IMPORTS = { ], } +const TYPED_RULES = { + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: { attributes: true } }], + // A `default` branch is a deliberate catch-all; the rule only has to catch a switch that silently + // falls through to undefined when a union grows. + "@typescript-eslint/switch-exhaustiveness-check": ["error", { considerDefaultExhaustiveForUnions: true }], + "@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports", fixStyle: "separate-type-imports" }], + "@typescript-eslint/no-unsafe-argument": "error", + "@typescript-eslint/no-unsafe-assignment": "error", + "@typescript-eslint/no-unsafe-call": "error", + "@typescript-eslint/no-unsafe-member-access": "error", + "@typescript-eslint/no-unsafe-return": "error", +} + export default tseslint.config( { ignores: ["dist/**", "node_modules/**", "*.config.js", "*.config.ts"], @@ -51,6 +65,16 @@ export default tseslint.config( ], }, }, + { + files: ["**/*.ts", "**/*.tsx"], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: TYPED_RULES, + }, { files: ["src/**/*.ts", "src/**/*.tsx"], rules: FRAMEWORK_FREE_IMPORTS, diff --git a/packages/shared/package.json b/packages/shared/package.json index 51d9bbf6..2ae0c06a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -170,7 +170,7 @@ "prepublishOnly": "pnpm build", "typecheck": "tsc --noEmit", "test": "vitest run", - "lint": "eslint .", + "lint": "eslint . --max-warnings=0", "clean": "rimraf dist *.tsbuildinfo", "dev": "tsup --watch" }, diff --git a/packages/shared/src/client/client.ts b/packages/shared/src/client/client.ts index ebba91ae..027785f8 100644 --- a/packages/shared/src/client/client.ts +++ b/packages/shared/src/client/client.ts @@ -1,4 +1,4 @@ -import { z } from "zod" +import type { z } from "zod" import { AppErrorSchema } from "../schemas/common.js" import { AppError, ErrorCode, isErrorCode } from "../types/errors.js" import { diff --git a/packages/ui/eslint.config.js b/packages/ui/eslint.config.js index 945dbd51..2ca35330 100644 --- a/packages/ui/eslint.config.js +++ b/packages/ui/eslint.config.js @@ -1,4 +1,5 @@ import eslint from "@eslint/js" +import pluginQuery from "@tanstack/eslint-plugin-query" import reactHooks from "eslint-plugin-react-hooks" import tseslint from "typescript-eslint" @@ -30,6 +31,20 @@ const RN_FREE_PATTERNS = [ { group: ["react-native/*", "react-native-*"], message: "This module is published as an RN-free subpath; it must not import react-native." }, ] +const TYPED_RULES = { + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: { attributes: true } }], + // A `default` branch is a deliberate catch-all; the rule only has to catch a switch that silently + // falls through to undefined when a union grows. + "@typescript-eslint/switch-exhaustiveness-check": ["error", { considerDefaultExhaustiveForUnions: true }], + "@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports", fixStyle: "separate-type-imports" }], + "@typescript-eslint/no-unsafe-argument": "error", + "@typescript-eslint/no-unsafe-assignment": "error", + "@typescript-eslint/no-unsafe-call": "error", + "@typescript-eslint/no-unsafe-member-access": "error", + "@typescript-eslint/no-unsafe-return": "error", +} + export default tseslint.config( { ignores: ["dist-types/**", "node_modules/**", "*.config.js", "*.config.ts", "scripts/**"], @@ -53,11 +68,33 @@ export default tseslint.config( ], }, }, + { + files: ["**/*.ts", "**/*.tsx"], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: TYPED_RULES, + }, { files: ["src/**/*.ts", "src/**/*.tsx"], plugins: { "react-hooks": reactHooks }, rules: { "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "error", + }, + }, + ...pluginQuery.configs["flat/recommended"], + { + rules: { + // The injected API client, query client and geolocation capability are stable per provider and + // never part of a query's identity, so they stay out of every query key. + "@tanstack/query/exhaustive-deps": [ + "error", + { allowlist: { variables: ["api", "qc", "geo"], types: ["ApiClient", "QueryClient"] } }, + ], }, }, { diff --git a/packages/ui/package.json b/packages/ui/package.json index 9fbe4f64..b60fea29 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -86,7 +86,7 @@ "build": "tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist-types", "typecheck": "tsc --noEmit -p tsconfig.json", "test": "vitest run", - "lint": "eslint .", + "lint": "eslint . --max-warnings=0", "i18n:check": "node scripts/check-i18n-keys.mjs", "i18n:gen": "node scripts/gen-i18n-resources.mjs", "clean": "rimraf dist-types *.tsbuildinfo" diff --git a/packages/ui/src/bodies/conversation/ConversationComposer.tsx b/packages/ui/src/bodies/conversation/ConversationComposer.tsx index f31c7248..661f596c 100644 --- a/packages/ui/src/bodies/conversation/ConversationComposer.tsx +++ b/packages/ui/src/bodies/conversation/ConversationComposer.tsx @@ -164,8 +164,8 @@ export function ConversationComposer({ onClose={() => setAttachOpen(false)} anchor={attachAnchor} canCreatePoll={canCreatePoll} - onPhoto={att.onAttach} - onCamera={att.onCapture} + onPhoto={() => void att.onAttach()} + onCamera={() => void att.onCapture()} onPoll={() => setPollCreateOpen(true)} /> - land( + (kind: CaptureSource) => { + void land( kind === "capture" ? () => camera.capture({ orientation: "portrait" }) : () => camera.pickFromLibrary(), - ), + ) + }, [camera, land], ) diff --git a/packages/ui/src/data/hooks/chat.ts b/packages/ui/src/data/hooks/chat.ts index da5a7a4e..3f3b4050 100644 --- a/packages/ui/src/data/hooks/chat.ts +++ b/packages/ui/src/data/hooks/chat.ts @@ -238,7 +238,17 @@ export function useChat(roomId: string, roomKind: RoomKind = "cleanup", options? setOnlineUserIds(new Set()) clearTypingState() clearAround() - }, [roomId, roomKind, clearTypingState, resetSendTracking, resetHistoryJournal, clearAround]) + }, [ + roomId, + roomKind, + clearTypingState, + resetSendTracking, + resetHistoryJournal, + clearAround, + setLiveMessages, + setOutbox, + setOnlineUserIds, + ]) useClearTypingTimersOnUnmount(typing.typingTimers) diff --git a/packages/ui/src/data/hooks/chatAround.ts b/packages/ui/src/data/hooks/chatAround.ts index f07512c9..3b690fe8 100644 --- a/packages/ui/src/data/hooks/chatAround.ts +++ b/packages/ui/src/data/hooks/chatAround.ts @@ -36,14 +36,14 @@ export function useChatAroundWindow({ api, roomId, roomKind, myUserId, setAround return false }) }, - [api, roomKind, roomId, myUserId], + [api, roomKind, roomId, myUserId, setAroundWindow], ) const clearAround = useCallback(() => { aroundSeqRef.current++ setAroundWindow(null) setAroundLoading(false) - }, []) + }, [setAroundWindow]) return { aroundLoading, fetchAround, clearAround } } diff --git a/packages/ui/src/data/hooks/chatHistory.ts b/packages/ui/src/data/hooks/chatHistory.ts index bdb2a133..ca74df4f 100644 --- a/packages/ui/src/data/hooks/chatHistory.ts +++ b/packages/ui/src/data/hooks/chatHistory.ts @@ -78,7 +78,7 @@ export function useChatHistoryCache({ : undefined) ) }, - [queryClient, roomId, roomKind], + [queryClient, roomId, roomKind, aroundWindowRef, liveMessagesRef], ) const isHistoryFetchInFlight = useCallback(() => { @@ -175,7 +175,7 @@ export function useChatHistoryCache({ ) }) }, - [queryClient, roomId, roomKind, isHistoryFetchInFlight], + [queryClient, roomId, roomKind, isHistoryFetchInFlight, setLiveMessages, setAroundWindow], ) const journalInbound = useCallback((batch: InboundFrame[]) => { diff --git a/packages/ui/src/data/hooks/chatInbound.ts b/packages/ui/src/data/hooks/chatInbound.ts index 1de6c271..b64065a1 100644 --- a/packages/ui/src/data/hooks/chatInbound.ts +++ b/packages/ui/src/data/hooks/chatInbound.ts @@ -57,7 +57,16 @@ export function useChatInbound({ } else { setLiveMessages((prev) => foldInboundBatch([], prev, batch).liveMessages) } - }, [queryClient, roomId, roomKind, journalInbound, isHistoryFetchInFlight, pruneLiveMessages]) + }, [ + queryClient, + roomId, + roomKind, + journalInbound, + isHistoryFetchInFlight, + pruneLiveMessages, + setOutbox, + setLiveMessages, + ]) const reconcile = useCallback( (message: ChatMessageDTO, explicitClientId?: string, viewerTruth?: boolean) => { diff --git a/packages/ui/src/data/hooks/chatOutbox.ts b/packages/ui/src/data/hooks/chatOutbox.ts index ec5a03d7..1f8efc4a 100644 --- a/packages/ui/src/data/hooks/chatOutbox.ts +++ b/packages/ui/src/data/hooks/chatOutbox.ts @@ -116,7 +116,7 @@ export function useChatOutbox({ }, timeoutMs) sendTimers.current.set(clientId, timer) }, - [clearSendTimer], + [clearSendTimer, setOutbox], ) const dispatch = useCallback( @@ -156,7 +156,7 @@ export function useChatOutbox({ prev.map((e) => (e.clientId === clientId ? { ...e, status: "failed" } : e)), ) }, - [socket, roomId, roomKind, stampRoomKind, armSendTimeout, clearSendTimer], + [socket, roomId, roomKind, stampRoomKind, armSendTimeout, clearSendTimer, setOutbox], ) const redispatch = useCallback( @@ -230,7 +230,7 @@ export function useChatOutbox({ } dispatch(clientId, body, mentionedUserIds, mediaList.map((m) => m.uploadId), replyToId) }, - [enabled, roomId, myUserId, user?.displayName, dispatch, findMessage], + [enabled, roomId, myUserId, user?.displayName, dispatch, findMessage, setOutbox, setTransientError], ) const retry = useCallback( @@ -243,7 +243,7 @@ export function useChatOutbox({ ) redispatch(entry) }, - [redispatch], + [redispatch, outboxRef, setOutbox, setTransientError], ) return { diff --git a/packages/ui/src/data/hooks/chatRoomSocket.ts b/packages/ui/src/data/hooks/chatRoomSocket.ts index 1da83eb0..2cb2add9 100644 --- a/packages/ui/src/data/hooks/chatRoomSocket.ts +++ b/packages/ui/src/data/hooks/chatRoomSocket.ts @@ -153,6 +153,11 @@ export function useChatRoomSocket({ clearAllSendTimers, resetReadAck, flushPendingReadAck, + setJoinRejected, + setTransientError, + setConnection, + setOnlineUserIds, + setAroundWindow, ]) // Held apart from the join effect so an in-place room switch is a leave/join on the open socket: under diff --git a/packages/ui/src/data/hooks/chatTyping.ts b/packages/ui/src/data/hooks/chatTyping.ts index 151a2b3a..49169cf9 100644 --- a/packages/ui/src/data/hooks/chatTyping.ts +++ b/packages/ui/src/data/hooks/chatTyping.ts @@ -66,5 +66,5 @@ export function useClearTypingTimersOnUnmount(typingTimers: MutableRefObject filterCleanupsWithinRadius(items, center, radiusM) }, [biasLat, biasLng, radiusM]) + // The key buckets `near` into its rounded feed cell so nearby callers share one entry; the request + // still carries the precise point. + // eslint-disable-next-line @tanstack/query/exhaustive-deps return useQuery({ // `limit` is part of BOTH keys: a sidebar preview (8), a search surface (10) and the events page's // own `useCleanups("upcoming", 50)` must not share one entry, or whichever refetched last diff --git a/packages/ui/src/data/hooks/resolveAddress.ts b/packages/ui/src/data/hooks/resolveAddress.ts index f83143e9..3452bdf3 100644 --- a/packages/ui/src/data/hooks/resolveAddress.ts +++ b/packages/ui/src/data/hooks/resolveAddress.ts @@ -51,10 +51,9 @@ export function useResolveAddress( opts?: { enabled?: boolean }, ): UseQueryResult { const api = useApi() - const key = point ? geocodePointKey(point) : "none" const enabled = point !== null && opts?.enabled !== false return useQuery({ - queryKey: queryKeys.resolvedAddress(key), + queryKey: queryKeys.resolvedAddress(point ? geocodePointKey(point) : "none"), enabled, queryFn: () => fetchResolvedAddress(api, point!), retry: false, diff --git a/packages/ui/src/map/Map.native.tsx b/packages/ui/src/map/Map.native.tsx index dd85c346..b338c587 100644 --- a/packages/ui/src/map/Map.native.tsx +++ b/packages/ui/src/map/Map.native.tsx @@ -198,7 +198,7 @@ export const Map = memo(forwardRef(function Map(props, ref) const { index, query } = useClusters(points) const indexRef = useRef(index) const [nodes, setNodes] = useState([]) - const nodesByMarkerRef = useRef>(new globalThis.Map()) + const nodesByMarkerRef = useRef(new globalThis.Map()) const lastRegionRef = useRef<{ bbox: BBox; zoom: number } | null>(null) const onRegionChangeRef = useRef(onRegionChange) const onUserCameraMoveRef = useRef(onUserCameraMove) diff --git a/packages/ui/src/map/Map.web.tsx b/packages/ui/src/map/Map.web.tsx index 6a4e0d50..f00a6a7f 100644 --- a/packages/ui/src/map/Map.web.tsx +++ b/packages/ui/src/map/Map.web.tsx @@ -77,7 +77,7 @@ export const Map = React.forwardRef(function Map(props, ref }) const containerRef = React.useRef(null) const mapRef = React.useRef(null) - const markersRef = React.useRef>(new globalThis.Map()) + const markersRef = React.useRef(new globalThis.Map()) const [mapReady, setMapReady] = React.useState(false) const initialCenterRef = React.useRef(initialCenter) const mode = useLayoutMode() diff --git a/packages/ui/src/map/ReportPickMap.native.tsx b/packages/ui/src/map/ReportPickMap.native.tsx index 4bc33385..5de6aa08 100644 --- a/packages/ui/src/map/ReportPickMap.native.tsx +++ b/packages/ui/src/map/ReportPickMap.native.tsx @@ -122,7 +122,7 @@ export const ReportPickMap = memo( const { index, query } = useClusters(points) const indexRef = useRef(index) const [nodes, setNodes] = useState([]) - const nodesByMarkerRef = useRef>(new globalThis.Map()) + const nodesByMarkerRef = useRef(new globalThis.Map()) const recomputeRef = useRef<() => void>(() => {}) useLayoutEffect(() => { diff --git a/packages/ui/src/map/ReportPickMap.web.tsx b/packages/ui/src/map/ReportPickMap.web.tsx index aedd75b7..7da2dfb8 100644 --- a/packages/ui/src/map/ReportPickMap.web.tsx +++ b/packages/ui/src/map/ReportPickMap.web.tsx @@ -67,7 +67,7 @@ export const ReportPickMap = React.forwardRef(null) const mapRef = React.useRef(null) - const markersRef = React.useRef>(new globalThis.Map()) + const markersRef = React.useRef(new globalThis.Map()) const meetingMarkerRef = React.useRef(null) const meetingRootRef = React.useRef(null) const [mapReady, setMapReady] = React.useState(false) diff --git a/packages/ui/src/map/homeMapCamera.web.ts b/packages/ui/src/map/homeMapCamera.web.ts index abefba72..9735272e 100644 --- a/packages/ui/src/map/homeMapCamera.web.ts +++ b/packages/ui/src/map/homeMapCamera.web.ts @@ -27,7 +27,7 @@ export function useFocusAndFlyToCamera( if (!map || !mapReady || !focus) return const lng = centerLngFor(focus.lng, FOCUS_ZOOM, mode, occlusionLeftRef) map.easeTo({ center: [lng, focus.lat], zoom: FOCUS_ZOOM, duration: CAMERA_EASE_MS }) - }, [mapReady, mode, focus]) + }, [mapReady, mode, focus, mapRef, occlusionLeftRef]) React.useEffect(() => { const map = mapRef.current @@ -35,5 +35,5 @@ export function useFocusAndFlyToCamera( const lng = centerLngFor(flyToRequest.lng, FOCUS_ZOOM, mode, occlusionLeftRef) map.easeTo({ center: [lng, flyToRequest.lat], zoom: FOCUS_ZOOM, duration: CAMERA_EASE_MS }) useMapFlyTo.getState().consume(flyToRequest.generation) - }, [mapReady, mode, flyToRequest]) + }, [mapReady, mode, flyToRequest, mapRef, occlusionLeftRef]) } diff --git a/packages/ui/src/map/homeMapOverlays.web.tsx b/packages/ui/src/map/homeMapOverlays.web.tsx index c65a302c..02c56121 100644 --- a/packages/ui/src/map/homeMapOverlays.web.tsx +++ b/packages/ui/src/map/homeMapOverlays.web.tsx @@ -15,9 +15,13 @@ type MlMapRef = React.RefObject /** `map.remove()` takes every marker and control with it, so each overlay drops its handles with the map. */ function useReleaseWithMap(mapReady: boolean, release: () => void): void { + // Every caller passes a fresh closure over stable refs, so the latest one is read at teardown instead of + // re-arming the effect on each render. + const releaseRef = React.useRef(release) + releaseRef.current = release React.useEffect(() => { if (!mapReady) return - return release + return () => releaseRef.current() }, [mapReady]) } @@ -57,7 +61,7 @@ export function useModeMapControls({ mapRef, mapReady, mode }: ModeMapControlsOp attribCtrlRef.current = new maplibregl.AttributionControl({ compact: false }) map.addControl(attribCtrlRef.current, "bottom-left") } - }, [mapReady, mode]) + }, [mapReady, mode, mapRef]) useReleaseWithMap(mapReady, () => { navCtrlRef.current = null attribCtrlRef.current = null @@ -101,7 +105,7 @@ export function useUserLocationDot({ userMarkerRef.current.setLngLat([userLocation!.lng, userLocation!.lat]) userMarkerRef.current.getElement().setAttribute("aria-label", t("a11y.userLocation")) } - }, [mapReady, showUserLocation, userLocation, t]) + }, [mapReady, showUserLocation, userLocation, t, mapRef]) useReleaseWithMap(mapReady, () => { userMarkerRef.current = null }) @@ -165,7 +169,7 @@ export function usePickMarker({ pickMarkerRef.current.setLngLat([pickDraft.lng, pickDraft.lat]) applyPinElementTheme(pickMarkerRef.current.getElement(), themeRef.current, pickFill) } - }, [mapReady, pickActive, pickDraft, pickPin, mode, scheme]) + }, [mapReady, pickActive, pickDraft, pickPin, mode, scheme, mapRef, occlusionLeftRef, themeRef]) useReleaseWithMap(mapReady, () => { pickMarkerRef.current = null }) @@ -218,7 +222,7 @@ export function useDropPinMarker({ mapRef, mapReady, droppedPin, scheme, t }: Dr , ) } - }, [mapReady, droppedPin, t, scheme]) + }, [mapReady, droppedPin, t, scheme, mapRef]) useReleaseWithMap(mapReady, () => { const dropRoot = dropRootRef.current if (dropRoot) queueMicrotask(() => dropRoot.unmount()) diff --git a/packages/ui/src/shell/BodyTransition.web.tsx b/packages/ui/src/shell/BodyTransition.web.tsx index fb1aa8b1..eb1c411f 100644 --- a/packages/ui/src/shell/BodyTransition.web.tsx +++ b/packages/ui/src/shell/BodyTransition.web.tsx @@ -5,7 +5,7 @@ import type { BodyTransitionDirection, BodyTransitionProps } from "./BodyTransit import { bodyTransitionPlan, translateRatio } from "./bodyTransitionModel" import { BODY_TIMING } from "./bodyTransitionTiming" import { cssTransitionParts } from "./motionCss" -import { forceReflow, useFlipPhase } from "./useFlipPhase" +import { forceReflow, useFlipPhase, type LayerTransitionEndEvent } from "./useFlipPhase" import { prefersReducedMotion } from "./webMedia" const IN_DURATION = motion.bodyPush.duration @@ -107,8 +107,8 @@ export function BodyTransition({ children, transitionKey, direction }: BodyTrans })) const committedChildRef = useRef(children) - const slotARef = useRef(null) - const slotBRef = useRef(null) + const slotARef = useRef>(null) + const slotBRef = useRef>(null) if (state.key !== transitionKey) { const instant = prefersReducedMotion() @@ -171,7 +171,7 @@ export function BodyTransition({ children, transitionKey, direction }: BodyTrans const outgoingContent = anim && !anim.outDropped ? {anim.outgoing} : null - const onLayerTransitionEnd = (slot: SlotId, event: any) => { + const onLayerTransitionEnd = (slot: SlotId, event: LayerTransitionEndEvent) => { if (!phase || !flipped) return if (slot !== activeSlot) return if (event?.target !== event?.currentTarget) return @@ -191,7 +191,7 @@ export function BodyTransition({ children, transitionKey, direction }: BodyTrans ref={slot === "a" ? slotARef : slotBRef} style={[styles.layer, castLayerStyle(layer, transition, zIndex, active)]} {...({ - onTransitionEnd: (event: any) => onLayerTransitionEnd(slot, event), + onTransitionEnd: (event: LayerTransitionEndEvent) => onLayerTransitionEnd(slot, event), } as any)} > {active ? activeContent : outgoingContent} diff --git a/packages/ui/src/shell/CompactShell.native.tsx b/packages/ui/src/shell/CompactShell.native.tsx index ddcac4a6..b01959e4 100644 --- a/packages/ui/src/shell/CompactShell.native.tsx +++ b/packages/ui/src/shell/CompactShell.native.tsx @@ -203,7 +203,7 @@ export function CompactShell({ renderBody = defaultRenderBody, closing = false, if (reportedSnapRef.current !== snap) { sheetRef.current?.snapToIndex(snap) } - }, [snap, closing]) + }, [snap, closing, reportedSnapRef]) // gorhom fires `onClose` when the slide lands, which the presence gate uses to unmount the sheet and // restore the dock. Entry is gorhom's own mount animation, so a lateral detail-to-detail swap never @@ -251,7 +251,7 @@ export function CompactShell({ renderBody = defaultRenderBody, closing = false, if (next !== null) setSnap(next) } return - }, [setSnap, t]) + }, [setSnap, t, currentIndexRef]) // The content region's height is owned here rather than flex:1 inside gorhom's animated content mask, so // the body's scroll viewport is bounded by numbers we control and the full content overflow scrolls. At diff --git a/packages/ui/src/shell/ContentBottomReserve.tsx b/packages/ui/src/shell/ContentBottomReserve.tsx index a20145ce..ce375cb1 100644 --- a/packages/ui/src/shell/ContentBottomReserve.tsx +++ b/packages/ui/src/shell/ContentBottomReserve.tsx @@ -1,6 +1,6 @@ import React, { createContext, forwardRef, useContext, useMemo } from "react" import { withExtraBottomPadding } from "./bottomPadding" -import { decorateScrollHost, type ScrollHostValue } from "./ScrollHost" +import { decorateScrollHost, type DecoratedScrollProps, type ScrollHostValue } from "./ScrollHost" const ContentBottomReserveContext = createContext(0) ContentBottomReserveContext.displayName = "ContentBottomReserveContext" @@ -15,7 +15,7 @@ function makeContentBottomReserveScroll( Base: React.ComponentType, useReserve: () => number, ): React.ComponentType { - const ContentBottomReserveScroll = forwardRef(function ContentBottomReserveScroll( + const ContentBottomReserveScroll = forwardRef(function ContentBottomReserveScroll( { contentContainerStyle, horizontal, ...rest }, ref, ) { diff --git a/packages/ui/src/shell/KeyboardAwareScroll.native.tsx b/packages/ui/src/shell/KeyboardAwareScroll.native.tsx index 4c138dca..d908bdb8 100644 --- a/packages/ui/src/shell/KeyboardAwareScroll.native.tsx +++ b/packages/ui/src/shell/KeyboardAwareScroll.native.tsx @@ -17,7 +17,7 @@ import { } from "react-native" import { SafeAreaInsetsContext } from "react-native-safe-area-context" import { motion } from "../theme" -import type { ScrollHostValue } from "./ScrollHost" +import type { DecoratedScrollProps, ScrollHostValue } from "./ScrollHost" import { withExtraBottomPadding } from "./bottomPadding" import { resolveHostFlag, type KeyboardAwareScrollHostOptions } from "./KeyboardAwareScroll.types" import { keyboardFocusStore } from "./keyboardFocusStore" @@ -40,9 +40,18 @@ import { useMergedRef } from "./useMergedRef" import { useRestingWindowHeight } from "./useRestingWindowHeight" import { KEYBOARD_HIDE_EVENT, KEYBOARD_PLATFORM, KEYBOARD_SHOW_EVENT } from "./keyboardPlatform" +type MeasureInWindow = (callback: (x: number, y: number, width: number, height: number) => void) => void + +/** The imperative surface of whichever RN ScrollView / FlatList the decorator wraps. */ +interface ScrollableNode { + scrollTo?: (options: { y: number; animated?: boolean }) => void + scrollToOffset?: (options: { offset: number; animated?: boolean }) => void + measureInWindow?: MeasureInWindow + getNativeScrollRef?: () => { measureInWindow?: MeasureInWindow } | null +} interface ScrollableAdapter { - scrollToOffset: (node: any, offset: number) => void + scrollToOffset: (node: ScrollableNode, offset: number) => void scrollEventThrottle?: number } @@ -59,7 +68,7 @@ const FLAT_LIST: ScrollableAdapter = { }, } -function measureViewportTop(node: any, apply: (top: number) => void): void { +function measureViewportTop(node: ScrollableNode, apply: (top: number) => void): void { const measurable = typeof node?.measureInWindow === "function" ? node : node?.getNativeScrollRef?.() if (!measurable || typeof measurable.measureInWindow !== "function") { @@ -76,12 +85,12 @@ function makeKeyboardAwareScrollable( ): React.ComponentType { const ownsFocusedInput = resolveHostFlag(options.ownsFocusedInput) const reserveKeyboardPadding = resolveHostFlag(options.reserveKeyboardPadding) - const KeyboardAwareScrollable = forwardRef(function KeyboardAwareScrollable( + const KeyboardAwareScrollable = forwardRef(function KeyboardAwareScrollable( { contentContainerStyle, onScroll, scrollEventThrottle, horizontal, ...rest }, ref, ) { const scopeId = useId() - const innerRef = useRef(null) + const innerRef = useRef(null) const offsetRef = useRef(0) const holdRef = useRef | null>(null) const [state, setState] = useState(() => initialScrollKeyboardState(scopeId)) diff --git a/packages/ui/src/shell/KeyboardAwareScroll.web.tsx b/packages/ui/src/shell/KeyboardAwareScroll.web.tsx index 86be19d8..59dad6a5 100644 --- a/packages/ui/src/shell/KeyboardAwareScroll.web.tsx +++ b/packages/ui/src/shell/KeyboardAwareScroll.web.tsx @@ -14,7 +14,7 @@ * never collapses the reserve of a scroller on a surface pushed over it. */ import React, { forwardRef, useEffect, useMemo, useRef } from "react" -import type { ScrollHostValue } from "./ScrollHost" +import type { DecoratedScrollProps, ScrollHostValue } from "./ScrollHost" import { withExtraBottomPadding } from "./bottomPadding" import { useKeyboardHostReserved } from "./keyboardScrollScope" import { KEYBOARD_REVEAL_MARGIN } from "./keyboardInsetModel" @@ -25,13 +25,22 @@ import { resolveHostFlag, type KeyboardAwareScrollHostOptions } from "./Keyboard /** Lets the keyboard open and visualViewport settle before measuring. */ const SETTLE_MS = 140 -function getScrollableNode(node: any): HTMLElement | null { +/** react-native-web's ScrollView ref exposes the DOM scroller through getScrollableNode(). */ +interface WebScrollRef { + getScrollableNode?: () => unknown +} + +function isScrollElement(value: unknown): value is HTMLElement { + return typeof (value as { scrollTop?: unknown }).scrollTop === "number" +} + +function getScrollableNode(node: WebScrollRef | null): HTMLElement | null { if (!node) return null if (typeof node.getScrollableNode === "function") { const el = node.getScrollableNode() - return el && typeof el.scrollTop === "number" ? (el as HTMLElement) : null + return el && isScrollElement(el) ? el : null } - return typeof node.scrollTop === "number" ? (node as HTMLElement) : null + return isScrollElement(node) ? node : null } function makeKeyboardAwareScrollView( @@ -42,11 +51,11 @@ function makeKeyboardAwareScrollView( // below does not depend on them. const ownsFocusedInput = resolveHostFlag(options.ownsFocusedInput) const reserveKeyboardPadding = resolveHostFlag(options.reserveKeyboardPadding) - const KeyboardAwareScrollView = forwardRef(function KeyboardAwareScrollView( + const KeyboardAwareScrollView = forwardRef(function KeyboardAwareScrollView( { contentContainerStyle, ...rest }, ref, ) { - const innerRef = useRef(null) + const innerRef = useRef(null) const inset = useKeyboardInset() const hostReserved = useKeyboardHostReserved() diff --git a/packages/ui/src/shell/MinimizeAwareScroll.native.tsx b/packages/ui/src/shell/MinimizeAwareScroll.native.tsx index 23839811..9bf6a5d0 100644 --- a/packages/ui/src/shell/MinimizeAwareScroll.native.tsx +++ b/packages/ui/src/shell/MinimizeAwareScroll.native.tsx @@ -1,6 +1,6 @@ import React, { forwardRef, useCallback, useEffect, useRef } from "react" import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native" -import { decorateScrollHost, type ScrollHostValue } from "./ScrollHost" +import { decorateScrollHost, type DecoratedScrollProps, type ScrollHostValue } from "./ScrollHost" import { useDockMinimizeStore } from "./dockMinimizeStore" import { useNavStore } from "../nav" @@ -15,7 +15,7 @@ const minimizeState = useDockMinimizeStore.getState const navState = useNavStore.getState function makeMinimizeAwareScroll(Base: React.ComponentType): React.ComponentType { - const MinimizeAwareScroll = forwardRef(function MinimizeAwareScroll( + const MinimizeAwareScroll = forwardRef(function MinimizeAwareScroll( { onScroll, scrollEventThrottle, horizontal, ...rest }, ref, ) { diff --git a/packages/ui/src/shell/PageStack.web.tsx b/packages/ui/src/shell/PageStack.web.tsx index 8c6598e2..e9ec1ce3 100644 --- a/packages/ui/src/shell/PageStack.web.tsx +++ b/packages/ui/src/shell/PageStack.web.tsx @@ -26,7 +26,7 @@ import { } from "./pageStackWebModel" import { ScrollHostProvider, type ScrollHostValue } from "./ScrollHost" import { DetailHeader, hasDetailHeader } from "./SheetHeader.shared" -import { forceReflow, useFlipPhase } from "./useFlipPhase" +import { forceReflow, useFlipPhase, type LayerTransitionEndEvent } from "./useFlipPhase" import { isCoarsePointer, prefersReducedMotion } from "./webMedia" const ANIMATED_TOKENS = pageLayerTokens(PAGE_MOTION, false, false) @@ -242,7 +242,7 @@ const WebPageLayer = memo(function WebPageLayer({ else node.setAttribute("inert", "") }, [active]) - const onTransitionEnd = (event: any) => { + const onTransitionEnd = (event: LayerTransitionEndEvent) => { if (settleNav === null) return if (event?.target !== event?.currentTarget) return if (event?.propertyName && event.propertyName !== settleProperty) return diff --git a/packages/ui/src/shell/ScrollHost.tsx b/packages/ui/src/shell/ScrollHost.tsx index 8c5f3f3c..a29dd0c6 100644 --- a/packages/ui/src/shell/ScrollHost.tsx +++ b/packages/ui/src/shell/ScrollHost.tsx @@ -26,7 +26,14 @@ * lands here as another `ScrollHostValue`, with no body change. */ import React, { createContext, useContext } from "react" -import { ScrollView as RNScrollView, FlatList as RNFlatList } from "react-native" +import { + ScrollView as RNScrollView, + FlatList as RNFlatList, + type Insets, + type NativeScrollEvent, + type StyleProp, + type ViewStyle, +} from "react-native" export interface ScrollHostValue { ScrollView: React.ComponentType @@ -42,6 +49,18 @@ export type ScrollHostListProps = Record & { ref?: React.Ref } +/** + * The props a decorator reads; everything else is forwarded untouched. `onScroll` receives only + * `{ nativeEvent }` because the reanimated handoff re-emits a worklet payload, not a full RN event. + */ +export interface DecoratedScrollProps { + contentContainerStyle?: StyleProp + horizontal?: boolean | null + onScroll?: (event: { nativeEvent: NativeScrollEvent }) => void + scrollEventThrottle?: number + scrollIndicatorInsets?: Insets +} + const DEFAULT_SCROLL_HOST: ScrollHostValue = { ScrollView: RNScrollView as React.ComponentType, FlatList: RNFlatList as unknown as React.ComponentType, diff --git a/packages/ui/src/shell/SearchBodyReveal.native.tsx b/packages/ui/src/shell/SearchBodyReveal.native.tsx index 1354e9d2..47cb5996 100644 --- a/packages/ui/src/shell/SearchBodyReveal.native.tsx +++ b/packages/ui/src/shell/SearchBodyReveal.native.tsx @@ -6,7 +6,13 @@ import { searchRevealExitStyle, searchRevealStyle } from "./bodyLayout" import { withExtraBottomPadding } from "./bottomPadding" import { dockMorphProgress } from "./dockMorphProgress.native" import { makeKeyboardAwareScrollHost } from "./KeyboardAwareScroll" -import { PLAIN_SCROLL_HOST, ScrollHostProvider, decorateScrollHost, type ScrollHostValue } from "./ScrollHost" +import { + PLAIN_SCROLL_HOST, + ScrollHostProvider, + decorateScrollHost, + type DecoratedScrollProps, + type ScrollHostValue, +} from "./ScrollHost" import { useSearchBarStore } from "./searchBarStore" import { resolveTabBarFootprint } from "./tabBarLogic" import { useTabBarStore } from "./tabBarStore" @@ -16,7 +22,7 @@ const DockClearanceFallbackContext = createContext(0) DockClearanceFallbackContext.displayName = "DockClearanceFallbackContext" function makeDockClearanceScroll(Base: React.ComponentType): React.ComponentType { - const DockClearanceScroll = forwardRef(function DockClearanceScroll( + const DockClearanceScroll = forwardRef(function DockClearanceScroll( { contentContainerStyle, horizontal, scrollIndicatorInsets, ...rest }, ref, ) { diff --git a/packages/ui/src/shell/SheetHandoffScroll.native.tsx b/packages/ui/src/shell/SheetHandoffScroll.native.tsx index 8eb04537..279eefbb 100644 --- a/packages/ui/src/shell/SheetHandoffScroll.native.tsx +++ b/packages/ui/src/shell/SheetHandoffScroll.native.tsx @@ -20,7 +20,7 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler" import Animated, { runOnJS, useAnimatedScrollHandler, useSharedValue } from "react-native-reanimated" import { useBottomSheetInternal, ANIMATION_SOURCE, ANIMATION_STATUS } from "@gorhom/bottom-sheet" import { useNavStore, type Snap } from "../nav" -import type { ScrollHostValue } from "./ScrollHost" +import type { DecoratedScrollProps, ScrollHostValue } from "./ScrollHost" import { HANDOFF_SLOP, handoffDestinationIndex, @@ -42,7 +42,7 @@ function makeSheetHandoffScroll( Base: React.ComponentType, AnimatedBase: React.ComponentType, ): React.ComponentType { - const SheetHandoffScroll = forwardRef(function SheetHandoffScroll( + const SheetHandoffScroll = forwardRef(function SheetHandoffScroll( { onScroll, scrollEventThrottle, horizontal, ...rest }, ref, ) { @@ -223,7 +223,7 @@ function makeSheetHandoffScroll( * for FlatList in reanimated 4; Animated.FlatList also supplies a CellRendererComponent. */ export function makeSheetHandoffScrollHost(base: ScrollHostValue): ScrollHostValue { return { - ScrollView: makeSheetHandoffScroll(base.ScrollView, Animated.ScrollView as any), - FlatList: makeSheetHandoffScroll(base.FlatList, Animated.FlatList as any), + ScrollView: makeSheetHandoffScroll(base.ScrollView, Animated.ScrollView as React.ComponentType), + FlatList: makeSheetHandoffScroll(base.FlatList, Animated.FlatList as React.ComponentType), } } diff --git a/packages/ui/src/shell/useFlipPhase.ts b/packages/ui/src/shell/useFlipPhase.ts index f3d585a8..e1c801d7 100644 --- a/packages/ui/src/shell/useFlipPhase.ts +++ b/packages/ui/src/shell/useFlipPhase.ts @@ -1,4 +1,9 @@ -import { useLayoutEffect, useRef } from "react" +import { useLayoutEffect, useRef, type TransitionEvent } from "react" + +export type LayerTransitionEndEvent = Pick< + TransitionEvent, + "target" | "currentTarget" | "propertyName" +> /** * Reads layout from the first target that has any, so the browser commits the start frame before the From c8a7829c38ae040820dd75795557312502694f85 Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 09:30:45 +0000 Subject: [PATCH 05/11] test: source pins and test typing follow the typed lint fixes --- .../src/api/unauthorized.test.ts | 7 ++-- .../src/components/firstRunNameCaps.test.ts | 1 + apps/community-mobile/src/lib/errors.test.ts | 2 +- .../src/lib/internalHref.test.ts | 2 +- .../src/lib/onboardingPlan.test.ts | 1 + .../src/lib/pushRegistration.test.ts | 19 ++++++----- .../src/lib/storageEnvMarker.test.ts | 1 + .../src/lib/universalLinks.test.ts | 2 +- .../src/lib/wsTransport.test.ts | 2 +- .../community-mobile/src/query/client.test.ts | 1 + apps/community-mobile/tests/a11yRoles.test.ts | 1 + .../tests/androidVersionCode.test.ts | 5 ++- .../tests/authFeedback.test.ts | 1 + apps/community-mobile/tests/bootGate.test.ts | 3 +- apps/community-mobile/tests/bootShell.test.ts | 4 ++- apps/community-mobile/tests/bootTheme.test.ts | 5 +-- .../tests/cameraWarmup.test.ts | 1 + .../tests/captureFeedback.test.ts | 3 +- .../tests/composerViewerScope.test.ts | 1 + .../tests/dropPinOcclusion.test.ts | 1 + .../tests/helpers/appConfig.ts | 29 ++++++++++++++++ .../tests/helpers/navRoutes.ts | 5 +-- .../tests/lastKnownFallback.test.ts | 1 + .../tests/linkSocketWiring.test.ts | 1 + .../tests/locateOnGrant.test.ts | 1 + .../tests/lucideDirectImports.test.ts | 31 +++++++++++------ .../tests/nativeGeolocation.test.ts | 1 + .../tests/notificationNav.test.ts | 1 + .../tests/notificationsMode.test.ts | 19 ++++------- .../tests/onboardingMapArt.test.ts | 19 +++++++++-- .../tests/onboardingShell.test.ts | 1 + .../tests/postLinkNav.test.ts | 1 + .../tests/releaseScriptsPortable.test.ts | 1 + .../tests/signInRefresh.test.ts | 1 + .../tests/splashAsset.test.ts | 33 +++++++++++-------- .../tests/splashColor.test.ts | 14 +++----- .../tests/splashConfig.test.ts | 17 ++++------ .../tests/swallowedErrors.test.ts | 1 + .../__tests__/datetime-intl-cache.test.ts | 3 +- packages/shared/__tests__/fakes.test.ts | 2 +- packages/shared/__tests__/geocode.test.ts | 9 +++-- .../__tests__/tokens-dark-scheme.test.ts | 2 +- .../src/host/__tests__/graphemes.test.ts | 3 +- .../announce/__tests__/announce.web.test.ts | 3 +- .../ui/src/bodies/__tests__/PostCard.test.ts | 2 +- .../__tests__/profileInvitations.test.ts | 2 +- .../bodies/__tests__/reportFlowExit.test.ts | 2 +- .../host/__tests__/analyticsLabels.test.ts | 2 +- .../host/__tests__/byEventRowA11y.test.ts | 2 +- .../host/__tests__/hostTeamModel.test.ts | 2 +- .../__tests__/dashboardModel.test.ts | 4 ++- .../src/data/__tests__/chatHookDeps.test.ts | 6 ++-- .../src/data/__tests__/chatRoomScope.test.ts | 2 +- .../src/data/__tests__/eventTeamTiers.test.ts | 2 +- .../ui/src/data/__tests__/uploadMedia.test.ts | 8 ++--- .../ui/src/data/hooks/__tests__/posts.test.ts | 25 +++++++------- .../src/map/__tests__/domMarkerLayer.test.ts | 3 +- .../map/__tests__/mapCameraContract.test.ts | 2 +- .../map/__tests__/pinAppearanceMemo.test.ts | 3 +- .../src/report/__tests__/submitUpload.test.ts | 21 ++++++++---- .../__tests__/keyboardInsetStore.test.ts | 3 +- .../__tests__/webAffordancesPlatform.test.ts | 3 +- 62 files changed, 224 insertions(+), 132 deletions(-) create mode 100644 apps/community-mobile/tests/helpers/appConfig.ts diff --git a/apps/community-mobile/src/api/unauthorized.test.ts b/apps/community-mobile/src/api/unauthorized.test.ts index 96ba7056..90cadce8 100644 --- a/apps/community-mobile/src/api/unauthorized.test.ts +++ b/apps/community-mobile/src/api/unauthorized.test.ts @@ -4,6 +4,7 @@ import type { TokenRead } from "@/auth/storage" import { carriedBearerToken, makeSessionTeardown, makeSingleFlight } from "./unauthorized.ts" const tick = () => new Promise((resolve) => setImmediate(resolve)) +const failOnError = (err: unknown) => assert.fail(err instanceof Error ? err : String(err)) test("the bearer VALUE is recovered whatever shape the headers arrived in", () => { assert.equal(carriedBearerToken({ Authorization: "Bearer abc.def" }), "abc.def") @@ -107,7 +108,7 @@ test("a burst of concurrent 401s runs the sign-out cascade exactly ONCE", async runs++ await gate }, - assert.fail, + failOnError, ) trigger("same") @@ -129,7 +130,7 @@ test("the latch releases, so a genuinely later 401 signs out again", async () => async () => { runs++ }, - assert.fail, + failOnError, ) trigger("same") @@ -149,7 +150,7 @@ test("two DIFFERENT keys never block each other, even in the same tick", async ( started.push(bearer) await tick() }, - assert.fail, + failOnError, ) trigger("stale") diff --git a/apps/community-mobile/src/components/firstRunNameCaps.test.ts b/apps/community-mobile/src/components/firstRunNameCaps.test.ts index 05dd6d26..a1174ca8 100644 --- a/apps/community-mobile/src/components/firstRunNameCaps.test.ts +++ b/apps/community-mobile/src/components/firstRunNameCaps.test.ts @@ -5,6 +5,7 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" import { test } from "node:test" +import { URL } from "node:url" const gate = readFileSync(new URL("./FirstRunGate.tsx", import.meta.url), "utf8") diff --git a/apps/community-mobile/src/lib/errors.test.ts b/apps/community-mobile/src/lib/errors.test.ts index ec790bc0..fc8b27d1 100644 --- a/apps/community-mobile/src/lib/errors.test.ts +++ b/apps/community-mobile/src/lib/errors.test.ts @@ -4,7 +4,7 @@ import { AppError, ErrorCode, isAppErrorLike } from "@civfix/shared" import { parseError } from "@civfix/shared/client" import { codeRejectionReason, friendlyError, isConflict, isRetryableError } from "./errors.ts" -function crossRealmAppError(code: string, message = "cross-realm"): unknown { +function crossRealmAppError(code: string, message = "cross-realm"): Error & { code: string } { const err = new Error(message) err.name = "AppError" return Object.assign(err, { code }) diff --git a/apps/community-mobile/src/lib/internalHref.test.ts b/apps/community-mobile/src/lib/internalHref.test.ts index 0d461e6e..3697ffd2 100644 --- a/apps/community-mobile/src/lib/internalHref.test.ts +++ b/apps/community-mobile/src/lib/internalHref.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import { readdirSync, readFileSync, statSync } from "node:fs" -import { fileURLToPath } from "node:url" +import { fileURLToPath, URL } from "node:url" import { join } from "node:path" import { test } from "node:test" import { diff --git a/apps/community-mobile/src/lib/onboardingPlan.test.ts b/apps/community-mobile/src/lib/onboardingPlan.test.ts index 87c75c69..80e99258 100644 --- a/apps/community-mobile/src/lib/onboardingPlan.test.ts +++ b/apps/community-mobile/src/lib/onboardingPlan.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" import { test } from "node:test" +import { URL } from "node:url" import { ONBOARDING_LAST_INDEX, ONBOARDING_PAGES, diff --git a/apps/community-mobile/src/lib/pushRegistration.test.ts b/apps/community-mobile/src/lib/pushRegistration.test.ts index 840a040c..e62ebe4c 100644 --- a/apps/community-mobile/src/lib/pushRegistration.test.ts +++ b/apps/community-mobile/src/lib/pushRegistration.test.ts @@ -1,6 +1,7 @@ import { test, mock } from "node:test" import assert from "node:assert/strict" import { readFileSync } from "node:fs" +import { URL } from "node:url" import { PUSH_REGISTRATION_KEY, PUSH_UNREGISTER_TIMEOUT_MS, @@ -345,7 +346,7 @@ test("an EXPIRED session still tells the server to release the push token", asyn const { deps, store, sent } = lapsedProbe() rememberPushRegistration(store, { platform: "ios", token: "ExponentPushToken[abc]" }) - unregisterLapsedSessionPush(deps) + void unregisterLapsedSessionPush(deps) await tick() assert.deepEqual(sent, [ @@ -361,7 +362,7 @@ test("the local registration is forgotten SYNCHRONOUSLY, before the call is even const { deps, store, sent } = lapsedProbe() rememberPushRegistration(store, { platform: "ios", token: "ExponentPushToken[abc]" }) - unregisterLapsedSessionPush(deps) + void unregisterLapsedSessionPush(deps) assert.equal(readPushRegistration(store), null) assert.deepEqual(sent, []) @@ -377,7 +378,7 @@ test("the bearer read is issued SYNCHRONOUSLY, ahead of the token clear that fol }) rememberPushRegistration(store, { platform: "ios", token: "ExponentPushToken[abc]" }) - unregisterLapsedSessionPush(deps) + void unregisterLapsedSessionPush(deps) assert.equal(reads, 1) }) @@ -450,7 +451,7 @@ test("a REJECTED unregister on an expired session never escapes teardown", async }) rememberPushRegistration(store, { platform: "ios", token: "ExponentPushToken[abc]" }) - unregisterLapsedSessionPush(deps) + void unregisterLapsedSessionPush(deps) await tick() assert.equal(attempts, 1) @@ -464,11 +465,11 @@ test("a THROWING or EMPTY keychain read leaves teardown complete and sends nothi }, }) rememberPushRegistration(thrower.store, { platform: "ios", token: "ExponentPushToken[abc]" }) - unregisterLapsedSessionPush(thrower.deps) + void unregisterLapsedSessionPush(thrower.deps) const empty = lapsedProbe({ readBearer: async () => null }) rememberPushRegistration(empty.store, { platform: "ios", token: "ExponentPushToken[abc]" }) - unregisterLapsedSessionPush(empty.deps) + void unregisterLapsedSessionPush(empty.deps) await tick() @@ -487,7 +488,7 @@ test("no persisted registration means an expired session reads no bearer and cal }, }) - unregisterLapsedSessionPush(deps) + void unregisterLapsedSessionPush(deps) await tick() assert.equal(reads, 0) @@ -498,8 +499,8 @@ test("sign-out's own teardown cannot double-unregister, the registration is alre const { deps, store, sent } = lapsedProbe() rememberPushRegistration(store, { platform: "ios", token: "ExponentPushToken[abc]" }) - unregisterLapsedSessionPush(deps) - unregisterLapsedSessionPush(deps) + void unregisterLapsedSessionPush(deps) + void unregisterLapsedSessionPush(deps) await tick() assert.equal(sent.length, 1) diff --git a/apps/community-mobile/src/lib/storageEnvMarker.test.ts b/apps/community-mobile/src/lib/storageEnvMarker.test.ts index a85ca03a..4edea934 100644 --- a/apps/community-mobile/src/lib/storageEnvMarker.test.ts +++ b/apps/community-mobile/src/lib/storageEnvMarker.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test" import assert from "node:assert/strict" import { readFileSync } from "node:fs" +import { URL } from "node:url" import { PROD_STORAGE_ENV, STORAGE_ENV_MARKER_KEY, diff --git a/apps/community-mobile/src/lib/universalLinks.test.ts b/apps/community-mobile/src/lib/universalLinks.test.ts index 2b665624..b2aa8ed9 100644 --- a/apps/community-mobile/src/lib/universalLinks.test.ts +++ b/apps/community-mobile/src/lib/universalLinks.test.ts @@ -2,7 +2,7 @@ import { test } from "node:test" import assert from "node:assert/strict" import { existsSync, readdirSync, statSync } from "node:fs" import { join } from "node:path" -import { fileURLToPath } from "node:url" +import { fileURLToPath, URL } from "node:url" import { resolveIncomingPath } from "./universalLinks.ts" const internal = (path: string) => ({ type: "internal", path }) as const diff --git a/apps/community-mobile/src/lib/wsTransport.test.ts b/apps/community-mobile/src/lib/wsTransport.test.ts index f9db9cbe..dadea830 100644 --- a/apps/community-mobile/src/lib/wsTransport.test.ts +++ b/apps/community-mobile/src/lib/wsTransport.test.ts @@ -40,8 +40,8 @@ test("a failed ticket mint never puts the bearer in the socket URL and opens not }) const opened = await openTicketedSocket(deps) assert.equal(opened, null) - assert.deepEqual(urls, []) assert.ok(urls.every((url) => !url.includes(BEARER) && !url.includes("token="))) + assert.deepEqual(urls, []) }) test("an empty ticket response is treated as unavailable, not as a reason to send the bearer", async () => { diff --git a/apps/community-mobile/src/query/client.test.ts b/apps/community-mobile/src/query/client.test.ts index 6f19be57..a1a4466a 100644 --- a/apps/community-mobile/src/query/client.test.ts +++ b/apps/community-mobile/src/query/client.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test" import assert from "node:assert/strict" import { readFileSync } from "node:fs" +import { URL } from "node:url" const CLIENT = readFileSync(new URL("./client.ts", import.meta.url), "utf8") diff --git a/apps/community-mobile/tests/a11yRoles.test.ts b/apps/community-mobile/tests/a11yRoles.test.ts index 0882367b..19df0c6f 100644 --- a/apps/community-mobile/tests/a11yRoles.test.ts +++ b/apps/community-mobile/tests/a11yRoles.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" import { test } from "node:test" +import { URL } from "node:url" const read = (rel: string) => readFileSync(new URL(rel, import.meta.url), "utf8") diff --git a/apps/community-mobile/tests/androidVersionCode.test.ts b/apps/community-mobile/tests/androidVersionCode.test.ts index dc8cec47..ff22d808 100644 --- a/apps/community-mobile/tests/androidVersionCode.test.ts +++ b/apps/community-mobile/tests/androidVersionCode.test.ts @@ -1,9 +1,8 @@ import assert from "node:assert/strict" -import { createRequire } from "node:module" import { test } from "node:test" +import { appConfigFactory } from "./helpers/appConfig.ts" -const require = createRequire(import.meta.url) -const appConfig = require("../app.config.js")({ config: {} }) +const appConfig = appConfigFactory({ config: {} }) const LOWEST_UNUSED_PLAY_VERSION_CODE = 6 diff --git a/apps/community-mobile/tests/authFeedback.test.ts b/apps/community-mobile/tests/authFeedback.test.ts index 8a6d94ff..9909f201 100644 --- a/apps/community-mobile/tests/authFeedback.test.ts +++ b/apps/community-mobile/tests/authFeedback.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" import { test } from "node:test" +import { URL } from "node:url" import { MIN_TOUCH_TARGET } from "../../../packages/ui/src/theme/touchTarget.ts" const read = (rel: string) => readFileSync(new URL(rel, import.meta.url), "utf8") diff --git a/apps/community-mobile/tests/bootGate.test.ts b/apps/community-mobile/tests/bootGate.test.ts index 426afcaf..9b12e181 100644 --- a/apps/community-mobile/tests/bootGate.test.ts +++ b/apps/community-mobile/tests/bootGate.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" import { test } from "node:test" +import { URL } from "node:url" import { BOOT_DEADLINE_MS, BOOT_GATE_THRESHOLDS_MS, @@ -320,7 +321,7 @@ test("the splash offers the connecting line and the retry the model asks for", ( test("a shared request is only joined by callers under the same key: a token write starts a fresh one", async () => { let key = 1 let runs = 0 - const gates: Array<(value: string) => void> = [] + const gates: ((value: string) => void)[] = [] const check = sharedDeadlineRequest( () => new Promise((resolve) => { diff --git a/apps/community-mobile/tests/bootShell.test.ts b/apps/community-mobile/tests/bootShell.test.ts index 2625a0be..cfbcca1e 100644 --- a/apps/community-mobile/tests/bootShell.test.ts +++ b/apps/community-mobile/tests/bootShell.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" import { test } from "node:test" +import { URL } from "node:url" const layout = readFileSync(new URL("../app/_layout.tsx", import.meta.url), "utf8") const crashScreen = readFileSync(new URL("../src/components/CrashScreen.tsx", import.meta.url), "utf8") @@ -105,7 +106,8 @@ test("the receipt decision runs store-first, and only a NEWER sandbox receipt me }) test("expo-file-system is required inside the guarded probe, never imported into the boot chain", () => { - assert.doesNotMatch(betaInstallModule, /^import .*"expo-file-system"/m) + // A type-only import is erased before bundling, so only a value import would load the module at boot. + assert.doesNotMatch(betaInstallModule, /^import (?!type ).*"expo-file-system"/m) const probe = betaInstallModule.slice(betaInstallModule.indexOf("function probeStoreKit")) const guard = probe.indexOf("try {") const load = probe.indexOf('require("expo-file-system")') diff --git a/apps/community-mobile/tests/bootTheme.test.ts b/apps/community-mobile/tests/bootTheme.test.ts index 3fa13d5d..142253e4 100644 --- a/apps/community-mobile/tests/bootTheme.test.ts +++ b/apps/community-mobile/tests/bootTheme.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" import { test } from "node:test" +import { URL } from "node:url" const layout = readFileSync(new URL("../app/_layout.tsx", import.meta.url), "utf8") const rootStack = readFileSync(new URL("../src/boot/RootStack.tsx", import.meta.url), "utf8") @@ -127,7 +128,7 @@ test("the offline gate's sign-out shows it is working and cannot be pressed twic assert.match(gate, /const inFlight = useRef\(false\)/) assert.match(gate, /if \(inFlight\.current\) return\s*inFlight\.current = true\s*setSigningOut\(true\)\s*try \{\s*await signOut\(\)\s*\} finally \{\s*inFlight\.current = false\s*setSigningOut\(false\)\s*\}/) assert.doesNotMatch(gate, /if \(signingOut\) return/) - const button = gate.slice(gate.indexOf("onPress={onSignOut}") - 300) - assert.match(button, /accessibilityState=\{\{ disabled: signingOut, busy: signingOut \}\}\s*disabled=\{signingOut\}\s*onPress=\{onSignOut\}/) + const button = gate.slice(gate.indexOf("onPress={() => void onSignOut()}") - 300) + assert.match(button, /accessibilityState=\{\{ disabled: signingOut, busy: signingOut \}\}\s*disabled=\{signingOut\}\s*onPress=\{\(\) => void onSignOut\(\)\}/) assert.match(button, /\{signingOut \? \(\s*/) }) diff --git a/apps/community-mobile/tests/cameraWarmup.test.ts b/apps/community-mobile/tests/cameraWarmup.test.ts index cc65f8d1..42bb40bd 100644 --- a/apps/community-mobile/tests/cameraWarmup.test.ts +++ b/apps/community-mobile/tests/cameraWarmup.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" import { test } from "node:test" +import { URL } from "node:url" const read = (rel: string) => readFileSync(new URL(rel, import.meta.url), "utf8") diff --git a/apps/community-mobile/tests/captureFeedback.test.ts b/apps/community-mobile/tests/captureFeedback.test.ts index 8abf5893..907d3e97 100644 --- a/apps/community-mobile/tests/captureFeedback.test.ts +++ b/apps/community-mobile/tests/captureFeedback.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" import { test } from "node:test" +import { URL } from "node:url" const viewfinder = readFileSync( new URL("../src/components/report/ReportViewfinder.tsx", import.meta.url), @@ -56,7 +57,7 @@ test("cleanup stops share one justified quiet-stop helper instead of anonymous n }) test("the shutter exposes its disabled and busy state to screen readers", () => { - const shutter = viewfinder.slice(viewfinder.indexOf("onPress={mode === \"photo\" ? onTakePhoto : onToggleRecord}")) + const shutter = viewfinder.slice(viewfinder.indexOf("onPress={() => void (mode === \"photo\" ? onTakePhoto() : onToggleRecord())}")) const props = shutter.slice(0, shutter.indexOf("style=")) assert.match(props, /disabled=\{busy && !recording\}/) assert.match(props, /accessibilityState=\{\{ disabled: busy && !recording, busy \}\}/) diff --git a/apps/community-mobile/tests/composerViewerScope.test.ts b/apps/community-mobile/tests/composerViewerScope.test.ts index afa98039..acbcd46b 100644 --- a/apps/community-mobile/tests/composerViewerScope.test.ts +++ b/apps/community-mobile/tests/composerViewerScope.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" import { test } from "node:test" +import { URL } from "node:url" const store = readFileSync(new URL("../src/store/authStore.ts", import.meta.url), "utf8") diff --git a/apps/community-mobile/tests/dropPinOcclusion.test.ts b/apps/community-mobile/tests/dropPinOcclusion.test.ts index b262adef..99dfe3da 100644 --- a/apps/community-mobile/tests/dropPinOcclusion.test.ts +++ b/apps/community-mobile/tests/dropPinOcclusion.test.ts @@ -4,6 +4,7 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" import { test } from "node:test" +import { URL } from "node:url" const home = readFileSync(new URL("../app/index.tsx", import.meta.url), "utf8") const cameraCall = home.slice( diff --git a/apps/community-mobile/tests/helpers/appConfig.ts b/apps/community-mobile/tests/helpers/appConfig.ts new file mode 100644 index 00000000..1d59faa6 --- /dev/null +++ b/apps/community-mobile/tests/helpers/appConfig.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict" +import { createRequire } from "node:module" + +/** + * The slice of app.config.js's output these tests read. Fields are required so a missing block still fails + * the test the way the untyped read did; the `dark` keys exist only to be asserted absent. + */ +export interface AppConfig { + userInterfaceStyle: string + splash: { backgroundColor: string; image: string; dark?: unknown } + android: { + versionCode: number + splash?: unknown + adaptiveIcon: { backgroundColor: string; dark?: unknown } + } + plugins: unknown[] +} + +export type AppConfigFactory = (context: { config: Record }) => AppConfig + +const require = createRequire(import.meta.url) + +export const appConfigFactory = require("../../app.config.js") as AppConfigFactory + +export function pluginOptions(config: AppConfig, plugin: string, missingMessage: string): Record { + const entry: unknown = config.plugins.find((candidate) => Array.isArray(candidate) && candidate[0] === plugin) + assert.ok(Array.isArray(entry), missingMessage) + return (entry as unknown[])[1] as Record +} diff --git a/apps/community-mobile/tests/helpers/navRoutes.ts b/apps/community-mobile/tests/helpers/navRoutes.ts index 514df1db..8fd31b8b 100644 --- a/apps/community-mobile/tests/helpers/navRoutes.ts +++ b/apps/community-mobile/tests/helpers/navRoutes.ts @@ -7,14 +7,15 @@ import { readFileSync } from "node:fs" import { createRequire } from "node:module" import { dirname, join } from "node:path" +import type * as TypeScript from "typescript" export async function loadNavRoutes(): Promise { const require = createRequire(import.meta.url) // Resolved through the package specifier so it follows the same @civfix/ui the app imports. const routesPath = join(dirname(require.resolve("@civfix/ui/package.json")), "src", "nav", "routes.ts") - const ts = require("typescript") + const ts = require("typescript") as typeof TypeScript const { outputText } = ts.transpileModule(readFileSync(routesPath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, }) - return import(`data:text/javascript,${encodeURIComponent(outputText)}`) + return import(`data:text/javascript,${encodeURIComponent(outputText)}`) as Promise } diff --git a/apps/community-mobile/tests/lastKnownFallback.test.ts b/apps/community-mobile/tests/lastKnownFallback.test.ts index 994ee88f..788653db 100644 --- a/apps/community-mobile/tests/lastKnownFallback.test.ts +++ b/apps/community-mobile/tests/lastKnownFallback.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" import { test } from "node:test" +import { URL } from "node:url" const read = (rel: string): string => readFileSync(new URL(rel, import.meta.url), "utf8") diff --git a/apps/community-mobile/tests/linkSocketWiring.test.ts b/apps/community-mobile/tests/linkSocketWiring.test.ts index 610dee65..3a84a2a6 100644 --- a/apps/community-mobile/tests/linkSocketWiring.test.ts +++ b/apps/community-mobile/tests/linkSocketWiring.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" import { test } from "node:test" +import { URL } from "node:url" const read = (rel: string) => readFileSync(new URL(rel, import.meta.url), "utf8") const ws = read("../src/lib/ws.ts") diff --git a/apps/community-mobile/tests/locateOnGrant.test.ts b/apps/community-mobile/tests/locateOnGrant.test.ts index 7478f2ab..00be71fe 100644 --- a/apps/community-mobile/tests/locateOnGrant.test.ts +++ b/apps/community-mobile/tests/locateOnGrant.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" import { test } from "node:test" +import { URL } from "node:url" import { FIRST_FIX_TIMEOUT_MS, GPS_TIMEOUT_MS } from "../src/lib/locationTimeouts.ts" const hook = readFileSync(new URL("../src/hooks/useUserLocation.ts", import.meta.url), "utf8") diff --git a/apps/community-mobile/tests/lucideDirectImports.test.ts b/apps/community-mobile/tests/lucideDirectImports.test.ts index 1e42183d..60eb4c28 100644 --- a/apps/community-mobile/tests/lucideDirectImports.test.ts +++ b/apps/community-mobile/tests/lucideDirectImports.test.ts @@ -6,13 +6,31 @@ import { tmpdir } from "node:os" import { dirname, join, relative } from "node:path" import { fileURLToPath } from "node:url" +type ImportNode = { + type: string + source?: { value: string } | null + importKind?: string + specifiers?: { type: string; importKind?: string }[] +} + +interface BabelConfigExports { + lucideDirectImports: unknown + lucideBarrelExports: (barrelFile: string) => Map +} + +/** The slice of @babel/core these tests drive; the package ships no types of its own. */ +interface BabelCore { + transformSync: (code: string, options: Record) => { code: string } + parseSync: (code: string, options: Record) => { program: { body: ImportNode[] } } +} + const require = createRequire(import.meta.url) -const { lucideDirectImports, lucideBarrelExports } = require("../babel.config.js") +const { lucideDirectImports, lucideBarrelExports } = require("../babel.config.js") as BabelConfigExports // community-mobile declares no @babel/core; resolve the one Metro transforms with (expo -> babel-preset-expo). const presetRequire = createRequire( createRequire(require.resolve("expo/package.json")).resolve("babel-preset-expo/package.json"), ) -const babel = presetRequire("@babel/core") +const babel = presetRequire("@babel/core") as BabelCore const BARRELS = new Set(["lucide-react-native", "lucide-react-native/icons"]) const installedEsmDir = join(require.resolve("lucide-react-native"), "..", "..", "esm") @@ -159,7 +177,7 @@ test("the installed barrels map every export to a module that exists, covering e (file) => file.endsWith(".mjs") && file !== "index.mjs", ) for (const barrel of ["lucide-react-native.mjs", join("icons", "index.mjs")]) { - const exports: Map = lucideBarrelExports(join(installedEsmDir, barrel)) + const exports = lucideBarrelExports(join(installedEsmDir, barrel)) const targets = new Set([...exports.values()].map(({ file }) => file)) for (const file of targets) assert.ok(existsSync(file), file) for (const icon of iconFiles) assert.ok(targets.has(join(installedEsmDir, "icons", icon)), `${barrel}: ${icon}`) @@ -187,13 +205,6 @@ function lucideImportSites(dir: string, found: string[] = []): string[] { return found } -type ImportNode = { - type: string - source?: { value: string } | null - importKind?: string - specifiers?: { type: string; importKind?: string }[] -} - function moduleNodes(code: string, filename: string): ImportNode[] { return babel.parseSync(code, { babelrc: false, diff --git a/apps/community-mobile/tests/nativeGeolocation.test.ts b/apps/community-mobile/tests/nativeGeolocation.test.ts index fd383f31..631bcbac 100644 --- a/apps/community-mobile/tests/nativeGeolocation.test.ts +++ b/apps/community-mobile/tests/nativeGeolocation.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" import { test } from "node:test" +import { URL } from "node:url" const source = readFileSync( new URL("../src/lib/nativeGeolocation.ts", import.meta.url), diff --git a/apps/community-mobile/tests/notificationNav.test.ts b/apps/community-mobile/tests/notificationNav.test.ts index 43ae7494..0a6c8eb2 100644 --- a/apps/community-mobile/tests/notificationNav.test.ts +++ b/apps/community-mobile/tests/notificationNav.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" import { test } from "node:test" +import { URL } from "node:url" import { isInternalLink } from "../src/lib/links.ts" import { bridgeKey, nativeBridgeKey, BRIDGE_ROUTE_NAMES } from "../src/lib/navBridge.ts" import { shellHostsEntries } from "../src/lib/internalHref.ts" diff --git a/apps/community-mobile/tests/notificationsMode.test.ts b/apps/community-mobile/tests/notificationsMode.test.ts index 96a59654..72f7ade2 100644 --- a/apps/community-mobile/tests/notificationsMode.test.ts +++ b/apps/community-mobile/tests/notificationsMode.test.ts @@ -1,22 +1,17 @@ import assert from "node:assert/strict" -import { createRequire } from "node:module" import { test } from "node:test" +import { appConfigFactory, pluginOptions } from "./helpers/appConfig.ts" -const require = createRequire(import.meta.url) -const appConfigFactory = require("../app.config.js") as (input: { - config: Record -}) => Record - -function notificationsPlugin(profile?: string): Record { - const previous = process.env.EAS_BUILD_PROFILE +function notificationsPlugin(profile?: string): Record { + const previous = process.env.EAS_BUILD_PROFILE as string | undefined if (profile === undefined) delete process.env.EAS_BUILD_PROFILE else process.env.EAS_BUILD_PROFILE = profile try { - const entry = appConfigFactory({ config: {} }).plugins.find( - (plugin: unknown) => Array.isArray(plugin) && plugin[0] === "expo-notifications", + return pluginOptions( + appConfigFactory({ config: {} }), + "expo-notifications", + "app.config.js declares no expo-notifications plugin tuple", ) - assert.ok(Array.isArray(entry), "app.config.js declares no expo-notifications plugin tuple") - return entry[1] } finally { if (previous === undefined) delete process.env.EAS_BUILD_PROFILE else process.env.EAS_BUILD_PROFILE = previous diff --git a/apps/community-mobile/tests/onboardingMapArt.test.ts b/apps/community-mobile/tests/onboardingMapArt.test.ts index 4947d3a1..20ec7280 100644 --- a/apps/community-mobile/tests/onboardingMapArt.test.ts +++ b/apps/community-mobile/tests/onboardingMapArt.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import { createHash } from "node:crypto" import { readFileSync, readdirSync } from "node:fs" -import { fileURLToPath } from "node:url" +import { fileURLToPath, URL } from "node:url" import { test } from "node:test" import { space } from "../../../packages/shared/src/tokens/design-tokens.ts" import { DEFAULT_ATTRIBUTION } from "../../../packages/ui/src/map/mapStyle.ts" @@ -28,7 +28,20 @@ const appDir = new URL("../", import.meta.url) const assetsDir = new URL("assets/onboarding/", appDir) const onboardingDir = new URL("src/components/onboarding/", appDir) const stagesDir = new URL("stages/", onboardingDir) -const manifest = JSON.parse(readFileSync(new URL("manifest.json", assetsDir), "utf8")) +interface ManifestEntry { + bytes: number + sha256: string + width: number + height: number + scene: unknown +} + +interface OnboardingArtManifest { + attribution: string + files: Record +} + +const manifest = JSON.parse(readFileSync(new URL("manifest.json", assetsDir), "utf8")) as OnboardingArtManifest const script = readFileSync(new URL("scripts/onboarding-map-art.mjs", appDir), "utf8") const MAX_TOTAL_BYTES = 1_500_000 @@ -269,7 +282,7 @@ test("every still was cut from the scene the table describes today", () => { test("the stills stay inside the weight budget and light differs from dark", () => { const total = Object.values(manifest.files).reduce( - (sum: number, entry: any) => sum + entry.bytes, + (sum: number, entry: ManifestEntry) => sum + entry.bytes, 0, ) assert.ok(total <= MAX_TOTAL_BYTES, `onboarding stills weigh ${total} bytes`) diff --git a/apps/community-mobile/tests/onboardingShell.test.ts b/apps/community-mobile/tests/onboardingShell.test.ts index 3c889eac..b82d8a5f 100644 --- a/apps/community-mobile/tests/onboardingShell.test.ts +++ b/apps/community-mobile/tests/onboardingShell.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" import { test } from "node:test" +import { URL } from "node:url" const layout = readFileSync(new URL("../app/_layout.tsx", import.meta.url), "utf8") const gate = readFileSync( diff --git a/apps/community-mobile/tests/postLinkNav.test.ts b/apps/community-mobile/tests/postLinkNav.test.ts index 96df2d00..1a7582df 100644 --- a/apps/community-mobile/tests/postLinkNav.test.ts +++ b/apps/community-mobile/tests/postLinkNav.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" import { test } from "node:test" +import { URL } from "node:url" import { isInternalLink } from "../src/lib/links.ts" import { bridgeKey } from "../src/lib/navBridge.ts" import { loadNavRoutes } from "./helpers/navRoutes.ts" diff --git a/apps/community-mobile/tests/releaseScriptsPortable.test.ts b/apps/community-mobile/tests/releaseScriptsPortable.test.ts index 2e0cb884..14e8f5b8 100644 --- a/apps/community-mobile/tests/releaseScriptsPortable.test.ts +++ b/apps/community-mobile/tests/releaseScriptsPortable.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" import { test } from "node:test" +import { URL } from "node:url" const read = (rel: string): string => readFileSync(new URL(rel, import.meta.url), "utf8") diff --git a/apps/community-mobile/tests/signInRefresh.test.ts b/apps/community-mobile/tests/signInRefresh.test.ts index 67b99785..91059bb9 100644 --- a/apps/community-mobile/tests/signInRefresh.test.ts +++ b/apps/community-mobile/tests/signInRefresh.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" import { test } from "node:test" +import { URL } from "node:url" const read = (rel: string) => readFileSync(new URL(rel, import.meta.url), "utf8") const store = read("../src/store/authStore.ts") diff --git a/apps/community-mobile/tests/splashAsset.test.ts b/apps/community-mobile/tests/splashAsset.test.ts index b4754020..51c6650a 100644 --- a/apps/community-mobile/tests/splashAsset.test.ts +++ b/apps/community-mobile/tests/splashAsset.test.ts @@ -1,13 +1,12 @@ import assert from "node:assert/strict" import { existsSync, readFileSync } from "node:fs" -import { createRequire } from "node:module" -import { fileURLToPath } from "node:url" +import { fileURLToPath, URL } from "node:url" import { test } from "node:test" import { tokens } from "@civfix/shared/tokens" +import { appConfigFactory, pluginOptions } from "./helpers/appConfig.ts" import { decodeRgbaPng, pixelAt, transparentShare } from "./helpers/png.ts" -const require = createRequire(import.meta.url) -const appConfig = require("../app.config.js")({ config: {} }) +const appConfig = appConfigFactory({ config: {} }) const appDir = new URL("../", import.meta.url) const SPLASH_SOURCE = "./assets/splash.png" @@ -29,7 +28,19 @@ function readGenerated(url: URL): string { return readFileSync(fileURLToPath(url), "utf8") } -function isDarkEntry(entry: { appearances?: { appearance: string; value: string }[] }): boolean { +interface AppearanceEntry { + appearances?: { appearance: string; value: string }[] +} + +interface ColorsetContents { + colors: (AppearanceEntry & { color: { components: Record } })[] +} + +interface ImagesetContents { + images: (AppearanceEntry & { filename?: unknown })[] +} + +function isDarkEntry(entry: AppearanceEntry): boolean { return (entry.appearances ?? []).some( (appearance) => appearance.appearance === "luminosity" && appearance.value === "dark", ) @@ -49,12 +60,8 @@ function assertComponents(components: Record, hex: string, label assert.equal(Number(components.alpha), 1, `${label}: alpha must be opaque`) } -function splashPluginOptions(): Record { - const entry = appConfig.plugins.find( - (plugin: unknown) => Array.isArray(plugin) && plugin[0] === "expo-splash-screen", - ) - assert.ok(Array.isArray(entry), "expo-splash-screen plugin tuple") - return entry[1] +function splashPluginOptions(): Record { + return pluginOptions(appConfig, "expo-splash-screen", "expo-splash-screen plugin tuple") } function assertTransparentCorners(png: ReturnType, label: string) { @@ -110,7 +117,7 @@ test( "the generated ios background colorset carries the light paper and nothing else", { skip: skipWithoutPrebuild }, () => { - const { colors } = JSON.parse(readGenerated(COLORSET)) + const { colors } = JSON.parse(readGenerated(COLORSET)) as ColorsetContents assert.equal( colors.length, 1, @@ -125,7 +132,7 @@ test( "the generated ios imageset carries no dark logo entry, and no dark file on disk", { skip: skipWithoutPrebuild }, () => { - const { images } = JSON.parse(readGenerated(new URL("Contents.json", IMAGESET))) + const { images } = JSON.parse(readGenerated(new URL("Contents.json", IMAGESET))) as ImagesetContents assert.equal(images.filter(isDarkEntry).length, 0) for (const entry of images) { assert.ok( diff --git a/apps/community-mobile/tests/splashColor.test.ts b/apps/community-mobile/tests/splashColor.test.ts index 8444fd0c..356c4beb 100644 --- a/apps/community-mobile/tests/splashColor.test.ts +++ b/apps/community-mobile/tests/splashColor.test.ts @@ -1,12 +1,12 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" -import { createRequire } from "node:module" import { test } from "node:test" +import { URL } from "node:url" import { tokens } from "@civfix/shared/tokens" +import { appConfigFactory, pluginOptions } from "./helpers/appConfig.ts" -const require = createRequire(import.meta.url) const appConfigSource = readFileSync(new URL("../app.config.js", import.meta.url), "utf8") -const appConfig = require("../app.config.js")({ config: {} }) +const appConfig = appConfigFactory({ config: {} }) const loadingSplash = readFileSync( new URL("../src/components/LoadingSplash.tsx", import.meta.url), "utf8", @@ -19,12 +19,8 @@ const launchThemeModule = readFileSync( const LIGHT = tokens.color.neutral.paper -function splashPlugin(): Record { - const entry = appConfig.plugins.find( - (plugin: unknown) => Array.isArray(plugin) && plugin[0] === "expo-splash-screen", - ) - assert.ok(Array.isArray(entry), "expo-splash-screen plugin tuple") - return entry[1] +function splashPlugin(): Record { + return pluginOptions(appConfig, "expo-splash-screen", "expo-splash-screen plugin tuple") } test("the native splash background is the light paper token", () => { diff --git a/apps/community-mobile/tests/splashConfig.test.ts b/apps/community-mobile/tests/splashConfig.test.ts index 59679b2a..5447ac8b 100644 --- a/apps/community-mobile/tests/splashConfig.test.ts +++ b/apps/community-mobile/tests/splashConfig.test.ts @@ -1,22 +1,17 @@ import assert from "node:assert/strict" import { existsSync } from "node:fs" -import { createRequire } from "node:module" -import { fileURLToPath } from "node:url" +import { fileURLToPath, URL } from "node:url" import { test } from "node:test" import { tokens } from "@civfix/shared/tokens" +import { appConfigFactory, pluginOptions } from "./helpers/appConfig.ts" -const require = createRequire(import.meta.url) -const appConfig = require("../app.config.js")({ config: {} }) +const appConfig = appConfigFactory({ config: {} }) const appDir = new URL("../", import.meta.url) const LIGHT = tokens.color.neutral.paper -function splashPlugin(): Record { - const entry = appConfig.plugins.find( - (plugin: unknown) => Array.isArray(plugin) && plugin[0] === "expo-splash-screen", - ) - assert.ok(Array.isArray(entry), "app.config.js declares no expo-splash-screen plugin tuple") - return entry[1] +function splashPlugin(): Record { + return pluginOptions(appConfig, "expo-splash-screen", "app.config.js declares no expo-splash-screen plugin tuple") } test("the app itself still follows the device appearance", () => { @@ -55,7 +50,7 @@ test("the android splash and adaptive icon sit on the same single light field", test("every splash image the config names is a file that exists", () => { const references = [appConfig.splash.image, splashPlugin().image] for (const reference of references) { - assert.equal(typeof reference, "string", "a splash image reference is missing") + assert.ok(typeof reference === "string", "a splash image reference is missing") assert.ok( existsSync(fileURLToPath(new URL(reference, appDir))), `${reference} is referenced by app.config.js but does not exist`, diff --git a/apps/community-mobile/tests/swallowedErrors.test.ts b/apps/community-mobile/tests/swallowedErrors.test.ts index 896c4394..3ca334fc 100644 --- a/apps/community-mobile/tests/swallowedErrors.test.ts +++ b/apps/community-mobile/tests/swallowedErrors.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" import { test } from "node:test" +import { URL } from "node:url" const read = (rel: string) => readFileSync(new URL(rel, import.meta.url), "utf8") diff --git a/packages/shared/__tests__/datetime-intl-cache.test.ts b/packages/shared/__tests__/datetime-intl-cache.test.ts index 7d4a7da5..8ac68864 100644 --- a/packages/shared/__tests__/datetime-intl-cache.test.ts +++ b/packages/shared/__tests__/datetime-intl-cache.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import type * as DatetimeModule from "../src/datetime.js" -type Datetime = typeof import("../src/datetime.js") +type Datetime = typeof DatetimeModule const LA = "America/Los_Angeles" const ZONES = [ diff --git a/packages/shared/__tests__/fakes.test.ts b/packages/shared/__tests__/fakes.test.ts index 923762bc..1d1d7fd5 100644 --- a/packages/shared/__tests__/fakes.test.ts +++ b/packages/shared/__tests__/fakes.test.ts @@ -145,7 +145,7 @@ describe("FakeChatService", () => { const msg = await chat.persist({ cleanupId: "cleanup-1", userId: "user-1", body: "hello" }) await chat.broadcast("cleanup-1", msg) expect(received).toHaveLength(1) - expect(JSON.parse(received[0]!).message.body).toBe("hello") + expect(JSON.parse(received[0]!)).toMatchObject({ message: { body: "hello" } }) await chat.persist({ cleanupId: "cleanup-1", userId: "user-1", body: "second" }) const page = await chat.history("cleanup-1", undefined, 1) diff --git a/packages/shared/__tests__/geocode.test.ts b/packages/shared/__tests__/geocode.test.ts index a78de440..04bb805b 100644 --- a/packages/shared/__tests__/geocode.test.ts +++ b/packages/shared/__tests__/geocode.test.ts @@ -6,6 +6,7 @@ import { mapboxSuggest, suggestAddresses, } from "../src/geocode.js" +import type { SuggestOptions } from "../src/geocode.js" /** * Tests for the unified forward geocoder. parseLatLng is pure; photonSuggest, mapboxSuggest and @@ -294,10 +295,12 @@ describe("suggestAddresses", () => { }) }) +type OnError = NonNullable + describe("suggestAddresses onError", () => { it("reports a Photon outage while still resolving []", async () => { vi.stubGlobal("fetch", vi.fn(async () => new Response("down", { status: 503 }))) - const onError = vi.fn() + const onError = vi.fn() await expect(suggestAddresses("echo park", { onError })).resolves.toEqual([]) expect(onError).toHaveBeenCalledTimes(1) expect(onError.mock.calls[0]?.[1]).toBe("photon") @@ -313,7 +316,7 @@ describe("suggestAddresses onError", () => { : photonResponse([{ lat: 34.0, lng: -118.2, props: { name: "Echo Park" } }]), ), ) - const onError = vi.fn() + const onError = vi.fn() const out = await suggestAddresses("echo park", { mapboxToken: "pk.test", onError }) expect(out[0]?.source).toBe("photon") expect(onError.mock.calls.map((call) => call[1])).toEqual(["mapbox"]) @@ -328,7 +331,7 @@ describe("suggestAddresses onError", () => { throw Object.assign(new Error("aborted"), { name: "AbortError" }) }), ) - const onError = vi.fn() + const onError = vi.fn() await expect(suggestAddresses("echo", { signal: controller.signal, onError })).rejects.toThrow() expect(onError).not.toHaveBeenCalled() }) diff --git a/packages/shared/__tests__/tokens-dark-scheme.test.ts b/packages/shared/__tests__/tokens-dark-scheme.test.ts index 5720b030..53dd404c 100644 --- a/packages/shared/__tests__/tokens-dark-scheme.test.ts +++ b/packages/shared/__tests__/tokens-dark-scheme.test.ts @@ -28,7 +28,7 @@ describe("dark color scheme", () => { it("keeps every color a 6-digit hex", () => { const hexes = leafPaths(darkColor).map((path) => - path.split(".").reduce((acc, key) => acc[key], darkColor), + path.split(".").reduce((acc, key) => (acc as Record)[key], darkColor), ) for (const hex of hexes) expect(hex).toMatch(/^#[0-9A-F]{6}$/) }) diff --git a/packages/shared/src/host/__tests__/graphemes.test.ts b/packages/shared/src/host/__tests__/graphemes.test.ts index 7c58ecba..9bbe46d7 100644 --- a/packages/shared/src/host/__tests__/graphemes.test.ts +++ b/packages/shared/src/host/__tests__/graphemes.test.ts @@ -1,11 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest" +import type * as GraphemesModule from "../graphemes.js" const FLAG = "🇺🇸" const FAMILY = "👩‍👩‍👧" const THUMB = "👍🏽" const ACCENT = "é" -async function loadWithoutSegmenter(): Promise { +async function loadWithoutSegmenter(): Promise { vi.resetModules() const original = Intl.Segmenter Reflect.deleteProperty(Intl as unknown as Record, "Segmenter") diff --git a/packages/ui/src/announce/__tests__/announce.web.test.ts b/packages/ui/src/announce/__tests__/announce.web.test.ts index 03aa6ba4..80f64d21 100644 --- a/packages/ui/src/announce/__tests__/announce.web.test.ts +++ b/packages/ui/src/announce/__tests__/announce.web.test.ts @@ -1,5 +1,6 @@ // The package's vitest runs in plain node (no jsdom), so this installs a minimal fake `document`. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import type * as AnnounceModule from "../announce.web" interface FakeElement { id: string @@ -37,7 +38,7 @@ function makeFakeDocument() { type FakeDocument = ReturnType let doc: FakeDocument -let announce: typeof import("../announce.web").announce +let announce: typeof AnnounceModule.announce beforeEach(async () => { vi.useFakeTimers() diff --git a/packages/ui/src/bodies/__tests__/PostCard.test.ts b/packages/ui/src/bodies/__tests__/PostCard.test.ts index 9eb6210c..8ef96fb3 100644 --- a/packages/ui/src/bodies/__tests__/PostCard.test.ts +++ b/packages/ui/src/bodies/__tests__/PostCard.test.ts @@ -247,7 +247,7 @@ describe("PostCard model", () => { new URL(`../../i18n/locales/${locale}/home-feed.json`, import.meta.url), "utf8", ) - expect(JSON.parse(catalog).post_card, locale).not.toHaveProperty("organizer") + expect((JSON.parse(catalog) as Record).post_card, locale).not.toHaveProperty("organizer") } }) diff --git a/packages/ui/src/bodies/__tests__/profileInvitations.test.ts b/packages/ui/src/bodies/__tests__/profileInvitations.test.ts index a29ff51a..83222f1e 100644 --- a/packages/ui/src/bodies/__tests__/profileInvitations.test.ts +++ b/packages/ui/src/bodies/__tests__/profileInvitations.test.ts @@ -8,7 +8,7 @@ const source = (file: string): string => readFileSync(new URL(file, import.meta. const catalog = (lng: string, ns: string): Record => JSON.parse( readFileSync(new URL(`../../i18n/locales/${lng}/${ns}.json`, import.meta.url), "utf8"), - ) + ) as Record const ev = (id: string): { id: string } => ({ id }) diff --git a/packages/ui/src/bodies/__tests__/reportFlowExit.test.ts b/packages/ui/src/bodies/__tests__/reportFlowExit.test.ts index 9dafb01d..bb2d40c6 100644 --- a/packages/ui/src/bodies/__tests__/reportFlowExit.test.ts +++ b/packages/ui/src/bodies/__tests__/reportFlowExit.test.ts @@ -32,7 +32,7 @@ describe("every exit from the report wizard returns to the surface it was launch for (const locale of LOCALES) { const catalog = read(`../../i18n/locales/${locale}/report-wizard.json`) expect(catalog).not.toContain("back_to_map") - expect(JSON.parse(catalog).submit.done).toBeTruthy() + expect((JSON.parse(catalog) as { submit: { done?: string } }).submit.done).toBeTruthy() } }) }) diff --git a/packages/ui/src/bodies/host/__tests__/analyticsLabels.test.ts b/packages/ui/src/bodies/host/__tests__/analyticsLabels.test.ts index c62c3f40..bd689614 100644 --- a/packages/ui/src/bodies/host/__tests__/analyticsLabels.test.ts +++ b/packages/ui/src/bodies/host/__tests__/analyticsLabels.test.ts @@ -16,7 +16,7 @@ const markdown = readFileSync( function catalog(lng: string, ns: string): Record> { return JSON.parse( readFileSync(new URL(`../../../i18n/locales/${lng}/${ns}.json`, import.meta.url), "utf8"), - ) + ) as Record> } function code(source: string): string { diff --git a/packages/ui/src/bodies/host/__tests__/byEventRowA11y.test.ts b/packages/ui/src/bodies/host/__tests__/byEventRowA11y.test.ts index 51c5e91d..f0dc78a3 100644 --- a/packages/ui/src/bodies/host/__tests__/byEventRowA11y.test.ts +++ b/packages/ui/src/bodies/host/__tests__/byEventRowA11y.test.ts @@ -9,7 +9,7 @@ const code = (src: string): string => function catalog(ns: string): Record { return JSON.parse( readFileSync(new URL(`../../../i18n/locales/en/${ns}.json`, import.meta.url), "utf8"), - ) + ) as Record } const en = { "host-analytics": catalog("host-analytics") } diff --git a/packages/ui/src/bodies/host/__tests__/hostTeamModel.test.ts b/packages/ui/src/bodies/host/__tests__/hostTeamModel.test.ts index d122676c..b1c0fe94 100644 --- a/packages/ui/src/bodies/host/__tests__/hostTeamModel.test.ts +++ b/packages/ui/src/bodies/host/__tests__/hostTeamModel.test.ts @@ -23,7 +23,7 @@ import { const catalog = (lng: string, ns: string): Record> => JSON.parse( readFileSync(new URL(`../../../i18n/locales/${lng}/${ns}.json`, import.meta.url), "utf8"), - ) + ) as Record> function member( id: string, diff --git a/packages/ui/src/bodies/host/dashboard/__tests__/dashboardModel.test.ts b/packages/ui/src/bodies/host/dashboard/__tests__/dashboardModel.test.ts index 9f2b0a8e..ab33815e 100644 --- a/packages/ui/src/bodies/host/dashboard/__tests__/dashboardModel.test.ts +++ b/packages/ui/src/bodies/host/dashboard/__tests__/dashboardModel.test.ts @@ -58,7 +58,9 @@ const DASHBOARD_FILES = folderSourceFiles( const dashboardNavSource = (): string => source("../useHostedEventNav.ts") const catalog = (lng: string, ns: string): Record => - JSON.parse(readFileSync(new URL(`../../../../i18n/locales/${lng}/${ns}.json`, import.meta.url), "utf8")) + JSON.parse( + readFileSync(new URL(`../../../../i18n/locales/${lng}/${ns}.json`, import.meta.url), "utf8"), + ) as Record function org(id: string, over: Partial = {}): OrganizationDTO { return { diff --git a/packages/ui/src/data/__tests__/chatHookDeps.test.ts b/packages/ui/src/data/__tests__/chatHookDeps.test.ts index e381f9a6..fe71d3cb 100644 --- a/packages/ui/src/data/__tests__/chatHookDeps.test.ts +++ b/packages/ui/src/data/__tests__/chatHookDeps.test.ts @@ -46,9 +46,11 @@ describe("useChat effect order", () => { describe("room-bound callbacks follow an in-place room switch", () => { it("findMessage and patchMessage re-create when the room id or kind changes", () => { const find = sliceBetween(HISTORY, "const findMessage = useCallback", "const isHistoryFetchInFlight") - expect(find).toContain("[queryClient, roomId, roomKind],") + expect(find).toContain("[queryClient, roomId, roomKind, aroundWindowRef, liveMessagesRef],") const patch = sliceBetween(HISTORY, "const patchMessage = useCallback", "const resetHistoryJournal") - expect(patch).toContain("[queryClient, roomId, roomKind, isHistoryFetchInFlight],") + expect(patch).toContain( + "[queryClient, roomId, roomKind, isHistoryFetchInFlight, setLiveMessages, setAroundWindow],", + ) }) it("delete and votePoll inherit that identity through findMessage and patchMessage", () => { diff --git a/packages/ui/src/data/__tests__/chatRoomScope.test.ts b/packages/ui/src/data/__tests__/chatRoomScope.test.ts index 2292e9ba..e8e25ba6 100644 --- a/packages/ui/src/data/__tests__/chatRoomScope.test.ts +++ b/packages/ui/src/data/__tests__/chatRoomScope.test.ts @@ -57,7 +57,7 @@ describe("room switch scoping in useChat", () => { expect(guard).toBeLessThan(refresh.indexOf("cacheOpsRef.current.push(op)")) const reset = section( "setLiveMessages([])\n setOutbox([])", - "}, [roomId, roomKind, clearTypingState, resetSendTracking, resetHistoryJournal, clearAround])", + " }, [", ) expect(reset).toContain("resetHistoryJournal()") expect(section("const resetHistoryJournal = useCallback", "}, [])", HISTORY)).toContain( diff --git a/packages/ui/src/data/__tests__/eventTeamTiers.test.ts b/packages/ui/src/data/__tests__/eventTeamTiers.test.ts index a326227c..79a4c9fc 100644 --- a/packages/ui/src/data/__tests__/eventTeamTiers.test.ts +++ b/packages/ui/src/data/__tests__/eventTeamTiers.test.ts @@ -13,7 +13,7 @@ import { const catalog = (lng: string, ns: string): Record> => JSON.parse( readFileSync(new URL(`../../i18n/locales/${lng}/${ns}.json`, import.meta.url), "utf8"), - ) + ) as Record> describe("the invitable tiers", () => { it("reads most to least privileged, which is NOT the contract's append order", () => { diff --git a/packages/ui/src/data/__tests__/uploadMedia.test.ts b/packages/ui/src/data/__tests__/uploadMedia.test.ts index 0600bb1b..d1b4662c 100644 --- a/packages/ui/src/data/__tests__/uploadMedia.test.ts +++ b/packages/ui/src/data/__tests__/uploadMedia.test.ts @@ -76,7 +76,7 @@ describe("uploadMedia", () => { camera: camera({ byteSize: 0 }), media: MEDIA, fetchImpl: okFetch(), - }).catch((e) => e) + }).catch((e: unknown) => e) expect(err).toBeInstanceOf(AppError) expect((err as AppError).code).toBe("MEDIA_REJECTED") expect(calls).toEqual([]) @@ -91,7 +91,7 @@ describe("uploadMedia", () => { }, } const err = await uploadMedia({ api: client, camera: broken, media: MEDIA, fetchImpl: okFetch() }).catch( - (e) => e, + (e: unknown) => e, ) expect((err as AppError).code).toBe("MEDIA_REJECTED") }) @@ -100,7 +100,7 @@ describe("uploadMedia", () => { const { client, calls } = api() const doFetch = vi.fn(async () => new Response(null, { status: 403 })) as unknown as typeof fetch const err = await uploadMedia({ api: client, camera: camera(), media: MEDIA, fetchImpl: doFetch }).catch( - (e) => e, + (e: unknown) => e, ) expect((err as AppError).message).toContain("403") expect(calls).toEqual(["presign"]) @@ -206,7 +206,7 @@ describe("the web progress path (XMLHttpRequest)", () => { camera: camera(), media: MEDIA, onProgress: () => {}, - }).catch((e) => e) + }).catch((e: unknown) => e) expect((err as AppError).message).toContain("403") expect(calls).toEqual(["presign"]) } finally { diff --git a/packages/ui/src/data/hooks/__tests__/posts.test.ts b/packages/ui/src/data/hooks/__tests__/posts.test.ts index 448cd74e..7e365033 100644 --- a/packages/ui/src/data/hooks/__tests__/posts.test.ts +++ b/packages/ui/src/data/hooks/__tests__/posts.test.ts @@ -12,6 +12,7 @@ import { buildCreateMutation, buildDeleteMutation, homeFeedPlaceholder, + type CreateCtx, type CreatePostVars, } from "../posts" @@ -20,14 +21,14 @@ import { * The tests exercise only the cache-patch BEHAVIOR, so the built options are viewed through this loose * alias to invoke onMutate/onError/onSuccess directly. */ -interface Loose { - onMutate: (v: V) => Promise +interface Loose { + onMutate: (v: V) => Promise onError: (e: unknown, v: V, c: unknown) => void onSuccess: (r: R, v: V, c: unknown) => void onSettled: (r: R | undefined, e: unknown, v: V) => void } -function loose(opts: unknown): Loose { - return opts as Loose +function loose(opts: unknown): Loose { + return opts as Loose } function person(id: string): PersonDTO { @@ -234,7 +235,7 @@ describe("useCreatePost optimism (buildCreateMutation)", () => { const optimistic = post("temp_1", { body: "brand new" }) const server = post("server_1", { body: "brand new" }) const api = { createPost: async (_input: PostComposeInput) => server } - const opts = loose(buildCreateMutation(qc, api)) + const opts = loose(buildCreateMutation(qc, api)) const vars: CreatePostVars = { input: { kind: "post", body: "brand new", mediaUploadIds: [], mentionedUserIds: [] }, optimistic, @@ -260,7 +261,7 @@ describe("useCreatePost optimism (buildCreateMutation)", () => { const optimistic = post("temp_reply", { kind: "reply", replyToId: "parent", body: "me too" }) const api = { createPost: async () => post("server_reply", { kind: "reply", replyToId: "parent" }) } - const opts = loose(buildCreateMutation(qc, api)) + const opts = loose(buildCreateMutation(qc, api)) const vars: CreatePostVars = { input: { kind: "reply", replyToId: "parent", body: "me too", mediaUploadIds: [], mentionedUserIds: [] }, optimistic, @@ -287,7 +288,7 @@ describe("useCreatePost optimism (buildCreateMutation)", () => { qc.setQueryData(queryKeys.post("first"), post("first", { kind: "reply", replyToId: "root" })) const api = { createPost: async () => post("server_answer", { kind: "reply", replyToId: "first" }) } - const opts = loose(buildCreateMutation(qc, api)) + const opts = loose(buildCreateMutation(qc, api)) const vars: CreatePostVars = { input: { kind: "reply", replyToId: "first", body: "answer", mediaUploadIds: [], mentionedUserIds: [] }, optimistic: post("temp_answer", { kind: "reply", replyToId: "first" }), @@ -306,7 +307,7 @@ describe("useCreatePost optimism (buildCreateMutation)", () => { it("skips the grandparent refresh, without throwing, when the parent detail is not cached", async () => { const qc = recordInvalidations(new QueryClient()) const api = { createPost: async () => post("server_answer", { kind: "reply", replyToId: "first" }) } - const opts = loose(buildCreateMutation(qc, api)) + const opts = loose(buildCreateMutation(qc, api)) const vars: CreatePostVars = { input: { kind: "reply", replyToId: "first", body: "answer", mediaUploadIds: [], mentionedUserIds: [] }, optimistic: post("temp_answer", { kind: "reply", replyToId: "first" }), @@ -322,7 +323,7 @@ describe("useCreatePost optimism (buildCreateMutation)", () => { qc.setQueryData(queryKeys.post("root"), post("root")) const api = { createPost: async () => post("server_reply", { kind: "reply", replyToId: "root" }) } - const opts = loose(buildCreateMutation(qc, api)) + const opts = loose(buildCreateMutation(qc, api)) const vars: CreatePostVars = { input: { kind: "reply", replyToId: "root", body: "reply", mediaUploadIds: [], mentionedUserIds: [] }, optimistic: post("temp_reply", { kind: "reply", replyToId: "root" }), @@ -347,7 +348,7 @@ describe("useCreatePost optimism (buildCreateMutation)", () => { const optimistic = post("temp_reply", { kind: "reply", replyToId: "parent", body: "me too" }) const api = { createPost: async () => post("server_reply", { kind: "reply", replyToId: "parent" }) } - const opts = loose(buildCreateMutation(qc, api)) + const opts = loose(buildCreateMutation(qc, api)) const vars: CreatePostVars = { input: { kind: "reply", replyToId: "parent", body: "me too", mediaUploadIds: [], mentionedUserIds: [] }, optimistic, @@ -380,7 +381,7 @@ describe("useCreatePost optimism (buildCreateMutation)", () => { const optimistic = post("temp_1") const server = post("server_1") const api = { createPost: async () => server } - const opts = loose(buildCreateMutation(qc, api)) + const opts = loose(buildCreateMutation(qc, api)) const vars: CreatePostVars = { input: { kind: "post", eventId: "event-1", mediaUploadIds: [], mentionedUserIds: [] }, optimistic, @@ -409,7 +410,7 @@ describe("useCreatePost optimism (buildCreateMutation)", () => { const optimistic = post("temp_1") const api = { createPost: async () => post("server_1") } - const opts = loose(buildCreateMutation(qc, api)) + const opts = loose(buildCreateMutation(qc, api)) const vars: CreatePostVars = { input: { kind: "post", body: "hello", mediaUploadIds: [], mentionedUserIds: [] }, optimistic, diff --git a/packages/ui/src/map/__tests__/domMarkerLayer.test.ts b/packages/ui/src/map/__tests__/domMarkerLayer.test.ts index 4b0e66fe..690c259c 100644 --- a/packages/ui/src/map/__tests__/domMarkerLayer.test.ts +++ b/packages/ui/src/map/__tests__/domMarkerLayer.test.ts @@ -1,5 +1,6 @@ // The package's vitest runs in plain node, so maplibre, react-dom's root and the DOM are minimal fakes. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import type { DesiredMarker as Desired, MarkerEntry as Entry } from "../domMarkerLayer.web" interface FakeEl { style: Record @@ -86,8 +87,6 @@ function fakeElement(): FakeEl { } const { syncMarkers } = await import("../domMarkerLayer.web") -type Desired = import("../domMarkerLayer.web").DesiredMarker -type Entry = import("../domMarkerLayer.web").MarkerEntry const map = {} as never diff --git a/packages/ui/src/map/__tests__/mapCameraContract.test.ts b/packages/ui/src/map/__tests__/mapCameraContract.test.ts index dc71d71e..438ddde5 100644 --- a/packages/ui/src/map/__tests__/mapCameraContract.test.ts +++ b/packages/ui/src/map/__tests__/mapCameraContract.test.ts @@ -26,7 +26,7 @@ describe("MapHandle.flyTo on the native seam", () => { describe("the focus ease on both seams", () => { it("keys on the focus object, so re-publishing the same pin eases back to it", () => { - expect(webCamera).toContain("}, [mapReady, mode, focus])") + expect(webCamera).toContain("}, [mapReady, mode, focus, mapRef, occlusionLeftRef])") expect(mapWeb).toContain("useFocusAndFlyToCamera(mapRef, mapReady, mode, focus, flyToRequest, occlusionLeftRef)") expect(mapNative).toContain("}, [focus])") expect(webCamera).not.toContain("focus?.id, focus?.lat") diff --git a/packages/ui/src/map/__tests__/pinAppearanceMemo.test.ts b/packages/ui/src/map/__tests__/pinAppearanceMemo.test.ts index c99bd34f..f1f8dfb6 100644 --- a/packages/ui/src/map/__tests__/pinAppearanceMemo.test.ts +++ b/packages/ui/src/map/__tests__/pinAppearanceMemo.test.ts @@ -5,12 +5,13 @@ * computed once per input. */ import { beforeEach, describe, expect, it, vi } from "vitest" +import type * as ChipContrast from "@civfix/shared/chip-contrast" import { colorSchemes } from "@civfix/shared/tokens" const calls = vi.hoisted(() => ({ count: 0 })) vi.mock("@civfix/shared/chip-contrast", async (importOriginal) => { - const actual = await importOriginal() + const actual = await importOriginal() return { ...actual, contrastRatio: (a: string, b: string) => { diff --git a/packages/ui/src/report/__tests__/submitUpload.test.ts b/packages/ui/src/report/__tests__/submitUpload.test.ts index 3157edb8..8e1aa61d 100644 --- a/packages/ui/src/report/__tests__/submitUpload.test.ts +++ b/packages/ui/src/report/__tests__/submitUpload.test.ts @@ -24,6 +24,15 @@ function neverSettlesFetch(): typeof fetch { })) as unknown as typeof fetch } +function rejection(upload: Promise): Promise { + return upload.then( + () => { + throw new Error("expected the upload to reject") + }, + (e: unknown) => e as AppError, + ) +} + describe("putUpload", () => { afterEach(() => vi.useRealTimers()) @@ -32,7 +41,7 @@ describe("putUpload", () => { }) it("(b) throws AppError INTERNAL with the status in the message on a non-ok response", async () => { - const err = await putUpload("https://x", {}, "body" as BodyInit, 1000, statusFetch(403)).catch((e) => e) + const err = await rejection(putUpload("https://x", {}, "body" as BodyInit, 1000, statusFetch(403))) expect(err).toBeInstanceOf(AppError) expect(err.code).toBe(ErrorCode.INTERNAL) expect(err.message).toContain("403") @@ -40,9 +49,7 @@ describe("putUpload", () => { it("(c) wraps a rejecting fetch in AppError INTERNAL, preserving the cause", async () => { const cause = new Error("network down") - const err = await putUpload("https://x", {}, "body" as BodyInit, 1000, rejectingFetch(cause)).catch( - (e) => e, - ) + const err = await rejection(putUpload("https://x", {}, "body" as BodyInit, 1000, rejectingFetch(cause))) expect(err).toBeInstanceOf(AppError) expect(err.code).toBe(ErrorCode.INTERNAL) expect(err.cause).toBe(cause) @@ -51,7 +58,7 @@ describe("putUpload", () => { it("(d) aborts and rejects with AppError when the PUT never settles past the timeout", async () => { vi.useFakeTimers() const promise = putUpload("https://x", {}, "body" as BodyInit, 1000, neverSettlesFetch()) - const assertion = promise.catch((e) => e) + const assertion = rejection(promise) await vi.advanceTimersByTimeAsync(UPLOAD_PUT_BASE_TIMEOUT_MS + 1) const err = await assertion expect(err).toBeInstanceOf(AppError) @@ -65,7 +72,7 @@ describe("putUpload", () => { expect(computedDeadline).toBeGreaterThan(UPLOAD_PUT_BASE_TIMEOUT_MS) const big = putUpload("https://x", {}, "body" as BodyInit, bigByteSize, neverSettlesFetch()) - const bigAssertion = big.catch((e) => e) + const bigAssertion = rejection(big) await vi.advanceTimersByTimeAsync(UPLOAD_PUT_BASE_TIMEOUT_MS + 1) expect(await Promise.race([bigAssertion, Promise.resolve("pending")])).toBe("pending") await vi.advanceTimersByTimeAsync(computedDeadline - UPLOAD_PUT_BASE_TIMEOUT_MS) @@ -74,7 +81,7 @@ describe("putUpload", () => { expect(bigErr.code).toBe(ErrorCode.INTERNAL) const small = putUpload("https://x", {}, "body" as BodyInit, 1000, neverSettlesFetch()) - const smallAssertion = small.catch((e) => e) + const smallAssertion = rejection(small) await vi.advanceTimersByTimeAsync(UPLOAD_PUT_BASE_TIMEOUT_MS - 1) expect(await Promise.race([smallAssertion, Promise.resolve("pending")])).toBe("pending") await vi.advanceTimersByTimeAsync(2) diff --git a/packages/ui/src/shell/__tests__/keyboardInsetStore.test.ts b/packages/ui/src/shell/__tests__/keyboardInsetStore.test.ts index 253e3acc..49aa4726 100644 --- a/packages/ui/src/shell/__tests__/keyboardInsetStore.test.ts +++ b/packages/ui/src/shell/__tests__/keyboardInsetStore.test.ts @@ -1,5 +1,6 @@ // The package's vitest runs in plain node, so this installs a minimal fake `window.visualViewport`. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import type * as KeyboardInsetModule from "../useKeyboardInset.web" type Handler = () => void @@ -18,7 +19,7 @@ function makeViewport(height: number) { } let vv: ReturnType -let store: typeof import("../useKeyboardInset.web") +let store: typeof KeyboardInsetModule beforeEach(async () => { vv = makeViewport(800) diff --git a/packages/ui/src/theme/__tests__/webAffordancesPlatform.test.ts b/packages/ui/src/theme/__tests__/webAffordancesPlatform.test.ts index e81c32fb..eabdbae4 100644 --- a/packages/ui/src/theme/__tests__/webAffordancesPlatform.test.ts +++ b/packages/ui/src/theme/__tests__/webAffordancesPlatform.test.ts @@ -3,10 +3,11 @@ * (react-native itself cannot load under this package's node vitest). */ import { afterEach, describe, expect, it, vi } from "vitest" +import type * as WebAffordancesModule from "../webAffordances" import { tokens } from "@civfix/shared/tokens" import type { Theme } from "../themes" -type Affordances = typeof import("../webAffordances") +type Affordances = typeof WebAffordancesModule async function load(os: "web" | "ios"): Promise { vi.resetModules() From aeb04bbe15a3e5f344571c59525bf8a60d986856 Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 09:14:22 +0000 Subject: [PATCH 06/11] i18n checks: call sites in ui, web and mobile resolve against the catalogs, and the generated catalogs are fresh --- packages/ui/scripts/check-i18n-resources.mjs | 28 +++ packages/ui/scripts/gen-i18n-resources.mjs | 135 +------------- packages/ui/scripts/i18n-modules.mjs | 122 +++++++++++++ scripts/check-i18n-call-sites.mjs | 176 +++++++++++++++++++ 4 files changed, 334 insertions(+), 127 deletions(-) create mode 100644 packages/ui/scripts/check-i18n-resources.mjs create mode 100644 packages/ui/scripts/i18n-modules.mjs create mode 100644 scripts/check-i18n-call-sites.mjs diff --git a/packages/ui/scripts/check-i18n-resources.mjs b/packages/ui/scripts/check-i18n-resources.mjs new file mode 100644 index 00000000..7bbcbf3f --- /dev/null +++ b/packages/ui/scripts/check-i18n-resources.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +/** + * Fails when the committed catalog modules differ from what gen-i18n-resources.mjs would write, e.g. a + * namespace JSON was added without re-running `i18n:gen`, which leaves the new catalog out of every + * bundle. It compares in memory and never writes, so a CI run cannot leave the tree dirty. + */ +import { existsSync, readdirSync, readFileSync } from "node:fs" +import { join } from "node:path" +import { CATALOGS_DIR, i18nDir, renderI18nModules } from "./i18n-modules.mjs" + +const { modules } = renderI18nModules() +const stale = [] +for (const [path, body] of modules) { + const file = join(i18nDir, path) + if (!existsSync(file)) stale.push(`missing: src/i18n/${path}`) + else if (readFileSync(file, "utf8") !== body) stale.push(`differs: src/i18n/${path}`) +} +for (const name of readdirSync(join(i18nDir, CATALOGS_DIR))) { + if (!modules.has(`${CATALOGS_DIR}/${name}`)) stale.push(`not generated: src/i18n/${CATALOGS_DIR}/${name}`) +} + +if (stale.length) { + console.error( + `i18n resources are stale; run \`pnpm --filter @civfix/ui i18n:gen\` and commit the result:\n ${stale.join("\n ")}`, + ) + process.exit(1) +} +console.log(`i18n resources fresh: ${modules.size} generated modules match src/i18n.`) diff --git a/packages/ui/scripts/gen-i18n-resources.mjs b/packages/ui/scripts/gen-i18n-resources.mjs index 30f43d6f..9908722d 100644 --- a/packages/ui/scripts/gen-i18n-resources.mjs +++ b/packages/ui/scripts/gen-i18n-resources.mjs @@ -1,133 +1,14 @@ #!/usr/bin/env node -/** - * Generates the i18n catalog modules from one static import per locale and namespace JSON: Metro cannot - * statically resolve a dynamic `require`, and webpack needs a literal `import()` specifier to emit one - * chunk per locale. A namespace missing for one locale is skipped there, and i18next falls back to `en`. - * - * Outputs, all under src/i18n/: - * catalogs/namespaces.ts the namespace list config.ts feeds to i18next's `ns` - * catalogs/.ts one locale's catalogs (namespace -> JSON) - * catalogs/lazy.ts an `import()` per non-fallback locale, for the web seam only - * resources.ts every locale assembled statically, for the native seam and the parity tests - */ -import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs" -import { dirname, join } from "node:path" -import { fileURLToPath } from "node:url" -import { LocaleEnum } from "@civfix/shared" +import { mkdirSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { CATALOGS_DIR, i18nDir, renderI18nModules } from "./i18n-modules.mjs" -const __dirname = dirname(fileURLToPath(import.meta.url)) -const i18nDir = join(__dirname, "..", "src", "i18n") -const localesDir = join(i18nDir, "locales") -const catalogsDir = join(i18nDir, "catalogs") +const { modules, summary } = renderI18nModules() -// The contract enum, not the folder list: config.ts hands the same list to i18next as supportedLngs, so -// the bundle carries exactly the locales the app can select. It reads the built package, so build -// @civfix/shared first after adding a locale. -const LOCALES = LocaleEnum.options - -// Must equal FALLBACK_LOCALE in src/i18n/resolveLocale.ts: the web bundles it statically so a key never -// renders raw while another locale's chunk is in flight. -const STATIC_LOCALE = "en" -if (!LOCALES.includes(STATIC_LOCALE)) throw new Error(`LocaleEnum no longer contains ${STATIC_LOCALE}`) - -// Sorted for stable output. -const namespaces = readdirSync(join(localesDir, STATIC_LOCALE)) - .filter((f) => f.endsWith(".json")) - .map((f) => f.replace(/\.json$/, "")) - .sort() - -function ident(lng, ns) { - return `${lng}_${ns}`.replace(/[^A-Za-z0-9_]/g, "_") -} - -function namespacesFor(lng) { - const present = new Set( - readdirSync(join(localesDir, lng)) - .filter((f) => f.endsWith(".json")) - .map((f) => f.replace(/\.json$/, "")), - ) - return namespaces.filter((ns) => present.has(ns)) -} - -const banner = `/** - * AUTO-GENERATED by scripts/gen-i18n-resources.mjs: DO NOT EDIT BY HAND. - * - * Re-run \`pnpm --filter @civfix/ui i18n:gen\` after adding or removing a namespace or locale. - */` - -rmSync(catalogsDir, { recursive: true, force: true }) -mkdirSync(catalogsDir, { recursive: true }) - -const written = [] -function write(path, body) { - writeFileSync(path, body) - written.push(path) -} - -write( - join(catalogsDir, "namespaces.ts"), - `${banner} - -/** Every namespace (catalog file) the app loads. \`config.ts\` passes this to i18next's \`ns\`. */ -export const namespaces = [ -${namespaces.map((ns) => ` "${ns}",`).join("\n")} -] as const -`, -) - -let importCount = 0 -for (const lng of LOCALES) { - const nsList = namespacesFor(lng) - importCount += nsList.length - write( - join(catalogsDir, `${lng}.ts`), - `${banner} -import type { ResourceLanguage } from "i18next" - -${nsList.map((ns) => `import ${ident(lng, ns)} from "../locales/${lng}/${ns}.json"`).join("\n")} - -const catalog: ResourceLanguage = { -${nsList.map((ns) => ` "${ns}": ${ident(lng, ns)},`).join("\n")} -} - -export default catalog -`, - ) -} - -const lazyLocales = LOCALES.filter((lng) => lng !== STATIC_LOCALE) -write( - join(catalogsDir, "lazy.ts"), - `${banner} -import type { ResourceLanguage } from "i18next" -import type { SupportedLocale } from "@civfix/shared" - -/** One loader per locale the web does not bundle statically; webpack emits each as its own chunk. */ -export const lazyCatalogs: Record< - Exclude, - () => Promise<{ default: ResourceLanguage }> -> = { -${lazyLocales.map((lng) => ` ${lng}: () => import(/* webpackChunkName: "i18n-${lng}" */ "./${lng}"),`).join("\n")} -} -`, -) - -write( - join(i18nDir, "resources.ts"), - `${banner} -import type { Resource } from "i18next" - -${LOCALES.map((lng) => `import ${lng} from "./catalogs/${lng}"`).join("\n")} - -export { namespaces } from "./catalogs/namespaces" - -/** Every locale assembled statically: locale -> namespace -> catalog object. */ -export const resources: Resource = { -${LOCALES.map((lng) => ` ${lng},`).join("\n")} -} -`, -) +rmSync(join(i18nDir, CATALOGS_DIR), { recursive: true, force: true }) +mkdirSync(join(i18nDir, CATALOGS_DIR), { recursive: true }) +for (const [path, body] of modules) writeFileSync(join(i18nDir, path), body) console.log( - `Wrote ${written.length} files under ${i18nDir}\n locales: ${LOCALES.join(", ")} (static on web: ${STATIC_LOCALE})\n namespaces: ${namespaces.length}\n imports: ${importCount}`, + `Wrote ${modules.size} files under ${i18nDir}\n locales: ${summary.locales.join(", ")} (static on web: ${summary.staticLocale})\n namespaces: ${summary.namespaces.length}\n imports: ${summary.importCount}`, ) diff --git a/packages/ui/scripts/i18n-modules.mjs b/packages/ui/scripts/i18n-modules.mjs new file mode 100644 index 00000000..41f351ea --- /dev/null +++ b/packages/ui/scripts/i18n-modules.mjs @@ -0,0 +1,122 @@ +/** + * Renders the i18n catalog modules from one static import per locale and namespace JSON: Metro cannot + * statically resolve a dynamic `require`, and webpack needs a literal `import()` specifier to emit one + * chunk per locale. A namespace missing for one locale is skipped there, and i18next falls back to `en`. + * + * Modules, keyed by path relative to src/i18n/: + * catalogs/namespaces.ts the namespace list config.ts feeds to i18next's `ns` + * catalogs/.ts one locale's catalogs (namespace -> JSON) + * catalogs/lazy.ts an `import()` per non-fallback locale, for the web seam only + * resources.ts every locale assembled statically, for the native seam and the parity tests + * + * gen-i18n-resources.mjs writes them; check-i18n-resources.mjs fails when the committed files differ. + */ +import { readdirSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { LocaleEnum } from "@civfix/shared" + +export const i18nDir = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "i18n") +export const CATALOGS_DIR = "catalogs" +const localesDir = join(i18nDir, "locales") + +// The contract enum, not the folder list: config.ts hands the same list to i18next as supportedLngs, so +// the bundle carries exactly the locales the app can select. It reads the built package, so build +// @civfix/shared first after adding a locale. +const LOCALES = LocaleEnum.options + +// Must equal FALLBACK_LOCALE in src/i18n/resolveLocale.ts: the web bundles it statically so a key never +// renders raw while another locale's chunk is in flight. +const STATIC_LOCALE = "en" +if (!LOCALES.includes(STATIC_LOCALE)) throw new Error(`LocaleEnum no longer contains ${STATIC_LOCALE}`) + +const jsonNames = (dir) => + readdirSync(dir) + .filter((f) => f.endsWith(".json")) + .map((f) => f.replace(/\.json$/, "")) + +function ident(lng, ns) { + return `${lng}_${ns}`.replace(/[^A-Za-z0-9_]/g, "_") +} + +const banner = `/** + * AUTO-GENERATED by scripts/gen-i18n-resources.mjs: DO NOT EDIT BY HAND. + * + * Re-run \`pnpm --filter @civfix/ui i18n:gen\` after adding or removing a namespace or locale. + */` + +export function renderI18nModules() { + // Sorted for stable output. + const namespaces = jsonNames(join(localesDir, STATIC_LOCALE)).sort() + const namespacesFor = (lng) => { + const present = new Set(jsonNames(join(localesDir, lng))) + return namespaces.filter((ns) => present.has(ns)) + } + + const modules = new Map() + modules.set( + `${CATALOGS_DIR}/namespaces.ts`, + `${banner} + +/** Every namespace (catalog file) the app loads. \`config.ts\` passes this to i18next's \`ns\`. */ +export const namespaces = [ +${namespaces.map((ns) => ` "${ns}",`).join("\n")} +] as const +`, + ) + + let importCount = 0 + for (const lng of LOCALES) { + const nsList = namespacesFor(lng) + importCount += nsList.length + modules.set( + `${CATALOGS_DIR}/${lng}.ts`, + `${banner} +import type { ResourceLanguage } from "i18next" + +${nsList.map((ns) => `import ${ident(lng, ns)} from "../locales/${lng}/${ns}.json"`).join("\n")} + +const catalog: ResourceLanguage = { +${nsList.map((ns) => ` "${ns}": ${ident(lng, ns)},`).join("\n")} +} + +export default catalog +`, + ) + } + + const lazyLocales = LOCALES.filter((lng) => lng !== STATIC_LOCALE) + modules.set( + `${CATALOGS_DIR}/lazy.ts`, + `${banner} +import type { ResourceLanguage } from "i18next" +import type { SupportedLocale } from "@civfix/shared" + +/** One loader per locale the web does not bundle statically; webpack emits each as its own chunk. */ +export const lazyCatalogs: Record< + Exclude, + () => Promise<{ default: ResourceLanguage }> +> = { +${lazyLocales.map((lng) => ` ${lng}: () => import(/* webpackChunkName: "i18n-${lng}" */ "./${lng}"),`).join("\n")} +} +`, + ) + + modules.set( + "resources.ts", + `${banner} +import type { Resource } from "i18next" + +${LOCALES.map((lng) => `import ${lng} from "./catalogs/${lng}"`).join("\n")} + +export { namespaces } from "./catalogs/namespaces" + +/** Every locale assembled statically: locale -> namespace -> catalog object. */ +export const resources: Resource = { +${LOCALES.map((lng) => ` ${lng},`).join("\n")} +} +`, + ) + + return { modules, summary: { locales: LOCALES, staticLocale: STATIC_LOCALE, namespaces, importCount } } +} diff --git a/scripts/check-i18n-call-sites.mjs b/scripts/check-i18n-call-sites.mjs new file mode 100644 index 00000000..1d46188e --- /dev/null +++ b/scripts/check-i18n-call-sites.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +/** + * Resolves every literal translate call in packages/ui and both apps against the en catalogs, per + * namespace. `packages/ui/scripts/check-i18n-keys.mjs` checks the catalogs themselves and matches bare + * keys against the union of every namespace, so it cannot see a key that exists only in another + * namespace, and it never scanned the apps: i18next has no `fallbackNS`, so either mistake renders the + * raw key. + * + * Namespace resolution mirrors packages/ui/src/i18n: `useT(ns)` is `useTranslation(ns)`, `useT()` binds + * config.ts's defaultNS, `ns:key` names a namespace explicitly, `.` nests, and `getFixedT(null, "ns")` + * binds one outside React. It is a regex over comment-stripped text, not a parser, so: + * - an identifier bound to several namespaces in one file resolves if ANY of them has the key; + * - a `t` with no binding in the file (passed in as a prop) falls back to the union of namespaces; + * - `${...}` template keys are dynamic and skipped; + * - a CLDR plural suffix on the catalog key counts as the base key. + */ +import { readdirSync, readFileSync } from "node:fs" +import { dirname, join, relative } from "node:path" +import { fileURLToPath } from "node:url" + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..") +const LOCALES_EN = join(ROOT, "packages/ui/src/i18n/locales/en") +const DEFAULT_NS = "common" +const SCANNED = [ + ["packages/ui", ["src"]], + ["apps/community-web", ["src"]], + ["apps/community-mobile", ["app", "src"]], +] +const SKIPPED_DIRS = new Set(["__tests__", "__testing__", "node_modules", "locales", "catalogs"]) +const PLURAL_SUFFIXES = ["", "_zero", "_one", "_two", "_few", "_many", "_other"] +const MAX_OPTIONS_LOOKAHEAD = 120 + +const leaves = new Map() +const nodes = new Map() +function collectKeys(ns, obj, prefix) { + for (const [k, v] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${k}` : k + if (v !== null && typeof v === "object" && !Array.isArray(v)) { + nodes.get(ns).add(path) + collectKeys(ns, v, path) + } else { + leaves.get(ns).add(path) + } + } +} +for (const file of readdirSync(LOCALES_EN).filter((f) => f.endsWith(".json"))) { + const ns = file.replace(/\.json$/, "") + leaves.set(ns, new Set()) + nodes.set(ns, new Set()) + collectKeys(ns, JSON.parse(readFileSync(join(LOCALES_EN, file), "utf8")), "") +} +const hasLeaf = (ns, key) => PLURAL_SUFFIXES.some((suffix) => leaves.get(ns)?.has(key + suffix)) +const namespacesWith = (key) => [...leaves.keys()].filter((ns) => hasLeaf(ns, key)) + +function sourceFiles(dir) { + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return [] + } + return entries.flatMap((entry) => { + if (SKIPPED_DIRS.has(entry.name)) return [] + const full = join(dir, entry.name) + if (entry.isDirectory()) return sourceFiles(full) + return /\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name) && !entry.name.endsWith(".d.ts") + ? [full] + : [] + }) +} + +// Blanks comments but keeps newlines so reported lines match the file; quoted strings are skipped so a +// "https://" inside one is not taken for a comment. +function stripComments(text) { + let out = "" + let i = 0 + while (i < text.length) { + const c = text[i] + const n = text[i + 1] + if (c === "/" && n === "/") { + while (i < text.length && text[i] !== "\n") i++ + } else if (c === "/" && n === "*") { + i += 2 + while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) out += text[i++] === "\n" ? "\n" : "" + i += 2 + } else if (c === '"' || c === "'" || c === "`") { + out += text[i++] + while (i < text.length && text[i] !== c) { + if (text[i] === "\\") out += text[i++] + out += text[i++] + } + out += text[i++] ?? "" + } else out += text[i++] + } + return out +} + +const lineOf = (text, index) => text.slice(0, index).split("\n").length +const STR = `(?:"([^"\\\\]*)"|'([^'\\\\]*)')` +const escapeId = (id) => id.replace(/\$/g, "\\$") + +function bindings(text) { + const bound = new Map() + const bind = (id, ns) => { + if (!bound.has(id)) bound.set(id, new Set()) + bound.get(id).add(ns) + } + for (const m of text.matchAll(new RegExp(`\\{([^{}]*)\\}\\s*=\\s*useT\\(\\s*(?:${STR})?\\s*\\)`, "g"))) { + const ns = m[2] ?? m[3] ?? DEFAULT_NS + for (const part of m[1].split(",")) { + const renamed = part.trim().match(/^t(?:\s*:\s*([A-Za-z_$][\w$]*))?$/) + if (renamed) bind(renamed[1] ?? "t", ns) + } + } + for (const m of text.matchAll(new RegExp(`(?:const|let)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*useT\\(\\s*(?:${STR})?\\s*\\)\\.t\\b`, "g"))) + bind(m[1], m[2] ?? m[3] ?? DEFAULT_NS) + for (const m of text.matchAll(new RegExp(`(?:const|let)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*[^;\\n]*getFixedT\\(\\s*null\\s*,\\s*${STR}\\s*\\)`, "g"))) + bind(m[1], m[2] ?? m[3]) + return bound +} + +const failures = [] +let checked = 0 + +for (const [workspace, dirs] of SCANNED) { + for (const file of dirs.flatMap((dir) => sourceFiles(join(ROOT, workspace, dir)))) { + const text = stripComments(readFileSync(file, "utf8")) + const rel = relative(ROOT, file) + const bound = bindings(text) + for (const id of new Set(["t", ...bound.keys()])) { + const call = new RegExp( + `(? 0 ? raw.slice(0, colon) : "" + let key = raw + let scope + if (prefix && leaves.has(prefix)) { + scope = [prefix] + key = raw.slice(colon + 1) + } else if (prefix && /^[a-z][a-z-]*$/.test(prefix)) { + failures.push(`${where}: namespace "${prefix}" has no en catalog`) + continue + } else { + const nsOption = options.match(new RegExp(`\\bns\\s*:\\s*${STR}`)) + if (nsOption) scope = [nsOption[1] ?? nsOption[2]] + else if (bound.has(id)) scope = [...bound.get(id)] + } + const candidates = scope ?? [...leaves.keys()] + if (candidates.some((ns) => hasLeaf(ns, key))) continue + if (candidates.some((ns) => nodes.get(ns)?.has(key))) { + if (!/returnObjects/.test(options)) failures.push(`${where}: names an object, not a string (needs returnObjects)`) + continue + } + const elsewhere = namespacesWith(key) + const label = scope ? `namespace ${scope.join("|")}` : "any namespace" + failures.push( + `${where}: not in ${label}${elsewhere.length ? `; it exists in ${elsewhere.join(", ")}` : ", nor any en catalog"}`, + ) + } + } + } +} + +if (failures.length) { + console.error(`i18n call sites that render a raw key (${failures.length}):\n ${failures.join("\n ")}`) + process.exit(1) +} +console.log(`i18n call-site check OK: ${checked} literal keys resolve against en in their namespace.`) From cd074b7e4310ff24f5c25f44e781b69051ec48fb Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 09:14:22 +0000 Subject: [PATCH 07/11] jscpd config with a duplication threshold just above today's --- .jscpd.json | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .jscpd.json diff --git a/.jscpd.json b/.jscpd.json new file mode 100644 index 00000000..0868ada1 --- /dev/null +++ b/.jscpd.json @@ -0,0 +1,45 @@ +{ + "path": [ + "packages/shared/src", + "packages/ui/src", + "packages/ui/scripts", + "apps/community-web/src", + "apps/community-web/functions", + "apps/community-web/scripts", + "apps/community-mobile/app", + "apps/community-mobile/src", + "apps/community-mobile/scripts", + "scripts" + ], + "format": ["typescript", "javascript"], + "formatsExts": { + "typescript": ["ts", "tsx", "mts", "cts"], + "javascript": ["js", "mjs", "cjs"] + }, + "minTokens": 60, + "ignore": [ + "**/node_modules/**", + "**/dist/**", + "**/dist-types/**", + "**/out/**", + "**/.next/**", + "**/.expo/**", + "**/.turbo/**", + "**/ios/**", + "**/android/**", + "**/locales/**", + "**/catalogs/**", + "**/__tests__/**", + "**/__testing__/**", + "**/tests/**", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.test.mjs", + "**/*.d.ts", + "**/i18n/resources.ts" + ], + "gitignore": true, + "reporters": ["console"], + "absolute": false, + "threshold": 0.7 +} From 68fea96e254036fdf18fbe82fb7c2c607fa89fb0 Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 09:14:22 +0000 Subject: [PATCH 08/11] knip config, with unused exports un-exported or removed and unused dev dependencies dropped; root scripts for knip, jscpd and the i18n checks --- apps/community-mobile/package.json | 3 +- .../onboarding/onboardingMapScenes.ts | 2 +- .../onboarding/stages/stageMotion.ts | 4 +- apps/community-mobile/src/lib/links.ts | 2 +- apps/community-mobile/src/lib/locale.ts | 2 +- apps/community-mobile/src/lib/mediaBytes.ts | 4 +- apps/community-mobile/src/push/register.ts | 6 +- .../community-mobile/src/query/cachePolicy.ts | 6 +- apps/community-mobile/src/theme/fonts.ts | 2 +- apps/community-web/package.json | 1 - .../src/components/console/export/index.ts | 2 +- .../console/forms/image-upload/index.ts | 1 - .../src/components/console/url-state.ts | 1 - .../features/host/attendees/roster-filters.ts | 2 +- .../features/host/org/create-org-screen.tsx | 2 +- .../src/features/host/page-builder/blocks.ts | 2 +- apps/community-web/src/lib/locale.ts | 2 +- apps/community-web/src/lib/team-invite.ts | 2 +- knip.jsonc | 79 ++++++++++ package.json | 5 +- packages/shared/package.json | 3 - packages/ui/package.json | 3 +- packages/ui/src/bodies/EmbeddedPost.tsx | 2 +- packages/ui/src/bodies/PostMediaGrid.tsx | 4 +- packages/ui/src/bodies/appearanceSelection.ts | 2 +- packages/ui/src/bodies/calendarModel.ts | 2 +- .../bodies/conversation/ChatLinkEmbeds.tsx | 4 +- .../ui/src/bodies/conversation/civfixLinks.ts | 2 - packages/ui/src/bodies/eventAddressField.ts | 2 +- .../ui/src/bodies/host/RosterCheckinList.tsx | 4 +- .../bodies/host/dashboard/dashboardModel.ts | 2 +- packages/ui/src/bodies/host/hostTeamModel.ts | 2 +- packages/ui/src/bodies/index.ts | 141 +----------------- packages/ui/src/bodies/postComposerMedia.ts | 2 +- packages/ui/src/bodies/postComposerModel.ts | 2 +- .../bodies/reportPicker/reportPickerModel.ts | 6 +- packages/ui/src/bodies/thread/threadModel.ts | 12 +- packages/ui/src/capabilities/fakes/index.ts | 4 +- packages/ui/src/capabilities/index.ts | 4 +- packages/ui/src/charts/chartGeometry.ts | 2 +- packages/ui/src/data/checkinOutbox.ts | 13 +- packages/ui/src/data/composerAttachable.ts | 2 +- packages/ui/src/data/fakes.ts | 4 +- packages/ui/src/data/hooks/analytics.ts | 4 +- packages/ui/src/data/hooks/announcements.ts | 6 +- packages/ui/src/data/hooks/chatRoom.ts | 2 +- packages/ui/src/data/hooks/cleanups.ts | 2 +- packages/ui/src/data/hooks/direct.ts | 2 +- packages/ui/src/data/hooks/host.ts | 12 +- packages/ui/src/data/hooks/nearbyBbox.ts | 2 +- packages/ui/src/data/hooks/orgs.ts | 4 +- packages/ui/src/data/hooks/posts.ts | 14 +- packages/ui/src/data/hooks/volunteer.ts | 2 +- packages/ui/src/data/index.ts | 2 +- packages/ui/src/map/clusterer.ts | 2 +- packages/ui/src/map/pins/DropPin.tsx | 2 +- packages/ui/src/map/pins/index.ts | 6 +- packages/ui/src/map/regionFetch.ts | 6 +- packages/ui/src/nav/routes.ts | 2 +- .../ui/src/primitives/MentionAutocomplete.tsx | 2 +- .../src/primitives/SettingsToggle.styles.ts | 2 +- packages/ui/src/primitives/SocialLinksRow.tsx | 4 +- packages/ui/src/primitives/TextInput.tsx | 2 +- packages/ui/src/primitives/TextInput.types.ts | 3 +- packages/ui/src/primitives/index.ts | 3 +- packages/ui/src/primitives/menuMotion.ts | 4 +- packages/ui/src/primitives/pollDraft.ts | 2 +- packages/ui/src/primitives/qrMatrix.ts | 2 +- .../ui/src/primitives/skeleton/Skeleton.tsx | 8 +- packages/ui/src/primitives/skeleton/index.ts | 4 - .../ui/src/primitives/slideUpSheetModel.ts | 2 +- .../ui/src/primitives/swipeActionsModel.ts | 2 +- .../ui/src/primitives/trendSparklineModel.ts | 2 +- packages/ui/src/report/captureDropTarget.ts | 2 +- packages/ui/src/share/index.ts | 1 - packages/ui/src/share/shareDelivery.ts | 4 +- packages/ui/src/share/shareToDm.ts | 2 +- packages/ui/src/share/useSharePostSession.ts | 2 - packages/ui/src/shell/SearchHeader.tsx | 7 - packages/ui/src/shell/index.ts | 1 - packages/ui/src/shell/useCoarsePointer.ts | 2 +- packages/ui/src/surface/index.ts | 1 - .../src/surface/liquidGlass/glassShaders.ts | 2 +- packages/ui/src/surface/liquidGlass/index.ts | 1 - packages/ui/src/theme/index.ts | 10 +- packages/ui/src/theme/motion.ts | 2 +- packages/ui/src/theme/schemes.ts | 4 +- pnpm-lock.yaml | 28 ---- 88 files changed, 204 insertions(+), 331 deletions(-) create mode 100644 knip.jsonc delete mode 100644 packages/ui/src/shell/SearchHeader.tsx diff --git a/apps/community-mobile/package.json b/apps/community-mobile/package.json index 2c6b0be2..5d1f78c6 100644 --- a/apps/community-mobile/package.json +++ b/apps/community-mobile/package.json @@ -84,8 +84,7 @@ "@types/react": "~19.1.0", "eslint": "^9.16.0", "eslint-config-expo": "~10.0.0", - "typescript": "~5.9.2", - "typescript-eslint": "^8.18.0" + "typescript": "~5.9.2" }, "expo": { "doctor": { diff --git a/apps/community-mobile/src/components/onboarding/onboardingMapScenes.ts b/apps/community-mobile/src/components/onboarding/onboardingMapScenes.ts index 9499d6a8..fe35549b 100644 --- a/apps/community-mobile/src/components/onboarding/onboardingMapScenes.ts +++ b/apps/community-mobile/src/components/onboarding/onboardingMapScenes.ts @@ -115,7 +115,7 @@ function worldPt(zoom: number): number { return TILE_PT * 2 ** zoom } -export function projectPoint(point: GeoPoint, zoom: number): MapFraction { +function projectPoint(point: GeoPoint, zoom: number): MapFraction { const world = worldPt(zoom) const sinLat = Math.sin((point.lat * Math.PI) / 180) return { diff --git a/apps/community-mobile/src/components/onboarding/stages/stageMotion.ts b/apps/community-mobile/src/components/onboarding/stages/stageMotion.ts index 696fbd95..72bfb672 100644 --- a/apps/community-mobile/src/components/onboarding/stages/stageMotion.ts +++ b/apps/community-mobile/src/components/onboarding/stages/stageMotion.ts @@ -14,7 +14,7 @@ import { } from "react-native-reanimated" import { motion } from "@/theme" -export const STAGE_HOLD_MS = 2000 +const STAGE_HOLD_MS = 2000 export const STAGE_STAGGER_MS = motion.gravityStagger export const STAGE_DROP_PX = motion.gravityDrop export const STAGE_RISE_PX = motion.fadeUp.distance @@ -50,7 +50,7 @@ export function segment(progress: number, from: number, to: number): number { return t } -export function stageStepFor(progress: number, stops: readonly number[]): number { +function stageStepFor(progress: number, stops: readonly number[]): number { "worklet" let step = 0 for (let i = 0; i < stops.length; i += 1) { diff --git a/apps/community-mobile/src/lib/links.ts b/apps/community-mobile/src/lib/links.ts index 711fdbbf..cec25f1c 100644 --- a/apps/community-mobile/src/lib/links.ts +++ b/apps/community-mobile/src/lib/links.ts @@ -13,7 +13,7 @@ export const ALLOWED_LINK_PREFIXES = [ "/settings", ] as const -export const ROOT_LINK = "/" +const ROOT_LINK = "/" export function isInternalLink(link: string): boolean { if (!link.startsWith("/")) return false diff --git a/apps/community-mobile/src/lib/locale.ts b/apps/community-mobile/src/lib/locale.ts index 7bd195be..56c015f8 100644 --- a/apps/community-mobile/src/lib/locale.ts +++ b/apps/community-mobile/src/lib/locale.ts @@ -11,7 +11,7 @@ import type { UserDTO } from "@civfix/shared" import { storage } from "@/lib/mmkv" import { LOCALE_KEY } from "@/lib/mmkvKeys" -export function readStoredLocale(): SupportedLocale | null { +function readStoredLocale(): SupportedLocale | null { try { const raw = storage.getString(LOCALE_KEY) if (!raw) return null diff --git a/apps/community-mobile/src/lib/mediaBytes.ts b/apps/community-mobile/src/lib/mediaBytes.ts index 954a0ec2..4ad183ab 100644 --- a/apps/community-mobile/src/lib/mediaBytes.ts +++ b/apps/community-mobile/src/lib/mediaBytes.ts @@ -1,5 +1,5 @@ -export const ALLOWED_IMAGE_CONTENT_TYPES = ["image/jpeg", "image/png", "image/webp"] as const -export const ALLOWED_VIDEO_CONTENT_TYPES = ["video/mp4", "video/quicktime"] as const +const ALLOWED_IMAGE_CONTENT_TYPES = ["image/jpeg", "image/png", "image/webp"] as const +const ALLOWED_VIDEO_CONTENT_TYPES = ["video/mp4", "video/quicktime"] as const const JPEG_SOI = [0xff, 0xd8] as const const PRESERVED_APP_MARKERS = new Set([0xe0, 0xe2, 0xee]) diff --git a/apps/community-mobile/src/push/register.ts b/apps/community-mobile/src/push/register.ts index 9e7e03b2..693d9ef0 100644 --- a/apps/community-mobile/src/push/register.ts +++ b/apps/community-mobile/src/push/register.ts @@ -62,7 +62,7 @@ function easProjectId(): string | undefined { return undefined } -export async function ensureAndroidChannels(): Promise { +async function ensureAndroidChannels(): Promise { if (Platform.OS !== "android") return const t = systemT() const lightColor = tokens.color.brand.bloom @@ -101,8 +101,8 @@ export async function ensureAndroidChannels(): Promise { } } -export const NOTIFICATION_CATEGORY_TICKET = "ticket" -export const NOTIFICATION_CATEGORY_HOST_BROADCAST = "host_broadcast" +const NOTIFICATION_CATEGORY_TICKET = "ticket" +const NOTIFICATION_CATEGORY_HOST_BROADCAST = "host_broadcast" export async function ensureNotificationCategories(): Promise { if (Platform.OS === "web") return diff --git a/apps/community-mobile/src/query/cachePolicy.ts b/apps/community-mobile/src/query/cachePolicy.ts index 0ecd4541..2f3d94e8 100644 --- a/apps/community-mobile/src/query/cachePolicy.ts +++ b/apps/community-mobile/src/query/cachePolicy.ts @@ -8,7 +8,7 @@ export const WRITE_DEBOUNCE_MS = 1000 export const CACHE_SHAPE_VERSION = "2" -export const PERSIST_KEY_PREFIXES: readonly string[] = [ +const PERSIST_KEY_PREFIXES: readonly string[] = [ "notifications", "threads", "cleanups", @@ -19,9 +19,9 @@ export const PERSIST_KEY_PREFIXES: readonly string[] = [ "orgs", ] -export const PERSISTED_REPORTS_SEGMENT = "mine" +const PERSISTED_REPORTS_SEGMENT = "mine" -export const PERSISTED_HOST_SEGMENTS: ReadonlySet = new Set([ +const PERSISTED_HOST_SEGMENTS: ReadonlySet = new Set([ "counters", "ticket-types", "questions", diff --git a/apps/community-mobile/src/theme/fonts.ts b/apps/community-mobile/src/theme/fonts.ts index efd52d3f..0d37a0ab 100644 --- a/apps/community-mobile/src/theme/fonts.ts +++ b/apps/community-mobile/src/theme/fonts.ts @@ -19,7 +19,7 @@ import { import { Baloo2_800ExtraBold } from "@expo-google-fonts/baloo-2" import { createAppFontRegistry } from "./fontAliases" -export const appFontRegistry = createAppFontRegistry({ +const appFontRegistry = createAppFontRegistry({ display: { regular: BricolageGrotesque_400Regular, medium: BricolageGrotesque_500Medium, diff --git a/apps/community-web/package.json b/apps/community-web/package.json index 2360ed6f..5b1cc431 100644 --- a/apps/community-web/package.json +++ b/apps/community-web/package.json @@ -39,7 +39,6 @@ "zustand": "^5.0.2" }, "devDependencies": { - "@eslint/js": "^9.16.0", "@fontsource/baloo-2": "^5", "@fontsource/bricolage-grotesque": "^5", "@fontsource/jetbrains-mono": "^5", diff --git a/apps/community-web/src/components/console/export/index.ts b/apps/community-web/src/components/console/export/index.ts index 97adda88..4012e1f1 100644 --- a/apps/community-web/src/components/console/export/index.ts +++ b/apps/community-web/src/components/console/export/index.ts @@ -1,4 +1,4 @@ -export { csvString, downloadCsv, csvFilename } from "./csv" +export { downloadCsv, csvFilename } from "./csv" export type { CsvRow, CsvValue } from "./csv" export { provenanceRows } from "./provenance" export type { ProvenanceInput, ProvenanceLabels } from "./provenance" diff --git a/apps/community-web/src/components/console/forms/image-upload/index.ts b/apps/community-web/src/components/console/forms/image-upload/index.ts index d3ab9d1e..69f7044c 100644 --- a/apps/community-web/src/components/console/forms/image-upload/index.ts +++ b/apps/community-web/src/components/console/forms/image-upload/index.ts @@ -1,4 +1,3 @@ -export { ImageUploadField } from "./image-upload-field" export type { ConsoleImage, ImageUploadFieldProps, diff --git a/apps/community-web/src/components/console/url-state.ts b/apps/community-web/src/components/console/url-state.ts index cdbd4d63..ae6666e2 100644 --- a/apps/community-web/src/components/console/url-state.ts +++ b/apps/community-web/src/components/console/url-state.ts @@ -34,7 +34,6 @@ export const CONSOLE_PARAM_KEYS = [ ...CONSOLE_PUSH_PARAM_KEYS, ] as const -export type ConsoleReplaceParamKey = (typeof CONSOLE_REPLACE_PARAM_KEYS)[number] export type ConsolePushParamKey = (typeof CONSOLE_PUSH_PARAM_KEYS)[number] export type ConsoleParamKey = (typeof CONSOLE_PARAM_KEYS)[number] diff --git a/apps/community-web/src/features/host/attendees/roster-filters.ts b/apps/community-web/src/features/host/attendees/roster-filters.ts index 615d6bc8..31b88abe 100644 --- a/apps/community-web/src/features/host/attendees/roster-filters.ts +++ b/apps/community-web/src/features/host/attendees/roster-filters.ts @@ -18,7 +18,7 @@ export const ROSTER_FILTERS: readonly RegistrationRosterFilter[] = [ "members", ] -export const ROSTER_SORTS: readonly RegistrationRosterSort[] = RegistrationRosterSortSchema.options +const ROSTER_SORTS: readonly RegistrationRosterSort[] = RegistrationRosterSortSchema.options export function isRosterFilter(value: string | undefined): value is RegistrationRosterFilter { return value !== undefined && (ROSTER_FILTERS as readonly string[]).includes(value) diff --git a/apps/community-web/src/features/host/org/create-org-screen.tsx b/apps/community-web/src/features/host/org/create-org-screen.tsx index b4de5cab..aa79778d 100644 --- a/apps/community-web/src/features/host/org/create-org-screen.tsx +++ b/apps/community-web/src/features/host/org/create-org-screen.tsx @@ -14,7 +14,7 @@ import { useConsoleNavigation } from "../console-context" import { OrgProfileForm } from "./org-profile-form" /** The URL flag the overview reads to open with the post-creation "next steps" card expanded. */ -export const ORG_WELCOME_PARAM = { tab: "welcome" } as const +const ORG_WELCOME_PARAM = { tab: "welcome" } as const export function CreateOrgScreen() { const { t } = useT("host-org") diff --git a/apps/community-web/src/features/host/page-builder/blocks.ts b/apps/community-web/src/features/host/page-builder/blocks.ts index 833e066a..fd90bff6 100644 --- a/apps/community-web/src/features/host/page-builder/blocks.ts +++ b/apps/community-web/src/features/host/page-builder/blocks.ts @@ -18,7 +18,7 @@ export const THEME_ACCENTS: readonly ThemeAccent[] = ["bloom", "moss", "sun", "s let blockCounter = 0 -export function newBlockId(kind: EventPageBlockKind): string { +function newBlockId(kind: EventPageBlockKind): string { blockCounter += 1 return `${kind}-${Date.now().toString(36)}-${blockCounter}` } diff --git a/apps/community-web/src/lib/locale.ts b/apps/community-web/src/lib/locale.ts index 579460f9..c9dc9c1c 100644 --- a/apps/community-web/src/lib/locale.ts +++ b/apps/community-web/src/lib/locale.ts @@ -50,7 +50,7 @@ function syncServerLocale(userId: string, code: SupportedLocale): Promise * Retry a locale settings sync that failed for the now-confirmed user. Runs only on a live-confirmed session * (never the optimistic snapshot, which has no CSRF token yet). */ -export function reconcileUnsyncedLocale(): Promise { +function reconcileUnsyncedLocale(): Promise { const { status, optimistic, user } = useAuthStore.getState() if (status !== "authenticated" || optimistic || !user) return Promise.resolve() if (safeGet("local", UNSYNCED_LOCALE_KEY) !== user.id) return Promise.resolve() diff --git a/apps/community-web/src/lib/team-invite.ts b/apps/community-web/src/lib/team-invite.ts index 7ea99c77..c3c037e1 100644 --- a/apps/community-web/src/lib/team-invite.ts +++ b/apps/community-web/src/lib/team-invite.ts @@ -1,7 +1,7 @@ import { safeGet, safeRemove, safeSet } from "@/lib/browser-storage" import { replaceUrlInPlace } from "@/lib/replace-url" -export const TEAM_INVITE_TOKEN_PARAM = "teamInvite" +const TEAM_INVITE_TOKEN_PARAM = "teamInvite" export const TEAM_INVITE_STASH_KEY = "civfix:event-team-invite" diff --git a/knip.jsonc b/knip.jsonc new file mode 100644 index 00000000..c539ee7c --- /dev/null +++ b/knip.jsonc @@ -0,0 +1,79 @@ +{ + "$schema": "https://unpkg.com/knip@5/schema.json", + "workspaces": { + ".": { + "entry": ["scripts/*.mjs"], + "project": ["scripts/*.mjs"] + }, + "packages/shared": { + "entry": ["src/**/*.test.ts", "__tests__/**/*.test.ts"], + "project": ["src/**/*.ts", "__tests__/**/*.ts"] + }, + "packages/ui": { + "entry": [ + "src/**/*.web.{ts,tsx}", + "src/**/*.native.{ts,tsx}", + "src/**/*.test.{ts,tsx}", + "scripts/*.mjs" + ], + "project": ["src/**/*.{ts,tsx}", "scripts/*.mjs"], + // @civfix/ui is private: its only consumers are the two apps in this repo, so an export of a + // package `exports` subpath that neither app imports is dead surface, not public API. + "includeEntryExports": true + }, + "apps/community-web": { + "entry": [ + // Dev-only gallery routes: next.config.mjs adds the `dev.tsx` page extension under `next dev`. + "src/app/**/{page,layout}.dev.tsx", + "functions/**/*.ts", + "scripts/*.mjs" + ], + "project": ["src/**/*.{ts,tsx}", "functions/**/*.ts", "scripts/*.mjs"], + "ignoreDependencies": [ + // scripts/copy-contract-fonts.mjs copies the font files by path; nothing imports the packages. + "@fontsource/.+", + // eslint.config.mjs resolves the react/jsx-a11y/import plugins through this preset's own + // require, which static analysis cannot follow. + "eslint-config-next", + // A JSDoc type import in postcss.config.mjs; the package ships with postcss. + "postcss-load-config" + ] + }, + "apps/community-mobile": { + "entry": [ + "scripts/*.mjs", + "tests/**/*.test.ts", + "src/**/*.test.ts" + ], + "project": ["app/**/*.{ts,tsx}", "src/**/*.{ts,tsx}", "tests/**/*.ts", "scripts/*.mjs"], + "ignoreDependencies": [ + // Removed by campaign PR 1 (a separate main-based PR); removing them here would conflict with it. + "expo-contacts", + "expo-sms", + // The one-off asset generators in scripts/ ask for these to be installed with --no-save; they + // are never runtime or CI dependencies. + "sharp", + "opentype.js" + ], + // Shell scripts run by package.json scripts, not package binaries. + "ignoreBinaries": ["scripts/store-build.sh", "scripts/prep-archive.sh"] + } + }, + // expo-doctor runs through npx on purpose (CI and the root `doctor` script): it is not a dependency. + "ignoreBinaries": ["expo-doctor"], + // A type used in its own file often names the shape of an exported value (props, a hook's return); + // un-exporting it breaks the ui declaration build (TS4023/TS4058), so only foreign-unused types count. + "ignoreExportsUsedInFile": { "interface": true, "type": true }, + "ignoreIssues": { + // Metro (native) and the web bundler (.web first) pick a platform twin by file extension, which + // knip cannot model: the twin's exports are consumed through the extension-less selector. + "packages/ui/src/**/*.{native,web}.{ts,tsx}": ["exports", "types"], + // Published contract: the DTO schema and its endpoint response alias are both public names that + // registry consumers import. + "packages/shared/src/**": ["duplicates"], + // Deliberate aliases that name one value for two call sites (step-1 and step-3 gate; back and + // trailing header button); tests pin the relation. + "packages/ui/src/bodies/channelWizard.ts": ["duplicates"], + "packages/ui/src/shell/detailHeader.ts": ["duplicates"] + } +} diff --git a/package.json b/package.json index 6a91441f..20a4d462 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,10 @@ "lint": "turbo run lint", "typecheck": "turbo run typecheck", "test": "turbo run test", - "i18n:check": "turbo run i18n:check", + "i18n:check": "turbo run i18n:check && pnpm i18n:check:call-sites", + "i18n:check:call-sites": "node scripts/check-i18n-call-sites.mjs", + "knip": "knip", + "jscpd": "jscpd", "clean": "turbo run clean", "doctor": "cd apps/community-mobile && npx expo-doctor", "changeset": "changeset", diff --git a/packages/shared/package.json b/packages/shared/package.json index 2ae0c06a..0dcd46e0 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -180,10 +180,7 @@ "devDependencies": { "@eslint/js": "^9.16.0", "@types/node": "^22.10.2", - "@typescript-eslint/eslint-plugin": "^8.18.0", - "@typescript-eslint/parser": "^8.18.0", "eslint": "^9.16.0", - "prettier": "^3.4.2", "rimraf": "^6.0.1", "tsup": "^8.3.5", "typescript": "^5.6.3", diff --git a/packages/ui/package.json b/packages/ui/package.json index b60fea29..a29e706f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -88,6 +88,7 @@ "test": "vitest run", "lint": "eslint . --max-warnings=0", "i18n:check": "node scripts/check-i18n-keys.mjs", + "i18n:check:resources": "node scripts/check-i18n-resources.mjs", "i18n:gen": "node scripts/gen-i18n-resources.mjs", "clean": "rimraf dist-types *.tsbuildinfo" }, @@ -156,8 +157,6 @@ "@types/react": "~19.1.0", "@types/react-dom": "~19.1.0", "@types/supercluster": "^7.1.3", - "@typescript-eslint/eslint-plugin": "^8.18.0", - "@typescript-eslint/parser": "^8.18.0", "eslint": "^9.16.0", "eslint-plugin-react-hooks": "^5.2.0", "expo-blur": "~15.0.8", diff --git a/packages/ui/src/bodies/EmbeddedPost.tsx b/packages/ui/src/bodies/EmbeddedPost.tsx index ddf89569..cc44f0ad 100644 --- a/packages/ui/src/bodies/EmbeddedPost.tsx +++ b/packages/ui/src/bodies/EmbeddedPost.tsx @@ -19,7 +19,7 @@ import { PostMediaGrid } from "./PostMediaGrid" import { buildPostIdentity } from "./postCardModel" import { embeddedPostA11yLabel } from "./embeddedPostLabel" -export const EMBEDDED_POST_BODY_CLAMP_LINES = 4 +const EMBEDDED_POST_BODY_CLAMP_LINES = 4 const MEDIA_RADIUS = 12 const MEDIA_MAX_HEIGHT = 220 diff --git a/packages/ui/src/bodies/PostMediaGrid.tsx b/packages/ui/src/bodies/PostMediaGrid.tsx index 2dcb880d..769ec73b 100644 --- a/packages/ui/src/bodies/PostMediaGrid.tsx +++ b/packages/ui/src/bodies/PostMediaGrid.tsx @@ -7,11 +7,11 @@ import { focusRingProps, stopPress, webCursor } from "../theme" import { useT } from "../i18n" import { postMediaA11yLabel } from "./postCardModel" -export function mediaAspect(media: PostDTO["media"][number]): number { +function mediaAspect(media: PostDTO["media"][number]): number { return media.width && media.height ? media.width / media.height : 4 / 3 } -export function postMediaGridThumbUri( +function postMediaGridThumbUri( media: PostDTO["media"][number], split: boolean, ): string | null { diff --git a/packages/ui/src/bodies/appearanceSelection.ts b/packages/ui/src/bodies/appearanceSelection.ts index 712490f3..76613b4e 100644 --- a/packages/ui/src/bodies/appearanceSelection.ts +++ b/packages/ui/src/bodies/appearanceSelection.ts @@ -15,7 +15,7 @@ export type AppearanceCommit = | { kind: "clear" } | { kind: "apply"; preference: AppearancePreference } -export function appearanceSelection( +function appearanceSelection( pending: AppearancePending | null, applied: AppearancePreference, ): AppearancePreference { diff --git a/packages/ui/src/bodies/calendarModel.ts b/packages/ui/src/bodies/calendarModel.ts index 244f28b5..bc0bba6f 100644 --- a/packages/ui/src/bodies/calendarModel.ts +++ b/packages/ui/src/bodies/calendarModel.ts @@ -150,7 +150,7 @@ export function durationChipFor( return DURATION_CHIP_HOURS.find((h) => h * MS_PER_HOUR === elapsed) ?? null } -export function formWallClock(date: Date, time: Date): WallClock { +function formWallClock(date: Date, time: Date): WallClock { return { year: date.getFullYear(), month: date.getMonth() + 1, diff --git a/packages/ui/src/bodies/conversation/ChatLinkEmbeds.tsx b/packages/ui/src/bodies/conversation/ChatLinkEmbeds.tsx index b2e6e05b..182ec025 100644 --- a/packages/ui/src/bodies/conversation/ChatLinkEmbeds.tsx +++ b/packages/ui/src/bodies/conversation/ChatLinkEmbeds.tsx @@ -22,9 +22,9 @@ import { normalizeHandle } from "../mentionText" import { useEmbedGate } from "./chatEmbedScope" import { civfixEntityRef, type CivfixLinkKind, type CivfixLinkRef } from "./civfixLinks" -export const EMBED_CARD_WIDTH = 260 +const EMBED_CARD_WIDTH = 260 -export const EMBED_RESERVED_HEIGHT: Record = { +const EMBED_RESERVED_HEIGHT: Record = { report: 64, event: 68, post: 88, diff --git a/packages/ui/src/bodies/conversation/civfixLinks.ts b/packages/ui/src/bodies/conversation/civfixLinks.ts index ded1b546..528e0a9a 100644 --- a/packages/ui/src/bodies/conversation/civfixLinks.ts +++ b/packages/ui/src/bodies/conversation/civfixLinks.ts @@ -18,8 +18,6 @@ export const CIVFIX_LINK_HOSTS: readonly string[] = [ "www.civfix.dev", ] -export const CIVFIX_APP_SCHEME = "civfix" - export const MAX_EMBEDS_PER_MESSAGE = 2 const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/ diff --git a/packages/ui/src/bodies/eventAddressField.ts b/packages/ui/src/bodies/eventAddressField.ts index 056c077a..57a2fa3c 100644 --- a/packages/ui/src/bodies/eventAddressField.ts +++ b/packages/ui/src/bodies/eventAddressField.ts @@ -2,7 +2,7 @@ import type { EventAddressSource, ResolveAddressResponse } from "@civfix/shared" import { MAX_EVENT_ADDRESS_LENGTH, geocodePointKey, isLocatedPrecision } from "@civfix/shared" import { applyNearPrefix, stripNearPrefix } from "./addressRowModel" -export const MIN_EVENT_ADDRESS_LENGTH = 3 +const MIN_EVENT_ADDRESS_LENGTH = 3 export type EventAddressStatus = "idle" | "resolving" | "resolved" | "manual" diff --git a/packages/ui/src/bodies/host/RosterCheckinList.tsx b/packages/ui/src/bodies/host/RosterCheckinList.tsx index 186780c1..72e84067 100644 --- a/packages/ui/src/bodies/host/RosterCheckinList.tsx +++ b/packages/ui/src/bodies/host/RosterCheckinList.tsx @@ -28,7 +28,7 @@ const CHECK_IN_HIT_SLOP = { top: CHECK_IN_SLOP_Y, bottom: CHECK_IN_SLOP_Y } const NAME_FALLBACKS = { guest: "", deleted: DELETED_USER_LABEL } -export const RosterCheckinRow = React.memo(function RosterCheckinRow({ +const RosterCheckinRow = React.memo(function RosterCheckinRow({ row, canCheckIn, pending, @@ -130,7 +130,7 @@ export interface RosterRowOptions { onUndo: (seatId: string) => void } -export function rosterItemRenderer({ timeZone, canCheckIn, pending, onCheckIn, onUndo }: RosterRowOptions) { +function rosterItemRenderer({ timeZone, canCheckIn, pending, onCheckIn, onUndo }: RosterRowOptions) { return function renderRosterItem({ item }: { item: RosterCheckinItem }) { if (item.kind === "slot-header") { return ( diff --git a/packages/ui/src/bodies/host/dashboard/dashboardModel.ts b/packages/ui/src/bodies/host/dashboard/dashboardModel.ts index 53ee4141..c1a0608c 100644 --- a/packages/ui/src/bodies/host/dashboard/dashboardModel.ts +++ b/packages/ui/src/bodies/host/dashboard/dashboardModel.ts @@ -224,7 +224,7 @@ export type OrgSettableRole = "admin" | "member" export const ORG_SETTABLE_ROLES: readonly OrgSettableRole[] = ["admin", "member"] -export const NO_ORG_MEMBER_ACTIONS: OrgMemberActions = { roles: [], canRemove: false } +const NO_ORG_MEMBER_ACTIONS: OrgMemberActions = { roles: [], canRemove: false } export function orgMemberActions(input: { member: OrganizationMemberDTO diff --git a/packages/ui/src/bodies/host/hostTeamModel.ts b/packages/ui/src/bodies/host/hostTeamModel.ts index 3a266033..12778e96 100644 --- a/packages/ui/src/bodies/host/hostTeamModel.ts +++ b/packages/ui/src/bodies/host/hostTeamModel.ts @@ -45,7 +45,7 @@ export function orderedTeamMembers( export type TeamMemberActions = RosterMemberActions -export const NO_TEAM_MEMBER_ACTIONS: TeamMemberActions = { roles: [], canRemove: false } +const NO_TEAM_MEMBER_ACTIONS: TeamMemberActions = { roles: [], canRemove: false } export function teamMemberActions(input: { member: EventTeamMemberDTO diff --git a/packages/ui/src/bodies/index.ts b/packages/ui/src/bodies/index.ts index c4f0779f..713489f9 100644 --- a/packages/ui/src/bodies/index.ts +++ b/packages/ui/src/bodies/index.ts @@ -1,4 +1,3 @@ -export { ProfileView, ProfileViewSkeleton } from "./ProfileView" export type { ProfileViewProps } from "./ProfileView" export { SocialBody } from "./SocialBody" @@ -12,18 +11,11 @@ export { AppearanceSettingsBody } from "./AppearanceSettingsBody" export { AppearanceOptionList } from "./AppearanceOptionList" export { SettingsBody } from "./SettingsBody" -export { setOnboardingTourPresenter, useOnboardingTourPresenter } from "./onboardingTour" +export { setOnboardingTourPresenter } from "./onboardingTour" export type { OnboardingTourPresenter } from "./onboardingTour" export { SettingsAccountBody } from "./SettingsAccountBody" -export { DonationLinkEditor } from "./settings/DonationLinkEditor" export type { DonationLinkEditorProps } from "./settings/DonationLinkEditor" -export { - donationLinkDirty, - donationLinkFieldError, - normalizeDonationLink, -} from "./settings/donationLinkField" export type { DonationLinkFieldError } from "./settings/donationLinkField" -export { donationLinkFor } from "./donationLink" export type { DonationLink, DonationLinkSource } from "./donationLink" export { SettingsPrivacyBody } from "./SettingsPrivacyBody" @@ -32,11 +24,8 @@ export type { ConnectionsBodyProps } from "./ConnectionsBody" export { LeaderboardBody } from "./LeaderboardBody" export type { LeaderboardBodyProps } from "./LeaderboardBody" -export { LeaderboardRow } from "./LeaderboardRow" export type { LeaderboardRowProps, LeaderboardRowEmphasis } from "./LeaderboardRow" -export { TopVolunteersCard } from "./host/TopVolunteersCard" export type { TopVolunteersCardProps } from "./host/TopVolunteersCard" -export { resolveDiscoveryGeoid } from "./leaderboardGeoid" export type { DiscoveryGeoid } from "./leaderboardGeoid" export { MembersBody } from "./MembersBody" @@ -49,50 +38,15 @@ export { ReportDetailBody } from "./ReportDetailBody" export { FeedBody } from "./FeedBody" export { SearchBody } from "./SearchBody" export { DiscoveryLeaderboard } from "./search/DiscoveryLeaderboard" -export { SearchResults } from "./search/SearchResults" -export { PostCard } from "./PostCard" -export { LinkedEventCard } from "./LinkedEventCard" export { PostComposer } from "./PostComposer" export { openReportFlow } from "./composerCreateFlow" export { PostThreadBody } from "./PostThreadBody" -export { PostMediaGrid, mediaAspect } from "./PostMediaGrid" export type { PostMediaGridProps } from "./PostMediaGrid" -export { ThreadFocalPost, ThreadFocalSkeleton } from "./thread/ThreadFocalPost" export type { ThreadFocalPostProps, ThreadFocalParent } from "./thread/ThreadFocalPost" -export { ThreadReplyRow } from "./thread/ThreadReplyRow" export type { ThreadReplyRowProps } from "./thread/ThreadReplyRow" -export { ThreadEmptyReplies } from "./thread/ThreadEmptyReplies" -export { ReplyComposer } from "./thread/ReplyComposer" export type { ReplyComposerProps, ReplyComposerHandle } from "./thread/ReplyComposer" -export { - MAX_REPLY_DRAFTS, - EMPTY_REPLY_DRAFT, - useReplyDraftStore, -} from "./thread/replyDraftStore" export type { ReplyDraft, ReplyDraftState } from "./thread/replyDraftStore" -export { - MIN_THREAD_VISIBLE, - REPLY_CHROME_FALLBACK, - REPLY_INPUT_MAX_CAP, - REPLY_INPUT_MIN, - REPLY_SURFACE_FRACTION, - REPLY_SURFACE_MIN, - REPLY_THUMBS_H, - REPLY_TRAY_MAX_CAP, - REPLY_TRAY_MIN, - THREAD_AVATAR_SIZE, - THREAD_HEADER_H, - THREAD_RAIL_GAP, - THREAD_RAIL_W, - buildFocalPostStats, - buildReplyComposerHeightPlan, - buildThreadRows, - isOptimisticPostId, - replyComposerState, - threadFocalExcerpt, - threadItems, -} from "./thread/threadModel" export type { FocalPostStat, FocalPostStatKey, @@ -105,18 +59,10 @@ export type { export { PostDetailBody } from "./PostDetailBody" export { SavedPostsBody } from "./SavedPostsBody" -export { ProfileStatsRow, buildProfileStats } from "./ProfileStatsRow" export type { ProfileStatItem, ProfileStatsRowProps, ProfileConnectionKey } from "./ProfileStatsRow" -export { ProfileTabBar } from "./profile/ProfileTabBar" export type { ProfileTabBarProps } from "./profile/ProfileTabBar" -export { ProfileTimelineLane, PROFILE_TIMELINE_BLEED } from "./profile/ProfileTimelineLane" export type { ProfileTimelineLaneProps } from "./profile/ProfileTimelineLane" -export { - PROFILE_DEFAULT_TAB, - PROFILE_TAB_ORDER, - buildProfileTabsModel, -} from "./profileTabsModel" export type { ProfileTabAvailability, ProfileTabDescriptor, @@ -129,7 +75,7 @@ export type { ServiceHoursSectionProps } from "./profile/ServiceHoursSection" export { ServiceHoursCertificateCard } from "./profile/ServiceHoursCertificateCard" export type { ServiceHoursCertificateCardProps } from "./profile/ServiceHoursCertificateCard" -export { formatHours, formatHoursDisplay } from "./formatHours" +export { formatHoursDisplay } from "./formatHours" export { ClusterReportsBody } from "./ClusterReportsBody" @@ -138,75 +84,22 @@ export { EventDetailBody } from "./EventDetailBody" export { CreateCleanupBody } from "./CreateCleanupBody" export type { CreateCleanupBodyProps, CreateCleanupStandaloneHost } from "./CreateCleanupBody" export { EditCleanupBody } from "./EditCleanupBody" -export { CleanupForm } from "./CleanupForm" -export { emptyCleanupForm } from "./cleanupFormModel" export type { CleanupFormValue } from "./cleanupFormModel" -export { SlotEditor } from "./SlotEditor" export type { SlotEditorProps } from "./SlotEditor" -export { EventSlotsBlock } from "./EventSlotsBlock" export type { EventSlotsBlockProps } from "./EventSlotsBlock" -export { EventHoursBlock } from "./EventHoursBlock" export type { EventHoursBlockProps } from "./EventHoursBlock" -export { - addSlotDraft, - buildSlotInputs, - isBlankSlotDraft, - makeSlotKey, - moveSlotDraft, - removeSlotDraft, - removedClaimedCount, - slotDraftError, - slotsFromCleanup, - slotsValid, - updateSlotDraft, -} from "./eventSlotsForm" export type { SlotDraft, SlotDraftError } from "./eventSlotsForm" -export { - boardHasTimedSlots, - claimSlotErrorKey, - currentShifts, - mySlotId, - slotDisplayOrder, - slotRemaining, - slotRowState, - slotWindow, -} from "./eventSlotsModel" export type { SlotRowState, SlotWindow } from "./eventSlotsModel" -export { AddressRow } from "./AddressRow" export type { AddressRowProps, AddressFocusTarget } from "./AddressRow" -export { - addressExternalPlan, - addressMapsOptions, - addressRowAffordances, - appleMapsUrl, - applyNearPrefix, - googleMapsUrl, -} from "./addressRowModel" export type { AddressPoint, AddressMapsOption, AddressExternalPlan } from "./addressRowModel" -export { LinkedReportCard } from "./LinkedReportCard" export type { LinkedReportCardData } from "./linkedReportCards" -export { ReportLinkPicker } from "./ReportLinkPicker" export type { ReportLinkPickerProps } from "./ReportLinkPicker" -export { ReportPicker } from "./reportPicker/ReportPicker" export type { ReportPickerProps } from "./reportPicker/ReportPicker" -export { useReportPickerFilters, PICKER_CATEGORIES } from "./reportPicker/reportPickerFilterStore" export type { ReportPickerFilterState } from "./reportPicker/reportPickerFilterStore" -export { ReportLinkRow } from "./ReportLinkRow" export type { ReportLinkRowProps } from "./ReportLinkRow" -export { - LINKED_REPORTS_COUNT_AT, - NEARBY_PREVIEW, - linkBlockState, - linkSheetMode, - linkedReportsPatch, - linkedReportsSummary, - nearbyReportRows, - sameIdSet, - toggleLinkedReportId, -} from "./linkReportsModel" export type { LinkBlockState, LinkSheetMode, @@ -215,32 +108,10 @@ export type { LinkedReportsSummary, NearbyReportRow, } from "./linkReportsModel" -export { - linkedRefToCardData, - mergeCardEntry, - pinToCardData, - reportToCardData, - sameCardEntry, - useLinkedReportCards, -} from "./linkedReportCards" export type { LinkedReportCardEntry, LinkedReportCardsState } from "./linkedReportCards" -export { reportThumbUrl } from "./reportsListModel" -export { linkedReportHeadline } from "./linkedReportHeadline" export type { LinkedReportHeadline } from "./linkedReportHeadline" -export { FeedShareBlock, FeedSharePreview, FeedShareEventCard } from "./FeedShareBlock" export type { FeedShareBlockProps, FeedSharePreviewProps } from "./FeedShareBlock" -export { - FEED_CAPTION_MAX, - FEED_CAPTION_COUNTER_AT, - buildFeedShareInput, - buildReportPreviewCard, - buildEventPreviewCard, - buildOptimisticFeedSharePost, - classifyFeedShareFailure, - findExistingFeedPost, - personFromAuthUser, -} from "./feedShare" export type { FeedShareValue, FeedShareTarget, @@ -253,7 +124,6 @@ export type { FeedShareAuthUser, OptimisticFeedSharePostArgs, } from "./feedShare" -export { rememberLocalReportThumb, localReportThumb, clearLocalReportThumbs } from "./localReportThumbs" export { NotificationsBody } from "./NotificationsBody" export { NotificationPrefsBody } from "./NotificationPrefsBody" @@ -261,11 +131,10 @@ export { NotificationPrefsBody } from "./NotificationPrefsBody" export { MessagingListBody } from "./MessagingListBody" export { ConversationBody } from "./ConversationBody" export type { ConversationBodyProps } from "./ConversationBody" -export { Bubble, DaySeparator } from "./conversation/MessageBubble" +export { Bubble } from "./conversation/MessageBubble" export type { BubbleProps } from "./conversation/MessageBubble" export { TypingBubble } from "./conversation/TypingBubble" -export { MemberPicker } from "./MemberPicker" export type { MemberPickerProps } from "./MemberPicker" export { NewGroupBody } from "./NewGroupBody" export { NewChannelBody } from "./NewChannelBody" @@ -278,14 +147,10 @@ export { DropPinBody } from "./DropPinBody" export type { DropPinBodyProps } from "./DropPinBody" -export { AddressSearch } from "./AddressSearch" export type { AddressSearchProps, AddressPick } from "./AddressSearch" -export { InlineDateTimePicker } from "./InlineDateTimePicker" export type { InlineDateTimePickerProps } from "./InlineDateTimePicker" -export { RoleChip } from "./RoleChip" export type { RoleChipProps, RoleChipTone } from "./RoleChip" export * from "./host" -export { openHostDashboard } from "./hostDashboardTarget" export type { HostDashboardTarget } from "./hostDashboardTarget" diff --git a/packages/ui/src/bodies/postComposerMedia.ts b/packages/ui/src/bodies/postComposerMedia.ts index 48107835..0b51b902 100644 --- a/packages/ui/src/bodies/postComposerMedia.ts +++ b/packages/ui/src/bodies/postComposerMedia.ts @@ -23,7 +23,7 @@ export function toPostComposerMedia(item: PendingAttachment): PostComposerMedia } } -export function toPendingAttachment(media: PostComposerMedia, index: number): PendingAttachment { +function toPendingAttachment(media: PostComposerMedia, index: number): PendingAttachment { return { id: `${CARRIED_ID_PREFIX}${index}:${media.uri}`, uri: media.uri, diff --git a/packages/ui/src/bodies/postComposerModel.ts b/packages/ui/src/bodies/postComposerModel.ts index 5615fcb8..558b2496 100644 --- a/packages/ui/src/bodies/postComposerModel.ts +++ b/packages/ui/src/bodies/postComposerModel.ts @@ -219,7 +219,7 @@ export function resolveComposerEvent( return availableEvent ? buildComposerEventRef(availableEvent) : null } -export function buildComposerReportRef( +function buildComposerReportRef( report: Pick, linkedAt = new Date().toISOString(), ): LinkedReportRef { diff --git a/packages/ui/src/bodies/reportPicker/reportPickerModel.ts b/packages/ui/src/bodies/reportPicker/reportPickerModel.ts index f53a3ab8..5ab9acd8 100644 --- a/packages/ui/src/bodies/reportPicker/reportPickerModel.ts +++ b/packages/ui/src/bodies/reportPicker/reportPickerModel.ts @@ -12,8 +12,8 @@ import type { LinkedReportCardEntry } from "../linkedReportCards" export const PICKER_ZOOM = 14 export const PICKER_RADIUS_M = 500 -export const PICKER_FETCH_PAD = 0.5 -export const PICKER_FETCH_PRECISION = 3 +const PICKER_FETCH_PAD = 0.5 +const PICKER_FETCH_PRECISION = 3 export const PICKER_MAX_FETCH_SPAN_DEG = PIN_SPAN_MAX_DEG export const PICKER_MAX_PINS = 400 export const PICKER_SEARCH_MIN_CHARS = 3 @@ -323,7 +323,7 @@ export interface PickerSections { matches: PickerRow[] } -export const SECTION_ORDER: readonly PickerRowPlace[] = ["matches", "linked", "view"] +const SECTION_ORDER: readonly PickerRowPlace[] = ["matches", "linked", "view"] export function pickerSections(rows: readonly PickerRow[]): PickerSections { const out: PickerSections = { linked: [], view: [], matches: [] } diff --git a/packages/ui/src/bodies/thread/threadModel.ts b/packages/ui/src/bodies/thread/threadModel.ts index de49ddbc..01ae70c1 100644 --- a/packages/ui/src/bodies/thread/threadModel.ts +++ b/packages/ui/src/bodies/thread/threadModel.ts @@ -3,16 +3,16 @@ import type { PostCounts, PostDTO } from "@civfix/shared" import { clamp } from "../../math/clamp" -export const THREAD_HEADER_H = 52 +const THREAD_HEADER_H = 52 export const MIN_THREAD_VISIBLE = 132 export const REPLY_SURFACE_MIN = 96 -export const REPLY_CHROME_FALLBACK = 92 -export const REPLY_THUMBS_H = 72 +const REPLY_CHROME_FALLBACK = 92 +const REPLY_THUMBS_H = 72 export const REPLY_INPUT_MIN = 40 export const REPLY_INPUT_MAX_CAP = 132 -export const REPLY_TRAY_MIN = 96 -export const REPLY_TRAY_MAX_CAP = 168 -export const REPLY_SURFACE_FRACTION = 0.62 +const REPLY_TRAY_MIN = 96 +const REPLY_TRAY_MAX_CAP = 168 +const REPLY_SURFACE_FRACTION = 0.62 export interface ReplyComposerHeightPlan { available: number diff --git a/packages/ui/src/capabilities/fakes/index.ts b/packages/ui/src/capabilities/fakes/index.ts index 5e567337..e92d5dcd 100644 --- a/packages/ui/src/capabilities/fakes/index.ts +++ b/packages/ui/src/capabilities/fakes/index.ts @@ -94,7 +94,7 @@ function recordOpen(log: string[], url: string): void { export type FakeOpenExternal = OpenExternalCapability & { opened: string[]; openedInApp: string[] } -export function makeFakeOpenExternal(): FakeOpenExternal { +function makeFakeOpenExternal(): FakeOpenExternal { const fake: FakeOpenExternal = { opened: [], openedInApp: [], @@ -112,7 +112,7 @@ export function makeFakeOpenExternal(): FakeOpenExternal { export type FakeOpenInternalHref = OpenInternalHrefCapability & { opened: string[] } -export function makeFakeOpenInternalHref(): FakeOpenInternalHref { +function makeFakeOpenInternalHref(): FakeOpenInternalHref { const fake: FakeOpenInternalHref = { opened: [], open(path: string): boolean { diff --git a/packages/ui/src/capabilities/index.ts b/packages/ui/src/capabilities/index.ts index abdeee70..2ffeeb9a 100644 --- a/packages/ui/src/capabilities/index.ts +++ b/packages/ui/src/capabilities/index.ts @@ -1,4 +1,4 @@ -export { CapabilitiesProvider, useCapabilities } from "./context" +export { CapabilitiesProvider } from "./context" export type { CapabilitiesProviderProps } from "./context" export { useCamera, @@ -30,4 +30,4 @@ export type { GeoPosition, } from "./types" export { webClipboardCapability } from "./webClipboard" -export { makeFakeCapabilities, makeFakeOpenInternalHref } from "./fakes" +export { makeFakeCapabilities } from "./fakes" diff --git a/packages/ui/src/charts/chartGeometry.ts b/packages/ui/src/charts/chartGeometry.ts index 88c59df6..6d2c239b 100644 --- a/packages/ui/src/charts/chartGeometry.ts +++ b/packages/ui/src/charts/chartGeometry.ts @@ -125,7 +125,7 @@ export function xToPixels(x: number, xMin: number, xMax: number, width: number): return round(((x - xMin) / (xMax - xMin)) * width) } -export const AXIS_LABEL_END_FRACTION = 0.85 +const AXIS_LABEL_END_FRACTION = 0.85 export type AxisLabelPlacement = { left: number } | { right: 0; textAlign: "right" } diff --git a/packages/ui/src/data/checkinOutbox.ts b/packages/ui/src/data/checkinOutbox.ts index 57375ad0..7b05c4e6 100644 --- a/packages/ui/src/data/checkinOutbox.ts +++ b/packages/ui/src/data/checkinOutbox.ts @@ -16,7 +16,7 @@ export const CHECKIN_OUTBOX_MAX = 500 const CHECKIN_REPLAY_BATCH = 25 -export const CHECKIN_RETRY_BACKOFF_MS: readonly number[] = [0, 5_000, 30_000, 120_000, 600_000] +const CHECKIN_RETRY_BACKOFF_MS: readonly number[] = [0, 5_000, 30_000, 120_000, 600_000] export type CheckinOutboxMethod = "scan" | "manual" @@ -202,15 +202,6 @@ const REFUSAL_ORDER: readonly CheckinOutcome[] = ( Object.keys(REPLAY_OUTCOME_KIND) as CheckinOutcome[] ).filter((outcome) => REPLAY_OUTCOME_KIND[outcome] === "refused") -export const EMPTY_REPLAY_REPORT: CheckinReplayReport = { - sent: 0, - refusals: [], - held: 0, - forbidden: 0, - discarded: 0, - retry: 0, -} - export function summarizeReplay(events: readonly ReplayEvent[]): CheckinReplayReport { const refused = new Map() let sent = 0 @@ -367,7 +358,7 @@ export function serializeOutbox(state: CheckinOutboxState, ownerId: string): str return JSON.stringify({ owner: ownerId, entries: state.entries }) } -export async function dropLegacyOutbox(persistence: OutboxStore): Promise { +async function dropLegacyOutbox(persistence: OutboxStore): Promise { try { await persistence.del(CHECKIN_OUTBOX_KEY) } catch { diff --git a/packages/ui/src/data/composerAttachable.ts b/packages/ui/src/data/composerAttachable.ts index 9899fd60..6b83799b 100644 --- a/packages/ui/src/data/composerAttachable.ts +++ b/packages/ui/src/data/composerAttachable.ts @@ -1,7 +1,7 @@ import type { ReportStatus } from "@civfix/shared" import { hasEventEnded, type EventWindowLike } from "@civfix/shared/host" -export const ATTACHABLE_REPORT_STATUSES = [ +const ATTACHABLE_REPORT_STATUSES = [ "submitted", "published", "acknowledged", diff --git a/packages/ui/src/data/fakes.ts b/packages/ui/src/data/fakes.ts index c67dab53..fcfca80b 100644 --- a/packages/ui/src/data/fakes.ts +++ b/packages/ui/src/data/fakes.ts @@ -21,7 +21,7 @@ import type { import type { AuthState, ChatSocketLike, DataContextValue } from "./types" import { applyToggle } from "./hooks/posts" -export const FAKE_APPROXIMATE_LOCATION: GetApproximateLocationResponse = { +const FAKE_APPROXIMATE_LOCATION: GetApproximateLocationResponse = { lat: 34.0522, lng: -118.2437, radiusKm: 25, @@ -279,7 +279,7 @@ export interface FakePostApi { * into `makeFakeApiClient`'s Proxy so `makeFakeDataContext()` serves the feed / thread / saves / profile * bodies believable data with no backend. Each list method returns a single page with `nextCursor: null`. */ -export function makeFakePostApi(): FakePostApi { +function makeFakePostApi(): FakePostApi { return { homeFeed: async (query) => { const filter = query?.filter ?? "all" diff --git a/packages/ui/src/data/hooks/analytics.ts b/packages/ui/src/data/hooks/analytics.ts index 781dbf72..5e279905 100644 --- a/packages/ui/src/data/hooks/analytics.ts +++ b/packages/ui/src/data/hooks/analytics.ts @@ -8,9 +8,9 @@ import type { import { useApi, useAuthState } from "../context" import { queryKeys } from "../keys" -export const EVENT_ANALYTICS_STALE_MS = 60_000 +const EVENT_ANALYTICS_STALE_MS = 60_000 -export const HOST_ANALYTICS_SUMMARY_RANGE: AnalyticsRange = "30d" +const HOST_ANALYTICS_SUMMARY_RANGE: AnalyticsRange = "30d" export function useHostAnalyticsSummary( orgId: string | null, diff --git a/packages/ui/src/data/hooks/announcements.ts b/packages/ui/src/data/hooks/announcements.ts index acb055a2..c0441262 100644 --- a/packages/ui/src/data/hooks/announcements.ts +++ b/packages/ui/src/data/hooks/announcements.ts @@ -11,15 +11,15 @@ import { ANNOUNCEMENT_CHANNELS } from "@civfix/shared" import { useApi } from "../context" import { queryKeys } from "../keys" -export const ANNOUNCEMENTS_PAGE_SIZE = 20 +const ANNOUNCEMENTS_PAGE_SIZE = 20 export const AUDIENCE_PREVIEW_DEBOUNCE_MS = 400 -export function audienceKey(audience: AnnouncementAudience): string { +function audienceKey(audience: AnnouncementAudience): string { return audience.kind === "slots" ? `slots:${[...audience.ids].sort().join(",")}` : audience.kind } -export function invalidateEventAnnouncements(qc: QueryClient, cleanupId: string): void { +function invalidateEventAnnouncements(qc: QueryClient, cleanupId: string): void { void qc.invalidateQueries({ queryKey: queryKeys.eventAnnouncementsRoot(cleanupId) }) } diff --git a/packages/ui/src/data/hooks/chatRoom.ts b/packages/ui/src/data/hooks/chatRoom.ts index 34d549f1..54992f7e 100644 --- a/packages/ui/src/data/hooks/chatRoom.ts +++ b/packages/ui/src/data/hooks/chatRoom.ts @@ -8,7 +8,7 @@ import type { import type { ApiClient } from "@civfix/shared/client" import type { InfiniteData } from "@tanstack/react-query" -export const CHAT_PAGE_SIZE = 30 +const CHAT_PAGE_SIZE = 30 export type ChatHistoryData = InfiniteData diff --git a/packages/ui/src/data/hooks/cleanups.ts b/packages/ui/src/data/hooks/cleanups.ts index 74a69a09..8866a5b5 100644 --- a/packages/ui/src/data/hooks/cleanups.ts +++ b/packages/ui/src/data/hooks/cleanups.ts @@ -131,7 +131,7 @@ function patchCleanupInFlatLists( mapCleanupRows(qc, id, (c) => ({ ...c, joined: next.joined, going: next.going })) } -export function nudgeCleanupInFlatLists(qc: QueryClient, id: string, joined: boolean): void { +function nudgeCleanupInFlatLists(qc: QueryClient, id: string, joined: boolean): void { mapCleanupRows(qc, id, (c) => ({ ...c, joined, going: Math.max(0, c.going + (joined ? 1 : -1)) })) } diff --git a/packages/ui/src/data/hooks/direct.ts b/packages/ui/src/data/hooks/direct.ts index f280f7e0..4152fd72 100644 --- a/packages/ui/src/data/hooks/direct.ts +++ b/packages/ui/src/data/hooks/direct.ts @@ -47,7 +47,7 @@ export function useUserSearch(rawQuery: string) { return { data, isLoading, isPending, isError, refetch, term: trimmed } } -export function useOpenDm() { +function useOpenDm() { const api = useApi() const qc = useQueryClient() return useMutation({ diff --git a/packages/ui/src/data/hooks/host.ts b/packages/ui/src/data/hooks/host.ts index accd948c..1d01166b 100644 --- a/packages/ui/src/data/hooks/host.ts +++ b/packages/ui/src/data/hooks/host.ts @@ -37,11 +37,11 @@ import { queryKeys } from "../keys" import { listItems } from "../types" import { cleanupDetailFilters, invalidateCleanupLists } from "./cleanups" -export const HOST_COUNTERS_POLL_MS = 20_000 +const HOST_COUNTERS_POLL_MS = 20_000 -export const INSIGHTS_LIVE_POLL_MS = HOST_COUNTERS_POLL_MS +const INSIGHTS_LIVE_POLL_MS = HOST_COUNTERS_POLL_MS -export const INSIGHTS_IDLE_POLL_MS = 5 * 60_000 +const INSIGHTS_IDLE_POLL_MS = 5 * 60_000 export const HOST_ROSTER_PAGE_SIZE = 50 @@ -55,7 +55,7 @@ const NO_LEGACY_CAPABILITIES: ReadonlySet = hostCapabilities({ orgRole: null, }) -export function legacyRoleCapabilities(cleanup: HostStandingView): ReadonlySet { +function legacyRoleCapabilities(cleanup: HostStandingView): ReadonlySet { if (cleanup.myCapabilities.length > 0) return NO_LEGACY_CAPABILITIES return hostCapabilities({ eventRole: cleanup.myRole ?? null, orgRole: null }) } @@ -374,13 +374,13 @@ export function hostedEventRows( return (pages ?? []).flatMap((page) => page.items) } -export function invalidateMyEventInvites(qc: QueryClient): void { +function invalidateMyEventInvites(qc: QueryClient): void { void qc.invalidateQueries({ queryKey: queryKeys.myEventInvites }) void qc.invalidateQueries({ queryKey: queryKeys.hostedEventsRoot }) void qc.invalidateQueries({ queryKey: queryKeys.notificationsRoot }) } -export const MY_EVENT_INVITES_PAGE_SIZE = 50 +const MY_EVENT_INVITES_PAGE_SIZE = 50 export function useMyEventInvites(opts: { enabled?: boolean } = {}) { const api = useApi() diff --git a/packages/ui/src/data/hooks/nearbyBbox.ts b/packages/ui/src/data/hooks/nearbyBbox.ts index aa4d0711..478651aa 100644 --- a/packages/ui/src/data/hooks/nearbyBbox.ts +++ b/packages/ui/src/data/hooks/nearbyBbox.ts @@ -13,7 +13,7 @@ export function roundNearbyCoord(n: number): number { return Math.round(n * factor) / factor } -export function pinSpanScale(padLat: number, padLng: number): number { +function pinSpanScale(padLat: number, padLng: number): number { const span = Math.max(2 * padLng, 4 * padLat) if (!Number.isFinite(span) || span <= PIN_SPAN_MAX_DEG) return 1 return PIN_SPAN_MAX_DEG / span diff --git a/packages/ui/src/data/hooks/orgs.ts b/packages/ui/src/data/hooks/orgs.ts index 6c5b5933..83d524c5 100644 --- a/packages/ui/src/data/hooks/orgs.ts +++ b/packages/ui/src/data/hooks/orgs.ts @@ -121,7 +121,7 @@ export function organizationEventRows( return rows } -export const ORG_MEMBERS_PAGE_SIZE = 50 +const ORG_MEMBERS_PAGE_SIZE = 50 export function useOrganizationMembers(orgId: string | undefined, opts: { enabled?: boolean } = {}) { const api = useApi() @@ -220,7 +220,7 @@ export function useRemoveOrganizationMember(orgId: string | undefined) { }) } -export function invalidateMyOrgInvites(qc: QueryClient): void { +function invalidateMyOrgInvites(qc: QueryClient): void { void qc.invalidateQueries({ queryKey: queryKeys.myOrgInvites }) void qc.invalidateQueries({ queryKey: queryKeys.myOrganizations }) void qc.invalidateQueries({ queryKey: queryKeys.notificationsRoot }) diff --git a/packages/ui/src/data/hooks/posts.ts b/packages/ui/src/data/hooks/posts.ts index 3ede6df6..7abb8b2d 100644 --- a/packages/ui/src/data/hooks/posts.ts +++ b/packages/ui/src/data/hooks/posts.ts @@ -81,7 +81,7 @@ function isInfinitePosts(v: unknown): v is InfiniteData { * Patch the target post in EVERY infinite LIST cache under the `["posts"]` prefix (home feed, replies, * user posts, saves). Any entry that is not an infinite post list is left untouched. */ -export function patchPostInListCaches( +function patchPostInListCaches( qc: QueryClient, postId: string, patch: (post: PostDTO) => PostDTO, @@ -134,7 +134,7 @@ export function patchPostCountsInCaches( } /** Find the target post's current value in any list cache under the prefix (for the rollback snapshot). */ -export function findPostInLists(qc: QueryClient, postId: string): PostDTO | undefined { +function findPostInLists(qc: QueryClient, postId: string): PostDTO | undefined { for (const [, data] of qc.getQueriesData>({ queryKey: queryKeys.postsRoot, })) { @@ -148,7 +148,7 @@ export function findPostInLists(qc: QueryClient, postId: string): PostDTO | unde } /** Prepend a post to page 0 of a specific infinite list cache (no-op when the list is not loaded). */ -export function prependToList(qc: QueryClient, key: readonly unknown[], post: PostDTO): void { +function prependToList(qc: QueryClient, key: readonly unknown[], post: PostDTO): void { qc.setQueryData>(key, (prev) => isInfinitePosts(prev) ? { @@ -162,7 +162,7 @@ export function prependToList(qc: QueryClient, key: readonly unknown[], post: Po } /** Append a post to the LAST page of a specific infinite list cache (replies are chronological). */ -export function appendToList(qc: QueryClient, key: readonly unknown[], post: PostDTO): void { +function appendToList(qc: QueryClient, key: readonly unknown[], post: PostDTO): void { qc.setQueryData>(key, (prev) => isInfinitePosts(prev) ? { @@ -185,7 +185,7 @@ function withoutPost( } /** Remove a post (by id) from every page of a specific infinite list cache. */ -export function removeFromList(qc: QueryClient, key: readonly unknown[], postId: string): void { +function removeFromList(qc: QueryClient, key: readonly unknown[], postId: string): void { qc.setQueryData>(key, (prev) => withoutPost(prev, postId)) } @@ -194,13 +194,13 @@ function removeFromAllLists(qc: QueryClient, postId: string): void { } /** True when a specific infinite list cache already contains the post. */ -export function listHasPost(qc: QueryClient, key: readonly unknown[], postId: string): boolean { +function listHasPost(qc: QueryClient, key: readonly unknown[], postId: string): boolean { const data = qc.getQueryData>(key) return isInfinitePosts(data) ? data.pages.some((p) => p.items.some((it) => it.id === postId)) : false } /** Replace the optimistic (temp-id) post with the authoritative server post in a specific list cache. */ -export function replaceInList( +function replaceInList( qc: QueryClient, key: readonly unknown[], tempId: string, diff --git a/packages/ui/src/data/hooks/volunteer.ts b/packages/ui/src/data/hooks/volunteer.ts index d8ac1299..e343f34f 100644 --- a/packages/ui/src/data/hooks/volunteer.ts +++ b/packages/ui/src/data/hooks/volunteer.ts @@ -45,7 +45,7 @@ import { coercePages } from "../infinitePages" import { queryKeys } from "../keys" import { cleanupDetailFilters } from "./cleanups" -export const LEADERBOARD_PAGE_SIZE = 50 +const LEADERBOARD_PAGE_SIZE = 50 /** * The leaderboard is a slow-moving aggregate (hours are credited after an event ends, not continuously), diff --git a/packages/ui/src/data/index.ts b/packages/ui/src/data/index.ts index c35ff371..aff37fb5 100644 --- a/packages/ui/src/data/index.ts +++ b/packages/ui/src/data/index.ts @@ -143,7 +143,7 @@ export { uploadMedia } from "./uploadMedia" export type { UploadProgress } from "./uploadMedia" export { fetchApproximateLocation } from "./fetchApproximateLocation" export { NOW_TICK_MS, useNow } from "./useNow" -export { SEARCH_DEBOUNCE_MS, useDebouncedValue } from "./hooks/useDebouncedValue" +export { useDebouncedValue } from "./hooks/useDebouncedValue" export { useEventBoundaryRefresh } from "./useEventBoundaryRefresh" export { FIRST_NAME_MAX, LAST_NAME_MAX, firstRunModel, splitName, stripHandlePrefix } from "./firstRunModel" export type { FirstRunAvailability, FirstRunInput, FirstRunView } from "./firstRunModel" diff --git a/packages/ui/src/map/clusterer.ts b/packages/ui/src/map/clusterer.ts index a052bef1..61d5b300 100644 --- a/packages/ui/src/map/clusterer.ts +++ b/packages/ui/src/map/clusterer.ts @@ -48,7 +48,7 @@ export const CLUSTER_LIST_ZOOM = 11 export const CLUSTER_MIN_POINTS = 3 export const CLUSTER_ZOOM_STEP = 2 export const AGGREGATE_EXPAND_ZOOM = 10 -export const KEY_PRECISION = 5 +const KEY_PRECISION = 5 export const WORLD_BBOX: BBox = { west: -180, south: -85, east: 180, north: 85 } export type MapClusterIndex = Supercluster diff --git a/packages/ui/src/map/pins/DropPin.tsx b/packages/ui/src/map/pins/DropPin.tsx index 1c6715de..4c622bef 100644 --- a/packages/ui/src/map/pins/DropPin.tsx +++ b/packages/ui/src/map/pins/DropPin.tsx @@ -4,7 +4,7 @@ import { useTheme, pinGlow } from "../../theme" import { PinSvg } from "./PinSvg" import { DROP_PIN_GLYPH } from "./glyphs" -export const DROP_PIN_SIZE = 52 +const DROP_PIN_SIZE = 52 export const DropPin = React.memo(function DropPin({ size = DROP_PIN_SIZE }: { size?: number }) { const t = useTheme() diff --git a/packages/ui/src/map/pins/index.ts b/packages/ui/src/map/pins/index.ts index e7b650b4..a66572d3 100644 --- a/packages/ui/src/map/pins/index.ts +++ b/packages/ui/src/map/pins/index.ts @@ -4,16 +4,14 @@ export { TeardropPin } from "./TeardropPin" export { EventPin } from "./EventPin" export { BlendPin } from "./BlendPin" export { ClusterBubble } from "./ClusterBubble" -export { DropPin, DROP_PIN_SIZE } from "./DropPin" +export { DropPin } from "./DropPin" export { PinSvg } from "./PinSvg" -export { PIN_GLYPHS, DROP_PIN_GLYPH, glyphForCategory } from "./glyphs" +export { glyphForCategory } from "./glyphs" export { pinAppearanceFor, eventPinTarget, reportPinTarget, clusterToneFor, - clusterBubbleAppearance, inkOnFill, - pinOutlineFor, } from "./appearance" export type { PinTarget, PinAppearance, ClusterTone, ClusterBubbleAppearance } from "./appearance" diff --git a/packages/ui/src/map/regionFetch.ts b/packages/ui/src/map/regionFetch.ts index 98dad5de..11d2bdb0 100644 --- a/packages/ui/src/map/regionFetch.ts +++ b/packages/ui/src/map/regionFetch.ts @@ -13,11 +13,11 @@ import { AGGREGATE_EXPAND_ZOOM } from "./clusterer" * region recorded as loaded would suppress every later refetch inside it. */ export const PAD_FACTOR = 0.6 -export const EDGE_MARGIN = 0.12 -export const MAX_COARSENESS = 3.5 +const EDGE_MARGIN = 0.12 +const MAX_COARSENESS = 3.5 /** Tapping an aggregate bubble zooms to AGGREGATE_EXPAND_ZOOM precisely so the refetch there returns pins. */ export const SERVER_PIN_ZOOM = AGGREGATE_EXPAND_ZOOM -export const VIEWPORT_REFERENCE_TILES = 8 +const VIEWPORT_REFERENCE_TILES = 8 const MAX_IMPLIED_ZOOM = 22 const WORLD_LNG_SPAN = 360 diff --git a/packages/ui/src/nav/routes.ts b/packages/ui/src/nav/routes.ts index 0fd303a2..7ded683b 100644 --- a/packages/ui/src/nav/routes.ts +++ b/packages/ui/src/nav/routes.ts @@ -58,7 +58,7 @@ const ROOM_KINDS: readonly RoomKind[] = ["dm", "report", "cleanup", "group"] const PREFIXED_THREAD_ROOMS: readonly RoomKind[] = ["dm", "report", "group"] // A body that renders its own header. It is blank after trim, so the shell draws no DetailBar for it. -export const BODY_OWNS_HEADER = " " +const BODY_OWNS_HEADER = " " type Captures = Readonly> diff --git a/packages/ui/src/primitives/MentionAutocomplete.tsx b/packages/ui/src/primitives/MentionAutocomplete.tsx index e71b2bab..ba331a98 100644 --- a/packages/ui/src/primitives/MentionAutocomplete.tsx +++ b/packages/ui/src/primitives/MentionAutocomplete.tsx @@ -30,7 +30,7 @@ function isJurisdiction(c: MentionCandidate): c is JurisdictionMentionCandidate return (c as JurisdictionMentionCandidate).kind === "jurisdiction" } -export const MENTION_TRAY_MAX_HEIGHT = 192 +const MENTION_TRAY_MAX_HEIGHT = 192 const TRAILING_MENTION = /(^|\s)@([^\s@]*)$/ diff --git a/packages/ui/src/primitives/SettingsToggle.styles.ts b/packages/ui/src/primitives/SettingsToggle.styles.ts index 7ee63713..ebe705a6 100644 --- a/packages/ui/src/primitives/SettingsToggle.styles.ts +++ b/packages/ui/src/primitives/SettingsToggle.styles.ts @@ -2,7 +2,7 @@ import type { ViewStyle } from "react-native" import { alpha } from "../theme/alpha" import type { Theme } from "../theme" -export const TRACK_OFF_ALPHA = 0.16 +const TRACK_OFF_ALPHA = 0.16 export function trackOffColor(t: Theme): string { return alpha(t.colors.text, TRACK_OFF_ALPHA) diff --git a/packages/ui/src/primitives/SocialLinksRow.tsx b/packages/ui/src/primitives/SocialLinksRow.tsx index 34ec122e..741ab6bf 100644 --- a/packages/ui/src/primitives/SocialLinksRow.tsx +++ b/packages/ui/src/primitives/SocialLinksRow.tsx @@ -16,8 +16,8 @@ import { SocialGlyph } from "./SocialGlyph" import { useToast } from "./toastContext" import { presentSocialPlatforms } from "./socialLinksModel" -export const SOCIAL_LINK_HIT_SIZE = 38 -export const SOCIAL_LINK_GLYPH_SIZE = 20 +const SOCIAL_LINK_HIT_SIZE = 38 +const SOCIAL_LINK_GLYPH_SIZE = 20 const SOCIAL_LINK_HIT_SLOP = hitSlopToTarget(SOCIAL_LINK_HIT_SIZE) export interface SocialLinksRowProps { diff --git a/packages/ui/src/primitives/TextInput.tsx b/packages/ui/src/primitives/TextInput.tsx index 1c0f8ec4..9f64d8ef 100644 --- a/packages/ui/src/primitives/TextInput.tsx +++ b/packages/ui/src/primitives/TextInput.tsx @@ -1,2 +1,2 @@ export { TextInput } from "./TextInput.web" -export type { TextInputProps, TextInputHandle } from "./TextInput.types" +export type { TextInputProps } from "./TextInput.types" diff --git a/packages/ui/src/primitives/TextInput.types.ts b/packages/ui/src/primitives/TextInput.types.ts index 73b50dbb..d3089142 100644 --- a/packages/ui/src/primitives/TextInput.types.ts +++ b/packages/ui/src/primitives/TextInput.types.ts @@ -1,4 +1,3 @@ -import type { TextInputProps as RNTextInputProps, TextInput as RNTextInput } from "react-native" +import type { TextInputProps as RNTextInputProps } from "react-native" export type TextInputProps = RNTextInputProps -export type TextInputHandle = RNTextInput diff --git a/packages/ui/src/primitives/index.ts b/packages/ui/src/primitives/index.ts index c1a58800..3f08db5f 100644 --- a/packages/ui/src/primitives/index.ts +++ b/packages/ui/src/primitives/index.ts @@ -88,7 +88,7 @@ export { KeyboardPinnedFooter } from "./KeyboardPinnedFooter" export type { KeyboardPinnedFooterProps } from "./KeyboardPinnedFooter" export { TextInput } from "./TextInput" -export type { TextInputProps, TextInputHandle } from "./TextInput.types" +export type { TextInputProps } from "./TextInput.types" export { TextField } from "./TextField" export type { TextFieldProps } from "./TextField" @@ -186,7 +186,6 @@ export { shouldCaptureActionsSwipe, shouldSnapOpen, SWIPE_ACTION_WIDTH_PX, - SWIPE_ACTIONS_SNAP_RATIO, } from "./swipeActionsModel" export { MessageContextMenu } from "./MessageContextMenu" export type { diff --git a/packages/ui/src/primitives/menuMotion.ts b/packages/ui/src/primitives/menuMotion.ts index ce57a829..1f80386f 100644 --- a/packages/ui/src/primitives/menuMotion.ts +++ b/packages/ui/src/primitives/menuMotion.ts @@ -4,7 +4,7 @@ import { motion, type TimingRecipe } from "../theme" import { useReducedMotion } from "../theme/useReducedMotion" import { MENU_SCALE_FROM, menuOrigin, type MenuOrigin } from "./menuMotionModel" -export { MENU_SCALE_FROM, menuOrigin } +export { menuOrigin } export type { AnchorRect, MenuCardRect, MenuOrigin } from "./menuMotionModel" declare const process: { env: { NODE_ENV?: string } } @@ -26,7 +26,7 @@ export interface MenuMotionRecipes { exit: TimingRecipe } -export const MENU_RECIPES: MenuMotionRecipes = { enter: motion.menuIn, exit: motion.menuOut } +const MENU_RECIPES: MenuMotionRecipes = { enter: motion.menuIn, exit: motion.menuOut } export function useMenuMotion({ visible, diff --git a/packages/ui/src/primitives/pollDraft.ts b/packages/ui/src/primitives/pollDraft.ts index ae8989b2..2d0ad1aa 100644 --- a/packages/ui/src/primitives/pollDraft.ts +++ b/packages/ui/src/primitives/pollDraft.ts @@ -52,7 +52,7 @@ export function removeOption(draft: PollDraft, idx: number): PollDraft { return { ...draft, options: draft.options.filter((_, i) => i !== idx) } } -export function optionTexts(draft: PollDraft): string[] { +function optionTexts(draft: PollDraft): string[] { return draft.options.map((o) => o.text) } diff --git a/packages/ui/src/primitives/qrMatrix.ts b/packages/ui/src/primitives/qrMatrix.ts index 2076fc5b..9d8902e9 100644 --- a/packages/ui/src/primitives/qrMatrix.ts +++ b/packages/ui/src/primitives/qrMatrix.ts @@ -2,7 +2,7 @@ import qrcode from "qrcode-generator" export const QR_QUIET_ZONE = 4 -export const QR_ERROR_CORRECTION = "M" as const +const QR_ERROR_CORRECTION = "M" as const export interface QrPath { path: string diff --git a/packages/ui/src/primitives/skeleton/Skeleton.tsx b/packages/ui/src/primitives/skeleton/Skeleton.tsx index 6c1e00f7..81273787 100644 --- a/packages/ui/src/primitives/skeleton/Skeleton.tsx +++ b/packages/ui/src/primitives/skeleton/Skeleton.tsx @@ -10,8 +10,8 @@ import { } from "react-native" import { makeThemedStyles, space, radius as radiusScale, useReducedMotion } from "../../theme" -export const SKELETON_PULSE_MS = 820 -export const SKELETON_PULSE_MIN = 0.45 +const SKELETON_PULSE_MS = 820 +const SKELETON_PULSE_MIN = 0.45 const pulseValue = new Animated.Value(1) let pulseSubscribers = 0 @@ -48,7 +48,7 @@ function releasePulse(): void { pulseValue.setValue(1) } -export function useSkeletonPulse(): Animated.Value | 1 { +function useSkeletonPulse(): Animated.Value | 1 { const reducedMotion = useReducedMotion() const animate = reducedMotion !== true useEffect(() => { @@ -124,7 +124,7 @@ export interface SkeletonRowKindSpec { readonly align: "center" | "flex-start" } -export const SKELETON_ROW_KINDS: Record = { +const SKELETON_ROW_KINDS: Record = { person: { avatar: 46, avatarRadius: 23, diff --git a/packages/ui/src/primitives/skeleton/index.ts b/packages/ui/src/primitives/skeleton/index.ts index 983135d6..92760cc4 100644 --- a/packages/ui/src/primitives/skeleton/index.ts +++ b/packages/ui/src/primitives/skeleton/index.ts @@ -5,10 +5,6 @@ export { SkeletonList, SkeletonGroup, SkeletonDetail, - useSkeletonPulse, - SKELETON_ROW_KINDS, - SKELETON_PULSE_MS, - SKELETON_PULSE_MIN, } from "./Skeleton" export type { SkeletonBlockProps, diff --git a/packages/ui/src/primitives/slideUpSheetModel.ts b/packages/ui/src/primitives/slideUpSheetModel.ts index 4d862a49..4dd3d4ed 100644 --- a/packages/ui/src/primitives/slideUpSheetModel.ts +++ b/packages/ui/src/primitives/slideUpSheetModel.ts @@ -1,6 +1,6 @@ export const SLIDE_UP_DISMISS_DISTANCE = 96 export const SLIDE_UP_DISMISS_VELOCITY = 0.9 -export const SLIDE_UP_CAPTURE_SLOP = 4 +const SLIDE_UP_CAPTURE_SLOP = 4 export type SlideUpDragOutcome = "dismiss" | "settle" diff --git a/packages/ui/src/primitives/swipeActionsModel.ts b/packages/ui/src/primitives/swipeActionsModel.ts index e9a219d1..4e5f780d 100644 --- a/packages/ui/src/primitives/swipeActionsModel.ts +++ b/packages/ui/src/primitives/swipeActionsModel.ts @@ -1,7 +1,7 @@ import { SWIPE_CAPTURE_SLOP_PX, startsInBackSwipeEdge } from "./backSwipeEdge" export const SWIPE_ACTION_WIDTH_PX = 72 -export const SWIPE_ACTIONS_SNAP_RATIO = 0.5 +const SWIPE_ACTIONS_SNAP_RATIO = 0.5 export const SWIPE_ACTIONS_FLING_VX = 0.35 export function actionsWidth(count: number): number { diff --git a/packages/ui/src/primitives/trendSparklineModel.ts b/packages/ui/src/primitives/trendSparklineModel.ts index 9dab257c..744e6dce 100644 --- a/packages/ui/src/primitives/trendSparklineModel.ts +++ b/packages/ui/src/primitives/trendSparklineModel.ts @@ -3,7 +3,7 @@ import type { SeriesPoint } from "@civfix/shared" export const SPARK_BAR_MAX_WIDTH = 24 export const SPARK_BAR_GAP = 2 export const SPARK_BAR_MIN_HEIGHT = 2 -export const SPARK_BAR_RADIUS = 4 +const SPARK_BAR_RADIUS = 4 export const SPARK_LINE_WIDTH = 2 export const SPARK_END_DOT_RADIUS = 4 diff --git a/packages/ui/src/report/captureDropTarget.ts b/packages/ui/src/report/captureDropTarget.ts index 80fcbe32..6c15bd79 100644 --- a/packages/ui/src/report/captureDropTarget.ts +++ b/packages/ui/src/report/captureDropTarget.ts @@ -5,4 +5,4 @@ export { captureDropTargetStyle, captureDropActiveStyleFor, } from "./captureDropTarget.web" -export { isDroppableType, type CaptureDropTarget, type DroppedItem } from "./captureDropTarget.shared" +export type { CaptureDropTarget, DroppedItem } from "./captureDropTarget.shared" diff --git a/packages/ui/src/share/index.ts b/packages/ui/src/share/index.ts index 6d297ed9..659a1ca4 100644 --- a/packages/ui/src/share/index.ts +++ b/packages/ui/src/share/index.ts @@ -1,4 +1,3 @@ export { SharePostProvider } from "./SharePostProvider" export type { SharePostProviderProps } from "./SharePostProvider" -export { useSharePost } from "./SharePostContext" export type { SharePostHandle, SharePostTarget } from "./types" diff --git a/packages/ui/src/share/shareDelivery.ts b/packages/ui/src/share/shareDelivery.ts index abfb02f0..1dff89b3 100644 --- a/packages/ui/src/share/shareDelivery.ts +++ b/packages/ui/src/share/shareDelivery.ts @@ -91,7 +91,7 @@ function unlessAborted(work: Promise, signal: AbortSignal | undefined): Pr }) } -export function waitForSocketOpen( +function waitForSocketOpen( socket: ChatSocketLike, timeoutMs: number, signal?: AbortSignal, @@ -133,7 +133,7 @@ interface AckWaiter { extend: (timeoutMs: number) => void } -export function ackWaiter( +function ackWaiter( socket: ChatSocketLike, clientId: string, roomId: string, diff --git a/packages/ui/src/share/shareToDm.ts b/packages/ui/src/share/shareToDm.ts index d3636645..7825b642 100644 --- a/packages/ui/src/share/shareToDm.ts +++ b/packages/ui/src/share/shareToDm.ts @@ -3,7 +3,7 @@ import { threadRoomId } from "../data/threadRoom" export const SHARE_DM_MAX_RECIPIENTS = 10 -export const SHARE_DM_RECENT_LIMIT = 20 +const SHARE_DM_RECENT_LIMIT = 20 export interface ShareRecipient { id: string diff --git a/packages/ui/src/share/useSharePostSession.ts b/packages/ui/src/share/useSharePostSession.ts index 1cbc512b..9efcb555 100644 --- a/packages/ui/src/share/useSharePostSession.ts +++ b/packages/ui/src/share/useSharePostSession.ts @@ -173,5 +173,3 @@ export function useSharePostSession({ visible, target, onClose }: SharePostSessi signIn, } } - -export type SharePostSession = ReturnType diff --git a/packages/ui/src/shell/SearchHeader.tsx b/packages/ui/src/shell/SearchHeader.tsx deleted file mode 100644 index e67e711c..00000000 --- a/packages/ui/src/shell/SearchHeader.tsx +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Platform selector for tooling only (tsc, the .d.ts build, plain Node): Metro and Next resolve the - * `.native` / `.web` siblings first. Re-exports the gorhom-free web seam through the explicit `.web` - * specifier so it never resolves back into this module. - */ -export { SearchHeader } from "./SearchHeader.web" -export type { SearchHeaderProps } from "./SearchHeader.types" diff --git a/packages/ui/src/shell/index.ts b/packages/ui/src/shell/index.ts index e9125d13..1025cc32 100644 --- a/packages/ui/src/shell/index.ts +++ b/packages/ui/src/shell/index.ts @@ -29,7 +29,6 @@ export type { SwipeBackDecision, SwipeBackTokens, } from "./pageStackModel" -export { SearchHeader } from "./SearchHeader" export type { SearchHeaderProps } from "./SearchHeader.types" export { DetailBar } from "./DetailBar" export type { DetailBarProps } from "./DetailBar" diff --git a/packages/ui/src/shell/useCoarsePointer.ts b/packages/ui/src/shell/useCoarsePointer.ts index 45236522..b37ea3e0 100644 --- a/packages/ui/src/shell/useCoarsePointer.ts +++ b/packages/ui/src/shell/useCoarsePointer.ts @@ -13,7 +13,7 @@ export function readCoarsePointer(): boolean { } /** The static export prerenders with no window; hydration must see the same `false` the markup was built with. */ -export function readCoarsePointerOnServer(): boolean { +function readCoarsePointerOnServer(): boolean { return false } diff --git a/packages/ui/src/surface/index.ts b/packages/ui/src/surface/index.ts index 5cece0bd..5098b1f2 100644 --- a/packages/ui/src/surface/index.ts +++ b/packages/ui/src/surface/index.ts @@ -8,6 +8,5 @@ export { DOCK_H, DOCK_GAP, DOCK_MORPH_SHRINK, - DOCK_MORPH_TOP_OFFSET, } from "./liquidGlass" export type { LiquidGlassDockProps, DockShapes } from "./liquidGlass" diff --git a/packages/ui/src/surface/liquidGlass/glassShaders.ts b/packages/ui/src/surface/liquidGlass/glassShaders.ts index 046d6be6..48829d31 100644 --- a/packages/ui/src/surface/liquidGlass/glassShaders.ts +++ b/packages/ui/src/surface/liquidGlass/glassShaders.ts @@ -9,7 +9,7 @@ */ import { Skia, type SkRuntimeEffect } from "@shopify/react-native-skia" -export const LIQUID_GLASS_SKSL = ` +const LIQUID_GLASS_SKSL = ` uniform vec4 leftBox; // left shape rect: x, y, w, h (local pts) uniform vec4 rightBox; // right shape rect: x, y, w, h uniform vec4 clearBox; // trailing dismiss (X) circle rect (parked off-screen unless focused) diff --git a/packages/ui/src/surface/liquidGlass/index.ts b/packages/ui/src/surface/liquidGlass/index.ts index 2c9ded53..f67ecc43 100644 --- a/packages/ui/src/surface/liquidGlass/index.ts +++ b/packages/ui/src/surface/liquidGlass/index.ts @@ -7,6 +7,5 @@ export { DOCK_H, DOCK_GAP, DOCK_MORPH_SHRINK, - DOCK_MORPH_TOP_OFFSET, } from "./liquidGlassModel" export type { DockShapes } from "./liquidGlassModel" diff --git a/packages/ui/src/theme/index.ts b/packages/ui/src/theme/index.ts index 1c5f7f2a..c27e0299 100644 --- a/packages/ui/src/theme/index.ts +++ b/packages/ui/src/theme/index.ts @@ -11,7 +11,6 @@ export { noShadow, radius, space, - lineHeight, motion, themes, themeFor, @@ -29,11 +28,7 @@ export type { export { APPEARANCE_PREFERENCES, DEFAULT_APPEARANCE_PREFERENCE, - DEFAULT_COLOR_SCHEME, - colorSchemes, - shadowSchemes, isAppearancePreference, - resolveSchemeName, resolveColorScheme, } from "./schemes" export type { @@ -108,7 +103,7 @@ export function pinGlow( export { categoryColor } export type { CategoryColorKey } -export { MOTION, EASE_STANDARD_CSS } from "./motion" +export { EASE_STANDARD_CSS } from "./motion" export { PRESSED_OPACITY, PRESSED_OPACITY_SUBTLE, @@ -119,7 +114,7 @@ export { export { MIN_TOUCH_TARGET, hitSlopToTarget } from "./touchTarget" export type { TimingRecipe, EaseTuple } from "./motion" -export { tint, wash } from "./color" +export { wash } from "./color" export const wordmarkColors: readonly string[] = [ tokens.color.brand.bloom, @@ -156,7 +151,6 @@ export { inputFocusedStyle, ROW_A11Y_PROPS, WEB_ROW_FOCUS_INSET, - FOCUS_RING_FOOTPRINT, FOCUS_RING_COLOR, FOCUS_RING_OFFSET, FOCUS_RING_WIDTH, diff --git a/packages/ui/src/theme/motion.ts b/packages/ui/src/theme/motion.ts index 017a79f1..a5aff706 100644 --- a/packages/ui/src/theme/motion.ts +++ b/packages/ui/src/theme/motion.ts @@ -1,7 +1,7 @@ export const EASE_STANDARD = [0.22, 1, 0.36, 1] as const export const EASE_STANDARD_CSS = "cubic-bezier(0.22,1,0.36,1)" -export const EASE_GRAVITY = [0.5, 0, 0.3, 1.35] as const +const EASE_GRAVITY = [0.5, 0, 0.3, 1.35] as const export type EaseTuple = readonly [number, number, number, number] export interface TimingRecipe { diff --git a/packages/ui/src/theme/schemes.ts b/packages/ui/src/theme/schemes.ts index cbdaf57c..e6342317 100644 --- a/packages/ui/src/theme/schemes.ts +++ b/packages/ui/src/theme/schemes.ts @@ -6,7 +6,7 @@ import { type ShadowTokens, } from "@civfix/shared/tokens" -export { colorSchemes, shadowSchemes } +export { colorSchemes } export type { ColorSchemeName, ColorPalette, ShadowTokens } export type AppearancePreference = "system" | ColorSchemeName @@ -23,7 +23,7 @@ export function isAppearancePreference(value: unknown): value is AppearancePrefe return typeof value === "string" && (APPEARANCE_PREFERENCES as readonly string[]).includes(value) } -export function isColorSchemeName(value: unknown): value is ColorSchemeName { +function isColorSchemeName(value: unknown): value is ColorSchemeName { return typeof value === "string" && (COLOR_SCHEMES as readonly string[]).includes(value) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2f0a58b..e52db229 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -233,9 +233,6 @@ importers: typescript: specifier: ~5.9.2 version: 5.9.3 - typescript-eslint: - specifier: ^8.18.0 - version: 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) apps/community-web: dependencies: @@ -297,9 +294,6 @@ importers: specifier: ^5.0.2 version: 5.0.15(@types/react@19.1.17)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)) devDependencies: - '@eslint/js': - specifier: ^9.16.0 - version: 9.39.5 '@fontsource/baloo-2': specifier: ^5 version: 5.3.0 @@ -385,18 +379,9 @@ importers: '@types/node': specifier: ^22.10.2 version: 22.20.1 - '@typescript-eslint/eslint-plugin': - specifier: ^8.18.0 - version: 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/parser': - specifier: ^8.18.0 - version: 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) eslint: specifier: ^9.16.0 version: 9.39.5(jiti@2.7.0) - prettier: - specifier: ^3.4.2 - version: 3.9.6 rimraf: specifier: ^6.0.1 version: 6.1.3 @@ -479,12 +464,6 @@ importers: '@types/supercluster': specifier: ^7.1.3 version: 7.1.3 - '@typescript-eslint/eslint-plugin': - specifier: ^8.18.0 - version: 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/parser': - specifier: ^8.18.0 - version: 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) eslint: specifier: ^9.16.0 version: 9.39.5(jiti@2.7.0) @@ -5712,11 +5691,6 @@ packages: engines: {node: '>=10.13.0'} hasBin: true - prettier@3.9.6: - resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} - engines: {node: '>=14'} - hasBin: true - pretty-bytes@5.6.0: resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} engines: {node: '>=6'} @@ -13320,8 +13294,6 @@ snapshots: prettier@2.8.8: {} - prettier@3.9.6: {} - pretty-bytes@5.6.0: {} pretty-format@27.5.1: From 61491a50dcb7366b3a0b461383ca5729fd9b1b85 Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 09:30:45 +0000 Subject: [PATCH 09/11] test: source pins follow the un-exported names --- apps/community-web/src/lib/locate.test.ts | 19 ++++++++++++++++--- packages/ui/src/__tests__/sourceGuards.ts | 6 +++--- .../bodies/__tests__/capabilityGates.test.ts | 2 +- .../bodies/__tests__/chatLinkEmbeds.test.ts | 4 ++-- .../__tests__/onboardingTourSurfaces.test.ts | 3 +-- .../__tests__/reportsSearchBackfill.test.ts | 2 +- .../host/__tests__/hostModeSurface.test.ts | 2 +- .../__tests__/rosterCheckinSurface.test.ts | 2 +- .../__tests__/reportDetailSource.ts | 4 ---- .../reportFlow/__tests__/reportFlowSource.ts | 2 +- .../__tests__/openInternalHrefSeam.test.ts | 2 +- 11 files changed, 28 insertions(+), 20 deletions(-) diff --git a/apps/community-web/src/lib/locate.test.ts b/apps/community-web/src/lib/locate.test.ts index aa45d5ed..257277b2 100644 --- a/apps/community-web/src/lib/locate.test.ts +++ b/apps/community-web/src/lib/locate.test.ts @@ -29,9 +29,22 @@ let geo: ReturnType async function freshModules() { vi.resetModules() - const locate = await import("@/lib/locate") - const webGeo = await import("@/lib/web-geolocation") - return { ...locate, webGeolocation: webGeo.webGeolocation } + const { + GEO_POSITION_OPTIONS, + getBrowserPosition, + getSharedBrowserFix, + resolvePreciseCenter, + resolvePreciseCenterAfterPrompt, + } = await import("@/lib/locate") + const { webGeolocation } = await import("@/lib/web-geolocation") + return { + GEO_POSITION_OPTIONS, + getBrowserPosition, + getSharedBrowserFix, + resolvePreciseCenter, + resolvePreciseCenterAfterPrompt, + webGeolocation, + } } beforeEach(() => { diff --git a/packages/ui/src/__tests__/sourceGuards.ts b/packages/ui/src/__tests__/sourceGuards.ts index ad82db59..622802e8 100644 --- a/packages/ui/src/__tests__/sourceGuards.ts +++ b/packages/ui/src/__tests__/sourceGuards.ts @@ -78,7 +78,7 @@ export function expectWrittenInLayoutEffect(source: string, assignment: string): expect(effect?.deps ?? null, `the layout effect writing ${assignment} must run after every commit`).toBeNull() } -export const THEME_TOUCH_TARGET_IMPORT = +const THEME_TOUCH_TARGET_IMPORT = /import \{[^}]*\bMIN_TOUCH_TARGET\b[^}]*\} from "[./]+\/theme(?:\/touchTarget)?"/ /** @@ -92,7 +92,7 @@ export function expectThemeTouchTarget(source: string): number { return MIN_TOUCH_TARGET } -export const THEME_HIT_SLOP_IMPORT = +const THEME_HIT_SLOP_IMPORT = /import \{[^}]*\bhitSlopToTarget\b[^}]*\} from "[./]+\/theme(?:\/touchTarget)?"/ /** @@ -120,7 +120,7 @@ export function folderSourceFiles(dir: URL, entry?: URL): URL[] { return entry ? [entry, ...parts] : parts } -export function folderSource(dir: URL, entry?: URL): string { +function folderSource(dir: URL, entry?: URL): string { return folderSourceFiles(dir, entry) .map((file) => readFileSync(file, "utf8")) .join("\n") diff --git a/packages/ui/src/bodies/__tests__/capabilityGates.test.ts b/packages/ui/src/bodies/__tests__/capabilityGates.test.ts index 10a3ace4..defec402 100644 --- a/packages/ui/src/bodies/__tests__/capabilityGates.test.ts +++ b/packages/ui/src/bodies/__tests__/capabilityGates.test.ts @@ -82,7 +82,7 @@ describe("the host gates read capabilities, not role strings", () => { describe("the invite inbox mutations", () => { it("invalidate the inbox, the hosted list and the notification feed together", () => { - expect(hooks).toContain("export function invalidateMyEventInvites(qc: QueryClient): void {") + expect(hooks).toContain("\nfunction invalidateMyEventInvites(qc: QueryClient): void {") for (const key of ["myEventInvites", "hostedEventsRoot", "notificationsRoot"]) { expect(hooks, `invalidateMyEventInvites misses ${key}`).toMatch( new RegExp(`queryKeys\\.${key}`), diff --git a/packages/ui/src/bodies/__tests__/chatLinkEmbeds.test.ts b/packages/ui/src/bodies/__tests__/chatLinkEmbeds.test.ts index d7a76d1b..89463322 100644 --- a/packages/ui/src/bodies/__tests__/chatLinkEmbeds.test.ts +++ b/packages/ui/src/bodies/__tests__/chatLinkEmbeds.test.ts @@ -105,7 +105,7 @@ describe("the embed cards reuse the house cards and the cached data hooks", () = describe("the embed states never break the bubble", () => { it("reserves each kind's own card height while it loads and falls back to the plain link on an error", () => { - expect(EMBEDS).toMatch(/export const EMBED_RESERVED_HEIGHT: Record = \{\s*report: 64,\s*event: 68,\s*post: 88,\s*person: 64,\s*org: 64,\s*\}/) + expect(EMBEDS).toMatch(/^const EMBED_RESERVED_HEIGHT: Record = \{\s*report: 64,\s*event: 68,\s*post: 88,\s*person: 64,\s*org: 64,\s*\}/m) expect(EMBEDS).toContain("") expect(EMBEDS).toMatch(/if \(data !== undefined\) return <>\{render\(data\)\}<\/>\s*if \(query\.isLoading\) return \s*return { }) it("gives the card column a stable width so scroll anchoring holds", () => { - expect(EMBEDS).toContain("export const EMBED_CARD_WIDTH = 260") + expect(EMBEDS).toContain("\nconst EMBED_CARD_WIDTH = 260") expect(EMBEDS).toMatch(/host: \{\s*width: EMBED_CARD_WIDTH,\s*maxWidth: "100%"/) }) diff --git a/packages/ui/src/bodies/__tests__/onboardingTourSurfaces.test.ts b/packages/ui/src/bodies/__tests__/onboardingTourSurfaces.test.ts index 858297f0..8f333699 100644 --- a/packages/ui/src/bodies/__tests__/onboardingTourSurfaces.test.ts +++ b/packages/ui/src/bodies/__tests__/onboardingTourSurfaces.test.ts @@ -44,11 +44,10 @@ describe("the onboarding-tour presenter seam", () => { expect(calls).toBe(1) }) - it("exports the host-facing setter and the subscribing hook from the bodies barrel, types included", () => { + it("exports the host-facing setter from the bodies barrel, types included", () => { const barrel = read("../index.ts") expect(barrel).toContain("setOnboardingTourPresenter") expect(barrel).not.toContain("getOnboardingTourPresenter") - expect(barrel).toContain("useOnboardingTourPresenter") expect(barrel).toContain('export type { OnboardingTourPresenter } from "./onboardingTour"') }) }) diff --git a/packages/ui/src/bodies/__tests__/reportsSearchBackfill.test.ts b/packages/ui/src/bodies/__tests__/reportsSearchBackfill.test.ts index 9065f235..882f4614 100644 --- a/packages/ui/src/bodies/__tests__/reportsSearchBackfill.test.ts +++ b/packages/ui/src/bodies/__tests__/reportsSearchBackfill.test.ts @@ -13,7 +13,7 @@ type Backfill = (input: { fetchNextPageFailed: boolean }) => string const searchBackfill = (model as unknown as { searchBackfill?: Backfill }).searchBackfill -const MAX = (model as unknown as { SEARCH_BACKFILL_MAX_PAGES?: number }).SEARCH_BACKFILL_MAX_PAGES +const MAX = model.SEARCH_BACKFILL_MAX_PAGES const typing = { filtering: true, diff --git a/packages/ui/src/bodies/host/__tests__/hostModeSurface.test.ts b/packages/ui/src/bodies/host/__tests__/hostModeSurface.test.ts index 6e9df166..d2ed18be 100644 --- a/packages/ui/src/bodies/host/__tests__/hostModeSurface.test.ts +++ b/packages/ui/src/bodies/host/__tests__/hostModeSurface.test.ts @@ -195,7 +195,7 @@ describe("the tickets row is gated on the platform, not on a capability every ho describe("insights wiring", () => { it("polls on the live cadence and idles otherwise", () => { expect(hooks).toContain("opts.live ? INSIGHTS_LIVE_POLL_MS : INSIGHTS_IDLE_POLL_MS") - expect(hooks).toContain("export const INSIGHTS_LIVE_POLL_MS = HOST_COUNTERS_POLL_MS") + expect(hooks).toContain("\nconst INSIGHTS_LIVE_POLL_MS = HOST_COUNTERS_POLL_MS") expect(body).toContain('live: clockPhase === "live"') }) diff --git a/packages/ui/src/bodies/host/__tests__/rosterCheckinSurface.test.ts b/packages/ui/src/bodies/host/__tests__/rosterCheckinSurface.test.ts index 42bd2d53..d357e4b8 100644 --- a/packages/ui/src/bodies/host/__tests__/rosterCheckinSurface.test.ts +++ b/packages/ui/src/bodies/host/__tests__/rosterCheckinSurface.test.ts @@ -45,7 +45,7 @@ describe("visibleRosterFilters", () => { describe("there is ONE check-in row in the package", () => { it("lives in RosterCheckinList and is reached from both host surfaces", () => { - expect(list).toContain("export const RosterCheckinRow") + expect(list).toContain("\nconst RosterCheckinRow") expect(paged).toContain("") expect(block).toContain(" readFileSync(file, "utf8")) .join("\n") } - -export function reportDetailPart(name: string): string { - return readFileSync(join(DETAIL_DIR, name), "utf8") -} diff --git a/packages/ui/src/bodies/reportFlow/__tests__/reportFlowSource.ts b/packages/ui/src/bodies/reportFlow/__tests__/reportFlowSource.ts index 5a7c14be..9d6a6be1 100644 --- a/packages/ui/src/bodies/reportFlow/__tests__/reportFlowSource.ts +++ b/packages/ui/src/bodies/reportFlow/__tests__/reportFlowSource.ts @@ -9,7 +9,7 @@ const BODY = fileURLToPath(new URL("../../ReportFlowBody.tsx", import.meta.url)) * Every source file the report wizard renders from, body first, so a guard cannot miss a split-out part and * a first-occurrence anchor still lands in the body's own JSX. */ -export function reportFlowSourceFiles(): string[] { +function reportFlowSourceFiles(): string[] { const parts = readdirSync(FLOW_DIR, { withFileTypes: true }) .filter((entry) => entry.isFile() && /\.tsx?$/.test(entry.name)) .map((entry) => join(FLOW_DIR, entry.name)) diff --git a/packages/ui/src/capabilities/__tests__/openInternalHrefSeam.test.ts b/packages/ui/src/capabilities/__tests__/openInternalHrefSeam.test.ts index 6f7b6494..07fadb72 100644 --- a/packages/ui/src/capabilities/__tests__/openInternalHrefSeam.test.ts +++ b/packages/ui/src/capabilities/__tests__/openInternalHrefSeam.test.ts @@ -26,7 +26,7 @@ describe("openInternalHref is an OPTIONAL capability, like every other host seam const barrel = code(read("../index.ts")) expect(barrel).toContain("useOpenInternalHref") expect(barrel).toContain("OpenInternalHrefCapability") - expect(barrel).toContain("makeFakeOpenInternalHref") + expect(barrel).toContain("makeFakeCapabilities") }) }) From a62a0fbb63ef92a92022cdf60432cb1f7261e344 Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 09:14:22 +0000 Subject: [PATCH 10/11] ci runs the i18n freshness and call-site checks, knip and jscpd in the packages job --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29f2223e..a7809845 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,12 @@ jobs: - run: node scripts/check-shared-version.mjs - run: pnpm turbo run build typecheck lint test --filter="./packages/*" - run: pnpm --filter @civfix/ui i18n:check + # Repo-wide gates (apps included) live here because this job already installs every workspace; + # the resources check imports the @civfix/shared dist the build step above produced. + - run: pnpm --filter @civfix/ui i18n:check:resources + - run: pnpm i18n:check:call-sites + - run: pnpm knip + - run: pnpm jscpd web: name: community-web From 2426b095e911d08e5c9f047806b5ed014e1639ff Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 09:30:33 +0000 Subject: [PATCH 11/11] readme lists the new checks, the call-site checker has fixture tests, and the unused prettier config goes with its dependency --- README.md | 4 +- knip.jsonc | 2 +- packages/shared/.prettierignore | 3 - packages/shared/.prettierrc | 8 --- scripts/check-i18n-call-sites.mjs | 3 +- scripts/check-i18n-call-sites.test.mjs | 76 ++++++++++++++++++++++++++ 6 files changed, 82 insertions(+), 14 deletions(-) delete mode 100644 packages/shared/.prettierignore delete mode 100644 packages/shared/.prettierrc create mode 100644 scripts/check-i18n-call-sites.test.mjs diff --git a/README.md b/README.md index 7dee7592..30b74002 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,9 @@ pnpm build # @civfix/shared -> dist (tsup), @civfix/ui -> dist-types, we pnpm typecheck pnpm lint pnpm test -pnpm i18n:check # @civfix/ui locale-key check +pnpm i18n:check # locale keys complete across languages, and every t(...) call site in ui, web and mobile resolves +pnpm knip # unused files, exports and dependencies +pnpm jscpd # duplicated code, fails above the configured threshold pnpm clean pnpm doctor # expo-doctor on the mobile app ``` diff --git a/knip.jsonc b/knip.jsonc index c539ee7c..42530f46 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -47,7 +47,7 @@ ], "project": ["app/**/*.{ts,tsx}", "src/**/*.{ts,tsx}", "tests/**/*.ts", "scripts/*.mjs"], "ignoreDependencies": [ - // Removed by campaign PR 1 (a separate main-based PR); removing them here would conflict with it. + // Declared but unused; they are being removed in a separate PR. Drop this entry once they are gone. "expo-contacts", "expo-sms", // The one-off asset generators in scripts/ ask for these to be installed with --no-save; they diff --git a/packages/shared/.prettierignore b/packages/shared/.prettierignore deleted file mode 100644 index 007ea8a7..00000000 --- a/packages/shared/.prettierignore +++ /dev/null @@ -1,3 +0,0 @@ -dist -node_modules -coverage diff --git a/packages/shared/.prettierrc b/packages/shared/.prettierrc deleted file mode 100644 index 69d9c2b1..00000000 --- a/packages/shared/.prettierrc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "semi": false, - "singleQuote": false, - "trailingComma": "all", - "printWidth": 100, - "tabWidth": 2, - "arrowParens": "always" -} diff --git a/scripts/check-i18n-call-sites.mjs b/scripts/check-i18n-call-sites.mjs index 1d46188e..a33dac32 100644 --- a/scripts/check-i18n-call-sites.mjs +++ b/scripts/check-i18n-call-sites.mjs @@ -18,7 +18,8 @@ import { readdirSync, readFileSync } from "node:fs" import { dirname, join, relative } from "node:path" import { fileURLToPath } from "node:url" -const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..") +// The fixture tests point it at a scratch tree; CI runs it on the repository. +const ROOT = process.env.I18N_CALL_SITES_ROOT ?? join(dirname(fileURLToPath(import.meta.url)), "..") const LOCALES_EN = join(ROOT, "packages/ui/src/i18n/locales/en") const DEFAULT_NS = "common" const SCANNED = [ diff --git a/scripts/check-i18n-call-sites.test.mjs b/scripts/check-i18n-call-sites.test.mjs new file mode 100644 index 00000000..fcd4f881 --- /dev/null +++ b/scripts/check-i18n-call-sites.test.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict" +import { spawnSync } from "node:child_process" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" +import { after, test } from "node:test" +import { fileURLToPath } from "node:url" + +const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "check-i18n-call-sites.mjs") +const roots = [] + +function fixture(files) { + const root = mkdtempSync(join(tmpdir(), "i18n-call-sites-")) + roots.push(root) + const tree = { + "packages/ui/src/i18n/locales/en/common.json": JSON.stringify({ save: "Save" }), + "packages/ui/src/i18n/locales/en/events.json": JSON.stringify({ + title: "Events", + count_one: "{{count}} event", + count_other: "{{count}} events", + steps: { first: "One" }, + }), + "apps/community-web/src/.keep": "", + "apps/community-mobile/app/.keep": "", + "apps/community-mobile/src/.keep": "", + ...files, + } + for (const [path, body] of Object.entries(tree)) { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), body) + } + return root +} + +function run(root) { + return spawnSync(process.execPath, [SCRIPT], { env: { ...process.env, I18N_CALL_SITES_ROOT: root }, encoding: "utf8" }) +} + +after(() => { + for (const root of roots) rmSync(root, { recursive: true, force: true }) +}) + +test("passes when every literal key resolves in its bound namespace, plural base keys included", () => { + const root = fixture({ + "packages/ui/src/A.tsx": 'const { t } = useT("events")\nt("title")\nt("count", { count: 2 })\nconst c = useT()\nc.t("save")\n', + }) + const result = run(root) + assert.equal(result.status, 0, result.stderr) +}) + +test("fails on a key that only exists in another namespace, and names where it lives", () => { + const root = fixture({ "apps/community-web/src/B.tsx": 'const { t } = useT("events")\nt("save")\n' }) + const result = run(root) + assert.equal(result.status, 1) + assert.match(result.stderr, /not in namespace events; it exists in common/) +}) + +test("fails on a missing key and on an unknown namespace prefix", () => { + const root = fixture({ "apps/community-mobile/src/C.tsx": 'const { t } = useT("events")\nt("nope")\nt("ghost:title")\n' }) + const result = run(root) + assert.equal(result.status, 1) + assert.match(result.stderr, /t\("nope"\): not in namespace events, nor any en catalog/) + assert.match(result.stderr, /namespace "ghost" has no en catalog/) +}) + +test("fails on an object key read without returnObjects, and accepts it with returnObjects", () => { + const bad = fixture({ "packages/ui/src/D.tsx": 'const { t } = useT("events")\nt("steps")\n' }) + assert.match(run(bad).stderr, /names an object, not a string/) + const good = fixture({ "packages/ui/src/E.tsx": 'const { t } = useT("events")\nt("steps", { returnObjects: true })\n' }) + assert.equal(run(good).status, 0) +}) + +test("skips dynamic template keys", () => { + const root = fixture({ "packages/ui/src/F.tsx": 'const { t } = useT("events")\nt(`steps.${step}`)\n' }) + assert.equal(run(root).status, 0) +})