fuzzyworld

north-star / commit

turned on ts strict mode

b4e7f682f461db138bda87148d26ec696a34f045 · Pavel Safronov · 2026-03-16 01:06:02 -0700 · parents 0b65bf684476

turned on ts strict mode
 package-lock.json         | 235 +++++++++++++++++++++++++++++++++++++++++++++-
 package.json              |   5 +-
 src/app.ts                |  15 +--
 src/compass.ts            |  56 +++++++----
 src/crypto.ts             |   8 +-
 src/darkmode.ts           |   4 +-
 src/friends-panel.ts      |  61 +++++++-----
 src/friends-sync.ts       |  28 ++++--
 src/participants-panel.ts |  75 +++++++++------
 src/qr.ts                 |   6 ++
 src/renderer.ts           |  24 ++++-
 src/room.ts               |   4 +-
 src/self-profile.ts       |  14 ++-
 src/session.ts            |  13 ++-
 src/utils.ts              |  30 ++++--
 src/wakelock.ts           |   4 +-
 tsconfig.json             |   2 +-
 vite.config.js            |   6 ++
 18 files changed, 470 insertions(+), 120 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index 07c7959..4b4f22e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,19 +1,44 @@
 {
-    "name": "north-star",
+    "name": "friend-compass",
     "version": "0.1.0",
     "lockfileVersion": 3,
     "requires": true,
     "packages": {
         "": {
-            "name": "north-star",
+            "name": "friend-compass",
             "version": "0.1.0",
             "dependencies": {
                 "firebase": "^12.0.0",
                 "qrcode": "^1.5.4"
             },
             "devDependencies": {
+                "@types/qrcode": "^1.5.6",
                 "typescript": "^5.8.2",
-                "vite": "^5.4.19"
+                "vite": "^5.4.19",
+                "vite-plugin-checker": "^0.12.0"
+            }
+        },
+        "node_modules/@babel/code-frame": {
+            "version": "7.29.0",
+            "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+            "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-validator-identifier": "^7.28.5",
+                "js-tokens": "^4.0.0",
+                "picocolors": "^1.1.1"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-validator-identifier": {
+            "version": "7.28.5",
+            "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+            "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.9.0"
             }
         },
         "node_modules/@esbuild/aix-ppc64": {
@@ -1370,6 +1395,15 @@
                 "undici-types": "~7.18.0"
             }
         },
+        "node_modules/@types/qrcode": {
+            "version": "1.5.6",
+            "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
+            "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
+            "dev": true,
+            "dependencies": {
+                "@types/node": "*"
+            }
+        },
         "node_modules/ansi-regex": {
             "version": "5.0.1",
             "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -1400,6 +1434,21 @@
                 "node": ">=6"
             }
         },
+        "node_modules/chokidar": {
+            "version": "4.0.3",
+            "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+            "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+            "dev": true,
+            "dependencies": {
+                "readdirp": "^4.0.1"
+            },
+            "engines": {
+                "node": ">= 14.16.0"
+            },
+            "funding": {
+                "url": "https://paulmillr.com/funding/"
+            }
+        },
         "node_modules/cliui": {
             "version": "8.0.1",
             "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -1504,6 +1553,23 @@
                 "node": ">=0.8.0"
             }
         },
+        "node_modules/fdir": {
+            "version": "6.5.0",
+            "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+            "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+            "dev": true,
+            "engines": {
+                "node": ">=12.0.0"
+            },
+            "peerDependencies": {
+                "picomatch": "^3 || ^4"
+            },
+            "peerDependenciesMeta": {
+                "picomatch": {
+                    "optional": true
+                }
+            }
+        },
         "node_modules/find-up": {
             "version": "4.1.0",
             "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@@ -1591,6 +1657,12 @@
                 "node": ">=8"
             }
         },
+        "node_modules/js-tokens": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+            "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+            "dev": true
+        },
         "node_modules/locate-path": {
             "version": "5.0.0",
             "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -1630,6 +1702,22 @@
                 "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
             }
         },
+        "node_modules/npm-run-path": {
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
+            "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==",
+            "dev": true,
+            "dependencies": {
+                "path-key": "^4.0.0",
+                "unicorn-magic": "^0.3.0"
+            },
+            "engines": {
+                "node": ">=18"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
         "node_modules/p-limit": {
             "version": "2.3.0",
             "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
@@ -1671,12 +1759,36 @@
                 "node": ">=8"
             }
         },
+        "node_modules/path-key": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
+            "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
         "node_modules/picocolors": {
             "version": "1.1.1",
             "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
             "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
             "dev": true
         },
+        "node_modules/picomatch": {
+            "version": "4.0.3",
+            "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+            "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+            "dev": true,
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/jonschlinkert"
+            }
+        },
         "node_modules/pngjs": {
             "version": "5.0.0",
             "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
@@ -1813,6 +1925,19 @@
                 "node": ">=6"
             }
         },
+        "node_modules/readdirp": {
+            "version": "4.1.2",
+            "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+            "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+            "dev": true,
+            "engines": {
+                "node": ">= 14.18.0"
+            },
+            "funding": {
+                "type": "individual",
+                "url": "https://paulmillr.com/funding/"
+            }
+        },
         "node_modules/require-directory": {
             "version": "2.1.1",
             "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -1927,6 +2052,28 @@
                 "node": ">=8"
             }
         },
+        "node_modules/tiny-invariant": {
+            "version": "1.3.3",
+            "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+            "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+            "dev": true
+        },
+        "node_modules/tinyglobby": {
+            "version": "0.2.15",
+            "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+            "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+            "dev": true,
+            "dependencies": {
+                "fdir": "^6.5.0",
+                "picomatch": "^4.0.3"
+            },
+            "engines": {
+                "node": ">=12.0.0"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/SuperchupuDev"
+            }
+        },
         "node_modules/tslib": {
             "version": "2.8.1",
             "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -1950,6 +2097,18 @@
             "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
             "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="
         },
+        "node_modules/unicorn-magic": {
+            "version": "0.3.0",
+            "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
+            "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
+            "dev": true,
+            "engines": {
+                "node": ">=18"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
         "node_modules/vite": {
             "version": "5.4.21",
             "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -2009,6 +2168,76 @@
                 }
             }
         },
+        "node_modules/vite-plugin-checker": {
+            "version": "0.12.0",
+            "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.12.0.tgz",
+            "integrity": "sha512-CmdZdDOGss7kdQwv73UyVgLPv0FVYe5czAgnmRX2oKljgEvSrODGuClaV3PDR2+3ou7N/OKGauDDBjy2MB07Rg==",
+            "dev": true,
+            "dependencies": {
+                "@babel/code-frame": "^7.27.1",
+                "chokidar": "^4.0.3",
+                "npm-run-path": "^6.0.0",
+                "picocolors": "^1.1.1",
+                "picomatch": "^4.0.3",
+                "tiny-invariant": "^1.3.3",
+                "tinyglobby": "^0.2.15",
+                "vscode-uri": "^3.1.0"
+            },
+            "engines": {
+                "node": ">=16.11"
+            },
+            "peerDependencies": {
+                "@biomejs/biome": ">=1.7",
+                "eslint": ">=9.39.1",
+                "meow": "^13.2.0",
+                "optionator": "^0.9.4",
+                "oxlint": ">=1",
+                "stylelint": ">=16",
+                "typescript": "*",
+                "vite": ">=5.4.21",
+                "vls": "*",
+                "vti": "*",
+                "vue-tsc": "~2.2.10 || ^3.0.0"
+            },
+            "peerDependenciesMeta": {
+                "@biomejs/biome": {
+                    "optional": true
+                },
+                "eslint": {
+                    "optional": true
+                },
+                "meow": {
+                    "optional": true
+                },
+                "optionator": {
+                    "optional": true
+                },
+                "oxlint": {
+                    "optional": true
+                },
+                "stylelint": {
+                    "optional": true
+                },
+                "typescript": {
+                    "optional": true
+                },
+                "vls": {
+                    "optional": true
+                },
+                "vti": {
+                    "optional": true
+                },
+                "vue-tsc": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/vscode-uri": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
+            "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
+            "dev": true
+        },
         "node_modules/web-vitals": {
             "version": "4.2.4",
             "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
diff --git a/package.json b/package.json
index ac812a2..f35100d 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,7 @@
     "type": "module",
     "scripts": {
         "dev": "vite",
+        "typecheck": "tsc --noEmit",
         "build": "vite build",
         "preview": "vite preview",
         "gen-commit-ts": "echo \"export const GIT_COMMIT='${CI_COMMIT_SHORT_SHA}'; export const IS_LOCAL_BUILD=false;\" > ./src/commit.ts"
@@ -14,7 +15,9 @@
         "qrcode": "^1.5.4"
     },
     "devDependencies": {
+        "@types/qrcode": "^1.5.6",
         "typescript": "^5.8.2",
-        "vite": "^5.4.19"
+        "vite": "^5.4.19",
+        "vite-plugin-checker": "^0.12.0"
     }
 }
\ No newline at end of file
diff --git a/src/app.ts b/src/app.ts
index 9621b78..c06d70b 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -16,14 +16,14 @@ import { PUBLIC_SETTINGS } from './public-settings';
 
 const debugCommit = document.getElementById('debug-commit');
 const nameForm = document.getElementById('name-form');
-const selfNameInput = document.getElementById('self-name-input');
-const selfColorInput = document.getElementById('self-color-input');
+const selfNameInput = document.getElementById('self-name-input') as HTMLInputElement | null;
+const selfColorInput = document.getElementById('self-color-input') as HTMLInputElement | null;
 const roomCurrent = document.getElementById('room-current');
 const inviteRoomQrList = document.getElementById('invite-room-qr-list');
 const calibrateHeadingLink = document.getElementById('calibrate-heading-link');
 const darkModeToggle = document.getElementById('dark-mode-toggle');
 const cleanModeToggle = document.getElementById('clean-mode-toggle');
-const createRoomLink = document.getElementById('create-room-link');
+const createRoomLink = document.getElementById('create-room-link') as HTMLAnchorElement | null;
 const roomLinks = document.getElementById('room-links');
 const friendsPanel = document.getElementById('friends-panel');
 const friendsToggle = document.getElementById('friends-toggle');
@@ -123,7 +123,7 @@ if (debugCommit instanceof HTMLAnchorElement) {
 
 // selfFriend is assigned below by initSelfProfile; syncSelfFriend is
 // only ever called after that point (from event handlers / intervals).
-let selfFriend;
+let selfFriend: any;
 let lastSyncedPosition: { lat: number; lon: number } | null = null;
 let lastLocationSyncAt = 0;
 let geolocationWatchId: number | null = null;
@@ -225,7 +225,7 @@ const requestRender = () => {
 const compassController = initCompassController({
     calibrateHeadingLink,
     initialHeadingCorrectionDeg: headingCorrectionDeg,
-    onHeadingCorrectionDegChange: (next) => { headingCorrectionDeg = next; },
+    onHeadingCorrectionDegChange: (next: number) => { headingCorrectionDeg = next; },
     requestRender
 });
 
@@ -283,8 +283,8 @@ const startSession = async () => {
             db,
             roomId,
             roomKey,
-            onUpdate: (updated) => {
-                const remoteFriendCount = updated.filter((friend) => friend?.id && friend.id !== selfFriend.id).length;
+            onUpdate: (updated: any[]) => {
+                const remoteFriendCount = updated.filter((friend: any) => friend?.id && friend.id !== selfFriend.id).length;
                 if (remoteFriendCount > previousRemoteFriendCount && inviteRoomQrList instanceof HTMLElement) {
                     bumpQrAutoCollapseCountdown(inviteRoomQrList);
                 }
@@ -377,6 +377,7 @@ document.addEventListener('visibilitychange', () => {
 
 const render = () => {
     if (!sessionStarted) return;
+    if (!content || !arrow) return;
 
     const now = Date.now();
     const friendsToRender = friendsController.getFriendsToRender(friends);
diff --git a/src/compass.ts b/src/compass.ts
index c913085..7dcfd4b 100644
--- a/src/compass.ts
+++ b/src/compass.ts
@@ -1,16 +1,25 @@
 import { normalizeAngle, normalizeSignedAngle } from './utils';
 import { PUBLIC_SETTINGS } from './public-settings';
 
+type CompassControllerOptions = {
+    calibrateHeadingLink: HTMLElement | null;
+    initialHeadingCorrectionDeg: number;
+    onHeadingCorrectionDegChange: (next: number) => void;
+    requestRender: () => void;
+};
+
+type CompassPermissionState = 'granted' | 'denied';
+
 export const initCompassController = ({
     calibrateHeadingLink,
     initialHeadingCorrectionDeg,
     onHeadingCorrectionDegChange,
     requestRender
-}) => {
+}: CompassControllerOptions) => {
     const HEADING_CORRECTION_STORAGE_KEY = 'friend-compass-heading-correction-deg';
     const deviceOrientationCtor =
         typeof DeviceOrientationEvent !== 'undefined'
-            ? (DeviceOrientationEvent as unknown as { requestPermission?: () => Promise<'granted' | 'denied'> })
+            ? (DeviceOrientationEvent as unknown as { requestPermission?: () => Promise<CompassPermissionState> })
             : null;
 
     const requiresExplicitCompassPermission =
@@ -20,7 +29,7 @@ export const initCompassController = ({
 
     let headingCorrectionDeg = initialHeadingCorrectionDeg;
     let selfHeading = 0;
-    let lastRawHeading = null;
+    let lastRawHeading: number | null = null;
 
     let compassEnabled = !requiresExplicitCompassPermission;
     let hasSeenOrientationEvent = false;
@@ -28,8 +37,8 @@ export const initCompassController = ({
     let compassButtonStatusLabel = '';
     let compassButtonStatusTitle = '';
     let compassButtonStatusError = false;
-    let compassStatusTimer = null;
-    let compassProbeTimer = null;
+    let compassStatusTimer: number | null = null;
+    let compassProbeTimer: number | null = null;
     let hasAbsoluteOrientation = false;
 
     const getHeadingUnavailableMessage = () => {
@@ -109,7 +118,17 @@ export const initCompassController = ({
         compassProbeTimer = null;
     };
 
-    const showCompassButtonStatus = ({ label, title = '', isError = false, durationMs = 0 }) => {
+    const showCompassButtonStatus = ({
+        label,
+        title = '',
+        isError = false,
+        durationMs = 0
+    }: {
+        label: string;
+        title?: string;
+        isError?: boolean;
+        durationMs?: number;
+    }) => {
         if (compassStatusTimer) {
             clearTimeout(compassStatusTimer);
             compassStatusTimer = null;
@@ -120,7 +139,7 @@ export const initCompassController = ({
         compassButtonStatusError = isError;
 
         if (durationMs > 0) {
-            compassStatusTimer = setTimeout(() => {
+            compassStatusTimer = window.setTimeout(() => {
                 clearCompassButtonStatus();
                 updateCompassButtonState();
             }, durationMs);
@@ -137,11 +156,11 @@ export const initCompassController = ({
             calibrateHeadingLink.classList.add('compass-required');
             calibrateHeadingLink.classList.remove('compass-error');
             calibrateHeadingLink.title = getHeadingUnavailableMessage();
-            calibrateHeadingLink.disabled = true;
+            (calibrateHeadingLink as HTMLButtonElement).disabled = true;
             return;
         }
 
-        calibrateHeadingLink.disabled = false;
+        (calibrateHeadingLink as HTMLButtonElement).disabled = false;
         calibrateHeadingLink.textContent = compassButtonStatusLabel || (compassEnabled ? 'Calibrate North' : 'Enable Compass');
         calibrateHeadingLink.title = compassButtonStatusTitle;
         calibrateHeadingLink.classList.toggle('compass-required', !compassEnabled);
@@ -155,7 +174,7 @@ export const initCompassController = ({
         if (!compassEnabled) return;
         if (hasSeenOrientationEvent || hasSeenHeadingValue) return;
 
-        compassProbeTimer = setTimeout(() => {
+        compassProbeTimer = window.setTimeout(() => {
             if (hasSeenOrientationEvent || hasSeenHeadingValue) return;
 
             const status = getHeadingUnavailableStatus();
@@ -185,15 +204,16 @@ export const initCompassController = ({
         if (window.screen?.orientation && typeof window.screen.orientation.angle === 'number') {
             return window.screen.orientation.angle;
         }
-        if (typeof window.orientation === 'number') {
-            return window.orientation;
+        if (typeof (window as any).orientation === 'number') {
+            return (window as any).orientation as number;
         }
         return 0;
     };
 
-    const getHeadingFromEvent = (orientationEvent) => {
-        if (typeof orientationEvent.webkitCompassHeading === 'number') {
-            return normalizeAngle(orientationEvent.webkitCompassHeading);
+    const getHeadingFromEvent = (orientationEvent: DeviceOrientationEvent) => {
+        const webkitEvent = orientationEvent as DeviceOrientationEvent & { webkitCompassHeading?: number };
+        if (typeof webkitEvent.webkitCompassHeading === 'number') {
+            return normalizeAngle(webkitEvent.webkitCompassHeading);
         }
 
         if (orientationEvent.alpha === null) {
@@ -208,7 +228,7 @@ export const initCompassController = ({
     scheduleCompassProbe();
 
     if (calibrateHeadingLink) {
-        calibrateHeadingLink.addEventListener('click', async (event) => {
+        calibrateHeadingLink.addEventListener('click', async (event: Event) => {
             event.preventDefault();
 
             if (!compassEnabled) {
@@ -259,7 +279,7 @@ export const initCompassController = ({
         });
     }
 
-    window.addEventListener('deviceorientationabsolute', (event) => {
+    window.addEventListener('deviceorientationabsolute', (event: DeviceOrientationEvent) => {
         clearCompassProbeTimer();
         hasSeenOrientationEvent = true;
         const heading = getHeadingFromEvent(event);
@@ -282,7 +302,7 @@ export const initCompassController = ({
         requestRender();
     });
 
-    window.addEventListener('deviceorientation', (event) => {
+    window.addEventListener('deviceorientation', (event: DeviceOrientationEvent) => {
         if (hasAbsoluteOrientation) return;
 
         clearCompassProbeTimer();
diff --git a/src/crypto.ts b/src/crypto.ts
index 35dc69d..c110f6c 100644
--- a/src/crypto.ts
+++ b/src/crypto.ts
@@ -4,20 +4,20 @@ const IV_BYTES = 12;
 export const generateRoomKey = () =>
     crypto.subtle.generateKey(AES_PARAMS, true, ['encrypt', 'decrypt']);
 
-export const exportRoomKey = async (key) => {
+export const exportRoomKey = async (key: CryptoKey) => {
     const raw = await crypto.subtle.exportKey('raw', key);
     return btoa(String.fromCharCode(...new Uint8Array(raw)))
         .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
 };
 
-export const importRoomKey = async (base64url) => {
+export const importRoomKey = async (base64url: string) => {
     const b64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
     const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
     const raw = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
     return crypto.subtle.importKey('raw', raw, AES_PARAMS, false, ['encrypt', 'decrypt']);
 };
 
-export const encryptJson = async (key, value) => {
+export const encryptJson = async (key: CryptoKey, value: unknown) => {
     const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
     const plaintext = new TextEncoder().encode(JSON.stringify(value));
     const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext);
@@ -26,7 +26,7 @@ export const encryptJson = async (key, value) => {
     return `${ivB64}:${ctB64}`;
 };
 
-export const decryptJson = async (key, encryptedValue) => {
+export const decryptJson = async (key: CryptoKey, encryptedValue: string) => {
     const [ivB64, ctB64] = encryptedValue.split(':');
     const iv = Uint8Array.from(atob(ivB64), (c) => c.charCodeAt(0));
     const ciphertext = Uint8Array.from(atob(ctB64), (c) => c.charCodeAt(0));
diff --git a/src/darkmode.ts b/src/darkmode.ts
index a3e4952..4956c8c 100644
--- a/src/darkmode.ts
+++ b/src/darkmode.ts
@@ -1,7 +1,7 @@
 export const DARK_MODE_STORAGE_KEY = 'friend-compass-dark-mode';
 
-export const initDarkMode = (toggleButton) => {
-    const applyDarkMode = (dark) => {
+export const initDarkMode = (toggleButton: HTMLElement | null) => {
+    const applyDarkMode = (dark: boolean) => {
         document.body.classList.toggle('dark', dark);
         if (toggleButton) toggleButton.textContent = dark ? '☀️' : '🌙';
         localStorage.setItem(DARK_MODE_STORAGE_KEY, dark ? '1' : '0');
diff --git a/src/friends-panel.ts b/src/friends-panel.ts
index 0f72d66..0a13644 100644
--- a/src/friends-panel.ts
+++ b/src/friends-panel.ts
@@ -2,6 +2,15 @@ import { getFriendPresenceState, PRESENCE_EMOJI } from './utils';
 import type { PresenceThresholds } from './utils';
 import { PUBLIC_SETTINGS } from './public-settings';
 
+type FriendModel = {
+    id: string;
+    name: string;
+    color: string;
+    position: { lat: number; lon: number };
+    timestamp?: number;
+    lastSeen?: number;
+};
+
 export const createFriendsPanelController = ({
     friendsPanel,
     friendsToggle,
@@ -32,13 +41,13 @@ export const createFriendsPanelController = ({
     const friendVisibility = (() => {
         try {
             const raw = localStorage.getItem(PARTICIPANT_VISIBILITY_STORAGE_KEY);
-            return raw ? JSON.parse(raw) : {};
+            return raw ? JSON.parse(raw) as Record<string, boolean> : {};
         } catch {
-            return {};
+            return {} as Record<string, boolean>;
         }
     })();
 
-    let friendOrder = (() => {
+    let friendOrder: string[] = (() => {
         try {
             const raw = localStorage.getItem(PARTICIPANT_ORDER_STORAGE_KEY);
             const parsed = raw ? JSON.parse(raw) : [];
@@ -52,13 +61,13 @@ export const createFriendsPanelController = ({
         localStorage.setItem(PARTICIPANT_ORDER_STORAGE_KEY, JSON.stringify(friendOrder));
     };
 
-    const getFriendDefaultRank = (friendId) => {
+    const getFriendDefaultRank = (friendId: string) => {
         if (friendId === northMarkerId) return 0;
         if (guardianAngelEnabled && friendId === guardianAngelId) return 1;
         return 2;
     };
 
-    const shouldShowMapLink = (friendId) => {
+    const shouldShowMapLink = (friendId: string) => {
         return friendId !== northMarkerId && (!guardianAngelEnabled || friendId !== guardianAngelId);
     };
 
@@ -77,14 +86,14 @@ export const createFriendsPanelController = ({
         };
     };
 
-    const ensureFriendOrder = (friendsToRender) => {
-        const ids = friendsToRender.map((friend) => friend.id);
+    const ensureFriendOrder = (friendsToRender: FriendModel[]) => {
+        const ids = friendsToRender.map((friend: FriendModel) => friend.id);
         const idSet = new Set(ids);
-        const preserved = friendOrder.filter((id) => idSet.has(id));
+        const preserved = friendOrder.filter((id: string) => idSet.has(id));
         const preservedSet = new Set(preserved);
-        const missing = ids.filter((id) => !preservedSet.has(id));
+        const missing = ids.filter((id: string) => !preservedSet.has(id));
 
-        missing.sort((a, b) => {
+        missing.sort((a: string, b: string) => {
             const rankDelta = getFriendDefaultRank(a) - getFriendDefaultRank(b);
             if (rankDelta !== 0) return rankDelta;
             return a.localeCompare(b);
@@ -93,7 +102,7 @@ export const createFriendsPanelController = ({
         const nextOrder = [...preserved, ...missing];
         const changed =
             nextOrder.length !== friendOrder.length ||
-            nextOrder.some((id, index) => id !== friendOrder[index]);
+            nextOrder.some((id: string, index: number) => id !== friendOrder[index]);
 
         if (changed) {
             friendOrder = nextOrder;
@@ -103,22 +112,22 @@ export const createFriendsPanelController = ({
         return friendOrder;
     };
 
-    const sortFriendsForDisplay = (friendsToRender) => {
+    const sortFriendsForDisplay = (friendsToRender: FriendModel[]) => {
         const order = ensureFriendOrder(friendsToRender);
-        const orderIndex = new Map(order.map((id, index) => [id, index]));
+        const orderIndex = new Map<string, number>(order.map((id: string, index: number) => [id, index]));
 
         return [...friendsToRender].sort((a, b) => {
             const aIndex = orderIndex.get(a.id);
             const bIndex = orderIndex.get(b.id);
-            return aIndex - bIndex;
+            return (aIndex ?? Number.MAX_SAFE_INTEGER) - (bIndex ?? Number.MAX_SAFE_INTEGER);
         });
     };
 
-    let draggedFriendId = null;
-    let draggedPointerId = null;
-    let dropTargetFriendId = null;
+    let draggedFriendId: string | null = null;
+    let draggedPointerId: number | null = null;
+    let dropTargetFriendId: string | null = null;
     let dropAfterTarget = false;
-    let lastFriendsToRender = [];
+    let lastFriendsToRender: FriendModel[] = [];
 
     const clearFriendDragVisuals = () => {
         if (!friendsPanel) return;
@@ -128,7 +137,7 @@ export const createFriendsPanelController = ({
         });
     };
 
-    const moveFriendToIndex = (friendId, targetIndex) => {
+    const moveFriendToIndex = (friendId: string, targetIndex: number) => {
         const fromIndex = friendOrder.indexOf(friendId);
         if (fromIndex < 0) return false;
 
@@ -271,14 +280,14 @@ export const createFriendsPanelController = ({
 
     applyFriendsPanelVisibility();
 
-    const isFriendVisible = (friendId) => friendVisibility[friendId] !== false;
-    const setFriendVisibility = (friendId, visible) => {
+    const isFriendVisible = (friendId: string) => friendVisibility[friendId] !== false;
+    const setFriendVisibility = (friendId: string, visible: boolean) => {
         friendVisibility[friendId] = visible;
         localStorage.setItem(PARTICIPANT_VISIBILITY_STORAGE_KEY, JSON.stringify(friendVisibility));
     };
 
-    const getFriendsToRender = (friends) => {
-        const friendsToRender = friends.filter((p) => p.id !== selfFriend.id);
+    const getFriendsToRender = (friends: FriendModel[]) => {
+        const friendsToRender = friends.filter((p: FriendModel) => p.id !== selfFriend.id);
 
         if (friendsToRender.findIndex((p) => p.id === northMarkerId) === -1) {
             friendsToRender.push({
@@ -307,7 +316,7 @@ export const createFriendsPanelController = ({
         return sortFriendsForDisplay(friendsToRender);
     };
 
-    const renderFriendControls = (friendsToRender) => {
+    const renderFriendControls = (friendsToRender: FriendModel[]) => {
         const orderedFriends = sortFriendsForDisplay(friendsToRender);
         lastFriendsToRender = orderedFriends;
         if (!friendsPanel) return;
@@ -376,7 +385,7 @@ export const createFriendsPanelController = ({
             dragHandle.textContent = '☰';
             dragHandle.title = `Drag to reorder ${friend.name}`;
             dragHandle.setAttribute('aria-label', `Drag to reorder ${friend.name}`);
-            dragHandle.addEventListener('pointerdown', (event) => {
+            dragHandle.addEventListener('pointerdown', (event: PointerEvent) => {
                 if (event.button !== undefined && event.button !== 0) return;
 
                 event.preventDefault();
@@ -403,7 +412,7 @@ export const createFriendsPanelController = ({
             if (!friendsPanel) return;
             friendsPanel.querySelectorAll<HTMLElement>('.friend-presence-status[data-friend-id]').forEach((el) => {
                 const friendId = el.dataset.friendId;
-                const friend = lastFriendsToRender.find((f) => f.id === friendId);
+                const friend = lastFriendsToRender.find((f: FriendModel) => f.id === friendId);
                 if (!friend) return;
                 const state = getFriendPresenceState(friend, now, presenceThresholds);
                 const emoji = PRESENCE_EMOJI[state] ?? '🟡';
diff --git a/src/friends-sync.ts b/src/friends-sync.ts
index d180d35..0a69bd2 100644
--- a/src/friends-sync.ts
+++ b/src/friends-sync.ts
@@ -1,8 +1,14 @@
 import { ref, onValue } from 'firebase/database';
+import type { Database } from 'firebase/database';
 import { decryptJson } from './crypto';
 
 type FriendRecord = {
     id?: string;
+    name?: string;
+    color?: string;
+    position?: { lat: number; lon: number };
+    timestamp?: number;
+    lastSeen?: number;
     encryptedState?: string;
     encryptedPosition?: string;
     [key: string]: unknown;
@@ -19,9 +25,19 @@ type FriendRecord = {
  * @param {function} opts.onUpdate - (friends: object[]) => void
  * @returns {{ friends: object[] }} Shared array reference, mutated in place on each update
  */
-export function subscribeToFriends({ db, roomId, roomKey, onUpdate }) {
+export function subscribeToFriends({
+    db,
+    roomId,
+    roomKey,
+    onUpdate
+}: {
+    db: Database;
+    roomId: string;
+    roomKey: CryptoKey | null;
+    onUpdate: (friends: FriendRecord[]) => void;
+}) {
     const friendsRef = ref(db, `rooms/${roomId}/friends`);
-    const friends = [];
+    const friends: FriendRecord[] = [];
     let snapshotVersion = 0;
 
     onValue(friendsRef, async (snapshot) => {
@@ -30,10 +46,10 @@ export function subscribeToFriends({ db, roomId, roomKey, onUpdate }) {
         const friendsData = snapshot.val();
         if (friendsData) {
             const rawFriends = Object.values(friendsData as Record<string, FriendRecord>);
-            const decrypted = await Promise.all(rawFriends.map(async (p) => {
+            const decrypted = await Promise.all(rawFriends.map(async (p): Promise<FriendRecord | null> => {
                 if (p.encryptedState && roomKey) {
                     try {
-                        const decryptedState = await decryptJson(roomKey, p.encryptedState);
+                        const decryptedState = await decryptJson(roomKey, p.encryptedState) as Record<string, unknown>;
                         return { ...decryptedState, lastSeen: p.lastSeen };
                     } catch (err) {
                         console.error('Failed to decrypt friend state', p.id, err);
@@ -42,7 +58,7 @@ export function subscribeToFriends({ db, roomId, roomKey, onUpdate }) {
                 }
                 if (p.encryptedPosition && roomKey) {
                     try {
-                        const position = await decryptJson(roomKey, p.encryptedPosition);
+                        const position = await decryptJson(roomKey, p.encryptedPosition) as { lat: number; lon: number };
                         const { encryptedPosition: _stripped, ...rest } = p;
                         return { ...rest, position };
                     } catch (err) {
@@ -54,7 +70,7 @@ export function subscribeToFriends({ db, roomId, roomKey, onUpdate }) {
             }));
             if (version !== snapshotVersion) return;
             friends.length = 0;
-            friends.push(...decrypted.filter(Boolean));
+            friends.push(...decrypted.filter((item): item is FriendRecord => item !== null));
         } else {
             if (version !== snapshotVersion) return;
             friends.length = 0;
diff --git a/src/participants-panel.ts b/src/participants-panel.ts
index b83a0cc..34da9cf 100644
--- a/src/participants-panel.ts
+++ b/src/participants-panel.ts
@@ -1,3 +1,12 @@
+type ParticipantModel = {
+    id: string;
+    name: string;
+    color: string;
+    position: { lat: number; lon: number };
+    timestamp?: number;
+    lastSeen?: number;
+};
+
 export const createParticipantsPanelController = ({
     participantsPanel,
     participantsToggle,
@@ -6,6 +15,14 @@ export const createParticipantsPanelController = ({
     northMarkerId,
     selfParticipant,
     selfPosition
+}: {
+    participantsPanel: HTMLElement | null;
+    participantsToggle: HTMLElement | null;
+    requestRender: () => void;
+    getReadableTextColor: (hexColor: string) => string;
+    northMarkerId: string;
+    selfParticipant: { id: string };
+    selfPosition: { lat: number; lon: number };
 }) => {
     const PARTICIPANT_VISIBILITY_STORAGE_KEY = 'friend-compass-friend-visibility';
     const PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY = 'friend-compass-friends-panel-visible';
@@ -14,17 +31,17 @@ export const createParticipantsPanelController = ({
     const participantVisibility = (() => {
         try {
             const raw = localStorage.getItem(PARTICIPANT_VISIBILITY_STORAGE_KEY);
-            return raw ? JSON.parse(raw) : {};
+            return raw ? JSON.parse(raw) as Record<string, boolean> : {} as Record<string, boolean>;
         } catch {
-            return {};
+            return {} as Record<string, boolean>;
         }
     })();
 
-    let participantOrder = (() => {
+    let participantOrder: string[] = (() => {
         try {
             const raw = localStorage.getItem(PARTICIPANT_ORDER_STORAGE_KEY);
             const parsed = raw ? JSON.parse(raw) : [];
-            return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : [];
+            return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [];
         } catch {
             return [];
         }
@@ -34,23 +51,23 @@ export const createParticipantsPanelController = ({
         localStorage.setItem(PARTICIPANT_ORDER_STORAGE_KEY, JSON.stringify(participantOrder));
     };
 
-    const getParticipantDefaultRank = (participantId) => {
+    const getParticipantDefaultRank = (participantId: string) => {
         if (participantId === northMarkerId) return 0;
         return 1;
     };
 
-    const shouldShowMapLink = (participantId) => {
+    const shouldShowMapLink = (participantId: string) => {
         return participantId !== northMarkerId;
     };
 
-    const ensureParticipantOrder = (participantsToRender) => {
-        const ids = participantsToRender.map((participant) => participant.id);
+    const ensureParticipantOrder = (participantsToRender: ParticipantModel[]) => {
+        const ids = participantsToRender.map((participant: ParticipantModel) => participant.id);
         const idSet = new Set(ids);
-        const preserved = participantOrder.filter((id) => idSet.has(id));
+        const preserved = participantOrder.filter((id: string) => idSet.has(id));
         const preservedSet = new Set(preserved);
-        const missing = ids.filter((id) => !preservedSet.has(id));
+        const missing = ids.filter((id: string) => !preservedSet.has(id));
 
-        missing.sort((a, b) => {
+        missing.sort((a: string, b: string) => {
             const rankDelta = getParticipantDefaultRank(a) - getParticipantDefaultRank(b);
             if (rankDelta !== 0) return rankDelta;
             return a.localeCompare(b);
@@ -59,7 +76,7 @@ export const createParticipantsPanelController = ({
         const nextOrder = [...preserved, ...missing];
         const changed =
             nextOrder.length !== participantOrder.length ||
-            nextOrder.some((id, index) => id !== participantOrder[index]);
+            nextOrder.some((id: string, index: number) => id !== participantOrder[index]);
 
         if (changed) {
             participantOrder = nextOrder;
@@ -69,32 +86,32 @@ export const createParticipantsPanelController = ({
         return participantOrder;
     };
 
-    const sortParticipantsForDisplay = (participantsToRender) => {
+    const sortParticipantsForDisplay = (participantsToRender: ParticipantModel[]) => {
         const order = ensureParticipantOrder(participantsToRender);
-        const orderIndex = new Map(order.map((id, index) => [id, index]));
+        const orderIndex = new Map<string, number>(order.map((id: string, index: number) => [id, index]));
 
         return [...participantsToRender].sort((a, b) => {
             const aIndex = orderIndex.get(a.id);
             const bIndex = orderIndex.get(b.id);
-            return aIndex - bIndex;
+            return (aIndex ?? Number.MAX_SAFE_INTEGER) - (bIndex ?? Number.MAX_SAFE_INTEGER);
         });
     };
 
-    let draggedParticipantId = null;
-    let draggedPointerId = null;
-    let dropTargetParticipantId = null;
+    let draggedParticipantId: string | null = null;
+    let draggedPointerId: number | null = null;
+    let dropTargetParticipantId: string | null = null;
     let dropAfterTarget = false;
-    let lastParticipantsToRender = [];
+    let lastParticipantsToRender: ParticipantModel[] = [];
 
     const clearParticipantDragVisuals = () => {
         if (!participantsPanel) return;
 
-        participantsPanel.querySelectorAll('.participant-row').forEach((row) => {
+        participantsPanel.querySelectorAll<HTMLElement>('.participant-row').forEach((row) => {
             row.classList.remove('is-dragging', 'drag-over-before', 'drag-over-after');
         });
     };
 
-    const moveParticipantToIndex = (participantId, targetIndex) => {
+    const moveParticipantToIndex = (participantId: string, targetIndex: number) => {
         const fromIndex = participantOrder.indexOf(participantId);
         if (fromIndex < 0) return false;
 
@@ -159,7 +176,7 @@ export const createParticipantsPanelController = ({
         applyParticipantsPanelVisibility();
     });
 
-    document.addEventListener('pointermove', (event) => {
+    document.addEventListener('pointermove', (event: PointerEvent) => {
         if (!draggedParticipantId || event.pointerId !== draggedPointerId) return;
 
         event.preventDefault();
@@ -201,7 +218,7 @@ export const createParticipantsPanelController = ({
         targetRow.classList.add(after ? 'drag-over-after' : 'drag-over-before');
     });
 
-    document.addEventListener('pointerup', (event) => {
+    document.addEventListener('pointerup', (event: PointerEvent) => {
         if (!draggedParticipantId || event.pointerId !== draggedPointerId) return;
 
         const draggedId = draggedParticipantId;
@@ -233,16 +250,16 @@ export const createParticipantsPanelController = ({
 
     applyParticipantsPanelVisibility();
 
-    const isParticipantVisible = (participantId) => participantVisibility[participantId] !== false;
-    const setParticipantVisibility = (participantId, visible) => {
+    const isParticipantVisible = (participantId: string) => participantVisibility[participantId] !== false;
+    const setParticipantVisibility = (participantId: string, visible: boolean) => {
         participantVisibility[participantId] = visible;
         localStorage.setItem(PARTICIPANT_VISIBILITY_STORAGE_KEY, JSON.stringify(participantVisibility));
     };
 
-    const getParticipantsToRender = (participants) => {
-        const participantsToRender = participants.filter((p) => p.id !== selfParticipant.id);
+    const getParticipantsToRender = (participants: ParticipantModel[]) => {
+        const participantsToRender = participants.filter((p: ParticipantModel) => p.id !== selfParticipant.id);
 
-        if (participantsToRender.findIndex((p) => p.id === northMarkerId) === -1) {
+        if (participantsToRender.findIndex((p: ParticipantModel) => p.id === northMarkerId) === -1) {
             participantsToRender.push({
                 id: northMarkerId,
                 name: 'North',
@@ -258,7 +275,7 @@ export const createParticipantsPanelController = ({
         return sortParticipantsForDisplay(participantsToRender);
     };
 
-    const renderParticipantControls = (participantsToRender) => {
+    const renderParticipantControls = (participantsToRender: ParticipantModel[]) => {
         lastParticipantsToRender = participantsToRender;
         if (!participantsPanel) return;
 
diff --git a/src/qr.ts b/src/qr.ts
index 0c49af2..31fdbfc 100644
--- a/src/qr.ts
+++ b/src/qr.ts
@@ -68,6 +68,12 @@ export const renderInviteQrCard = async ({
     url,
     statusText,
     roomId
+}: {
+    container: HTMLElement | null;
+    title: string;
+    url: string;
+    statusText: string;
+    roomId: string;
 }) => {
     if (!container) return;
 
diff --git a/src/renderer.ts b/src/renderer.ts
index 88bc29a..fe06f6c 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -3,6 +3,15 @@ import type { PresenceThresholds } from './utils';
 
 type ArrowElement = HTMLElement & { currentAngle: number | null };
 
+type RenderableFriend = {
+    id: string;
+    name: string;
+    color: string;
+    position: { lat: number; lon: number };
+    timestamp?: number;
+    lastSeen?: number;
+};
+
 export const renderArrows = ({
     content,
     arrow,
@@ -12,12 +21,21 @@ export const renderArrows = ({
     isFriendVisible,
     getArrowDisplayColor,
     presenceThresholds,
+}: {
+    content: HTMLElement;
+    arrow: HTMLElement;
+    friendsToRender: RenderableFriend[];
+    selfFriend: RenderableFriend;
+    selfHeading: number;
+    isFriendVisible: (friendId: string) => boolean;
+    getArrowDisplayColor: (hexColor: string) => string;
+    presenceThresholds: PresenceThresholds;
 }) => {
     const now = Date.now();
     const activeArrowIds = new Set(
         friendsToRender
-            .filter((target) => target.id !== selfFriend.id)
-            .map((target) => `arrow-${target.id}`)
+            .filter((target: RenderableFriend) => target.id !== selfFriend.id)
+            .map((target: RenderableFriend) => `arrow-${target.id}`)
     );
 
     for (const target of friendsToRender) {
@@ -81,7 +99,7 @@ export const renderArrows = ({
         }
     }
 
-    content.querySelectorAll('.arrow[id^="arrow-"]').forEach((arrowElement) => {
+    content.querySelectorAll<HTMLElement>('.arrow[id^="arrow-"]').forEach((arrowElement: HTMLElement) => {
         if (!activeArrowIds.has(arrowElement.id)) {
             arrowElement.remove();
         }
diff --git a/src/room.ts b/src/room.ts
index 263f40e..fea2a5b 100644
--- a/src/room.ts
+++ b/src/room.ts
@@ -2,12 +2,12 @@ import { PUBLIC_SETTINGS } from './public-settings';
 
 const ROOM_KEY_PATTERN = /^[A-Za-z0-9_-]+$/;
 
-export const buildRoomHash = (nextRoomId, nextRoomKeyBase64 = null) => {
+export const buildRoomHash = (nextRoomId: string, nextRoomKeyBase64: string | null = null) => {
     const keyPart = nextRoomKeyBase64 ? `.${nextRoomKeyBase64}` : '';
     return `${nextRoomId}${keyPart}`;
 };
 
-export const parseRoomFromHash = (hashValue) => {
+export const parseRoomFromHash = (hashValue: string) => {
     const normalizedHash = (hashValue || '').startsWith('#') ? hashValue.slice(1) : (hashValue || '');
     const compactMatch = normalizedHash.match(new RegExp(`^([a-z0-9]{${PUBLIC_SETTINGS.room.idLength}})(?:\\.([A-Za-z0-9_-]+))?$`));
     if (!compactMatch) return null;
diff --git a/src/self-profile.ts b/src/self-profile.ts
index e72ebce..0a4c868 100644
--- a/src/self-profile.ts
+++ b/src/self-profile.ts
@@ -43,6 +43,14 @@ export async function initSelfProfile({
     selfPosition,
     syncSelf,
     requestRender
+}: {
+    nameForm: HTMLElement | null;
+    selfNameInput: HTMLInputElement | null;
+    selfColorInput: HTMLInputElement | null;
+    uid: string;
+    selfPosition: { lat: number; lon: number };
+    syncSelf: () => Promise<void>;
+    requestRender: () => void;
 }) {
     const initialSelfName = await resolveInitialSelfName();
     const initialSelfColor = resolveInitialSelfColor();
@@ -58,7 +66,7 @@ export async function initSelfProfile({
     if (selfNameInput) selfNameInput.value = selfFriend.name;
     if (selfColorInput) selfColorInput.value = selfFriend.color;
 
-    const applySelfName = async (rawName) => {
+    const applySelfName = async (rawName: string) => {
         const nextName = normalizeName(rawName) || await createRandomUserName();
         if (nextName === selfFriend.name) {
             if (selfNameInput) selfNameInput.value = nextName;
@@ -71,7 +79,7 @@ export async function initSelfProfile({
         requestRender();
     };
 
-    const applySelfColor = async (rawColor) => {
+    const applySelfColor = async (rawColor: string) => {
         const nextColor = normalizeColor(rawColor) || createRandomColor();
         if (nextColor === selfFriend.color) {
             if (selfColorInput) selfColorInput.value = nextColor;
@@ -85,7 +93,7 @@ export async function initSelfProfile({
     };
 
     if (nameForm) {
-        nameForm.addEventListener('submit', async (event) => {
+        nameForm.addEventListener('submit', async (event: Event) => {
             event.preventDefault();
             await applySelfName(selfNameInput ? selfNameInput.value : selfFriend.name);
             await applySelfColor(selfColorInput ? selfColorInput.value : selfFriend.color);
diff --git a/src/session.ts b/src/session.ts
index 5fefb3d..55640c0 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -15,10 +15,17 @@ export async function initSession({
     isLocalCommit,
     BASE_ROOM_URL,
     GITLAB_PAGES_BASE_URL
+}: {
+    roomCurrent: HTMLElement | null;
+    createRoomLink: HTMLAnchorElement | null;
+    inviteRoomQrList: HTMLElement | null;
+    isLocalCommit: boolean;
+    BASE_ROOM_URL: string;
+    GITLAB_PAGES_BASE_URL: string;
 }) {
-    let roomKey = null;
-    let roomKeyBase64 = null;
-    let roomId;
+    let roomKey: CryptoKey | null = null;
+    let roomKeyBase64: string | null = null;
+    let roomId: string;
 
     const parsedRoom = parseRoomFromHash(location.hash);
     if (parsedRoom) {
diff --git a/src/utils.ts b/src/utils.ts
index d569125..0d60b93 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,20 +1,29 @@
-export const getShortestDelta = (fromAngle, toAngle) => ((toAngle - fromAngle + 540) % 360) - 180;
+type FriendLike = {
+    timestamp?: number;
+    lastSeen?: number;
+};
+
+type UserAgentDataWithModel = Navigator['userAgentData'] & {
+    getHighEntropyValues: (hints: string[]) => Promise<{ model?: string }>;
+};
+
+export const getShortestDelta = (fromAngle: number, toAngle: number) => ((toAngle - fromAngle + 540) % 360) - 180;
 
-export const normalizeAngle = (angle) => ((angle % 360) + 360) % 360;
+export const normalizeAngle = (angle: number) => ((angle % 360) + 360) % 360;
 
-export const normalizeSignedAngle = (angle) => {
+export const normalizeSignedAngle = (angle: number) => {
     const normalized = normalizeAngle(angle);
     return normalized > 180 ? normalized - 360 : normalized;
 };
 
-export const normalizeName = (value) => (value || '').trim().slice(0, 32);
+export const normalizeName = (value: string | null | undefined) => (value || '').trim().slice(0, 32);
 
-export const normalizeColor = (value) => {
+export const normalizeColor = (value: string | null | undefined) => {
     const candidate = (value || '').trim().toLowerCase();
     return /^#[0-9a-f]{6}$/.test(candidate) ? candidate : '';
 };
 
-export const getReadableTextColor = (hexColor) => {
+export const getReadableTextColor = (hexColor: string) => {
     const normalized = normalizeColor(hexColor);
     if (!normalized) return '#0f172a';
 
@@ -26,7 +35,7 @@ export const getReadableTextColor = (hexColor) => {
     return luminance > 145 ? '#0f172a' : '#f8fafc';
 };
 
-export const getArrowDisplayColor = (hexColor) => {
+export const getArrowDisplayColor = (hexColor: string) => {
     if (!document.body.classList.contains('dark')) return hexColor;
     const normalized = normalizeColor(hexColor);
     if (!normalized) return '#ffffff';
@@ -43,7 +52,7 @@ export type PresenceThresholds = {
     recentlyOfflineMs: number;
 };
 
-export const getFriendPresenceState = (friend, now: number, thresholds: PresenceThresholds): 'online' | 'recent-offline' | 'stale-offline' => {
+export const getFriendPresenceState = (friend: FriendLike, now: number, thresholds: PresenceThresholds): 'online' | 'recent-offline' | 'stale-offline' => {
     const friendTimestamp = Number(friend.lastSeen ?? friend.timestamp);
     if (!Number.isFinite(friendTimestamp)) return 'stale-offline';
     const ageMs = Math.max(0, now - friendTimestamp);
@@ -59,9 +68,10 @@ export const PRESENCE_EMOJI: Record<string, string> = {
 };
 
 export const getDeviceHint = async () => {
-    if (navigator.userAgentData) {
+    const userAgentData = (navigator as Navigator & { userAgentData?: UserAgentDataWithModel }).userAgentData;
+    if (userAgentData) {
         try {
-            const data = await navigator.userAgentData.getHighEntropyValues(['model']);
+            const data = await userAgentData.getHighEntropyValues(['model']);
             if (data.model) return data.model;
         } catch {
             // Fall through to UA parsing.
diff --git a/src/wakelock.ts b/src/wakelock.ts
index 07bdeb1..a0c606f 100644
--- a/src/wakelock.ts
+++ b/src/wakelock.ts
@@ -1,5 +1,5 @@
 export const createWakeLockManager = () => {
-    let wakeLockSentinel = null;
+    let wakeLockSentinel: any = null;
     const hasWakeLockSupport = typeof navigator !== 'undefined' && 'wakeLock' in navigator;
 
     const requestWakeLock = async () => {
@@ -8,7 +8,7 @@ export const createWakeLockManager = () => {
         if (wakeLockSentinel) return;
 
         try {
-            wakeLockSentinel = await navigator.wakeLock.request('screen');
+            wakeLockSentinel = await (navigator as any).wakeLock.request('screen');
             wakeLockSentinel.addEventListener('release', () => {
                 wakeLockSentinel = null;
                 if (document.visibilityState === 'visible') {
diff --git a/tsconfig.json b/tsconfig.json
index ed4f24c..b24e7d2 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -12,7 +12,7 @@
             "vite/client"
         ],
         "allowJs": false,
-        "strict": false,
+        "strict": true,
         "isolatedModules": true,
         "noEmit": true,
         "skipLibCheck": true
diff --git a/vite.config.js b/vite.config.js
index 61da345..8c40294 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,8 +1,14 @@
 import { defineConfig } from 'vite';
+import checker from 'vite-plugin-checker';
 
 export default defineConfig({
     root: 'src',
     base: './',
+    plugins: [
+        checker({
+            typescript: true,
+        }),
+    ],
     build: {
         target: 'esnext',
         outDir: '../public',