fuzzyworld

north-star / commit

modularized the app

36d64b379ad084b360b6b7051dcdde4e53be30ad · Pavel Safronov · 2026-03-15 16:52:05 -0700 · parents d9978ceba24a

modularized the app
 src/app.js                | 1284 ++++-----------------------------------------
 src/compass.js            |  312 +++++++++++
 src/crypto.js             |   35 ++
 src/darkmode.js           |   24 +
 src/firebase.js           |   27 +
 src/participants-panel.js |  339 ++++++++++++
 src/participants-sync.js  |   59 +++
 src/qr.js                 |   51 ++
 src/renderer.js           |   78 +++
 src/room.js               |   18 +
 src/self-profile.js       |  109 ++++
 src/session.js            |   69 +++
 src/utils.js              |   71 +++
 src/wakelock.js           |   24 +
 14 files changed, 1310 insertions(+), 1190 deletions(-)

diff --git a/src/app.js b/src/app.js
index 7ddb00a..f37505a 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1,11 +1,15 @@
-import { onValue } from "https://www.gstatic.com/firebasejs/12.10.0/firebase-database.js";
-import { initializeApp } from "https://www.gstatic.com/firebasejs/12.10.0/firebase-app.js";
-import {
-    getAuth,
-    signInAnonymously,
-} from 'https://www.gstatic.com/firebasejs/12.10.0/firebase-auth.js';
-import { getDatabase, ref, set, onDisconnect } from "https://www.gstatic.com/firebasejs/12.10.0/firebase-database.js";
-import QRCode from 'https://cdn.jsdelivr.net/npm/qrcode@1.5.4/+esm';
+import { onDisconnect, ref, set } from "https://www.gstatic.com/firebasejs/12.10.0/firebase-database.js";
+import { getReadableTextColor, getArrowDisplayColor } from './utils.js';
+import { encryptJson } from './crypto.js';
+import { initDarkMode } from './darkmode.js';
+import { createWakeLockManager } from './wakelock.js';
+import { initFirebaseSession } from './firebase.js';
+import { initSession } from './session.js';
+import { initSelfProfile } from './self-profile.js';
+import { subscribeToParticipants } from './participants-sync.js';
+import { initCompassController } from './compass.js';
+import { createParticipantsPanelController } from './participants-panel.js';
+import { renderArrows } from './renderer.js';
 
 const debugParagraph = document.getElementById('debug');
 const nameForm = document.getElementById('name-form');
@@ -18,65 +22,33 @@ const darkModeToggle = document.getElementById('dark-mode-toggle');
 const createRoomLink = document.getElementById('create-room-link');
 const participantsPanel = document.getElementById('participants-panel');
 const participantsToggle = document.getElementById('participants-toggle');
+const content = document.getElementById('content');
+const arrow = document.getElementById('default-arrow');
+
 const logDebug = (message) => {
     if (message !== debugParagraph.innerHTML) {
         debugParagraph.innerHTML = message;
     }
-}
+};
 
-const arrow = document.getElementById('default-arrow');
 document.head.appendChild(Object.assign(document.createElement('link'), { rel: 'icon', href: './image.png' }));
-const getShortestDelta = (fromAngle, toAngle) => ((toAngle - fromAngle + 540) % 360) - 180;
-const normalizeAngle = (angle) => ((angle % 360) + 360) % 360;
-const normalizeSignedAngle = (angle) => {
-    const normalized = normalizeAngle(angle);
-    return normalized > 180 ? normalized - 360 : normalized;
-};
+
 const RENDER_INTERVAL_MS = 100;
 const LOCATION_SYNC_INTERVAL_MS = 3000;
 const HEADING_CORRECTION_STORAGE_KEY = 'north-star-heading-correction-deg';
-const DARK_MODE_STORAGE_KEY = 'north-star-dark-mode';
+const NORTH_MARKER_ID = 'north-marker';
+const GUARDIAN_ANGEL_ID = 'test';
+
 let headingCorrectionDeg = (() => {
     const parsed = Number(localStorage.getItem(HEADING_CORRECTION_STORAGE_KEY));
     return Number.isFinite(parsed) ? parsed : 0;
 })();
+
 let isLocalCommit = true;
 let GIT_COMMIT = 'local-dev';
 
-const applyDarkMode = (dark) => {
-    document.body.classList.toggle('dark', dark);
-    if (darkModeToggle) darkModeToggle.textContent = dark ? '☀️' : '🌙';
-    localStorage.setItem(DARK_MODE_STORAGE_KEY, dark ? '1' : '0');
-};
-const storedDarkMode = localStorage.getItem(DARK_MODE_STORAGE_KEY);
-const prefersDark = storedDarkMode !== null
-    ? storedDarkMode === '1'
-    : window.matchMedia('(prefers-color-scheme: dark)').matches;
-applyDarkMode(prefersDark);
-if (darkModeToggle) {
-    darkModeToggle.addEventListener('click', () => applyDarkMode(!document.body.classList.contains('dark')));
-}
-let wakeLockSentinel = null;
-const hasWakeLockSupport = typeof navigator !== 'undefined' && 'wakeLock' in navigator;
-
-const requestWakeLock = async () => {
-    if (!hasWakeLockSupport) return;
-    if (document.visibilityState !== 'visible') return;
-    if (wakeLockSentinel) return;
-
-    try {
-        wakeLockSentinel = await navigator.wakeLock.request('screen');
-        wakeLockSentinel.addEventListener('release', () => {
-            wakeLockSentinel = null;
-            if (document.visibilityState === 'visible') {
-                void requestWakeLock();
-            }
-        });
-    } catch (err) {
-        // Wake lock can fail on unsupported browsers or restricted contexts.
-        console.debug('Wake lock request failed', err);
-    }
-};
+initDarkMode(darkModeToggle);
+const { requestWakeLock } = createWakeLockManager();
 
 try {
     const commitModule = await import('./commit.js');
@@ -88,871 +60,89 @@ try {
     // Local/dev environments may not have commit.js.
 }
 
-/**
- * @typedef {Object} Position
- * @property {number} lat
- * @property {number} lon
- */
-
-/**
- * @typedef {Object} Participant
- * @property {string} id
- * @property {string} name
- * @property {string} color
- * @property {Position} position
- * @property {number} timestamp
- * 
- * {
- *  id: 'abc123',
- *  name: 'Alice',
- *  color: '#ff0000',
- *  position: {
- *    lat: 37.7749,
- *    lon: -122.4194
- *  },
- *  timestamp: 1625247600000
- * }
- */
-
-// Space Needle: 47.62055039884746, -122.34928833200904
-const spaceNeedle = { lat: 47.62055039884746, lon: -122.34928833200904 };
-// Cascadia Pizza: 47.62664023883763, -122.1661814443633
-const cascadiaPizza = { lat: 47.62664023883763, lon: -122.1661814443633 };
-
 const selfPosition = {
     lat: 47.62055039884746,
     lon: -122.34928833200904
 };
-// Heading, assume north orientation by default, but if we get a heading from the device, use that to rotate the arrow accordingly
-let selfHeading = 0;
-let lastRawHeading = null;
-
-// ── End-to-end participant-state encryption ────────────────────────────────
-// The room key lives only in the URL hash (#key=…), which browsers never send
-// to any server. Firebase therefore stores only opaque ciphertext.
-const _AES_PARAMS = { name: 'AES-GCM', length: 256 };
-const _IV_BYTES = 12;
-
-const generateRoomKey = () =>
-    crypto.subtle.generateKey(_AES_PARAMS, true, ['encrypt', 'decrypt']);
-
-const exportRoomKey = async (key) => {
-    const raw = await crypto.subtle.exportKey('raw', key);
-    return btoa(String.fromCharCode(...new Uint8Array(raw)))
-        .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
-};
 
-const importRoomKey = async (base64url) => {
-    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']);
-};
-
-const encryptJson = async (key, value) => {
-    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);
-    const ivB64 = btoa(String.fromCharCode(...iv));
-    const ctB64 = btoa(String.fromCharCode(...new Uint8Array(ciphertext)));
-    return `${ivB64}:${ctB64}`;
-};
-
-const decryptJson = async (key, encryptedValue) => {
-    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));
-    const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
-    return JSON.parse(new TextDecoder().decode(plaintext));
-};
-// ─────────────────────────────────────────────────────────────────────────────
-
-const firebaseConfig = {
-    apiKey: "AIzaSyA4G7e13Gt1XAIds1Y63X_RFw-2O48iCLM",
-    authDomain: "north-star-e5dee.firebaseapp.com",
-    projectId: "north-star-e5dee",
-    storageBucket: "north-star-e5dee.firebasestorage.app",
-    messagingSenderId: "999461150314",
-    appId: "1:999461150314:web:53f3c6216dd4b0468bf413",
-    databaseURL: "https://north-star-e5dee-default-rtdb.firebaseio.com/"
-};
-initializeApp(firebaseConfig);
-
-const app = initializeApp(firebaseConfig);
-const auth = getAuth(app);
-const db = getDatabase(app);
-
-const authSignInResponse = await signInAnonymously(auth);
-console.debug('Signed in anonymously:', authSignInResponse);
-const uid = authSignInResponse.user.uid;
-console.debug('User ID:', uid);
+const { db, uid } = await initFirebaseSession();
 
 const BASE_ROOM_URL = `${window.location.origin}${window.location.pathname}`;
 const GITLAB_PAGES_BASE_URL = 'https://pavelsafronov.gitlab.io/north-star/';
-const ROOM_ID_LENGTH = 8;
-const ROOM_KEY_PATTERN = /^[A-Za-z0-9_-]+$/;
-
-const buildRoomHash = (nextRoomId, nextRoomKeyBase64 = null) => {
-    const keyPart = nextRoomKeyBase64 ? `.${nextRoomKeyBase64}` : '';
-    return `${nextRoomId}${keyPart}`;
-};
-
-// Parse room ID and optional encryption key from compact hash format: #<roomId>[.<roomKeyBase64>]
-const parseRoomFromHash = (hashValue) => {
-    const normalizedHash = (hashValue || '').startsWith('#') ? hashValue.slice(1) : (hashValue || '');
-
-    const compactMatch = normalizedHash.match(/^([a-z0-9]{8})(?:\.([A-Za-z0-9_-]+))?$/);
-    if (!compactMatch) return null;
-
-    const roomValue = compactMatch[1];
-    const keyValue = compactMatch[2] || null;
-    if (keyValue && !ROOM_KEY_PATTERN.test(keyValue)) return null;
-    return { roomId: roomValue, roomKeyBase64: keyValue };
-};
-
-let roomKey = null;       // CryptoKey | null — null means plaintext fallback
-let roomKeyBase64 = null; // string | null
-let roomId;
-
-const parsedRoom = parseRoomFromHash(location.hash);
-if (parsedRoom) {
-    roomId = parsedRoom.roomId;
-    if (parsedRoom.roomKeyBase64) {
-        try {
-            roomKey = await importRoomKey(parsedRoom.roomKeyBase64);
-            roomKeyBase64 = parsedRoom.roomKeyBase64;
-        } catch (err) {
-            console.error('Failed to import room key from URL — running without encryption', err);
-        }
-    }
-} else {
-    // New room: generate a fresh AES-GCM key and embed it in the hash.
-    roomId = crypto.randomUUID().replace(/-/g, '').slice(0, ROOM_ID_LENGTH);
-    roomKey = await generateRoomKey();
-    roomKeyBase64 = await exportRoomKey(roomKey);
-    location.hash = buildRoomHash(roomId, roomKeyBase64);
-}
-console.debug('Room ID:', roomId, '| Encrypted:', roomKey !== null);
-
-if (roomCurrent) {
-    roomCurrent.textContent = `You are currently in room ${roomId}`;
-}
-
-const inviteRoomUrl = `${BASE_ROOM_URL}#${buildRoomHash(roomId, roomKeyBase64)}`;
-if (createRoomLink) {
-    createRoomLink.href = BASE_ROOM_URL;
-}
-
-const renderInviteQrCard = async ({ title, url, statusText }) => {
-    if (!inviteRoomQrList) return;
-
-    const card = document.createElement('div');
-    card.className = 'room-qr-card';
-    card.setAttribute('aria-label', title ? `${title} invite QR code` : 'Invite QR code');
-
-    if (title) {
-        const label = document.createElement('p');
-        label.className = 'room-qr-title';
-        label.textContent = title;
-        card.appendChild(label);
-    }
-
-    const qrImage = document.createElement('img');
-    qrImage.className = 'room-qr';
-    qrImage.alt = title ? `${title}: scan to join room ${roomId}` : `Scan to join room ${roomId}`;
-    card.appendChild(qrImage);
-
-    const status = document.createElement('p');
-    status.className = 'room-link-status';
-    status.textContent = statusText;
-    card.appendChild(status);
-
-    inviteRoomQrList.appendChild(card);
-
-    try {
-        const qrDataUrl = await QRCode.toDataURL(url, {
-            width: 400,
-            margin: 4,
-            errorCorrectionLevel: 'H',
-            color: {
-                dark: '#0f172a',
-                light: '#ffffff'
-            }
-        });
-        qrImage.src = qrDataUrl;
-    } catch (err) {
-        console.error('Failed to generate invite QR code', err);
-        status.textContent = 'Failed to create QR';
-        status.classList.add('error');
-    }
-};
-
-if (inviteRoomQrList) {
-    inviteRoomQrList.textContent = '';
-    const isLocalRun = isLocalCommit || ['localhost', '127.0.0.1', '::1'].includes(location.hostname);
-
-    const roomHash = buildRoomHash(roomId, roomKeyBase64);
-    await renderInviteQrCard({
-        title: isLocalRun ? 'This Device URL' : '',
-        url: inviteRoomUrl,
-        statusText: 'Scan to join this room'
-    });
-
-    if (isLocalRun) {
-        await renderInviteQrCard({
-            title: 'GitLab Pages URL',
-            url: `${GITLAB_PAGES_BASE_URL}#${roomHash}`,
-            statusText: 'Scan to join on hosted app'
-        });
-    }
-}
-
-const requiresExplicitCompassPermission =
-    typeof DeviceOrientationEvent !== 'undefined' &&
-    typeof DeviceOrientationEvent.requestPermission === 'function';
-const hasDeviceOrientationSupport = typeof DeviceOrientationEvent !== 'undefined';
-const compassDefinitelyUnsupported = !hasDeviceOrientationSupport;
-let compassEnabled = !requiresExplicitCompassPermission;
-let hasSeenOrientationEvent = false;
-let hasSeenHeadingValue = false;
-let compassButtonStatusLabel = '';
-let compassButtonStatusTitle = '';
-let compassButtonStatusError = false;
-let compassStatusTimer = null;
-let compassProbeTimer = null;
-const COMPASS_PROBE_TIMEOUT_MS = 2500;
-
-const getHeadingUnavailableMessage = () => {
-    if (compassDefinitelyUnsupported) {
-        return 'This browser/device does not expose orientation sensors, so a compass is unavailable here.';
-    }
-
-    if (requiresExplicitCompassPermission && !compassEnabled) {
-        return 'Compass access is not enabled yet. Tap Enable Compass first.';
-    }
-
-    if (!hasSeenOrientationEvent) {
-        return 'No orientation sensor data has been detected on this device. This setup likely has no compass/motion sensor available (common on desktop/laptop hardware).';
-    }
-
-    if (!hasSeenHeadingValue) {
-        return 'Orientation events are arriving, but compass heading is unavailable in this browser/device context.';
-    }
-
-    return 'Heading sensor data is not ready yet. Try again in a moment.';
-};
-
-const getHeadingUnavailableStatus = () => {
-    if (compassDefinitelyUnsupported) {
-        return {
-            label: 'Compass Unavailable',
-            title: 'This browser/device does not expose orientation sensors, so a compass is unavailable here.',
-            isError: true
-        };
-    }
-
-    if (requiresExplicitCompassPermission && !compassEnabled) {
-        return {
-            label: 'Enable Compass First',
-            title: 'Compass access is not enabled yet. Tap Enable Compass first.',
-            isError: true
-        };
-    }
-
-    if (!hasSeenOrientationEvent) {
-        return {
-            label: 'No Sensor Data',
-            title: 'No orientation sensor data has been detected on this device. This setup likely has no compass/motion sensor available (common on desktop/laptop hardware).',
-            isError: true
-        };
-    }
-
-    if (!hasSeenHeadingValue) {
-        return {
-            label: 'Heading Unavailable',
-            title: 'Orientation events are arriving, but compass heading is unavailable in this browser/device context.',
-            isError: true
-        };
-    }
-
-    return {
-        label: 'Sensor Warming Up',
-        title: 'Heading sensor data is not ready yet. Try again in a moment.',
-        isError: false
-    };
-};
-
-const clearCompassButtonStatus = () => {
-    if (compassStatusTimer) {
-        clearTimeout(compassStatusTimer);
-        compassStatusTimer = null;
-    }
-
-    compassButtonStatusLabel = '';
-    compassButtonStatusTitle = '';
-    compassButtonStatusError = false;
-};
-
-const clearCompassProbeTimer = () => {
-    if (!compassProbeTimer) return;
-    clearTimeout(compassProbeTimer);
-    compassProbeTimer = null;
-};
-
-const scheduleCompassProbe = () => {
-    clearCompassProbeTimer();
-
-    if (compassDefinitelyUnsupported) return;
-    if (!compassEnabled) return;
-    if (hasSeenOrientationEvent || hasSeenHeadingValue) return;
-
-    compassProbeTimer = setTimeout(() => {
-        if (hasSeenOrientationEvent || hasSeenHeadingValue) return;
-
-        const status = getHeadingUnavailableStatus();
-        showCompassButtonStatus({
-            label: status.label,
-            title: status.title,
-            isError: status.isError
-        });
-    }, COMPASS_PROBE_TIMEOUT_MS);
-};
-
-const showCompassButtonStatus = ({ label, title = '', isError = false, durationMs = 0 }) => {
-    if (compassStatusTimer) {
-        clearTimeout(compassStatusTimer);
-        compassStatusTimer = null;
-    }
-
-    compassButtonStatusLabel = label;
-    compassButtonStatusTitle = title;
-    compassButtonStatusError = isError;
-
-    if (durationMs > 0) {
-        compassStatusTimer = setTimeout(() => {
-            clearCompassButtonStatus();
-            updateCompassButtonState();
-        }, durationMs);
-    }
-
-    updateCompassButtonState();
-};
-
-const updateCompassButtonState = () => {
-    if (!calibrateHeadingLink) return;
-
-    if (compassDefinitelyUnsupported) {
-        calibrateHeadingLink.textContent = 'Compass Unavailable';
-        calibrateHeadingLink.classList.add('compass-required');
-        calibrateHeadingLink.classList.remove('compass-error');
-        calibrateHeadingLink.title = getHeadingUnavailableMessage();
-        calibrateHeadingLink.disabled = true;
-        return;
-    }
-
-    calibrateHeadingLink.disabled = false;
-    calibrateHeadingLink.textContent = compassButtonStatusLabel || (compassEnabled ? 'Calibrate North' : 'Enable Compass');
-    calibrateHeadingLink.title = compassButtonStatusTitle;
-    calibrateHeadingLink.classList.toggle('compass-required', !compassEnabled);
-    calibrateHeadingLink.classList.toggle('compass-error', compassButtonStatusError);
-};
-
-const requestCompassPermission = async () => {
-    if (!requiresExplicitCompassPermission) {
-        return true;
-    }
-
-    try {
-        const permissionState = await DeviceOrientationEvent.requestPermission();
-        return permissionState === 'granted';
-    } catch (err) {
-        console.error('Failed to request compass permission', err);
-        return false;
-    }
-};
-
-updateCompassButtonState();
-scheduleCompassProbe();
-
-if (calibrateHeadingLink) {
-    calibrateHeadingLink.addEventListener('click', async (event) => {
-        event.preventDefault();
-
-        if (!compassEnabled) {
-            const granted = await requestCompassPermission();
-            if (!granted) {
-                showCompassButtonStatus({
-                    label: 'Compass Denied',
-                    title: 'Compass permission was denied. Enable Motion & Orientation Access in Safari settings and try again.',
-                    isError: true
-                });
-                return;
-            }
-
-            compassEnabled = true;
-            clearCompassButtonStatus();
-            updateCompassButtonState();
-            scheduleCompassProbe();
-            showCompassButtonStatus({
-                label: 'Compass Enabled',
-                title: 'Compass access is active. You can now calibrate north.',
-                durationMs: 1500
-            });
-            return;
-        }
-
-        if (lastRawHeading === null) {
-            const status = getHeadingUnavailableStatus();
-            showCompassButtonStatus({
-                label: status.label,
-                title: status.title,
-                isError: status.isError,
-                durationMs: status.isError ? 0 : 1500
-            });
-            return;
-        }
-
-        // Make current phone-forward direction equal to north (0 deg).
-        headingCorrectionDeg = normalizeSignedAngle(-lastRawHeading);
-        localStorage.setItem(HEADING_CORRECTION_STORAGE_KEY, String(headingCorrectionDeg));
-        selfHeading = normalizeAngle(lastRawHeading + headingCorrectionDeg);
-
-        showCompassButtonStatus({
-            label: 'North Calibrated',
-            title: 'Calibration applied.',
-            durationMs: 1500
-        });
-        requestRender();
-    });
-}
-
-const NAME_STORAGE_KEY = 'north-star-self-name-v2';
-const COLOR_STORAGE_KEY = 'north-star-self-color';
-const PARTICIPANT_VISIBILITY_STORAGE_KEY = 'north-star-participant-visibility';
-const PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY = 'north-star-participants-panel-visible';
-const PARTICIPANT_ORDER_STORAGE_KEY = 'north-star-participant-order';
-const NORTH_MARKER_ID = 'north-marker';
-const GUARDIAN_ANGEL_ID = 'test';
-const normalizeName = (value) => (value || '').trim().slice(0, 32);
-const normalizeColor = (value) => {
-    const candidate = (value || '').trim().toLowerCase();
-    return /^#[0-9a-f]{6}$/.test(candidate) ? candidate : '';
-};
-const getReadableTextColor = (hexColor) => {
-    const normalized = normalizeColor(hexColor);
-    if (!normalized) return '#0f172a';
-
-    const r = parseInt(normalized.slice(1, 3), 16);
-    const g = parseInt(normalized.slice(3, 5), 16);
-    const b = parseInt(normalized.slice(5, 7), 16);
-    const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
-
-    return luminance > 145 ? '#0f172a' : '#f8fafc';
-};
-
-// In dark mode, swap colors that are too dark to see on a dark background to white.
-const getArrowDisplayColor = (hexColor) => {
-    if (!document.body.classList.contains('dark')) return hexColor;
-    const normalized = normalizeColor(hexColor);
-    if (!normalized) return '#ffffff';
-    const r = parseInt(normalized.slice(1, 3), 16) / 255;
-    const g = parseInt(normalized.slice(3, 5), 16) / 255;
-    const b = parseInt(normalized.slice(5, 7), 16) / 255;
-    const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
-    return luminance < 0.2 ? '#ffffff' : hexColor;
-};
-
-const getDeviceHint = async () => {
-    // Try high-entropy UA data first (Chromium only).
-    if (navigator.userAgentData) {
-        try {
-            const data = await navigator.userAgentData.getHighEntropyValues(['model']);
-            if (data.model) return data.model;
-        } catch {
-            // Fall through to UA string parsing.
-        }
-    }
-
-    const ua = navigator.userAgent;
-    // iPhone / iPad
-    const iosMatch = ua.match(/\(([^)]+)\)/);
-    if (/iPhone/.test(ua)) return 'iPhone';
-    if (/iPad/.test(ua)) return 'iPad';
-    // Named Android device (e.g. "SM-G991B Build/..." or "Pixel 7 Build/...")
-    const androidModel = ua.match(/;\s*([^;]+?)\s+Build\//);
-    if (androidModel) return androidModel[1];
-    // OS family fallback
-    if (/Android/.test(ua)) return 'Android';
-    if (/Mac/.test(ua)) return 'Mac';
-    if (/Windows/.test(ua)) return 'PC';
-    if (/Linux/.test(ua)) return 'Linux';
-    return null;
-};
-
-const createRandomUserName = async () => {
-    const hint = await getDeviceHint();
-    const suffix = Math.floor(1000 + Math.random() * 9000);
-    return hint ? `${hint} ${suffix}` : `User ${suffix}`;
-};
-
-const createRandomColor = () => `#${Math.floor(Math.random() * 0x1000000).toString(16).padStart(6, '0')}`;
-const resolveInitialSelfName = async () => {
-    const savedName = normalizeName(localStorage.getItem(NAME_STORAGE_KEY));
-
-    if (savedName) return savedName;
-
-    const generatedName = await createRandomUserName();
-    localStorage.setItem(NAME_STORAGE_KEY, generatedName);
-    return generatedName;
-};
-const resolveInitialSelfColor = () => {
-    const savedColor = normalizeColor(localStorage.getItem(COLOR_STORAGE_KEY));
-
-    if (savedColor) return savedColor;
-
-    const generatedColor = createRandomColor();
-    localStorage.setItem(COLOR_STORAGE_KEY, generatedColor);
-    return generatedColor;
-};
-
-const initialSelfName = await resolveInitialSelfName();
-const initialSelfColor = resolveInitialSelfColor();
-const participantVisibility = (() => {
-    try {
-        const raw = localStorage.getItem(PARTICIPANT_VISIBILITY_STORAGE_KEY);
-        return raw ? JSON.parse(raw) : {};
-    } catch {
-        return {};
-    }
-})();
-let participantOrder = (() => {
-    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') : [];
-    } catch {
-        return [];
-    }
-})();
-
-const saveParticipantOrder = () => {
-    localStorage.setItem(PARTICIPANT_ORDER_STORAGE_KEY, JSON.stringify(participantOrder));
-};
-
-const getParticipantDefaultRank = (participantId) => {
-    if (participantId === NORTH_MARKER_ID) return 0;
-    if (participantId === GUARDIAN_ANGEL_ID || participantId === 'guardian-angel') return 1;
-    return 2;
-};
-
-const shouldShowMapLink = (participantId) => {
-    return participantId !== NORTH_MARKER_ID && participantId !== GUARDIAN_ANGEL_ID;
-};
-
-const ensureParticipantOrder = (participantsToRender) => {
-    const ids = participantsToRender.map(participant => participant.id);
-    const idSet = new Set(ids);
-    const preserved = participantOrder.filter(id => idSet.has(id));
-    const preservedSet = new Set(preserved);
-    const missing = ids.filter(id => !preservedSet.has(id));
-
-    missing.sort((a, b) => {
-        const rankDelta = getParticipantDefaultRank(a) - getParticipantDefaultRank(b);
-        if (rankDelta !== 0) return rankDelta;
-        return a.localeCompare(b);
-    });
-
-    const nextOrder = [...preserved, ...missing];
-    const changed =
-        nextOrder.length !== participantOrder.length ||
-        nextOrder.some((id, index) => id !== participantOrder[index]);
-
-    if (changed) {
-        participantOrder = nextOrder;
-        saveParticipantOrder();
-    }
-
-    return participantOrder;
-};
 
-const sortParticipantsForDisplay = (participantsToRender) => {
-    const order = ensureParticipantOrder(participantsToRender);
-    const orderIndex = new Map(order.map((id, index) => [id, index]));
-
-    return [...participantsToRender].sort((a, b) => {
-        const aIndex = orderIndex.get(a.id);
-        const bIndex = orderIndex.get(b.id);
-        return aIndex - bIndex;
-    });
-};
-
-let draggedParticipantId = null;
-let draggedPointerId = null;
-let dropTargetParticipantId = null;
-let dropAfterTarget = false;
-
-const clearParticipantDragVisuals = () => {
-    if (!participantsPanel) return;
-
-    participantsPanel.querySelectorAll('.participant-row').forEach((row) => {
-        row.classList.remove('is-dragging', 'drag-over-before', 'drag-over-after');
-    });
-};
-
-const moveParticipantToIndex = (participantId, targetIndex) => {
-    const fromIndex = participantOrder.indexOf(participantId);
-    if (fromIndex < 0) return false;
-
-    const boundedIndex = Math.max(0, Math.min(targetIndex, participantOrder.length - 1));
-    if (fromIndex === boundedIndex) return false;
-
-    participantOrder.splice(fromIndex, 1);
-    participantOrder.splice(boundedIndex, 0, participantId);
-    saveParticipantOrder();
-    return true;
-};
-
-const endParticipantDrag = () => {
-    draggedParticipantId = null;
-    draggedPointerId = null;
-    dropTargetParticipantId = null;
-    dropAfterTarget = false;
-    clearParticipantDragVisuals();
-};
-
-const getInitialParticipantsPanelVisible = () => {
-    const saved = localStorage.getItem(PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY);
-    if (saved === 'true' || saved === 'false') {
-        return saved === 'true';
-    }
-
-    return !window.matchMedia('(max-width: 720px)').matches;
-};
-
-let participantsPanelVisible = getInitialParticipantsPanelVisible();
-const applyParticipantsPanelVisibility = () => {
-    if (participantsPanel) {
-        participantsPanel.classList.toggle('is-hidden', !participantsPanelVisible);
-    }
-    if (participantsToggle) {
-        participantsToggle.textContent = participantsPanelVisible ? 'Hide Participants' : 'Show Participants';
-        participantsToggle.setAttribute('aria-expanded', String(participantsPanelVisible));
-    }
-
-    localStorage.setItem(PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY, String(participantsPanelVisible));
-};
-
-if (participantsToggle) {
-    participantsToggle.addEventListener('click', () => {
-        participantsPanelVisible = !participantsPanelVisible;
-        applyParticipantsPanelVisibility();
-    });
-}
-
-document.addEventListener('pointerdown', (event) => {
-    if (!participantsPanelVisible) return;
-
-    const target = event.target;
-    if (!(target instanceof Node)) return;
-
-    const clickedInsidePanel = participantsPanel ? participantsPanel.contains(target) : false;
-    const clickedToggle = participantsToggle ? participantsToggle.contains(target) : false;
-
-    if (clickedInsidePanel || clickedToggle) return;
-
-    participantsPanelVisible = false;
-    applyParticipantsPanelVisibility();
-});
-
-document.addEventListener('pointermove', (event) => {
-    if (!draggedParticipantId || event.pointerId !== draggedPointerId) return;
-
-    event.preventDefault();
-
-    const pointed = document.elementFromPoint(event.clientX, event.clientY);
-    if (!(pointed instanceof Element) || !participantsPanel) {
-        dropTargetParticipantId = null;
-        dropAfterTarget = false;
-        clearParticipantDragVisuals();
-        return;
-    }
-
-    const targetRow = pointed.closest('.participant-row');
-    clearParticipantDragVisuals();
-
-    const draggedRow = participantsPanel.querySelector(`[data-participant-id="${draggedParticipantId}"]`);
-    if (draggedRow) {
-        draggedRow.classList.add('is-dragging');
-    }
-
-    if (!(targetRow instanceof HTMLElement) || !participantsPanel.contains(targetRow)) {
-        dropTargetParticipantId = null;
-        dropAfterTarget = false;
-        return;
-    }
-
-    const targetId = targetRow.dataset.participantId;
-    if (!targetId || targetId === draggedParticipantId) {
-        dropTargetParticipantId = null;
-        dropAfterTarget = false;
-        return;
-    }
-
-    const rect = targetRow.getBoundingClientRect();
-    const after = event.clientY > rect.top + rect.height / 2;
-
-    dropTargetParticipantId = targetId;
-    dropAfterTarget = after;
-    targetRow.classList.add(after ? 'drag-over-after' : 'drag-over-before');
-});
-
-document.addEventListener('pointerup', (event) => {
-    if (!draggedParticipantId || event.pointerId !== draggedPointerId) return;
-
-    const draggedId = draggedParticipantId;
-    const targetId = dropTargetParticipantId;
-    const insertAfter = dropAfterTarget;
-    endParticipantDrag();
-
-    if (!targetId) return;
-
-    const targetIndex = participantOrder.indexOf(targetId);
-    if (targetIndex < 0) return;
-
-    let nextIndex = insertAfter ? targetIndex + 1 : targetIndex;
-    const currentIndex = participantOrder.indexOf(draggedId);
-    if (currentIndex >= 0 && currentIndex < nextIndex) {
-        nextIndex -= 1;
-    }
-
-    if (!moveParticipantToIndex(draggedId, nextIndex)) return;
-
-    renderParticipantControls();
-    requestRender();
-});
-
-document.addEventListener('pointercancel', () => {
-    if (!draggedParticipantId) return;
-    endParticipantDrag();
+const { roomId, roomKey } = await initSession({
+    roomCurrent,
+    createRoomLink,
+    inviteRoomQrList,
+    isLocalCommit,
+    BASE_ROOM_URL,
+    GITLAB_PAGES_BASE_URL
 });
 
-applyParticipantsPanelVisibility();
-
-const isParticipantVisible = (participantId) => participantVisibility[participantId] !== false;
-const setParticipantVisibility = (participantId, visible) => {
-    participantVisibility[participantId] = visible;
-    localStorage.setItem(PARTICIPANT_VISIBILITY_STORAGE_KEY, JSON.stringify(participantVisibility));
-};
-
-const selfParticipant = {
-    id: uid,
-    name: initialSelfName,
-    color: initialSelfColor,
-    position: selfPosition,
-    timestamp: Date.now()
-};
-
 const participantRef = ref(db, `rooms/${roomId}/participants/${uid}`);
 onDisconnect(participantRef).remove();
 
-async function syncSelfParticipant() {
+// selfParticipant is assigned below by initSelfProfile; syncSelfParticipant is
+// only ever called after that point (from event handlers / intervals).
+let selfParticipant;
+
+const syncSelfParticipant = async () => {
     selfParticipant.timestamp = Date.now();
-    let dbRecord;
-    if (roomKey) {
-        dbRecord = {
-            encryptedState: await encryptJson(roomKey, selfParticipant)
-        };
-    } else {
-        dbRecord = selfParticipant;
-    }
-    console.debug('Syncing self participant:', dbRecord);
+    const dbRecord = roomKey
+        ? { encryptedState: await encryptJson(roomKey, selfParticipant) }
+        : selfParticipant;
     try {
         await set(participantRef, dbRecord);
     } catch (err) {
         console.error('Error syncing participant in database', err);
     }
-}
-
-let renderFrameRequested = false;
-const requestRender = () => {
-    if (renderFrameRequested) {
-        return;
-    }
-
-    renderFrameRequested = true;
-    requestAnimationFrame(() => {
-        renderFrameRequested = false;
-        render();
-    });
 };
 
 let lastLocationSyncAt = 0;
 const maybeSyncLocation = () => {
     const now = Date.now();
-    if (now - lastLocationSyncAt < LOCATION_SYNC_INTERVAL_MS) {
-        return;
-    }
-
+    if (now - lastLocationSyncAt < LOCATION_SYNC_INTERVAL_MS) return;
     lastLocationSyncAt = now;
     void syncSelfParticipant();
 };
 
-if (selfNameInput) {
-    selfNameInput.value = selfParticipant.name;
-}
-if (selfColorInput) {
-    selfColorInput.value = selfParticipant.color;
-}
-
-const applySelfName = async (rawName) => {
-    const nextName = normalizeName(rawName) || await createRandomUserName();
-
-    if (nextName === selfParticipant.name) {
-        if (selfNameInput) selfNameInput.value = nextName;
-        return;
-    }
-
-    selfParticipant.name = nextName;
-    localStorage.setItem(NAME_STORAGE_KEY, nextName);
-    if (selfNameInput) selfNameInput.value = nextName;
-    await syncSelfParticipant();
-    requestRender();
-};
-
-const applySelfColor = async (rawColor) => {
-    const nextColor = normalizeColor(rawColor) || createRandomColor();
-
-    if (nextColor === selfParticipant.color) {
-        if (selfColorInput) selfColorInput.value = nextColor;
-        return;
-    }
-
-    selfParticipant.color = nextColor;
-    localStorage.setItem(COLOR_STORAGE_KEY, nextColor);
-    if (selfColorInput) selfColorInput.value = nextColor;
-    await syncSelfParticipant();
-    requestRender();
+let renderFrameRequested = false;
+const requestRender = () => {
+    if (renderFrameRequested) return;
+    renderFrameRequested = true;
+    requestAnimationFrame(() => {
+        renderFrameRequested = false;
+        render();
+    });
 };
 
-if (nameForm) {
-    nameForm.addEventListener('submit', async (event) => {
-        event.preventDefault();
-        await applySelfName(selfNameInput ? selfNameInput.value : selfParticipant.name);
-        await applySelfColor(selfColorInput ? selfColorInput.value : selfParticipant.color);
-        if (selfNameInput) selfNameInput.blur();
-    });
-}
+({ selfParticipant } = await initSelfProfile({
+    nameForm,
+    selfNameInput,
+    selfColorInput,
+    uid,
+    selfPosition,
+    syncSelf: syncSelfParticipant,
+    requestRender
+}));
+
+const compassController = initCompassController({
+    calibrateHeadingLink,
+    initialHeadingCorrectionDeg: headingCorrectionDeg,
+    onHeadingCorrectionDegChange: (next) => { headingCorrectionDeg = next; },
+    requestRender
+});
 
-if (selfNameInput) {
-    selfNameInput.addEventListener('change', async () => {
-        await applySelfName(selfNameInput.value);
-    });
-}
-if (selfColorInput) {
-    selfColorInput.addEventListener('change', async () => {
-        await applySelfColor(selfColorInput.value);
-    });
-}
+const participantsController = createParticipantsPanelController({
+    participantsPanel,
+    participantsToggle,
+    requestRender,
+    getReadableTextColor,
+    northMarkerId: NORTH_MARKER_ID,
+    guardianAngelId: GUARDIAN_ANGEL_ID,
+    selfParticipant,
+    selfPosition
+});
 
 navigator.geolocation.watchPosition(async (pos) => {
     selfPosition.lat = pos.coords.latitude;
@@ -963,327 +153,41 @@ navigator.geolocation.watchPosition(async (pos) => {
 }, (err) => {
     console.error('Error getting geolocation', err);
 });
-const getScreenOrientationAngle = () => {
-    if (window.screen?.orientation && typeof window.screen.orientation.angle === 'number') {
-        return window.screen.orientation.angle;
-    }
-    if (typeof window.orientation === 'number') {
-        return window.orientation;
-    }
-    return 0;
-};
-
-const getHeadingFromEvent = (orientationEvent) => {
-    // iOS Safari: already compass heading of the top of the screen.
-    if (typeof orientationEvent.webkitCompassHeading === 'number') {
-        return normalizeAngle(orientationEvent.webkitCompassHeading);
-    }
-
-    if (orientationEvent.alpha === null) {
-        return null;
-    }
-
-    // Generic alpha -> compass heading, then compensate screen rotation.
-    const alphaHeading = normalizeAngle(360 - orientationEvent.alpha);
-    return normalizeAngle(alphaHeading + getScreenOrientationAngle());
-};
-
-let hasAbsoluteOrientation = false;
-
-window.addEventListener('deviceorientationabsolute', (event) => {
-    clearCompassProbeTimer();
-    hasSeenOrientationEvent = true;
-    const heading = getHeadingFromEvent(event);
-    if (heading === null) return;
-
-    if (!compassEnabled) {
-        compassEnabled = true;
-        updateCompassButtonState();
-    }
-
-    hasSeenHeadingValue = true;
-    if (compassButtonStatusError) {
-        clearCompassButtonStatus();
-        updateCompassButtonState();
-    }
-    hasAbsoluteOrientation = true;
-    lastRawHeading = heading;
-    selfHeading = normalizeAngle(heading + headingCorrectionDeg);
-    requestRender();
-});
-
-window.addEventListener('deviceorientation', (event) => {
-    if (hasAbsoluteOrientation) return;
-
-    clearCompassProbeTimer();
-    hasSeenOrientationEvent = true;
-
-    const heading = getHeadingFromEvent(event);
-    if (heading === null) return;
-
-    if (!compassEnabled) {
-        compassEnabled = true;
-        updateCompassButtonState();
-    }
-
-    hasSeenHeadingValue = true;
-    if (compassButtonStatusError) {
-        clearCompassButtonStatus();
-        updateCompassButtonState();
-    }
-    lastRawHeading = heading;
-    selfHeading = normalizeAngle(heading + headingCorrectionDeg);
-    requestRender();
-});
 
 document.addEventListener('visibilitychange', () => {
     if (document.visibilityState !== 'visible') return;
-    scheduleCompassProbe();
+    compassController.scheduleCompassProbe();
     void requestWakeLock();
 });
 
 void requestWakeLock();
 
-const participantsRef = ref(db, `rooms/${roomId}/participants`);
-// onValue(participantsRef, (snapshot) => {
-//     const participants = snapshot.val(); // object of uid → participant
-//     console.log(participants);
-//     // update arrows in your UI here
-// });
-const participants = [];
-let participantsSnapshotVersion = 0;
-onValue(participantsRef, async (snapshot) => {
-    const snapshotVersion = ++participantsSnapshotVersion;
-    console.debug('Participants data updated:', snapshot.val());
-
-    const participantsData = snapshot.val(); // object of uid → participant
-    if (participantsData) {
-        const rawParticipants = Object.values(participantsData);
-        const decrypted = await Promise.all(rawParticipants.map(async (p) => {
-            if (p.encryptedState && roomKey) {
-                try {
-                    return await decryptJson(roomKey, p.encryptedState);
-                } catch (err) {
-                    console.error('Failed to decrypt participant state', p.id, err);
-                    return null; // exclude participant whose data can't be decrypted
-                }
-            }
-            if (p.encryptedPosition && roomKey) {
-                try {
-                    const position = await decryptJson(roomKey, p.encryptedPosition);
-                    const { encryptedPosition: _stripped, ...rest } = p;
-                    return { ...rest, position };
-                } catch (err) {
-                    console.error('Failed to decrypt legacy participant position', p.id, err);
-                    return null;
-                }
-            }
-            return p; // plaintext fallback for rooms without a key
-        }));
-        if (snapshotVersion !== participantsSnapshotVersion) return;
-        participants.length = 0;
-        participants.push(...decrypted.filter(Boolean));
-    } else {
-        if (snapshotVersion !== participantsSnapshotVersion) return;
-        console.warn('No participants data');
-        participants.length = 0;
+const { participants } = subscribeToParticipants({
+    db,
+    roomId,
+    roomKey,
+    onUpdate: (updated) => {
+        const participantsToRender = participantsController.getParticipantsToRender(updated);
+        participantsController.renderParticipantControls(participantsToRender);
+        requestRender();
     }
-
-    console.debug('Updated participants array:', participants);
-    renderParticipantControls();
-    requestRender();
 });
 
-const renderParticipantControls = () => {
-    const participantsToRender = getParticipantsToRender();
-
-    participantsPanel.textContent = '';
-    const heading = document.createElement('h3');
-    heading.textContent = 'Participants';
-    participantsPanel.appendChild(heading);
-
-    for (const participant of participantsToRender) {
-        const row = document.createElement('div');
-        row.className = 'participant-row';
-        row.dataset.participantId = participant.id;
-
-        const checkbox = document.createElement('input');
-        checkbox.type = 'checkbox';
-        checkbox.checked = isParticipantVisible(participant.id);
-        checkbox.addEventListener('change', () => {
-            setParticipantVisibility(participant.id, checkbox.checked);
-        });
-
-        const nameWrapper = document.createElement('div');
-        nameWrapper.className = 'participant-name-badge';
-        nameWrapper.style.backgroundColor = participant.color;
-        nameWrapper.style.color = getReadableTextColor(participant.color);
-
-        const nameText = document.createElement('span');
-        nameText.className = 'participant-name';
-        nameText.textContent = participant.name;
-
-        nameWrapper.appendChild(nameText);
-
-        const mapLink = document.createElement('a');
-        mapLink.className = 'participant-map-link';
-        mapLink.href = `https://www.google.com/maps?q=${participant.position.lat},${participant.position.lon}`;
-        mapLink.target = '_blank';
-        mapLink.rel = 'noopener noreferrer';
-        mapLink.textContent = '📍';
-        mapLink.title = `Open ${participant.name} in Google Maps`;
-        mapLink.setAttribute('aria-label', `Open ${participant.name} in Google Maps`);
-
-        row.appendChild(checkbox);
-        if (shouldShowMapLink(participant.id)) {
-            nameWrapper.appendChild(mapLink);
-        }
-
-        const dragHandle = document.createElement('button');
-        dragHandle.type = 'button';
-        dragHandle.className = 'participant-drag-handle';
-        dragHandle.textContent = '☰';
-        dragHandle.title = `Drag to reorder ${participant.name}`;
-        dragHandle.setAttribute('aria-label', `Drag to reorder ${participant.name}`);
-        dragHandle.addEventListener('pointerdown', (event) => {
-            if (event.button !== undefined && event.button !== 0) return;
-
-            event.preventDefault();
-            draggedParticipantId = participant.id;
-            draggedPointerId = event.pointerId;
-            dropTargetParticipantId = null;
-            dropAfterTarget = false;
-
-            clearParticipantDragVisuals();
-            row.classList.add('is-dragging');
-        });
-
-        row.appendChild(dragHandle);
-        row.appendChild(nameWrapper);
-        participantsPanel.appendChild(row);
-    }
-};
-
-let lastSAngle = null;
-const getParticipantsToRender = () => {
-    const participantsToRender = participants.filter(p => p.id !== selfParticipant.id);
-
-    // Add a synthetic North marker relative to the current self position for heading validation.
-    if (participantsToRender.findIndex(p => p.id === NORTH_MARKER_ID) === -1) {
-        participantsToRender.push({
-            id: NORTH_MARKER_ID,
-            name: 'North',
-            color: '#000000',
-            position: {
-                lat: selfPosition.lat + 0.001,
-                lon: selfPosition.lon
-            },
-            timestamp: Date.now()
-        });
-    }
-
-    // For testing, add a "stalker" participant that moves in a circle around the self participant. This is useful for testing the arrow rotation without needing multiple devices. The stalker will NOT be added as a participant in the database, so it will show up only on the local device and won't affect other participants.
-    const stalkerAngle = (Date.now() / 10000) % (2 * Math.PI); // angle in radians that changes over time
-    // console.debug('Stalker angle (radians):', stalkerAngle);
-    const stalkerDistance = 0.001; // distance in degrees (about 111 meters)
-    const stalkerPosition = {
-        lat: selfPosition.lat + stalkerDistance * Math.cos(stalkerAngle),
-        lon: selfPosition.lon + stalkerDistance * Math.sin(stalkerAngle)
-    };
-    const testParticipant = {
-        id: GUARDIAN_ANGEL_ID,
-        name: 'Guardian Angel',
-        color: '#ff0000',
-        position: stalkerPosition,
-        timestamp: Date.now()
-    };
-    // participantsToRender.push(testParticipant);
-
-    return sortParticipantsForDisplay(participantsToRender);
-};
-
 const render = () => {
-    const participantsToRender = getParticipantsToRender();
-    const content = document.getElementById('content');
-    const activeArrowIds = new Set(
-        participantsToRender
-            .filter(target => target.id !== selfParticipant.id)
-            .map(target => `arrow-${target.id}`)
-    );
-
-    // For each participant, calculate the angle from self to the participant, and rotate the arrow accordingly. Also set the color of the arrow to the participant's color.
-    // Skip self participant, we don't want to render an arrow for ourselves.
-    for (const target of participantsToRender) {
-        if (target.id === selfParticipant.id) continue;
-
-        // create a new arrow element if it doesn't exist
-        // copy from 'default-arrow' element, but set id to 'arrow-PARTICIPANT_ID' and display to block
-        let existingArrow = document.getElementById('arrow-' + target.id);
-        if (!existingArrow) {
-            const newArrow = arrow.cloneNode(true);
-            newArrow.id = 'arrow-' + target.id;
-            newArrow.style.display = 'block';
-            newArrow.currentAngle = null; // track the current angle for smooth rotation
-
-            const arrowLabelAnchor = document.createElement('div');
-            arrowLabelAnchor.className = 'arrow-label-anchor';
-
-            const arrowLabel = document.createElement('span');
-            arrowLabel.className = 'arrow-label';
-            arrowLabel.textContent = target.name;
-            arrowLabelAnchor.appendChild(arrowLabel);
-            newArrow.appendChild(arrowLabelAnchor);
-
-            content.appendChild(newArrow);
-            existingArrow = newArrow;
-        }
-
-        // Keep DOM order in sync with participant order so stacking matches the list.
-        content.appendChild(existingArrow);
-
-        if (!isParticipantVisible(target.id)) {
-            existingArrow.style.display = 'none';
-            continue;
-        }
-
-        existingArrow.style.display = 'block';
-
-        // Rotate the arrow toward the other participant and use their color.
-        const dx = target.position.lon - selfParticipant.position.lon;
-        const dy = target.position.lat - selfParticipant.position.lat;
-        const rawAngle = Math.atan2(dx, dy) * (180 / Math.PI);
-
-        // Consider the heading of the device to rotate the arrow accordingly
-        const targetAngle = normalizeAngle(rawAngle - selfHeading);
-
-        let currentArrowAngle = existingArrow.currentAngle;
-        if (currentArrowAngle === null) {
-            currentArrowAngle = targetAngle;
-        } else {
-            currentArrowAngle += getShortestDelta(currentArrowAngle, targetAngle) * 0.1; // smooth the rotation
-        }
-
-        existingArrow.currentAngle = currentArrowAngle;
-        existingArrow.style.transform = `rotate(${currentArrowAngle}deg)`;
-        existingArrow.style.color = getArrowDisplayColor(target.color);
-
-        const arrowLabel = existingArrow.querySelector('.arrow-label');
-        if (arrowLabel) {
-            arrowLabel.textContent = target.name;
-            arrowLabel.style.transform = `rotate(${-currentArrowAngle}deg)`;
-        }
-    }
-
-    // Remove arrows for participants who are no longer present.
-    content.querySelectorAll('.arrow[id^="arrow-"]').forEach((arrowElement) => {
-        if (!activeArrowIds.has(arrowElement.id)) {
-            arrowElement.remove();
-        }
+    const participantsToRender = participantsController.getParticipantsToRender(participants);
+
+    renderArrows({
+        content,
+        arrow,
+        participantsToRender,
+        selfParticipant,
+        selfHeading: compassController.getSelfHeading(),
+        isParticipantVisible: participantsController.isParticipantVisible,
+        getArrowDisplayColor
     });
 
-    // output debug info
     logDebug(`Real participants: ${participants.length} | Commit: ${isLocalCommit ? 'local' : `<a href="https://gitlab.com/PavelSafronov/north-star/-/commit/${GIT_COMMIT}" target="_blank">${GIT_COMMIT}</a>`} | Repo: <a href="https://gitlab.com/PavelSafronov/north-star" target="_blank">north-star</a>`);
 };
 
 await syncSelfParticipant();
-setInterval(render, RENDER_INTERVAL_MS); // capped render loop for smoother mobile performance
\ No newline at end of file
+setInterval(render, RENDER_INTERVAL_MS);
diff --git a/src/compass.js b/src/compass.js
new file mode 100644
index 0000000..744db0f
--- /dev/null
+++ b/src/compass.js
@@ -0,0 +1,312 @@
+import { normalizeAngle, normalizeSignedAngle } from './utils.js';
+
+export const initCompassController = ({
+    calibrateHeadingLink,
+    initialHeadingCorrectionDeg,
+    onHeadingCorrectionDegChange,
+    requestRender
+}) => {
+    const HEADING_CORRECTION_STORAGE_KEY = 'north-star-heading-correction-deg';
+
+    const requiresExplicitCompassPermission =
+        typeof DeviceOrientationEvent !== 'undefined' &&
+        typeof DeviceOrientationEvent.requestPermission === 'function';
+    const hasDeviceOrientationSupport = typeof DeviceOrientationEvent !== 'undefined';
+    const compassDefinitelyUnsupported = !hasDeviceOrientationSupport;
+
+    let headingCorrectionDeg = initialHeadingCorrectionDeg;
+    let selfHeading = 0;
+    let lastRawHeading = null;
+
+    let compassEnabled = !requiresExplicitCompassPermission;
+    let hasSeenOrientationEvent = false;
+    let hasSeenHeadingValue = false;
+    let compassButtonStatusLabel = '';
+    let compassButtonStatusTitle = '';
+    let compassButtonStatusError = false;
+    let compassStatusTimer = null;
+    let compassProbeTimer = null;
+    let hasAbsoluteOrientation = false;
+
+    const COMPASS_PROBE_TIMEOUT_MS = 2500;
+
+    const getHeadingUnavailableMessage = () => {
+        if (compassDefinitelyUnsupported) {
+            return 'This browser/device does not expose orientation sensors, so a compass is unavailable here.';
+        }
+
+        if (requiresExplicitCompassPermission && !compassEnabled) {
+            return 'Compass access is not enabled yet. Tap Enable Compass first.';
+        }
+
+        if (!hasSeenOrientationEvent) {
+            return 'No orientation sensor data has been detected on this device. This setup likely has no compass/motion sensor available (common on desktop/laptop hardware).';
+        }
+
+        if (!hasSeenHeadingValue) {
+            return 'Orientation events are arriving, but compass heading is unavailable in this browser/device context.';
+        }
+
+        return 'Heading sensor data is not ready yet. Try again in a moment.';
+    };
+
+    const getHeadingUnavailableStatus = () => {
+        if (compassDefinitelyUnsupported) {
+            return {
+                label: 'Compass Unavailable',
+                title: 'This browser/device does not expose orientation sensors, so a compass is unavailable here.',
+                isError: true
+            };
+        }
+
+        if (requiresExplicitCompassPermission && !compassEnabled) {
+            return {
+                label: 'Enable Compass First',
+                title: 'Compass access is not enabled yet. Tap Enable Compass first.',
+                isError: true
+            };
+        }
+
+        if (!hasSeenOrientationEvent) {
+            return {
+                label: 'No Sensor Data',
+                title: 'No orientation sensor data has been detected on this device. This setup likely has no compass/motion sensor available (common on desktop/laptop hardware).',
+                isError: true
+            };
+        }
+
+        if (!hasSeenHeadingValue) {
+            return {
+                label: 'Heading Unavailable',
+                title: 'Orientation events are arriving, but compass heading is unavailable in this browser/device context.',
+                isError: true
+            };
+        }
+
+        return {
+            label: 'Sensor Warming Up',
+            title: 'Heading sensor data is not ready yet. Try again in a moment.',
+            isError: false
+        };
+    };
+
+    const clearCompassButtonStatus = () => {
+        if (compassStatusTimer) {
+            clearTimeout(compassStatusTimer);
+            compassStatusTimer = null;
+        }
+
+        compassButtonStatusLabel = '';
+        compassButtonStatusTitle = '';
+        compassButtonStatusError = false;
+    };
+
+    const clearCompassProbeTimer = () => {
+        if (!compassProbeTimer) return;
+        clearTimeout(compassProbeTimer);
+        compassProbeTimer = null;
+    };
+
+    const showCompassButtonStatus = ({ label, title = '', isError = false, durationMs = 0 }) => {
+        if (compassStatusTimer) {
+            clearTimeout(compassStatusTimer);
+            compassStatusTimer = null;
+        }
+
+        compassButtonStatusLabel = label;
+        compassButtonStatusTitle = title;
+        compassButtonStatusError = isError;
+
+        if (durationMs > 0) {
+            compassStatusTimer = setTimeout(() => {
+                clearCompassButtonStatus();
+                updateCompassButtonState();
+            }, durationMs);
+        }
+
+        updateCompassButtonState();
+    };
+
+    const updateCompassButtonState = () => {
+        if (!calibrateHeadingLink) return;
+
+        if (compassDefinitelyUnsupported) {
+            calibrateHeadingLink.textContent = 'Compass Unavailable';
+            calibrateHeadingLink.classList.add('compass-required');
+            calibrateHeadingLink.classList.remove('compass-error');
+            calibrateHeadingLink.title = getHeadingUnavailableMessage();
+            calibrateHeadingLink.disabled = true;
+            return;
+        }
+
+        calibrateHeadingLink.disabled = false;
+        calibrateHeadingLink.textContent = compassButtonStatusLabel || (compassEnabled ? 'Calibrate North' : 'Enable Compass');
+        calibrateHeadingLink.title = compassButtonStatusTitle;
+        calibrateHeadingLink.classList.toggle('compass-required', !compassEnabled);
+        calibrateHeadingLink.classList.toggle('compass-error', compassButtonStatusError);
+    };
+
+    const scheduleCompassProbe = () => {
+        clearCompassProbeTimer();
+
+        if (compassDefinitelyUnsupported) return;
+        if (!compassEnabled) return;
+        if (hasSeenOrientationEvent || hasSeenHeadingValue) return;
+
+        compassProbeTimer = setTimeout(() => {
+            if (hasSeenOrientationEvent || hasSeenHeadingValue) return;
+
+            const status = getHeadingUnavailableStatus();
+            showCompassButtonStatus({
+                label: status.label,
+                title: status.title,
+                isError: status.isError
+            });
+        }, COMPASS_PROBE_TIMEOUT_MS);
+    };
+
+    const requestCompassPermission = async () => {
+        if (!requiresExplicitCompassPermission) {
+            return true;
+        }
+
+        try {
+            const permissionState = await DeviceOrientationEvent.requestPermission();
+            return permissionState === 'granted';
+        } catch (err) {
+            console.error('Failed to request compass permission', err);
+            return false;
+        }
+    };
+
+    const getScreenOrientationAngle = () => {
+        if (window.screen?.orientation && typeof window.screen.orientation.angle === 'number') {
+            return window.screen.orientation.angle;
+        }
+        if (typeof window.orientation === 'number') {
+            return window.orientation;
+        }
+        return 0;
+    };
+
+    const getHeadingFromEvent = (orientationEvent) => {
+        if (typeof orientationEvent.webkitCompassHeading === 'number') {
+            return normalizeAngle(orientationEvent.webkitCompassHeading);
+        }
+
+        if (orientationEvent.alpha === null) {
+            return null;
+        }
+
+        const alphaHeading = normalizeAngle(360 - orientationEvent.alpha);
+        return normalizeAngle(alphaHeading + getScreenOrientationAngle());
+    };
+
+    updateCompassButtonState();
+    scheduleCompassProbe();
+
+    if (calibrateHeadingLink) {
+        calibrateHeadingLink.addEventListener('click', async (event) => {
+            event.preventDefault();
+
+            if (!compassEnabled) {
+                const granted = await requestCompassPermission();
+                if (!granted) {
+                    showCompassButtonStatus({
+                        label: 'Compass Denied',
+                        title: 'Compass permission was denied. Enable Motion & Orientation Access in Safari settings and try again.',
+                        isError: true
+                    });
+                    return;
+                }
+
+                compassEnabled = true;
+                clearCompassButtonStatus();
+                updateCompassButtonState();
+                scheduleCompassProbe();
+                showCompassButtonStatus({
+                    label: 'Compass Enabled',
+                    title: 'Compass access is active. You can now calibrate north.',
+                    durationMs: 1500
+                });
+                return;
+            }
+
+            if (lastRawHeading === null) {
+                const status = getHeadingUnavailableStatus();
+                showCompassButtonStatus({
+                    label: status.label,
+                    title: status.title,
+                    isError: status.isError,
+                    durationMs: status.isError ? 0 : 1500
+                });
+                return;
+            }
+
+            headingCorrectionDeg = normalizeSignedAngle(-lastRawHeading);
+            localStorage.setItem(HEADING_CORRECTION_STORAGE_KEY, String(headingCorrectionDeg));
+            onHeadingCorrectionDegChange(headingCorrectionDeg);
+            selfHeading = normalizeAngle(lastRawHeading + headingCorrectionDeg);
+
+            showCompassButtonStatus({
+                label: 'North Calibrated',
+                title: 'Calibration applied.',
+                durationMs: 1500
+            });
+            requestRender();
+        });
+    }
+
+    window.addEventListener('deviceorientationabsolute', (event) => {
+        clearCompassProbeTimer();
+        hasSeenOrientationEvent = true;
+        const heading = getHeadingFromEvent(event);
+        if (heading === null) return;
+
+        if (!compassEnabled) {
+            compassEnabled = true;
+            updateCompassButtonState();
+        }
+
+        hasSeenHeadingValue = true;
+        if (compassButtonStatusError) {
+            clearCompassButtonStatus();
+            updateCompassButtonState();
+        }
+
+        hasAbsoluteOrientation = true;
+        lastRawHeading = heading;
+        selfHeading = normalizeAngle(heading + headingCorrectionDeg);
+        requestRender();
+    });
+
+    window.addEventListener('deviceorientation', (event) => {
+        if (hasAbsoluteOrientation) return;
+
+        clearCompassProbeTimer();
+        hasSeenOrientationEvent = true;
+
+        const heading = getHeadingFromEvent(event);
+        if (heading === null) return;
+
+        if (!compassEnabled) {
+            compassEnabled = true;
+            updateCompassButtonState();
+        }
+
+        hasSeenHeadingValue = true;
+        if (compassButtonStatusError) {
+            clearCompassButtonStatus();
+            updateCompassButtonState();
+        }
+
+        lastRawHeading = heading;
+        selfHeading = normalizeAngle(heading + headingCorrectionDeg);
+        requestRender();
+    });
+
+    return {
+        getSelfHeading: () => selfHeading,
+        scheduleCompassProbe
+    };
+};
diff --git a/src/crypto.js b/src/crypto.js
new file mode 100644
index 0000000..35dc69d
--- /dev/null
+++ b/src/crypto.js
@@ -0,0 +1,35 @@
+const AES_PARAMS = { name: 'AES-GCM', length: 256 };
+const IV_BYTES = 12;
+
+export const generateRoomKey = () =>
+    crypto.subtle.generateKey(AES_PARAMS, true, ['encrypt', 'decrypt']);
+
+export const exportRoomKey = async (key) => {
+    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) => {
+    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) => {
+    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);
+    const ivB64 = btoa(String.fromCharCode(...iv));
+    const ctB64 = btoa(String.fromCharCode(...new Uint8Array(ciphertext)));
+    return `${ivB64}:${ctB64}`;
+};
+
+export const decryptJson = async (key, encryptedValue) => {
+    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));
+    const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
+    return JSON.parse(new TextDecoder().decode(plaintext));
+};
diff --git a/src/darkmode.js b/src/darkmode.js
new file mode 100644
index 0000000..be81bf4
--- /dev/null
+++ b/src/darkmode.js
@@ -0,0 +1,24 @@
+export const DARK_MODE_STORAGE_KEY = 'north-star-dark-mode';
+
+export const initDarkMode = (toggleButton) => {
+    const applyDarkMode = (dark) => {
+        document.body.classList.toggle('dark', dark);
+        if (toggleButton) toggleButton.textContent = dark ? '☀️' : '🌙';
+        localStorage.setItem(DARK_MODE_STORAGE_KEY, dark ? '1' : '0');
+    };
+
+    const storedDarkMode = localStorage.getItem(DARK_MODE_STORAGE_KEY);
+    const prefersDark = storedDarkMode !== null
+        ? storedDarkMode === '1'
+        : window.matchMedia('(prefers-color-scheme: dark)').matches;
+
+    applyDarkMode(prefersDark);
+
+    if (toggleButton) {
+        toggleButton.addEventListener('click', () => {
+            applyDarkMode(!document.body.classList.contains('dark'));
+        });
+    }
+
+    return { applyDarkMode };
+};
diff --git a/src/firebase.js b/src/firebase.js
new file mode 100644
index 0000000..df296f2
--- /dev/null
+++ b/src/firebase.js
@@ -0,0 +1,27 @@
+import { initializeApp } from 'https://www.gstatic.com/firebasejs/12.10.0/firebase-app.js';
+import { getAuth, signInAnonymously } from 'https://www.gstatic.com/firebasejs/12.10.0/firebase-auth.js';
+import { getDatabase } from 'https://www.gstatic.com/firebasejs/12.10.0/firebase-database.js';
+
+const firebaseConfig = {
+    apiKey: 'AIzaSyA4G7e13Gt1XAIds1Y63X_RFw-2O48iCLM',
+    authDomain: 'north-star-e5dee.firebaseapp.com',
+    projectId: 'north-star-e5dee',
+    storageBucket: 'north-star-e5dee.firebasestorage.app',
+    messagingSenderId: '999461150314',
+    appId: '1:999461150314:web:53f3c6216dd4b0468bf413',
+    databaseURL: 'https://north-star-e5dee-default-rtdb.firebaseio.com/'
+};
+
+export const initFirebaseSession = async () => {
+    const app = initializeApp(firebaseConfig);
+    const auth = getAuth(app);
+    const db = getDatabase(app);
+
+    const authSignInResponse = await signInAnonymously(auth);
+    const uid = authSignInResponse.user.uid;
+
+    console.debug('Signed in anonymously:', authSignInResponse);
+    console.debug('User ID:', uid);
+
+    return { db, uid };
+};
diff --git a/src/participants-panel.js b/src/participants-panel.js
new file mode 100644
index 0000000..c814ed4
--- /dev/null
+++ b/src/participants-panel.js
@@ -0,0 +1,339 @@
+export const createParticipantsPanelController = ({
+    participantsPanel,
+    participantsToggle,
+    requestRender,
+    getReadableTextColor,
+    northMarkerId,
+    guardianAngelId,
+    selfParticipant,
+    selfPosition
+}) => {
+    const PARTICIPANT_VISIBILITY_STORAGE_KEY = 'north-star-participant-visibility';
+    const PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY = 'north-star-participants-panel-visible';
+    const PARTICIPANT_ORDER_STORAGE_KEY = 'north-star-participant-order';
+
+    const participantVisibility = (() => {
+        try {
+            const raw = localStorage.getItem(PARTICIPANT_VISIBILITY_STORAGE_KEY);
+            return raw ? JSON.parse(raw) : {};
+        } catch {
+            return {};
+        }
+    })();
+
+    let participantOrder = (() => {
+        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') : [];
+        } catch {
+            return [];
+        }
+    })();
+
+    const saveParticipantOrder = () => {
+        localStorage.setItem(PARTICIPANT_ORDER_STORAGE_KEY, JSON.stringify(participantOrder));
+    };
+
+    const getParticipantDefaultRank = (participantId) => {
+        if (participantId === northMarkerId) return 0;
+        if (participantId === guardianAngelId || participantId === 'guardian-angel') return 1;
+        return 2;
+    };
+
+    const shouldShowMapLink = (participantId) => {
+        return participantId !== northMarkerId && participantId !== guardianAngelId;
+    };
+
+    const ensureParticipantOrder = (participantsToRender) => {
+        const ids = participantsToRender.map((participant) => participant.id);
+        const idSet = new Set(ids);
+        const preserved = participantOrder.filter((id) => idSet.has(id));
+        const preservedSet = new Set(preserved);
+        const missing = ids.filter((id) => !preservedSet.has(id));
+
+        missing.sort((a, b) => {
+            const rankDelta = getParticipantDefaultRank(a) - getParticipantDefaultRank(b);
+            if (rankDelta !== 0) return rankDelta;
+            return a.localeCompare(b);
+        });
+
+        const nextOrder = [...preserved, ...missing];
+        const changed =
+            nextOrder.length !== participantOrder.length ||
+            nextOrder.some((id, index) => id !== participantOrder[index]);
+
+        if (changed) {
+            participantOrder = nextOrder;
+            saveParticipantOrder();
+        }
+
+        return participantOrder;
+    };
+
+    const sortParticipantsForDisplay = (participantsToRender) => {
+        const order = ensureParticipantOrder(participantsToRender);
+        const orderIndex = new Map(order.map((id, index) => [id, index]));
+
+        return [...participantsToRender].sort((a, b) => {
+            const aIndex = orderIndex.get(a.id);
+            const bIndex = orderIndex.get(b.id);
+            return aIndex - bIndex;
+        });
+    };
+
+    let draggedParticipantId = null;
+    let draggedPointerId = null;
+    let dropTargetParticipantId = null;
+    let dropAfterTarget = false;
+    let lastParticipantsToRender = [];
+
+    const clearParticipantDragVisuals = () => {
+        if (!participantsPanel) return;
+
+        participantsPanel.querySelectorAll('.participant-row').forEach((row) => {
+            row.classList.remove('is-dragging', 'drag-over-before', 'drag-over-after');
+        });
+    };
+
+    const moveParticipantToIndex = (participantId, targetIndex) => {
+        const fromIndex = participantOrder.indexOf(participantId);
+        if (fromIndex < 0) return false;
+
+        const boundedIndex = Math.max(0, Math.min(targetIndex, participantOrder.length - 1));
+        if (fromIndex === boundedIndex) return false;
+
+        participantOrder.splice(fromIndex, 1);
+        participantOrder.splice(boundedIndex, 0, participantId);
+        saveParticipantOrder();
+        return true;
+    };
+
+    const endParticipantDrag = () => {
+        draggedParticipantId = null;
+        draggedPointerId = null;
+        dropTargetParticipantId = null;
+        dropAfterTarget = false;
+        clearParticipantDragVisuals();
+    };
+
+    const getInitialParticipantsPanelVisible = () => {
+        const saved = localStorage.getItem(PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY);
+        if (saved === 'true' || saved === 'false') {
+            return saved === 'true';
+        }
+
+        return !window.matchMedia('(max-width: 720px)').matches;
+    };
+
+    let participantsPanelVisible = getInitialParticipantsPanelVisible();
+    const applyParticipantsPanelVisibility = () => {
+        if (participantsPanel) {
+            participantsPanel.classList.toggle('is-hidden', !participantsPanelVisible);
+        }
+        if (participantsToggle) {
+            participantsToggle.textContent = participantsPanelVisible ? 'Hide Participants' : 'Show Participants';
+            participantsToggle.setAttribute('aria-expanded', String(participantsPanelVisible));
+        }
+
+        localStorage.setItem(PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY, String(participantsPanelVisible));
+    };
+
+    if (participantsToggle) {
+        participantsToggle.addEventListener('click', () => {
+            participantsPanelVisible = !participantsPanelVisible;
+            applyParticipantsPanelVisibility();
+        });
+    }
+
+    document.addEventListener('pointerdown', (event) => {
+        if (!participantsPanelVisible) return;
+
+        const target = event.target;
+        if (!(target instanceof Node)) return;
+
+        const clickedInsidePanel = participantsPanel ? participantsPanel.contains(target) : false;
+        const clickedToggle = participantsToggle ? participantsToggle.contains(target) : false;
+
+        if (clickedInsidePanel || clickedToggle) return;
+
+        participantsPanelVisible = false;
+        applyParticipantsPanelVisibility();
+    });
+
+    document.addEventListener('pointermove', (event) => {
+        if (!draggedParticipantId || event.pointerId !== draggedPointerId) return;
+
+        event.preventDefault();
+
+        const pointed = document.elementFromPoint(event.clientX, event.clientY);
+        if (!(pointed instanceof Element) || !participantsPanel) {
+            dropTargetParticipantId = null;
+            dropAfterTarget = false;
+            clearParticipantDragVisuals();
+            return;
+        }
+
+        const targetRow = pointed.closest('.participant-row');
+        clearParticipantDragVisuals();
+
+        const draggedRow = participantsPanel.querySelector(`[data-participant-id="${draggedParticipantId}"]`);
+        if (draggedRow) {
+            draggedRow.classList.add('is-dragging');
+        }
+
+        if (!(targetRow instanceof HTMLElement) || !participantsPanel.contains(targetRow)) {
+            dropTargetParticipantId = null;
+            dropAfterTarget = false;
+            return;
+        }
+
+        const targetId = targetRow.dataset.participantId;
+        if (!targetId || targetId === draggedParticipantId) {
+            dropTargetParticipantId = null;
+            dropAfterTarget = false;
+            return;
+        }
+
+        const rect = targetRow.getBoundingClientRect();
+        const after = event.clientY > rect.top + rect.height / 2;
+
+        dropTargetParticipantId = targetId;
+        dropAfterTarget = after;
+        targetRow.classList.add(after ? 'drag-over-after' : 'drag-over-before');
+    });
+
+    document.addEventListener('pointerup', (event) => {
+        if (!draggedParticipantId || event.pointerId !== draggedPointerId) return;
+
+        const draggedId = draggedParticipantId;
+        const targetId = dropTargetParticipantId;
+        const insertAfter = dropAfterTarget;
+        endParticipantDrag();
+
+        if (!targetId) return;
+
+        const targetIndex = participantOrder.indexOf(targetId);
+        if (targetIndex < 0) return;
+
+        let nextIndex = insertAfter ? targetIndex + 1 : targetIndex;
+        const currentIndex = participantOrder.indexOf(draggedId);
+        if (currentIndex >= 0 && currentIndex < nextIndex) {
+            nextIndex -= 1;
+        }
+
+        if (!moveParticipantToIndex(draggedId, nextIndex)) return;
+
+        renderParticipantControls(lastParticipantsToRender);
+        requestRender();
+    });
+
+    document.addEventListener('pointercancel', () => {
+        if (!draggedParticipantId) return;
+        endParticipantDrag();
+    });
+
+    applyParticipantsPanelVisibility();
+
+    const isParticipantVisible = (participantId) => participantVisibility[participantId] !== false;
+    const setParticipantVisibility = (participantId, visible) => {
+        participantVisibility[participantId] = visible;
+        localStorage.setItem(PARTICIPANT_VISIBILITY_STORAGE_KEY, JSON.stringify(participantVisibility));
+    };
+
+    const getParticipantsToRender = (participants) => {
+        const participantsToRender = participants.filter((p) => p.id !== selfParticipant.id);
+
+        if (participantsToRender.findIndex((p) => p.id === northMarkerId) === -1) {
+            participantsToRender.push({
+                id: northMarkerId,
+                name: 'North',
+                color: '#000000',
+                position: {
+                    lat: selfPosition.lat + 0.001,
+                    lon: selfPosition.lon
+                },
+                timestamp: Date.now()
+            });
+        }
+
+        return sortParticipantsForDisplay(participantsToRender);
+    };
+
+    const renderParticipantControls = (participantsToRender) => {
+        lastParticipantsToRender = participantsToRender;
+        if (!participantsPanel) return;
+
+        participantsPanel.textContent = '';
+        const heading = document.createElement('h3');
+        heading.textContent = 'Participants';
+        participantsPanel.appendChild(heading);
+
+        for (const participant of participantsToRender) {
+            const row = document.createElement('div');
+            row.className = 'participant-row';
+            row.dataset.participantId = participant.id;
+
+            const checkbox = document.createElement('input');
+            checkbox.type = 'checkbox';
+            checkbox.checked = isParticipantVisible(participant.id);
+            checkbox.addEventListener('change', () => {
+                setParticipantVisibility(participant.id, checkbox.checked);
+                requestRender();
+            });
+
+            const nameWrapper = document.createElement('div');
+            nameWrapper.className = 'participant-name-badge';
+            nameWrapper.style.backgroundColor = participant.color;
+            nameWrapper.style.color = getReadableTextColor(participant.color);
+
+            const nameText = document.createElement('span');
+            nameText.className = 'participant-name';
+            nameText.textContent = participant.name;
+            nameWrapper.appendChild(nameText);
+
+            const mapLink = document.createElement('a');
+            mapLink.className = 'participant-map-link';
+            mapLink.href = `https://www.google.com/maps?q=${participant.position.lat},${participant.position.lon}`;
+            mapLink.target = '_blank';
+            mapLink.rel = 'noopener noreferrer';
+            mapLink.textContent = '📍';
+            mapLink.title = `Open ${participant.name} in Google Maps`;
+            mapLink.setAttribute('aria-label', `Open ${participant.name} in Google Maps`);
+
+            row.appendChild(checkbox);
+            if (shouldShowMapLink(participant.id)) {
+                nameWrapper.appendChild(mapLink);
+            }
+
+            const dragHandle = document.createElement('button');
+            dragHandle.type = 'button';
+            dragHandle.className = 'participant-drag-handle';
+            dragHandle.textContent = '☰';
+            dragHandle.title = `Drag to reorder ${participant.name}`;
+            dragHandle.setAttribute('aria-label', `Drag to reorder ${participant.name}`);
+            dragHandle.addEventListener('pointerdown', (event) => {
+                if (event.button !== undefined && event.button !== 0) return;
+
+                event.preventDefault();
+                draggedParticipantId = participant.id;
+                draggedPointerId = event.pointerId;
+                dropTargetParticipantId = null;
+                dropAfterTarget = false;
+
+                clearParticipantDragVisuals();
+                row.classList.add('is-dragging');
+            });
+
+            row.appendChild(dragHandle);
+            row.appendChild(nameWrapper);
+            participantsPanel.appendChild(row);
+        }
+    };
+
+    return {
+        isParticipantVisible,
+        getParticipantsToRender,
+        renderParticipantControls
+    };
+};
diff --git a/src/participants-sync.js b/src/participants-sync.js
new file mode 100644
index 0000000..3046698
--- /dev/null
+++ b/src/participants-sync.js
@@ -0,0 +1,59 @@
+import { ref, onValue } from "https://www.gstatic.com/firebasejs/12.10.0/firebase-database.js";
+import { decryptJson } from './crypto.js';
+
+/**
+ * Subscribes to the participants node for a room, decrypting each record
+ * as needed. Calls onUpdate(participants) after every snapshot resolves.
+ *
+ * @param {object} opts
+ * @param {object} opts.db - Firebase Database instance
+ * @param {string} opts.roomId
+ * @param {CryptoKey|null} opts.roomKey - AES-GCM key, or null for unencrypted rooms
+ * @param {function} opts.onUpdate - (participants: object[]) => void
+ * @returns {{ participants: object[] }} Shared array reference, mutated in place on each update
+ */
+export function subscribeToParticipants({ db, roomId, roomKey, onUpdate }) {
+    const participantsRef = ref(db, `rooms/${roomId}/participants`);
+    const participants = [];
+    let snapshotVersion = 0;
+
+    onValue(participantsRef, async (snapshot) => {
+        const version = ++snapshotVersion;
+
+        const participantsData = snapshot.val();
+        if (participantsData) {
+            const rawParticipants = Object.values(participantsData);
+            const decrypted = await Promise.all(rawParticipants.map(async (p) => {
+                if (p.encryptedState && roomKey) {
+                    try {
+                        return await decryptJson(roomKey, p.encryptedState);
+                    } catch (err) {
+                        console.error('Failed to decrypt participant state', p.id, err);
+                        return null;
+                    }
+                }
+                if (p.encryptedPosition && roomKey) {
+                    try {
+                        const position = await decryptJson(roomKey, p.encryptedPosition);
+                        const { encryptedPosition: _stripped, ...rest } = p;
+                        return { ...rest, position };
+                    } catch (err) {
+                        console.error('Failed to decrypt legacy participant position', p.id, err);
+                        return null;
+                    }
+                }
+                return p;
+            }));
+            if (version !== snapshotVersion) return;
+            participants.length = 0;
+            participants.push(...decrypted.filter(Boolean));
+        } else {
+            if (version !== snapshotVersion) return;
+            participants.length = 0;
+        }
+
+        onUpdate(participants);
+    });
+
+    return { participants };
+}
diff --git a/src/qr.js b/src/qr.js
new file mode 100644
index 0000000..49b50b6
--- /dev/null
+++ b/src/qr.js
@@ -0,0 +1,51 @@
+import QRCode from 'https://cdn.jsdelivr.net/npm/qrcode@1.5.4/+esm';
+
+export const renderInviteQrCard = async ({
+    container,
+    title,
+    url,
+    statusText,
+    roomId
+}) => {
+    if (!container) return;
+
+    const card = document.createElement('div');
+    card.className = 'room-qr-card';
+    card.setAttribute('aria-label', title ? `${title} invite QR code` : 'Invite QR code');
+
+    if (title) {
+        const label = document.createElement('p');
+        label.className = 'room-qr-title';
+        label.textContent = title;
+        card.appendChild(label);
+    }
+
+    const qrImage = document.createElement('img');
+    qrImage.className = 'room-qr';
+    qrImage.alt = title ? `${title}: scan to join room ${roomId}` : `Scan to join room ${roomId}`;
+    card.appendChild(qrImage);
+
+    const status = document.createElement('p');
+    status.className = 'room-link-status';
+    status.textContent = statusText;
+    card.appendChild(status);
+
+    container.appendChild(card);
+
+    try {
+        const qrDataUrl = await QRCode.toDataURL(url, {
+            width: 400,
+            margin: 4,
+            errorCorrectionLevel: 'H',
+            color: {
+                dark: '#0f172a',
+                light: '#ffffff'
+            }
+        });
+        qrImage.src = qrDataUrl;
+    } catch (err) {
+        console.error('Failed to generate invite QR code', err);
+        status.textContent = 'Failed to create QR';
+        status.classList.add('error');
+    }
+};
diff --git a/src/renderer.js b/src/renderer.js
new file mode 100644
index 0000000..0e5bcfe
--- /dev/null
+++ b/src/renderer.js
@@ -0,0 +1,78 @@
+import { getShortestDelta, normalizeAngle } from './utils.js';
+
+export const renderArrows = ({
+    content,
+    arrow,
+    participantsToRender,
+    selfParticipant,
+    selfHeading,
+    isParticipantVisible,
+    getArrowDisplayColor
+}) => {
+    const activeArrowIds = new Set(
+        participantsToRender
+            .filter((target) => target.id !== selfParticipant.id)
+            .map((target) => `arrow-${target.id}`)
+    );
+
+    for (const target of participantsToRender) {
+        if (target.id === selfParticipant.id) continue;
+
+        let existingArrow = document.getElementById(`arrow-${target.id}`);
+        if (!existingArrow) {
+            const newArrow = arrow.cloneNode(true);
+            newArrow.id = `arrow-${target.id}`;
+            newArrow.style.display = 'block';
+            newArrow.currentAngle = null;
+
+            const arrowLabelAnchor = document.createElement('div');
+            arrowLabelAnchor.className = 'arrow-label-anchor';
+
+            const arrowLabel = document.createElement('span');
+            arrowLabel.className = 'arrow-label';
+            arrowLabel.textContent = target.name;
+            arrowLabelAnchor.appendChild(arrowLabel);
+            newArrow.appendChild(arrowLabelAnchor);
+
+            content.appendChild(newArrow);
+            existingArrow = newArrow;
+        }
+
+        content.appendChild(existingArrow);
+
+        if (!isParticipantVisible(target.id)) {
+            existingArrow.style.display = 'none';
+            continue;
+        }
+
+        existingArrow.style.display = 'block';
+
+        const dx = target.position.lon - selfParticipant.position.lon;
+        const dy = target.position.lat - selfParticipant.position.lat;
+        const rawAngle = Math.atan2(dx, dy) * (180 / Math.PI);
+        const targetAngle = normalizeAngle(rawAngle - selfHeading);
+
+        let currentArrowAngle = existingArrow.currentAngle;
+        if (currentArrowAngle === null) {
+            currentArrowAngle = targetAngle;
+        } else {
+            currentArrowAngle += getShortestDelta(currentArrowAngle, targetAngle) * 0.1;
+        }
+
+        existingArrow.currentAngle = currentArrowAngle;
+        existingArrow.style.transform = `rotate(${currentArrowAngle}deg)`;
+        existingArrow.style.color = getArrowDisplayColor(target.color);
+
+        const arrowLabel = existingArrow.querySelector('.arrow-label');
+        if (arrowLabel) {
+            arrowLabel.textContent = target.name;
+            arrowLabel.style.transform = `rotate(${-currentArrowAngle}deg)`;
+        }
+    }
+
+    content.querySelectorAll('.arrow[id^="arrow-"]').forEach((arrowElement) => {
+        if (!activeArrowIds.has(arrowElement.id)) {
+            arrowElement.remove();
+        }
+    });
+};
diff --git a/src/room.js b/src/room.js
new file mode 100644
index 0000000..1c4cc0c
--- /dev/null
+++ b/src/room.js
@@ -0,0 +1,18 @@
+export const ROOM_ID_LENGTH = 8;
+const ROOM_KEY_PATTERN = /^[A-Za-z0-9_-]+$/;
+
+export const buildRoomHash = (nextRoomId, nextRoomKeyBase64 = null) => {
+    const keyPart = nextRoomKeyBase64 ? `.${nextRoomKeyBase64}` : '';
+    return `${nextRoomId}${keyPart}`;
+};
+
+export const parseRoomFromHash = (hashValue) => {
+    const normalizedHash = (hashValue || '').startsWith('#') ? hashValue.slice(1) : (hashValue || '');
+    const compactMatch = normalizedHash.match(/^([a-z0-9]{8})(?:\.([A-Za-z0-9_-]+))?$/);
+    if (!compactMatch) return null;
+
+    const roomValue = compactMatch[1];
+    const keyValue = compactMatch[2] || null;
+    if (keyValue && !ROOM_KEY_PATTERN.test(keyValue)) return null;
+    return { roomId: roomValue, roomKeyBase64: keyValue };
+};
diff --git a/src/self-profile.js b/src/self-profile.js
new file mode 100644
index 0000000..153dfcf
--- /dev/null
+++ b/src/self-profile.js
@@ -0,0 +1,109 @@
+import { normalizeName, normalizeColor, createRandomUserName, createRandomColor } from './utils.js';
+
+const NAME_STORAGE_KEY = 'north-star-self-name-v2';
+const COLOR_STORAGE_KEY = 'north-star-self-color';
+
+const resolveInitialSelfName = async () => {
+    const savedName = normalizeName(localStorage.getItem(NAME_STORAGE_KEY));
+    if (savedName) return savedName;
+
+    const generatedName = await createRandomUserName();
+    localStorage.setItem(NAME_STORAGE_KEY, generatedName);
+    return generatedName;
+};
+
+const resolveInitialSelfColor = () => {
+    const savedColor = normalizeColor(localStorage.getItem(COLOR_STORAGE_KEY));
+    if (savedColor) return savedColor;
+
+    const generatedColor = createRandomColor();
+    localStorage.setItem(COLOR_STORAGE_KEY, generatedColor);
+    return generatedColor;
+};
+
+/**
+ * Creates the selfParticipant object, populates form inputs, and wires
+ * name/color change handlers.
+ *
+ * @param {object} opts
+ * @param {HTMLElement} opts.nameForm
+ * @param {HTMLInputElement} opts.selfNameInput
+ * @param {HTMLInputElement} opts.selfColorInput
+ * @param {string} opts.uid - Firebase UID used as participant ID
+ * @param {object} opts.selfPosition - Shared { lat, lon } reference (mutated externally)
+ * @param {function} opts.syncSelf - async () => void, called after name/color changes
+ * @param {function} opts.requestRender - triggers a render frame
+ * @returns {{ selfParticipant: object }}
+ */
+export async function initSelfProfile({
+    nameForm,
+    selfNameInput,
+    selfColorInput,
+    uid,
+    selfPosition,
+    syncSelf,
+    requestRender
+}) {
+    const initialSelfName = await resolveInitialSelfName();
+    const initialSelfColor = resolveInitialSelfColor();
+
+    const selfParticipant = {
+        id: uid,
+        name: initialSelfName,
+        color: initialSelfColor,
+        position: selfPosition,
+        timestamp: Date.now()
+    };
+
+    if (selfNameInput) selfNameInput.value = selfParticipant.name;
+    if (selfColorInput) selfColorInput.value = selfParticipant.color;
+
+    const applySelfName = async (rawName) => {
+        const nextName = normalizeName(rawName) || await createRandomUserName();
+        if (nextName === selfParticipant.name) {
+            if (selfNameInput) selfNameInput.value = nextName;
+            return;
+        }
+        selfParticipant.name = nextName;
+        localStorage.setItem(NAME_STORAGE_KEY, nextName);
+        if (selfNameInput) selfNameInput.value = nextName;
+        await syncSelf();
+        requestRender();
+    };
+
+    const applySelfColor = async (rawColor) => {
+        const nextColor = normalizeColor(rawColor) || createRandomColor();
+        if (nextColor === selfParticipant.color) {
+            if (selfColorInput) selfColorInput.value = nextColor;
+            return;
+        }
+        selfParticipant.color = nextColor;
+        localStorage.setItem(COLOR_STORAGE_KEY, nextColor);
+        if (selfColorInput) selfColorInput.value = nextColor;
+        await syncSelf();
+        requestRender();
+    };
+
+    if (nameForm) {
+        nameForm.addEventListener('submit', async (event) => {
+            event.preventDefault();
+            await applySelfName(selfNameInput ? selfNameInput.value : selfParticipant.name);
+            await applySelfColor(selfColorInput ? selfColorInput.value : selfParticipant.color);
+            if (selfNameInput) selfNameInput.blur();
+        });
+    }
+
+    if (selfNameInput) {
+        selfNameInput.addEventListener('change', async () => {
+            await applySelfName(selfNameInput.value);
+        });
+    }
+
+    if (selfColorInput) {
+        selfColorInput.addEventListener('change', async () => {
+            await applySelfColor(selfColorInput.value);
+        });
+    }
+
+    return { selfParticipant };
+}
diff --git a/src/session.js b/src/session.js
new file mode 100644
index 0000000..4892ff7
--- /dev/null
+++ b/src/session.js
@@ -0,0 +1,69 @@
+import { generateRoomKey, exportRoomKey, importRoomKey } from './crypto.js';
+import { buildRoomHash, parseRoomFromHash, ROOM_ID_LENGTH } from './room.js';
+import { renderInviteQrCard } from './qr.js';
+
+/**
+ * Parses an existing room from the URL hash, or generates a new one.
+ * Sets room UI elements and renders invite QR cards.
+ * Returns { roomId, roomKey, roomKeyBase64 }.
+ */
+export async function initSession({
+    roomCurrent,
+    createRoomLink,
+    inviteRoomQrList,
+    isLocalCommit,
+    BASE_ROOM_URL,
+    GITLAB_PAGES_BASE_URL
+}) {
+    let roomKey = null;
+    let roomKeyBase64 = null;
+    let roomId;
+
+    const parsedRoom = parseRoomFromHash(location.hash);
+    if (parsedRoom) {
+        roomId = parsedRoom.roomId;
+        if (parsedRoom.roomKeyBase64) {
+            try {
+                roomKey = await importRoomKey(parsedRoom.roomKeyBase64);
+                roomKeyBase64 = parsedRoom.roomKeyBase64;
+            } catch (err) {
+                console.error('Failed to import room key from URL — running without encryption', err);
+            }
+        }
+    } else {
+        roomId = crypto.randomUUID().replace(/-/g, '').slice(0, ROOM_ID_LENGTH);
+        roomKey = await generateRoomKey();
+        roomKeyBase64 = await exportRoomKey(roomKey);
+        location.hash = buildRoomHash(roomId, roomKeyBase64);
+    }
+
+    if (roomCurrent) roomCurrent.textContent = `You are currently in room ${roomId}`;
+    if (createRoomLink) createRoomLink.href = BASE_ROOM_URL;
+
+    if (inviteRoomQrList) {
+        const inviteRoomUrl = `${BASE_ROOM_URL}#${buildRoomHash(roomId, roomKeyBase64)}`;
+        inviteRoomQrList.textContent = '';
+        const isLocalRun = isLocalCommit || ['localhost', '127.0.0.1', '::1'].includes(location.hostname);
+        const roomHash = buildRoomHash(roomId, roomKeyBase64);
+
+        await renderInviteQrCard({
+            container: inviteRoomQrList,
+            title: isLocalRun ? 'This Device URL' : '',
+            url: inviteRoomUrl,
+            statusText: 'Scan to join this room',
+            roomId
+        });
+
+        if (isLocalRun) {
+            await renderInviteQrCard({
+                container: inviteRoomQrList,
+                title: 'GitLab Pages URL',
+                url: `${GITLAB_PAGES_BASE_URL}#${roomHash}`,
+                statusText: 'Scan to join on hosted app',
+                roomId
+            });
+        }
+    }
+
+    return { roomId, roomKey, roomKeyBase64 };
+}
diff --git a/src/utils.js b/src/utils.js
new file mode 100644
index 0000000..9425f6e
--- /dev/null
+++ b/src/utils.js
@@ -0,0 +1,71 @@
+export const getShortestDelta = (fromAngle, toAngle) => ((toAngle - fromAngle + 540) % 360) - 180;
+
+export const normalizeAngle = (angle) => ((angle % 360) + 360) % 360;
+
+export const normalizeSignedAngle = (angle) => {
+    const normalized = normalizeAngle(angle);
+    return normalized > 180 ? normalized - 360 : normalized;
+};
+
+export const normalizeName = (value) => (value || '').trim().slice(0, 32);
+
+export const normalizeColor = (value) => {
+    const candidate = (value || '').trim().toLowerCase();
+    return /^#[0-9a-f]{6}$/.test(candidate) ? candidate : '';
+};
+
+export const getReadableTextColor = (hexColor) => {
+    const normalized = normalizeColor(hexColor);
+    if (!normalized) return '#0f172a';
+
+    const r = parseInt(normalized.slice(1, 3), 16);
+    const g = parseInt(normalized.slice(3, 5), 16);
+    const b = parseInt(normalized.slice(5, 7), 16);
+    const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
+
+    return luminance > 145 ? '#0f172a' : '#f8fafc';
+};
+
+export const getArrowDisplayColor = (hexColor) => {
+    if (!document.body.classList.contains('dark')) return hexColor;
+    const normalized = normalizeColor(hexColor);
+    if (!normalized) return '#ffffff';
+
+    const r = parseInt(normalized.slice(1, 3), 16) / 255;
+    const g = parseInt(normalized.slice(3, 5), 16) / 255;
+    const b = parseInt(normalized.slice(5, 7), 16) / 255;
+    const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
+    return luminance < 0.2 ? '#ffffff' : hexColor;
+};
+
+export const getDeviceHint = async () => {
+    if (navigator.userAgentData) {
+        try {
+            const data = await navigator.userAgentData.getHighEntropyValues(['model']);
+            if (data.model) return data.model;
+        } catch {
+            // Fall through to UA parsing.
+        }
+    }
+
+    const ua = navigator.userAgent;
+    if (/iPhone/.test(ua)) return 'iPhone';
+    if (/iPad/.test(ua)) return 'iPad';
+
+    const androidModel = ua.match(/;\s*([^;]+?)\s+Build\//);
+    if (androidModel) return androidModel[1];
+
+    if (/Android/.test(ua)) return 'Android';
+    if (/Mac/.test(ua)) return 'Mac';
+    if (/Windows/.test(ua)) return 'PC';
+    if (/Linux/.test(ua)) return 'Linux';
+    return null;
+};
+
+export const createRandomUserName = async () => {
+    const hint = await getDeviceHint();
+    const suffix = Math.floor(1000 + Math.random() * 9000);
+    return hint ? `${hint} ${suffix}` : `User ${suffix}`;
+};
+
+export const createRandomColor = () => `#${Math.floor(Math.random() * 0x1000000).toString(16).padStart(6, '0')}`;
diff --git a/src/wakelock.js b/src/wakelock.js
new file mode 100644
index 0000000..07bdeb1
--- /dev/null
+++ b/src/wakelock.js
@@ -0,0 +1,24 @@
+export const createWakeLockManager = () => {
+    let wakeLockSentinel = null;
+    const hasWakeLockSupport = typeof navigator !== 'undefined' && 'wakeLock' in navigator;
+
+    const requestWakeLock = async () => {
+        if (!hasWakeLockSupport) return;
+        if (document.visibilityState !== 'visible') return;
+        if (wakeLockSentinel) return;
+
+        try {
+            wakeLockSentinel = await navigator.wakeLock.request('screen');
+            wakeLockSentinel.addEventListener('release', () => {
+                wakeLockSentinel = null;
+                if (document.visibilityState === 'visible') {
+                    void requestWakeLock();
+                }
+            });
+        } catch (err) {
+            console.debug('Wake lock request failed', err);
+        }
+    };
+
+    return { requestWakeLock };
+};