bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

app.js (595986B)


      1 ο»Ώconst connBadge = document.getElementById("connBadge");
      2 const lanHint = document.getElementById("lanHint");
      3 
      4 const appRoot = document.querySelector(".app");
      5 const toggleSidebarBtn = document.getElementById("toggleSidebar");
      6 const showSidebarBtn = document.getElementById("showSidebar");
      7 const togglePeopleBtn = document.getElementById("togglePeople");
      8 const peopleDrawerEl = document.getElementById("peopleDrawer");
      9 const closePeopleBtn = document.getElementById("closePeople");
     10 const instanceTitleEl = document.getElementById("instanceTitle");
     11 const instanceSubtitleEl = document.getElementById("instanceSubtitle");
     12 const peopleMembersTabBtn = document.getElementById("peopleMembersTab");
     13 const peopleDmsTabBtn = document.getElementById("peopleDmsTab");
     14 const peopleMembersViewEl = document.getElementById("peopleMembersView");
     15 const peopleDmsViewEl = document.getElementById("peopleDmsView");
     16 const peopleSearchEl = document.getElementById("peopleSearch");
     17 const peopleListEl = document.getElementById("peopleList");
     18 const mobileNavEl = document.getElementById("mobileNav");
     19 const mobileFourthBtn = document.getElementById("mobileFourthBtn");
     20 const mobileMoreSheetEl = document.getElementById("mobileMoreSheet");
     21 const mobileMoreCloseBtn = document.getElementById("mobileMoreClose");
     22 const mobileMoreSearchEl = document.getElementById("mobileMoreSearch");
     23 const mobileMoreListEl = document.getElementById("mobileMoreList");
     24 const mobileScreenHostEl = document.getElementById("mobileScreenHost");
     25 const enableNotifsBtn = document.getElementById("enableNotifs");
     26 const notifStatus = document.getElementById("notifStatus");
     27 const notifSoundToggleEl = document.getElementById("notifSoundToggle");
     28 const notifNewHiveToggleEl = document.getElementById("notifNewHiveToggle");
     29 const notifReplyPingToggleEl = document.getElementById("notifReplyPingToggle");
     30 const notifMyHiveChatsToggleEl = document.getElementById("notifMyHiveChatsToggle");
     31 const notifRecentHiveChatsToggleEl = document.getElementById("notifRecentHiveChatsToggle");
     32 const toggleReactionsEl = document.getElementById("toggleReactions");
     33 const hivesViewModeEl = document.getElementById("hivesViewMode");
     34 const toggleRackLayoutEl = document.getElementById("toggleRackLayout");
     35 const toggleSideRackEl = document.getElementById("toggleSideRack");
     36 const toggleRightRackEl = document.getElementById("toggleRightRack");
     37 const layoutPresetEl = document.getElementById("layoutPreset");
     38 const uiScaleEl = document.getElementById("uiScale");
     39 const deviceLayoutEl = document.getElementById("deviceLayout");
     40 const appearancePresetEl = document.getElementById("appearancePreset");
     41 const appearanceApplyPresetBtn = document.getElementById("appearanceApplyPreset");
     42 const appearanceResetPreviewBtn = document.getElementById("appearanceResetPreview");
     43 const appearanceBgEl = document.getElementById("appearanceBg");
     44 const appearancePanelEl = document.getElementById("appearancePanel");
     45 const appearanceTextEl = document.getElementById("appearanceText");
     46 const appearanceAccentEl = document.getElementById("appearanceAccent");
     47 const appearanceAccent2El = document.getElementById("appearanceAccent2");
     48 const appearanceGoodEl = document.getElementById("appearanceGood");
     49 const appearanceBadEl = document.getElementById("appearanceBad");
     50 const appearanceMutedPctEl = document.getElementById("appearanceMutedPct");
     51 const appearanceLinePctEl = document.getElementById("appearanceLinePct");
     52 const appearancePanel2PctEl = document.getElementById("appearancePanel2Pct");
     53 const appearanceFontBodyEl = document.getElementById("appearanceFontBody");
     54 const appearanceFontMonoEl = document.getElementById("appearanceFontMono");
     55 const appearanceSaveBtn = document.getElementById("appearanceSave");
     56 const appearanceClearBtn = document.getElementById("appearanceClear");
     57 const appearanceStatusEl = document.getElementById("appearanceStatus");
     58 const stayConnectedEl = document.getElementById("stayConnected");
     59 const enableHintsEl = document.getElementById("enableHints");
     60 const chatEnterModeEl = document.getElementById("chatEnterMode");
     61 const openShortcutHelpBtn = document.getElementById("openShortcutHelp");
     62 const resetCurrentLayoutBtn = document.getElementById("resetCurrentLayout");
     63 const dockHotbarEl = document.getElementById("dockHotbar");
     64 const showSideRackBtn = document.getElementById("showSideRack");
     65 const showRightRackBtn = document.getElementById("showRightRack");
     66 const chatModToggleWrapEl = document.getElementById("chatModToggleWrap");
     67 const chatModToggleEl = document.getElementById("chatModToggle");
     68 const poweredByVersionEl = document.getElementById("poweredByVersion");
     69 
     70 const authHint = document.getElementById("authHint");
     71 const accountPanel = document.getElementById("accountPanel");
     72 const onboardingCard = document.getElementById("onboardingCard");
     73 const onboardingBody = document.getElementById("onboardingBody");
     74 const onboardingAcceptBtn = document.getElementById("onboardingAccept");
     75 const onboardingRefreshBtn = document.getElementById("onboardingRefresh");
     76 const userLabel = document.getElementById("userLabel");
     77 const authForm = document.getElementById("authForm");
     78 const authUser = document.getElementById("authUser");
     79 const authPass = document.getElementById("authPass");
     80 const codeRow = document.getElementById("codeRow");
     81 const authCode = document.getElementById("authCode");
     82 const registerBtn = document.getElementById("registerBtn");
     83 const tourBtn = document.getElementById("tourBtn");
     84 const logoutBtn = document.getElementById("logoutBtn");
     85 
     86 const profileImageInput = document.getElementById("profileImage");
     87 const profilePreview = document.getElementById("profilePreview");
     88 const removeProfileImageBtn = document.getElementById("removeProfileImage");
     89 const nameColorInput = document.getElementById("nameColor");
     90 const saveProfileBtn = document.getElementById("saveProfile");
     91 const profileStatus = document.getElementById("profileStatus");
     92 // Instance + plugin admin UI lives in Moderation -> Server tab (rendered dynamically).
     93 const modPanelEl = document.getElementById("modPanel");
     94 const modBodyEl = document.getElementById("modBody");
     95 const modRefreshBtn = document.getElementById("modRefresh");
     96 const modReportStatusEl = document.getElementById("modReportStatus");
     97 const modModal = document.getElementById("modModal");
     98 const modModalTitle = document.getElementById("modModalTitle");
     99 const modModalBody = document.getElementById("modModalBody");
    100 const modModalPrimary = document.getElementById("modModalPrimary");
    101 const modModalCancel = document.getElementById("modModalCancel");
    102 const modModalClose = document.getElementById("modModalClose");
    103 const modModalStatus = document.getElementById("modModalStatus");
    104 const mediaModal = document.getElementById("mediaModal");
    105 const mediaModalTitle = document.getElementById("mediaModalTitle");
    106 const mediaModalImg = document.getElementById("mediaModalImg");
    107 const mediaModalOpenLink = document.getElementById("mediaModalOpenLink");
    108 const mediaModalCopyLink = document.getElementById("mediaModalCopyLink");
    109 const mediaModalClose = document.getElementById("mediaModalClose");
    110 const mediaModalStatus = document.getElementById("mediaModalStatus");
    111 const shortcutHelpModal = document.getElementById("shortcutHelpModal");
    112 const shortcutHelpCloseBtn = document.getElementById("shortcutHelpClose");
    113 const sidebarScrollEl = document.querySelector(".sidebarScroll");
    114 
    115 const newPostForm = document.getElementById("newPostForm");
    116 const pollinatePanel = document.getElementById("pollinatePanel");
    117 const toggleComposerBtn = document.getElementById("toggleComposer");
    118 const toggleComposerInlineBtn = document.getElementById("toggleComposerInline");
    119 const mainRackEl = document.getElementById("mainRack");
    120 const mainWorkspaceRackEl = document.getElementById("mainWorkspaceRack");
    121 const mainSideRackEl = document.getElementById("mainSideRack");
    122 const hivesPanelEl = document.getElementById("hivesPanel");
    123 const postTitleInput = document.getElementById("postTitle");
    124 const postImageInput = document.getElementById("postImage");
    125 const postAudioInput = document.getElementById("postAudio");
    126 const editor = document.getElementById("editor");
    127 const postCollectionEl = document.getElementById("postCollection");
    128 const keywordsEl = document.getElementById("keywords");
    129 const ttlMinutesEl = document.getElementById("ttlMinutes");
    130 const ttlPresetEl = document.getElementById("ttlPreset");
    131 const ttlPermanentEl = document.getElementById("ttlPermanent");
    132 const isProtectedEl = document.getElementById("isProtected");
    133 const postModeEl = document.getElementById("postMode");
    134 const streamKindRowEl = document.getElementById("streamKindRow");
    135 const streamKindEl = document.getElementById("streamKind");
    136 const postPasswordEl = document.getElementById("postPassword");
    137 
    138 const filterKeywordsEl = document.getElementById("filterKeywords");
    139 const filterAuthorEl = document.getElementById("filterAuthor");
    140 const sortByEl = document.getElementById("sortBy");
    141 const mobileHiveSearchBtn = document.getElementById("mobileHiveSearch");
    142 const mobileSortCycleBtn = document.getElementById("mobileSortCycle");
    143 const clearFilterBtn = document.getElementById("clearFilter");
    144 const feedEl = document.getElementById("feed");
    145 const hiveTabsEl = document.getElementById("hiveTabs");
    146 const onboardingGateHintEl = document.getElementById("onboardingGateHint");
    147 const onboardingPanelEl = document.getElementById("onboardingPanel");
    148 const onboardingPanelBodyEl = document.getElementById("onboardingPanelBody");
    149 const onboardingPanelAcceptBtn = document.getElementById("onboardingPanelAccept");
    150 const onboardingPanelRefreshBtn = document.getElementById("onboardingPanelRefresh");
    151 const bzlSplashEl = document.getElementById("bzlSplash");
    152 const bzlSplashStartBtn = document.getElementById("bzlSplashStartBtn");
    153 const bzlSplashProgressFill = document.getElementById("bzlSplashProgressFill");
    154 const bzlSplashTipEl = document.getElementById("bzlSplashTip");
    155 const authGateEl = document.getElementById("authGate");
    156 const authGateHintEl = document.getElementById("authGateHint");
    157 const authGateFormEl = document.getElementById("authGateForm");
    158 const authGateUserEl = document.getElementById("authGateUser");
    159 const authGatePassEl = document.getElementById("authGatePass");
    160 const authGateCodeRowEl = document.getElementById("authGateCodeRow");
    161 const authGateCodeEl = document.getElementById("authGateCode");
    162 const authGateRegisterEl = document.getElementById("authGateRegister");
    163 const authGateOnboardingBodyEl = document.getElementById("authGateOnboardingBody");
    164 const authGateAcceptBtn = document.getElementById("authGateAccept");
    165 const authGateRefreshBtn = document.getElementById("authGateRefresh");
    166 const profileViewPanel = document.getElementById("profileViewPanel");
    167 const profileViewTitle = document.getElementById("profileViewTitle");
    168 const profileViewMeta = document.getElementById("profileViewMeta");
    169 const profileCard = document.getElementById("profileCard");
    170 const profileBackBtn = document.getElementById("profileBackBtn");
    171 const profileEditToggleBtn = document.getElementById("profileEditToggleBtn");
    172 const profileEditPanel = document.getElementById("profileEditPanel");
    173 const profilePronounsInput = document.getElementById("profilePronouns");
    174 const profileThemeSongUrlInput = document.getElementById("profileThemeSongUrl");
    175 const profileThemeSongUploadBtn = document.getElementById("profileThemeSongUploadBtn");
    176 const profileThemeSongClearBtn = document.getElementById("profileThemeSongClearBtn");
    177 const profileThemeSongFileInput = document.getElementById("profileThemeSongFile");
    178 const profileThemeSongPreview = document.getElementById("profileThemeSongPreview");
    179 const profileBioToolbar = document.getElementById("profileBioToolbar");
    180 const profileBioEditor = document.getElementById("profileBioEditor");
    181 const profileBioImageFileInput = document.getElementById("profileBioImageFile");
    182 const profileBioAudioFileInput = document.getElementById("profileBioAudioFile");
    183 const profileAddLinkBtn = document.getElementById("profileAddLinkBtn");
    184 const profileLinksEditor = document.getElementById("profileLinksEditor");
    185 const profileSaveBtn = document.getElementById("profileSaveBtn");
    186 const profileCancelBtn = document.getElementById("profileCancelBtn");
    187 
    188 const chatTitle = document.getElementById("chatTitle");
    189 const chatMeta = document.getElementById("chatMeta");
    190 const chatContextSelectEl = document.getElementById("chatContextSelect");
    191 const chatBackToListBtn = document.getElementById("chatBackToList");
    192 const streamStageEl = document.getElementById("streamStage");
    193 const streamStageTitleEl = document.getElementById("streamStageTitle");
    194 const streamStageStatusEl = document.getElementById("streamStageStatus");
    195 const streamStagePrimaryBtn = document.getElementById("streamStagePrimary");
    196 const streamStageVideoEl = document.getElementById("streamStageVideo");
    197 const streamStageAudioEl = document.getElementById("streamStageAudio");
    198 const streamStagePlaceholderEl = document.getElementById("streamStagePlaceholder");
    199 const streamVoiceControlsEl = document.getElementById("streamVoiceControls");
    200 const streamVoiceJoinToggleEl = document.getElementById("streamVoiceJoinToggle");
    201 const streamVoiceMuteBtn = document.getElementById("streamVoiceMuteBtn");
    202 const streamVoiceDeafenBtn = document.getElementById("streamVoiceDeafenBtn");
    203 const streamVoiceUsersEl = document.getElementById("streamVoiceUsers");
    204 const chatMessagesEl = document.getElementById("chatMessages");
    205 const typingIndicator = document.getElementById("typingIndicator");
    206 const chatForm = document.getElementById("chatForm");
    207 const chatReplyBanner = document.getElementById("chatReplyBanner");
    208 const chatReplyWho = document.getElementById("chatReplyWho");
    209 const chatReplyText = document.getElementById("chatReplyText");
    210 const chatReplyCancelBtn = document.getElementById("chatReplyCancel");
    211 const chatEditor = document.getElementById("chatEditor");
    212 const mentionMenuEl = document.getElementById("mentionMenu");
    213 const chatImageInput = document.getElementById("chatImage");
    214 const chatAudioInput = document.getElementById("chatAudio");
    215 
    216 // When selecting images/audio for chat, route the insertion to the most-recently focused rich editor
    217 // (main chat panel or a chat instance panel).
    218 let chatUploadTargetEditor = chatEditor;
    219 const walkieBarEl = document.getElementById("walkieBar");
    220 const walkieRecordBtn = document.getElementById("walkieRecordBtn");
    221 const walkieStatusEl = document.getElementById("walkieStatus");
    222 const sidebarPanelEl = document.querySelector(".sidebar");
    223 const chatResizeHandle = document.getElementById("chatResizeHandle");
    224 const sidebarResizeHandle = document.getElementById("sidebarResizeHandle");
    225 const mainResizeHandle = document.getElementById("mainResizeHandle");
    226 const chatPanelEl = document.querySelector(".chat");
    227 const peopleResizeHandle = document.getElementById("peopleResizeHandle");
    228 const chatHeaderEl = chatPanelEl ? chatPanelEl.querySelector(".panelHeader") : null;
    229 const editModal = document.getElementById("editModal");
    230 const editModalTitle = document.getElementById("editModalTitle");
    231 const editModalCloseBtn = document.getElementById("editModalClose");
    232 const editModalCancelBtn = document.getElementById("editModalCancel");
    233 const editModalSaveBtn = document.getElementById("editModalSave");
    234 const editModalStatus = document.getElementById("editModalStatus");
    235 const editModalPostTitleRow = document.getElementById("editModalPostTitleRow");
    236 const editModalPostTitleInput = document.getElementById("editModalPostTitle");
    237 const editModalPostMeta = document.getElementById("editModalPostMeta");
    238 const editModalKeywordsInput = document.getElementById("editModalKeywords");
    239 const editModalCollectionSelect = document.getElementById("editModalCollection");
    240 const editModalProtectedToggle = document.getElementById("editModalProtected");
    241 const editModalModeSelect = document.getElementById("editModalMode");
    242 const editModalStreamKindRow = document.getElementById("editModalStreamKindRow");
    243 const editModalStreamKindSelect = document.getElementById("editModalStreamKind");
    244 const editModalPasswordRow = document.getElementById("editModalPasswordRow");
    245 const editModalPasswordInput = document.getElementById("editModalPassword");
    246 const editModalToolbar = document.getElementById("editModalToolbar");
    247 const editModalEditor = document.getElementById("editModalEditor");
    248 const editModalImageInput = document.getElementById("editModalImage");
    249 const editModalAudioInput = document.getElementById("editModalAudio");
    250 
    251 // Temporarily force rack mode on (hide toggle) while the feature stabilizes.
    252 const FORCE_RACK_MODE = true;
    253 
    254 // Display prefs (device layout + text scale)
    255 const UI_SCALE_KEY = "bzl_uiScale"; // "auto" | "xs" | "sm" | "md" | "lg"
    256 const DEVICE_LAYOUT_KEY = "bzl_deviceLayout"; // "auto" | "widescreen" | "fourThree" | "threeTwo" | "ultrawide" | "portrait"
    257 const USER_APPEARANCE_KEY = "bzl_userAppearance_v1";
    258 
    259 /** @type {Map<string, any>} */
    260 const posts = new Map();
    261 /** @type {Record<string, {image?: string, color?: string}>} */
    262 let profiles = {};
    263 
    264 /** @type {Map<string, any[]>} */
    265 const chatByPost = new Map();
    266 /** @type {Map<string, number>} */
    267 const unreadByPostId = new Map();
    268 /** @type {Map<string, Set<string>>} */
    269 const typingUsersByPostId = new Map();
    270 const ownRecentChatByPostId = new Map();
    271 /** @type {Set<string>} */
    272 const myReacts = new Set();
    273 /** @type {Map<string, number>} */
    274 const reactPulseByKey = new Map();
    275 let allowedReactions = ["πŸ‘", "❀️", "😑", "😭", "πŸ₯Ί", "πŸ˜‚"];
    276 
    277 let clientId = null;
    278 let loggedInUser = null;
    279 let loggedInRole = "member";
    280 let canModerate = false;
    281 let canRegisterFirstUser = false;
    282 let registrationEnabled = false;
    283 let activeChatPostId = null;
    284 let activeMapsRoomId = "";
    285 let activeMapsRoomTitle = "";
    286 let activeMapsChatScope = "local"; // "local" | "global"
    287 /** @type {Map<string, any[]>} */
    288 const mapsChatGlobalByMapId = new Map();
    289 /** @type {Map<string, any[]>} */
    290 const mapsChatLocalByMapId = new Map();
    291 let pendingProfileImage = "";
    292 let windowFocused = true;
    293 let typingStopTimer = null;
    294 let lastTypingSentAt = 0;
    295 let modTab = "reports";
    296 let onboardingViewerTab = "about";
    297 let authGateOnboardingTab = "about";
    298 let onboardingAdminTab = "about";
    299 let onboardingAdminDraft = {
    300   enabled: true,
    301   aboutContent: "",
    302   requireAcceptance: false,
    303   blockReadUntilAccepted: false,
    304   roleSelectEnabled: true,
    305   selfAssignableRoleIds: [],
    306   rules: [],
    307 };
    308 let onboardingAdminDraftStamp = "";
    309 const onboardingAdminExpandedRuleIds = new Set();
    310 let modReports = [];
    311 let modUsers = [];
    312 let modLog = [];
    313 let devLog = [];
    314 let modLogView = localStorage.getItem("bzl_modLogView") || "dev"; // "dev" | "moderation"
    315 let devLogAutoScroll = localStorage.getItem("bzl_devLogAutoScroll") !== "0";
    316 let modModalContext = null;
    317 let lanUrls = [];
    318 const MOBILE_LAYOUT_KEY = "bzl_mobile_layout_v1";
    319 let mobilePanel = "hives"; // Back-compat: used by older call sites (maps to mobile "screen" now).
    320 let mobileMoreOpen = false;
    321 let mobileHostPanelId = "";
    322 const mobileHostRestoreParentByPanelId = new Map();
    323 const mobileHostedPanelIds = new Set();
    324 const mobileHostEphemeralPanelIds = new Set();
    325 let composerOpen = false;
    326 let touchStartX = 0;
    327 let touchStartY = 0;
    328 let touchTracking = false;
    329 let peopleOpen = false;
    330 let peopleTab = "members";
    331 let peopleMembers = [];
    332 let openPostMenuId = "";
    333 
    334 // Multi-instance chat panels (MVP: per-hive/post chat panels).
    335 /** @type {Map<string, {postId:string}>} */
    336 const chatPanelInstances = new Map();
    337 
    338 function isChatInstancePanelId(panelId) {
    339   const id = String(panelId || "");
    340   return id.startsWith("chat:post:");
    341 }
    342 
    343 function chatInstancePanelIdForPost(postId) {
    344   const pid = String(postId || "").trim();
    345   if (!pid) return "";
    346   return `chat:post:${pid}`;
    347 }
    348 let dmThreads = [];
    349 /** @type {Map<string, any>} */
    350 let dmThreadsById = new Map();
    351 /** @type {Map<string, any[]>} */
    352 const dmMessagesByThreadId = new Map();
    353 let activeDmThreadId = null;
    354 let pendingOpenDmThreadId = "";
    355 const CHAT_RECENTS_LIMIT = 24;
    356 let recentHiveChatIds = [];
    357 let recentDmChatThreadIds = [];
    358 let syncingChatContextSelect = false;
    359 let walkieRecording = false;
    360 let walkieStartAt = 0;
    361 let walkieRecorder = null;
    362 let walkieChunks = [];
    363 let walkieCtx = null;
    364 let walkieMicStream = null;
    365 let walkieMixNode = null;
    366 let walkieDestNode = null;
    367 let walkieDispatchBuffer = null;
    368 let streamEnabled = false;
    369 let streamIceServers = [{ urls: ["stun:stun.l.google.com:19302"] }];
    370 const streamLiveByPostId = new Map();
    371 let streamCurrentPostId = "";
    372 let streamCurrentRole = "idle"; // "idle" | "viewer" | "host"
    373 let streamCurrentHostClientId = "";
    374 let streamRemoteHostClientId = "";
    375 let streamLocalMedia = null;
    376 let streamRemoteMedia = null;
    377 let streamRemoteKind = "webcam";
    378 const streamPeerByClientId = new Map();
    379 const streamRemoteMediaByClientId = new Map();
    380 const streamPeerUsernameByClientId = new Map();
    381 const streamRemoteAudioByClientId = new Map();
    382 const streamPeerVolumeByClientId = new Map();
    383 let streamVoiceMedia = null;
    384 let streamVoiceJoined = false;
    385 let streamVoiceMuted = false;
    386 let streamVoiceDeafened = false;
    387 const SESSION_TOKEN_KEY = "bzl_session_token";
    388 const TOUR_SEEN_VERSION = 2;
    389 const TOUR_TASK_POLL_MS = 500;
    390 const TOUR_AUTO_ADVANCE_MS = 1400;
    391 const SPLASH_MIN_MS = 700;
    392 const SPLASH_SFX_URL = "/assets/sfx/bzl_sound.mp3";
    393 const CLIENT_IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
    394 const CLIENT_AUDIO_UPLOAD_MAX_BYTES = 150 * 1024 * 1024;
    395 let allowedPostReactions = ["πŸ‘", "❀️", "😑", "😭", "πŸ₯Ί", "πŸ˜‚", "⭐"];
    396 let allowedChatReactions = ["πŸ‘", "❀️", "😑", "😭", "πŸ₯Ί", "πŸ˜‚"];
    397 let userPrefs = { starredPostIds: [], hiddenPostIds: [], ignoredUsers: [], blockedUsers: [] };
    398 let showReactions = localStorage.getItem("bzl_showReactions") !== "0";
    399 let chatDock = localStorage.getItem("bzl_chatDock") === "right" ? "right" : "left";
    400 let activeHiveView = "all";
    401 let collections = [];
    402 let customRoles = [];
    403 let plugins = [];
    404 const loadedPluginClientVersionById = new Map(); // pluginId -> version string
    405 let centerView = "hives";
    406 const HIVES_VIEW_MODE_KEY = "bzl_hivesViewMode";
    407 const HIVES_LIST_AUTO_THRESHOLD_PX = 520;
    408 let lastHivesWidthPx = 0;
    409 let hivesResizeObserver = null;
    410 let guidedTourOverlayEl = null;
    411 let guidedTourCardEl = null;
    412 let guidedTourFocusEl = null;
    413 let guidedTourStepEl = null;
    414 let guidedTourTitleEl = null;
    415 let guidedTourBodyEl = null;
    416 let guidedTourTaskEl = null;
    417 let guidedTourStatusEl = null;
    418 let guidedTourDontShowEl = null;
    419 let guidedTourPrevBtn = null;
    420 let guidedTourNextBtn = null;
    421 let guidedTourSkipBtn = null;
    422 let guidedTourTargetEl = null;
    423 let guidedTourTaskTimer = null;
    424 let guidedTourAutoAdvanceTimer = null;
    425 let guidedTourStepContext = {};
    426 let guidedTourAutoStartedForUser = "";
    427 let guidedTourState = { active: false, index: 0, steps: [], startedSignedIn: false };
    428 let guestAuthPanelRevealed = false;
    429 let splashStartedAt = Date.now();
    430 let splashMinDone = false;
    431 let splashAudioDone = false;
    432 let splashVisible = true;
    433 let splashNeedsGesture = false;
    434 let splashProgressRaf = 0;
    435 let splashAudio = null;
    436 let splashTipTimer = 0;
    437 let splashLastTip = "";
    438 
    439 const SPLASH_TIPS = [
    440   "Eyy, looking good!",
    441   "We're about to crank it to 11.",
    442   "bzl is the sound a digital bee makes.",
    443   "1337 speak is overrated. I prefer Webdings.",
    444   "bzl is free and open source!",
    445   "The root trans means through or across.",
    446   "What is the air-speed velocity of an unladen swallow?",
    447   "What if the universe is one big atom?",
    448   "Wonder what happened to Tom from Myspace?",
    449   "They don't make 'em like this anymore.",
    450   "What's future nostalgia mean?",
    451   "Shortcut tip: R toggles the Members list rail.",
    452   "Shortcut tip: [ and ] cycle layout presets.",
    453   "Shortcut tip: Up/Left/Right/Down controls hovered panels.",
    454   "Workflow tip: add from hotbar, then resize and reorder to taste.",
    455   "Workflow tip: minimize panels you are not using to keep focus.",
    456   "Profile tip: add a theme song in your profile editor.",
    457   "Theme tip: try a preset first, then tweak accent and font.",
    458   "Theme tip: mono fonts can make dense panels easier to scan.",
    459   "Use Quick duration for common post expiry times.",
    460   "Pin your core panels left-most for each workflow.",
    461   "Livekit powers low-latency voice/video rooms in Bzl.",
    462   "Livekit tip: one panel for stream, one for chat is a great combo.",
    463   "Azakaela makes cool music.",
    464   "A watched splash screen never boils.",
    465   "Bees would absolutely use panel presets.",
    466   "If this loads any faster, it becomes time travel.",
    467   "Hydration check: sip something.",
    468   "Your future self says this layout slaps.",
    469   "This message is brought to you by electrons.",
    470   "Somewhere, a rubber duck is nodding.",
    471   "If in doubt, open one more panel.",
    472   "This app contains 0% artificial buzzwords.",
    473   "Do not feed the race conditions.",
    474   "The hotbar is just a tiny panel hotel.",
    475   "All bugs are features in chrysalis.",
    476   "One does not simply ignore keyboard shortcuts.",
    477   "Completely normal amount of glow here.",
    478   "Please remain calm while we summon vibes.",
    479   "Loading... but make it dramatic.",
    480   "No panels were harmed in this boot.",
    481   "Your layout called. It wants more symmetry.",
    482   "Some assembly required. Mostly emotional.",
    483   "This is the good timeline.",
    484   "If found, return to nearest hive.",
    485   "The bee movie quote budget was denied.",
    486   "Yes, this is a real loading message.",
    487   "You bring the ideas, we bring the panels.",
    488 ];
    489 
    490 function isOwnerRole(role) {
    491   return String(role || "").toLowerCase() === "owner";
    492 }
    493 
    494 function isAdminRole(role) {
    495   return String(role || "").toLowerCase() === "admin";
    496 }
    497 
    498 function isModeratorRole(role) {
    499   return String(role || "").toLowerCase() === "moderator";
    500 }
    501 
    502 function isStaffRole(role) {
    503   return isOwnerRole(role) || isAdminRole(role) || isModeratorRole(role);
    504 }
    505 
    506 function canManagePluginsRole(role) {
    507   return isOwnerRole(role) || isAdminRole(role);
    508 }
    509 
    510 // --- Rack layout (experimental) ------------------------------------------------
    511 
    512 const RACK_LAYOUT_ENABLED_KEY = "bzl_rackLayout_enabled";
    513 const RACK_LAYOUT_STATE_KEY = "bzl_rackLayout_state_v2";
    514 const RACK_SIDE_COLLAPSED_KEY = "bzl_rackLayout_sideCollapsed";
    515 const RACK_RIGHT_COLLAPSED_KEY = "bzl_rackLayout_rightCollapsed";
    516 const WORKSPACE_EXPANDED_PRIMARY_KEY = "bzl_workspace_expandedPrimary";
    517 const WORKSPACE_EXPANDED_DISPLACED_KEY = "bzl_workspace_expandedDisplaced";
    518 const WORKSPACE_INFINITE_MODE = true;
    519 const RIGHT_RACK_FIXED_PANEL_ID = "people";
    520 
    521 /**
    522  * @typedef {{
    523  *   version: 2,
    524  *   presetId: string,
    525  *   docked: { bottom: string[] },
    526  *   racks?: { workspaceLeft?: string[], workspaceRight?: string[], side?: string[], right?: string[] },
    527  * }} RackLayoutState
    528  */
    529 
    530 /** @type {RackLayoutState} */
    531 let rackLayoutState = {
    532   version: 2,
    533   presetId: "onboardingDefault",
    534   docked: { bottom: [] },
    535   racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
    536   panelSizes: {},
    537 };
    538 let rackLayoutEnabled = false;
    539 let rightRackEl = null;
    540 let mainRack = null;
    541 let mainSideRack = null;
    542 const WORKSPACE_ACTIVE_PRIMARY_KEY = "bzl_workspace_activePrimary";
    543 
    544 function workspaceInfiniteMode() {
    545   return Boolean(rackLayoutEnabled && WORKSPACE_INFINITE_MODE);
    546 }
    547 
    548 function isRightRackFixedPanel(panelId) {
    549   return String(panelId || "").trim() === RIGHT_RACK_FIXED_PANEL_ID;
    550 }
    551 
    552 function readBoolPref(key, fallback = false) {
    553   try {
    554     const raw = localStorage.getItem(key);
    555     if (raw == null) return fallback;
    556     return raw === "1" || raw === "true";
    557   } catch {
    558     return fallback;
    559   }
    560 }
    561 
    562 function writeBoolPref(key, value) {
    563   try {
    564     localStorage.setItem(key, value ? "1" : "0");
    565   } catch {
    566     // ignore
    567   }
    568 }
    569 
    570 function readWorkspaceExpandedPrimary() {
    571   return readStringPref(WORKSPACE_EXPANDED_PRIMARY_KEY, "").trim();
    572 }
    573 
    574 function writeWorkspaceExpandedPrimary(panelId) {
    575   writeStringPref(WORKSPACE_EXPANDED_PRIMARY_KEY, String(panelId || "").trim());
    576 }
    577 
    578 function readWorkspaceExpandedDisplaced() {
    579   return readStringPref(WORKSPACE_EXPANDED_DISPLACED_KEY, "").trim();
    580 }
    581 
    582 function writeWorkspaceExpandedDisplaced(panelId) {
    583   writeStringPref(WORKSPACE_EXPANDED_DISPLACED_KEY, String(panelId || "").trim());
    584 }
    585 
    586 function clearWorkspaceExpandedState() {
    587   writeWorkspaceExpandedPrimary("");
    588   writeWorkspaceExpandedDisplaced("");
    589 }
    590 
    591 function togglePrimaryExpand(panelId) {
    592   if (!rackLayoutEnabled) return;
    593   const id = String(panelId || "").trim();
    594   if (!id) return;
    595   if (!panelCanExpand(id)) return;
    596 
    597   const current = readWorkspaceExpandedPrimary();
    598   const left = ensureWorkspaceLeftRack();
    599   const right = ensureWorkspaceRightRack();
    600   if (!left || !right) return;
    601 
    602   // If the panel isn't in a workspace slot, pull it into the workspace first.
    603   const panelEl = getPanelElement(id);
    604   if (panelEl) {
    605     const inWorkspace = panelEl.parentElement === left || panelEl.parentElement === right;
    606     if (!inWorkspace) {
    607       const leftExisting = left.querySelector?.(":scope > .rackPanel:not(.hidden)");
    608       const rightExisting = right.querySelector?.(":scope > .rackPanel:not(.hidden)");
    609       const leftEmpty = !leftExisting;
    610       const rightEmpty = !rightExisting;
    611       // Prefer the right slot for "aux" expandables like Moderation/Composer.
    612       const target = rightEmpty ? right : leftEmpty ? left : right;
    613       const existing = target === left ? leftExisting : rightExisting;
    614       if (existing instanceof HTMLElement && existing !== panelEl) {
    615         const existingId = String(existing.dataset?.panelId || "").trim();
    616         if (existingId) dockPanel(existingId);
    617       }
    618       target.appendChild(panelEl);
    619       syncRackStateFromDom();
    620       enforceWorkspaceRules();
    621     }
    622   }
    623 
    624   const leftPanel = left.querySelector?.(":scope > .rackPanel");
    625   const rightPanel = right.querySelector?.(":scope > .rackPanel");
    626   const leftId = String(leftPanel?.dataset?.panelId || "").trim();
    627   const rightId = String(rightPanel?.dataset?.panelId || "").trim();
    628 
    629   if (current && current === id) {
    630     // Collapse: try to restore the displaced panel (if any) back into the now-visible other slot.
    631     const displaced = readWorkspaceExpandedDisplaced();
    632     clearWorkspaceExpandedState();
    633     if (displaced && isDocked(displaced)) {
    634       undockPanel(displaced);
    635       const el = getPanelElement(displaced);
    636       if (el) {
    637         if (leftId === id && !rightId) right.appendChild(el);
    638         else if (rightId === id && !leftId) left.appendChild(el);
    639       }
    640     }
    641     enforceWorkspaceRules();
    642     return;
    643   }
    644 
    645   // Expand: if the other slot is occupied, dock it so it stays accessible via hotbar.
    646   writeWorkspaceExpandedPrimary(id);
    647   let displaced = "";
    648   if (leftId === id && rightId) displaced = rightId;
    649   if (rightId === id && leftId) displaced = leftId;
    650   if (displaced && displaced !== id) {
    651     writeWorkspaceExpandedDisplaced(displaced);
    652     dockPanel(displaced);
    653   } else {
    654     writeWorkspaceExpandedDisplaced("");
    655   }
    656   enforceWorkspaceRules();
    657 }
    658 
    659 function readStringPref(key, fallback = "") {
    660   try {
    661     const raw = localStorage.getItem(key);
    662     if (raw == null) return fallback;
    663     return String(raw);
    664   } catch {
    665     return fallback;
    666   }
    667 }
    668 
    669 function normalizeUiScale(raw) {
    670   const v = String(raw || "").trim().toLowerCase();
    671   if (v === "auto") return "auto";
    672   if (v === "xs" || v === "compact") return "xs";
    673   if (v === "sm" || v === "small") return "sm";
    674   if (v === "lg" || v === "large") return "lg";
    675   return "md";
    676 }
    677 
    678 function normalizeDeviceLayout(raw) {
    679   const v = String(raw || "").trim().toLowerCase();
    680   if (v === "widescreen") return "widescreen";
    681   if (v === "fourthree" || v === "fourThree".toLowerCase() || v === "4:3" || v === "4x3") return "fourThree";
    682   if (v === "threetwo" || v === "threeTwo".toLowerCase() || v === "3:2" || v === "3x2") return "threeTwo";
    683   if (v === "ultrawide") return "ultrawide";
    684   if (v === "portrait") return "portrait";
    685   return "auto";
    686 }
    687 
    688 function detectViewportSize() {
    689   const w = Math.max(1, Number(window.innerWidth) || 1);
    690   const h = Math.max(1, Number(window.innerHeight) || 1);
    691   // Keep this intentionally simple: we mostly care about "can we fit columns sanely?"
    692   // Consider both width and height so low-res (ex: 1280x720) can auto-compact.
    693   if (w <= 1100 || h <= 720) return "xs";
    694   if (w <= 1400 || h <= 820) return "sm";
    695   if (w <= 1800) return "md";
    696   return "lg";
    697 }
    698 
    699 function detectAspectLayout() {
    700   const w = Math.max(1, Number(window.innerWidth) || 1);
    701   const h = Math.max(1, Number(window.innerHeight) || 1);
    702   const ratio = w / h;
    703   // Heuristics:
    704   // - Portrait: <= ~1.25
    705   // - 4:3-ish: 1.25..1.38
    706   // - 3:2-ish: 1.38..1.62 (covers 3:2 and nearby)
    707   // - Widescreen: 1.62..1.95 (16:10..~2:1)
    708   // - Ultrawide: >= 1.95
    709   if (ratio <= 1.25) return "portrait";
    710   if (ratio < 1.38) return "fourThree";
    711   if (ratio >= 1.38 && ratio < 1.62) return "threeTwo";
    712   if (ratio >= 1.95) return "ultrawide";
    713   return "widescreen";
    714 }
    715 
    716 function applyDisplayPrefs() {
    717   const root = document.documentElement;
    718   if (!root) return;
    719   const scalePref = normalizeUiScale(readStringPref(UI_SCALE_KEY, "auto"));
    720   const layoutPref = normalizeDeviceLayout(readStringPref(DEVICE_LAYOUT_KEY, "auto"));
    721   const layout = layoutPref === "auto" ? detectAspectLayout() : layoutPref;
    722   const viewport = detectViewportSize();
    723   const scale =
    724     scalePref === "auto" ? (viewport === "xs" ? "xs" : viewport === "sm" ? "sm" : "md") : scalePref;
    725 
    726   root.dataset.uiScale = scale;
    727   root.dataset.uiScalePref = scalePref;
    728   root.dataset.deviceLayout = layoutPref;
    729   root.dataset.aspect = layout;
    730   root.dataset.viewport = viewport;
    731 
    732   if (uiScaleEl) uiScaleEl.value = scalePref;
    733   if (deviceLayoutEl) deviceLayoutEl.value = layoutPref;
    734 }
    735 
    736 function initDisplayPrefsUi() {
    737   applyDisplayPrefs();
    738   if (uiScaleEl) {
    739     uiScaleEl.value = normalizeUiScale(readStringPref(UI_SCALE_KEY, "auto"));
    740     uiScaleEl.addEventListener("change", () => {
    741       const next = normalizeUiScale(uiScaleEl.value);
    742       try {
    743         localStorage.setItem(UI_SCALE_KEY, next);
    744       } catch {
    745         // ignore
    746       }
    747       applyDisplayPrefs();
    748     });
    749   }
    750   if (deviceLayoutEl) {
    751     deviceLayoutEl.value = normalizeDeviceLayout(readStringPref(DEVICE_LAYOUT_KEY, "auto"));
    752     deviceLayoutEl.addEventListener("change", () => {
    753       const next = normalizeDeviceLayout(deviceLayoutEl.value);
    754       try {
    755         localStorage.setItem(DEVICE_LAYOUT_KEY, next);
    756       } catch {
    757         // ignore
    758       }
    759       applyDisplayPrefs();
    760     });
    761   }
    762 
    763   let resizeTimer = null;
    764   window.addEventListener("resize", () => {
    765     if (resizeTimer) window.clearTimeout(resizeTimer);
    766     resizeTimer = window.setTimeout(() => {
    767       resizeTimer = null;
    768       // Always re-apply (viewport changes matter even when layout is manually pinned).
    769       applyDisplayPrefs();
    770     }, 90);
    771   });
    772 }
    773 
    774 function writeStringPref(key, value) {
    775   try {
    776     localStorage.setItem(key, String(value));
    777   } catch {
    778     // ignore
    779   }
    780 }
    781 
    782 function resolveHivesViewMode() {
    783   const pref = readStringPref(HIVES_VIEW_MODE_KEY, "list");
    784   const normalized = String(pref || "auto").toLowerCase();
    785   if (normalized === "list") return "list";
    786   if (normalized === "cards") return "cards";
    787   // auto (currently treated as list by default; we can reintroduce responsive modes later)
    788   return "list";
    789 }
    790 
    791 function applyHivesViewMode() {
    792   const mode = resolveHivesViewMode();
    793   const list = mode === "list";
    794   feedEl?.classList.toggle("hivesListView", list);
    795   hivesPanelEl?.classList.toggle("hivesListView", list);
    796 }
    797 
    798 function installHivesAutoViewMode() {
    799   if (!hivesPanelEl) return;
    800   if (typeof ResizeObserver === "undefined") {
    801     window.addEventListener("resize", () => applyHivesViewMode());
    802     return;
    803   }
    804   if (hivesResizeObserver) return;
    805   hivesResizeObserver = new ResizeObserver((entries) => {
    806     const entry = entries && entries[0];
    807     const w = Number(entry?.contentRect?.width || 0);
    808     if (!w) return;
    809     const rounded = Math.round(w);
    810     if (rounded === lastHivesWidthPx) return;
    811     lastHivesWidthPx = rounded;
    812     applyHivesViewMode();
    813   });
    814   try {
    815     hivesResizeObserver.observe(hivesPanelEl);
    816   } catch {
    817     // ignore
    818   }
    819 }
    820 
    821 function setSideCollapsed(collapsed, opts) {
    822   const options = opts && typeof opts === "object" ? opts : {};
    823   const persist = options.persist !== false;
    824   const updateControls = options.updateControls !== false;
    825   if (!appRoot) return;
    826   appRoot.classList.toggle("sideCollapsed", Boolean(collapsed));
    827   if (persist) writeBoolPref(RACK_SIDE_COLLAPSED_KEY, Boolean(collapsed));
    828   if (updateControls && toggleSideRackEl) toggleSideRackEl.checked = !Boolean(collapsed);
    829   updateSideRackEmptyState();
    830   updateWorkspaceMinView();
    831 }
    832 
    833 function setRightCollapsed(collapsed, opts) {
    834   const options = opts && typeof opts === "object" ? opts : {};
    835   const persist = options.persist !== false;
    836   const updateControls = options.updateControls !== false;
    837   if (!appRoot) return;
    838   appRoot.classList.toggle("rightCollapsed", Boolean(collapsed));
    839   if (persist) writeBoolPref(RACK_RIGHT_COLLAPSED_KEY, Boolean(collapsed));
    840   if (updateControls && toggleRightRackEl) toggleRightRackEl.checked = !Boolean(collapsed);
    841   updateWorkspaceMinView();
    842 }
    843 
    844 function updateSideRackEmptyState() {
    845   if (!appRoot) return;
    846   const side = mainSideRackEl || mainSideRack || document.getElementById("mainSideRack");
    847   if (!(side instanceof HTMLElement)) return;
    848   const hasVisible = Boolean(side.querySelector?.(".rackPanel:not(.hidden)"));
    849   appRoot.classList.toggle("sideRackEmpty", !hasVisible);
    850 }
    851 
    852 // Panel registry (skeleton): this will become the primary way core + plugins register UI panels.
    853 // For now, it powers rack mode (docking + ordering + workspace rules) and plugin panel shells.
    854 /** @type {Map<string, {id:string,title:string,icon?:string,source:string,role:string,defaultRack:string,element?:HTMLElement|null}>} */
    855 const panelRegistry = new Map();
    856 
    857 function registerCorePanel(def) {
    858   const id = String(def?.id || "").trim();
    859   if (!id) return;
    860   const title = String(def?.title || id).trim();
    861   const icon = typeof def?.icon === "string" ? def.icon : "";
    862   const role = typeof def?.role === "string" ? def.role : "aux";
    863   const defaultRack = typeof def?.defaultRack === "string" ? def.defaultRack : "right";
    864   const element = def?.element instanceof HTMLElement ? def.element : null;
    865   panelRegistry.set(id, { id, title, icon, source: "core", role, defaultRack, element });
    866 }
    867 
    868 function normalizeWorkspacePanelSize(size) {
    869   const raw = String(size || "").trim().toLowerCase();
    870   if (raw === "skinny" || raw === "full") return raw;
    871   return "half";
    872 }
    873 
    874 function panelAllowsSkinnyWorkspaceSize(panelId) {
    875   const id = String(panelId || "").trim();
    876   if (!id) return false;
    877   if (id === "moderation") return false;
    878   return true;
    879 }
    880 
    881 function panelWorkspaceSize(panelId) {
    882   const id = String(panelId || "").trim();
    883   if (!id) return "half";
    884   const sizes = rackLayoutState?.panelSizes && typeof rackLayoutState.panelSizes === "object" ? rackLayoutState.panelSizes : {};
    885   const normalized = normalizeWorkspacePanelSize(sizes[id] || "half");
    886   if (normalized === "skinny" && !panelAllowsSkinnyWorkspaceSize(id)) return "half";
    887   return normalized;
    888 }
    889 
    890 function workspaceHalfPanelWidthPx() {
    891   return Math.min(680, Math.max(340, Math.round(window.innerWidth * 0.74)));
    892 }
    893 
    894 function workspaceSkinnyPanelWidthPx() {
    895   return Math.min(360, Math.max(220, Math.round(window.innerWidth * 0.42)));
    896 }
    897 
    898 function workspaceSidePanelsEnabled() {
    899   if (!appRoot) return false;
    900   return !(appRoot.classList.contains("sideCollapsed") && appRoot.classList.contains("rightCollapsed"));
    901 }
    902 
    903 function ensureWorkspaceMinSpacer() {
    904   const workspace = ensureWorkspaceStripRack();
    905   if (!(workspace instanceof HTMLElement)) return null;
    906   let spacer = workspace.querySelector?.(":scope > .workspaceMinSpacer");
    907   if (!(spacer instanceof HTMLElement)) {
    908     spacer = document.createElement("div");
    909     spacer.className = "workspaceMinSpacer";
    910     workspace.appendChild(spacer);
    911   }
    912   if (workspace.lastElementChild !== spacer) workspace.appendChild(spacer);
    913   return spacer;
    914 }
    915 
    916 function panelWorkspaceWidthPx(panelId) {
    917   const size = panelWorkspaceSize(panelId);
    918   const half = workspaceHalfPanelWidthPx();
    919   if (size === "full") return half * 2;
    920   if (size === "skinny") return workspaceSkinnyPanelWidthPx();
    921   return half;
    922 }
    923 
    924 function updateWorkspaceMinView() {
    925   if (!workspaceInfiniteMode()) return;
    926   const workspace = ensureWorkspaceStripRack();
    927   if (!(workspace instanceof HTMLElement)) return;
    928   const spacer = ensureWorkspaceMinSpacer();
    929   if (!(spacer instanceof HTMLElement)) return;
    930   const panelIds = Array.from(workspace.querySelectorAll(":scope > .rackPanel:not(.hidden)"))
    931     .map((el) => String(el?.dataset?.panelId || "").trim())
    932     .filter(Boolean);
    933   const currentWidth = panelIds.reduce((sum, id) => sum + panelWorkspaceWidthPx(id), 0);
    934   const half = workspaceHalfPanelWidthPx();
    935   const minRequired = workspaceSidePanelsEnabled() ? half * 2 + workspaceSkinnyPanelWidthPx() : half * 2;
    936   const deficit = Math.max(0, Math.round(minRequired - currentWidth));
    937   spacer.style.width = `${deficit}px`;
    938 }
    939 
    940 function installWorkspaceScrollSnapStep() {
    941   const workspace = ensureWorkspaceStripRack();
    942   if (!(workspace instanceof HTMLElement)) return;
    943   if (workspace.dataset.snapStepInstalled === "1") return;
    944   workspace.dataset.snapStepInstalled = "1";
    945   let snapTimer = null;
    946   workspace.addEventListener("scroll", () => {
    947     if (!workspaceInfiniteMode()) return;
    948     if (appRoot?.classList.contains("rackIsDragging")) return;
    949     if (snapTimer) clearTimeout(snapTimer);
    950     snapTimer = setTimeout(() => {
    951       const step = workspaceHalfPanelWidthPx();
    952       if (!step) return;
    953       const current = workspace.scrollLeft;
    954       const maxScroll = Math.max(0, workspace.scrollWidth - workspace.clientWidth);
    955       let target = Math.round(current / step) * step;
    956       target = Math.max(0, Math.min(maxScroll, target));
    957       const nearRightEdge = maxScroll > 0 && current >= maxScroll - Math.max(28, Math.round(step * 0.45));
    958       if (nearRightEdge) target = maxScroll;
    959       if (Math.abs(target - current) < 4) return;
    960       workspace.scrollTo({ left: target, behavior: "smooth" });
    961     }, 90);
    962   });
    963 }
    964 
    965 function refreshPanelSizeButtons(panelId) {
    966   const id = String(panelId || "").trim();
    967   if (!id) return;
    968   const size = panelWorkspaceSize(id);
    969   const allowSkinny = panelAllowsSkinnyWorkspaceSize(id);
    970   const root = getPanelElement(id);
    971   if (!(root instanceof HTMLElement)) return;
    972   for (const btn of root.querySelectorAll(`[data-panelsize][data-panelid="${cssEscape(id)}"]`)) {
    973     const btnSize = normalizeWorkspacePanelSize(btn.getAttribute("data-panelsize") || "");
    974     if (btnSize === "skinny") btn.disabled = !allowSkinny;
    975     btn.classList.toggle("isActive", btnSize === size);
    976   }
    977 }
    978 
    979 function applyPanelWorkspaceSize(panelEl) {
    980   const el = panelEl instanceof HTMLElement ? panelEl : null;
    981   if (!el) return;
    982   const panelId = String(el.dataset.panelId || "").trim();
    983   const inWorkspace = el.parentElement && String(el.parentElement.id || "") === "mainWorkspaceRack";
    984   el.classList.toggle("workspaceSizeSkinny", Boolean(inWorkspace && panelWorkspaceSize(panelId) === "skinny"));
    985   el.classList.toggle("workspaceSizeHalf", Boolean(inWorkspace && panelWorkspaceSize(panelId) === "half"));
    986   el.classList.toggle("workspaceSizeFull", Boolean(inWorkspace && panelWorkspaceSize(panelId) === "full"));
    987   refreshPanelSizeButtons(panelId);
    988 }
    989 
    990 function applyAllWorkspacePanelSizes() {
    991   const workspace = ensureWorkspaceStripRack();
    992   if (!(workspace instanceof HTMLElement)) return;
    993   for (const panel of workspace.querySelectorAll(":scope > .rackPanel")) applyPanelWorkspaceSize(panel);
    994   updateWorkspaceMinView();
    995 }
    996 
    997 function setPanelWorkspaceSize(panelId, size) {
    998   const id = String(panelId || "").trim();
    999   if (!id) return;
   1000   let next = normalizeWorkspacePanelSize(size);
   1001   if (next === "skinny" && !panelAllowsSkinnyWorkspaceSize(id)) next = "half";
   1002   if (!rackLayoutState.panelSizes || typeof rackLayoutState.panelSizes !== "object") rackLayoutState.panelSizes = {};
   1003   rackLayoutState.panelSizes[id] = next;
   1004   saveRackLayoutState();
   1005   const panelEl = getPanelElement(id);
   1006   if (panelEl) applyPanelWorkspaceSize(panelEl);
   1007 }
   1008 
   1009 function panelCanUseWorkspaceSizes(panelId) {
   1010   const id = String(panelId || "").trim();
   1011   if (!id) return false;
   1012   if (id.startsWith("chat:")) return true;
   1013   return true;
   1014 }
   1015 
   1016 function installPanelSizeButtons(headerEl, panelId) {
   1017   if (!(headerEl instanceof HTMLElement)) return;
   1018   const id = String(panelId || "").trim();
   1019   if (!id) return;
   1020   if (!panelCanUseWorkspaceSizes(id)) return;
   1021   const row = headerEl.querySelector(".row") || headerEl;
   1022   if (row.querySelector(`[data-panelsizerow="${cssEscape(id)}"]`)) return;
   1023   const wrap = document.createElement("div");
   1024   wrap.className = "panelSizeControls";
   1025   wrap.setAttribute("data-panelsizerow", id);
   1026   wrap.innerHTML = `
   1027     <button type="button" class="ghost smallBtn panelSizeBtn" data-panelsize="skinny" data-panelid="${escapeHtml(id)}" title="Skinny width">S</button>
   1028     <button type="button" class="ghost smallBtn panelSizeBtn" data-panelsize="half" data-panelid="${escapeHtml(id)}" title="Half width">H</button>
   1029     <button type="button" class="ghost smallBtn panelSizeBtn" data-panelsize="full" data-panelid="${escapeHtml(id)}" title="Full width">F</button>
   1030   `;
   1031   row.appendChild(wrap);
   1032   refreshPanelSizeButtons(id);
   1033 }
   1034 
   1035 function togglePanelSkinny(panelId) {
   1036   if (!rackLayoutEnabled) return;
   1037   const id = String(panelId || "").trim();
   1038   if (!id) return;
   1039   if (!panelIsSkinnyCapable(id)) return;
   1040   const panelEl = getPanelElement(id);
   1041   if (!panelEl) return;
   1042 
   1043   const left = ensureWorkspaceLeftRack();
   1044   const right = ensureWorkspaceRightRack();
   1045   const side = ensureMainSideRack();
   1046   if (!left || !right || !side) return;
   1047 
   1048   const parentId = rackIdForPanelElement(panelEl);
   1049   const inSkinny = parentId === "mainSideRack" || parentId === "rightRack";
   1050 
   1051   if (inSkinny) {
   1052     // Move to workspace (prefer an empty slot; otherwise prefer right).
   1053     const leftExisting = left.querySelector?.(":scope > .rackPanel:not(.hidden)");
   1054     const rightExisting = right.querySelector?.(":scope > .rackPanel:not(.hidden)");
   1055     const target = !rightExisting ? right : !leftExisting ? left : right;
   1056     const existing = target === left ? leftExisting : rightExisting;
   1057     if (existing instanceof HTMLElement && existing !== panelEl) {
   1058       const existingId = String(existing.dataset?.panelId || "").trim();
   1059       if (existingId) dockPanel(existingId);
   1060     }
   1061     target.appendChild(panelEl);
   1062     rememberPanelLastRack(id, target.id);
   1063     saveRackLayoutState();
   1064     syncRackStateFromDom();
   1065     enforceWorkspaceRules();
   1066     return;
   1067   }
   1068 
   1069   // Move to side rack (skinny).
   1070   setSideCollapsed(false);
   1071   side.prepend(panelEl);
   1072   rememberPanelLastRack(id, side.id);
   1073   saveRackLayoutState();
   1074   syncRackStateFromDom();
   1075   enforceWorkspaceRules();
   1076 }
   1077 
   1078 registerCorePanel({ id: "chat", title: "Chat", icon: "πŸ’¬", role: "primary", defaultRack: "main", element: chatPanelEl });
   1079 registerCorePanel({ id: "hives", title: "Hives", icon: "🐝", role: "primary", defaultRack: "main", element: hivesPanelEl });
   1080 registerCorePanel({ id: "onboarding", title: "Onboarding", icon: "🧭", role: "primary", defaultRack: "main", element: onboardingPanelEl });
   1081 registerCorePanel({ id: "people", title: "Members list", icon: "πŸ‘₯", role: "aux", defaultRack: "right", element: peopleDrawerEl });
   1082 registerCorePanel({ id: "moderation", title: "Moderation", icon: "πŸ›‘οΈ", role: "aux", defaultRack: "right", element: modPanelEl });
   1083 registerCorePanel({ id: "profile", title: "Profile", icon: "πŸ‘€", role: "transient", defaultRack: "main", element: profileViewPanel });
   1084 registerCorePanel({ id: "composer", title: "New Hive", icon: "✍️", role: "aux", defaultRack: "main", element: pollinatePanel });
   1085 
   1086 let pluginRackPanelEl = null;
   1087 let pluginRackWidgetsRackEl = null;
   1088 let pluginRackAddMenuEl = null;
   1089 
   1090 function closePluginRackAddMenu() {
   1091   if (!pluginRackAddMenuEl) return;
   1092   try {
   1093     pluginRackAddMenuEl.remove();
   1094   } catch {
   1095     // ignore
   1096   }
   1097   pluginRackAddMenuEl = null;
   1098 }
   1099 
   1100 function panelIsPluginOwned(panelId) {
   1101   const id = String(panelId || "").trim();
   1102   if (!id) return false;
   1103   if (id.startsWith("chat:")) return false;
   1104   const entry = panelRegistry.get(id);
   1105   const src = typeof entry?.source === "string" ? entry.source : "";
   1106   return src.startsWith("plugin:");
   1107 }
   1108 
   1109 function panelIsHostableInPluginRack(panelId) {
   1110   const id = String(panelId || "").trim();
   1111   if (!id) return false;
   1112   if (id === "pluginRack") return false;
   1113   if (!panelIsPluginOwned(id)) return false;
   1114   // Widgets should be small, stackable tools (not full workspace surfaces like Maps).
   1115   if (panelRole(id) === "primary") return false;
   1116   return true;
   1117 }
   1118 
   1119 function ensurePluginRackPanel() {
   1120   if (pluginRackPanelEl instanceof HTMLElement && pluginRackPanelEl.isConnected) return pluginRackPanelEl;
   1121 
   1122   if (!(pluginRackPanelEl instanceof HTMLElement)) {
   1123     const shell = document.createElement("section");
   1124     shell.className = "panel panelFill pluginRackPanel rackPanel";
   1125     shell.dataset.panelId = "pluginRack";
   1126     shell.innerHTML = `
   1127       <div class="panelHeader">
   1128         <div class="panelTitle">${escapeHtml("Plugin Rack")}</div>
   1129         <div class="row"></div>
   1130       </div>
   1131       <div class="panelBody pluginRackBody">
   1132         <div class="pluginRackToolbar">
   1133           <button type="button" class="ghost smallBtn" data-pluginrackadd="1">+ Add widget</button>
   1134           <div class="small muted pluginRackHint">Drop plugin panels here to stack them.</div>
   1135         </div>
   1136         <div id="pluginRackWidgetsRack" class="pluginRackWidgets" aria-label="Plugin widgets"></div>
   1137       </div>
   1138     `;
   1139     pluginRackPanelEl = shell;
   1140     pluginRackWidgetsRackEl = shell.querySelector("#pluginRackWidgetsRack");
   1141 
   1142     shell.querySelector("[data-pluginrackadd]")?.addEventListener("click", (e) => {
   1143       const anchor = e.currentTarget;
   1144       if (pluginRackAddMenuEl) closePluginRackAddMenu();
   1145       else openPluginRackAddMenu(anchor);
   1146     });
   1147   }
   1148 
   1149   // Ensure it's registered as a core panel for docking + layout state.
   1150   registerCorePanel({ id: "pluginRack", title: "Plugin Rack", icon: "🧩", role: "aux", defaultRack: "main", element: pluginRackPanelEl });
   1151 
   1152   // Append into the DOM so it can be docked/restored. (It will typically live in the hotbar.)
   1153   const side = ensureMainSideRack();
   1154   if (side && pluginRackPanelEl.parentElement !== side) side.appendChild(pluginRackPanelEl);
   1155 
   1156   return pluginRackPanelEl;
   1157 }
   1158 
   1159 function ensurePluginRackWidgetsRack() {
   1160   ensurePluginRackPanel();
   1161   return pluginRackWidgetsRackEl instanceof HTMLElement ? pluginRackWidgetsRackEl : null;
   1162 }
   1163 
   1164 function readPluginRackWidgetsOrder() {
   1165   const rack = ensurePluginRackWidgetsRack();
   1166   return rack ? readRackOrder(rack) : [];
   1167 }
   1168 
   1169 function removePanelFromPluginRack(panelId) {
   1170   const id = String(panelId || "").trim();
   1171   if (!id) return;
   1172   rackLayoutState.pluginRackWidgets = Array.isArray(rackLayoutState.pluginRackWidgets)
   1173     ? rackLayoutState.pluginRackWidgets.filter((x) => x !== id)
   1174     : [];
   1175   const el = getPanelElement(id);
   1176   if (el) el.classList.remove("pluginRackWidget");
   1177   const rack = ensurePluginRackWidgetsRack();
   1178   if (rack && el && el.parentElement === rack) rack.removeChild(el);
   1179   const side = ensureMainSideRack();
   1180   if (side && el && !el.parentElement) side.appendChild(el);
   1181 }
   1182 
   1183 function hostPanelInPluginRack(panelId) {
   1184   const id = String(panelId || "").trim();
   1185   if (!id) return;
   1186   if (!rackLayoutEnabled) return;
   1187   if (!panelIsHostableInPluginRack(id)) {
   1188     toast("Can't add widget", `${panelTitle(id)} can't be hosted in Plugin Rack.`);
   1189     return;
   1190   }
   1191 
   1192   const rack = ensurePluginRackWidgetsRack();
   1193   const el = getPanelElement(id);
   1194   if (!rack || !el) return;
   1195 
   1196   // Hosting implies it should be visible in the rack, not docked.
   1197   if (isDocked(id)) undockPanel(id);
   1198 
   1199   const lastRack = rackIdForPanelElement(el);
   1200   if (lastRack) rememberPanelLastRack(id, lastRack);
   1201 
   1202   el.classList.add("pluginRackWidget");
   1203   if (el.parentElement !== rack) rack.appendChild(el);
   1204 
   1205   const next = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []);
   1206   next.add(id);
   1207   rackLayoutState.pluginRackWidgets = Array.from(next);
   1208   saveRackLayoutState();
   1209   syncRackStateFromDom();
   1210   enforceWorkspaceRules();
   1211 }
   1212 
   1213 function openPluginRackAddMenu(anchorEl) {
   1214   closePluginRackAddMenu();
   1215   if (!(anchorEl instanceof HTMLElement)) return;
   1216   if (!rackLayoutEnabled) return;
   1217 
   1218   const hosted = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []);
   1219   const candidates = Array.from(panelRegistry.keys())
   1220     .filter((id) => panelIsHostableInPluginRack(id) && !hosted.has(id))
   1221     .sort((a, b) => panelTitle(a).localeCompare(panelTitle(b)));
   1222 
   1223   const items = candidates
   1224     .map((id) => `<button type="button" class="ghost smallBtn" data-pluginrackhost="${escapeHtml(id)}">${escapeHtml(panelTitle(id))}</button>`)
   1225     .join("");
   1226 
   1227   const menu = document.createElement("div");
   1228   menu.className = "hotbarAddMenu pluginRackAddMenu";
   1229   menu.innerHTML = `
   1230     <div class="small muted" style="padding:6px 8px 4px;">Add widget</div>
   1231     <div class="hotbarAddMenuList">${items || `<div class="small muted" style="padding:6px 8px;">No plugin widgets available.</div>`}</div>
   1232   `;
   1233 
   1234   const rect = anchorEl.getBoundingClientRect();
   1235   const left = Math.max(12, Math.min(window.innerWidth - 260, rect.left));
   1236   const top = Math.max(12, Math.min(window.innerHeight - 320, rect.bottom + 8));
   1237   menu.style.left = `${left}px`;
   1238   menu.style.top = `${top}px`;
   1239 
   1240   menu.addEventListener("click", (e) => {
   1241     const btn = e.target.closest?.("[data-pluginrackhost]");
   1242     if (!btn) return;
   1243     const id = String(btn.getAttribute("data-pluginrackhost") || "").trim();
   1244     if (!id) return;
   1245     hostPanelInPluginRack(id);
   1246     closePluginRackAddMenu();
   1247   });
   1248 
   1249   document.body.appendChild(menu);
   1250   pluginRackAddMenuEl = menu;
   1251 }
   1252 
   1253 // Rack mode: Profile should behave like a normal dockable panel (not a flow that replaces Hives).
   1254 // Override the role after the initial core registration (Map#set will replace the previous entry).
   1255 panelRegistry.set("profile", { ...(panelRegistry.get("profile") || { id: "profile", source: "core" }), role: "aux" });
   1256 
   1257 // Expose for quick inspection in the browser console while iterating.
   1258 window.__bzlPanels = { panelRegistry };
   1259 
   1260 const PRESET_DEFS = {
   1261   // Presets are hard-applied (exact placement). Anything not explicitly placed starts in the hotbar.
   1262   // In workspace-infinite mode, these arrays are merged into one ordered strip.
   1263   onboardingDefault: {
   1264     presetId: "onboardingDefault",
   1265     label: "Onboarding (Default)",
   1266     group: "user",
   1267     workspaceLeftOrder: ["onboarding", "hives"],
   1268     workspaceRightOrder: ["chat", "people"],
   1269     sideOrder: ["composer", "profile"],
   1270     sideCollapsed: false,
   1271     rightOrder: ["pluginRack"],
   1272     dockBottom: ["pluginRack", "maps", "library-browser", "library-shelf", "library-reader"],
   1273   },
   1274   tutorial: {
   1275     presetId: "tutorial",
   1276     label: "Tutorial",
   1277     group: "user",
   1278     workspaceLeftOrder: ["hives", "chat"],
   1279     workspaceRightOrder: ["profile"],
   1280     sideOrder: ["composer"],
   1281     sideCollapsed: true,
   1282     rightOrder: ["people"],
   1283     dockBottom: ["onboarding", "composer", "moderation", "maps", "pluginRack", "library-browser", "library-shelf", "library-reader"],
   1284   },
   1285   social: {
   1286     presetId: "social",
   1287     label: "Default (Social)",
   1288     group: "user",
   1289     workspaceLeftOrder: ["hives", "chat"],
   1290     workspaceRightOrder: ["people", "profile"],
   1291     sideOrder: ["composer", "pluginRack"],
   1292     sideCollapsed: true,
   1293     rightOrder: ["onboarding"],
   1294     dockBottom: ["maps", "library-browser", "library-shelf", "library-reader"],
   1295   },
   1296   chatFocus: {
   1297     presetId: "chatFocus",
   1298     label: "Chat Focus",
   1299     group: "user",
   1300     workspaceLeftOrder: ["chat", "hives"],
   1301     workspaceRightOrder: ["people", "profile"],
   1302     expandedPrimary: "chat",
   1303     sideOrder: ["composer"],
   1304     sideCollapsed: true,
   1305     rightOrder: ["pluginRack"],
   1306     dockBottom: ["onboarding", "maps", "library-browser", "library-shelf", "library-reader"],
   1307   },
   1308   browse: {
   1309     presetId: "browse",
   1310     label: "Browse",
   1311     group: "user",
   1312     workspaceLeftOrder: ["hives", "chat"],
   1313     workspaceRightOrder: ["profile", "people"],
   1314     expandedPrimary: "hives",
   1315     sideOrder: ["composer", "pluginRack"],
   1316     sideCollapsed: true,
   1317     rightOrder: ["onboarding"],
   1318     dockBottom: ["maps", "library-browser", "library-shelf", "library-reader"],
   1319   },
   1320   creator: {
   1321     presetId: "creator",
   1322     label: "Creator",
   1323     group: "user",
   1324     workspaceLeftOrder: ["hives", "composer"],
   1325     workspaceRightOrder: ["chat", "people"],
   1326     composerOpen: true,
   1327     sideOrder: ["profile", "pluginRack"],
   1328     sideCollapsed: true,
   1329     rightOrder: ["onboarding"],
   1330     dockBottom: ["maps", "library-browser", "library-shelf", "library-reader"],
   1331   },
   1332   mapsSession: {
   1333     presetId: "mapsSession",
   1334     label: "Maps Session",
   1335     group: "user",
   1336     workspaceLeftOrder: ["maps", "chat"], // if installed
   1337     workspaceRightOrder: ["hives", "people"],
   1338     sideOrder: ["profile", "composer"],
   1339     sideCollapsed: true,
   1340     rightOrder: ["pluginRack"],
   1341     dockBottom: ["onboarding", "library-browser", "library-shelf", "library-reader"],
   1342   },
   1343   quiet: {
   1344     presetId: "quiet",
   1345     label: "Quiet (No People)",
   1346     group: "user",
   1347     workspaceLeftOrder: ["hives", "profile"],
   1348     workspaceRightOrder: ["composer", "library-reader"],
   1349     sideOrder: [],
   1350     sideCollapsed: true,
   1351     rightOrder: [],
   1352     rightCollapsed: true,
   1353     dockBottom: ["pluginRack", "chat", "people", "maps", "library-browser", "library-shelf", "onboarding"],
   1354   },
   1355   readingNook: {
   1356     presetId: "readingNook",
   1357     label: "Reading Nook",
   1358     group: "user",
   1359     workspaceLeftOrder: ["library-reader", "library-shelf"],
   1360     workspaceRightOrder: ["library-browser", "chat"],
   1361     sideOrder: ["profile", "people"],
   1362     sideCollapsed: true,
   1363     rightOrder: ["pluginRack"],
   1364     dockBottom: ["hives", "composer", "maps", "onboarding"],
   1365   },
   1366   libraryCurator: {
   1367     presetId: "libraryCurator",
   1368     label: "Library Curator",
   1369     group: "user",
   1370     workspaceLeftOrder: ["library-browser", "library-shelf"],
   1371     workspaceRightOrder: ["library-reader", "hives"],
   1372     sideOrder: ["chat", "profile"],
   1373     sideCollapsed: true,
   1374     rightOrder: ["pluginRack"],
   1375     dockBottom: ["people", "composer", "maps", "onboarding"],
   1376   },
   1377   ops: {
   1378     presetId: "ops",
   1379     label: "Ops",
   1380     group: "mod",
   1381     modOnly: true,
   1382     workspaceLeftOrder: ["moderation", "chat"],
   1383     workspaceRightOrder: ["hives", "people"],
   1384     sideOrder: ["profile", "composer"],
   1385     sideCollapsed: true,
   1386     rightOrder: ["pluginRack"],
   1387     dockBottom: ["onboarding", "maps", "library-browser", "library-shelf", "library-reader"],
   1388   },
   1389   reportsFocus: {
   1390     presetId: "reportsFocus",
   1391     label: "Reports Focus",
   1392     group: "mod",
   1393     modOnly: true,
   1394     workspaceLeftOrder: ["moderation", "chat"],
   1395     workspaceRightOrder: ["hives", "people"],
   1396     expandedPrimary: "moderation",
   1397     sideOrder: ["profile"],
   1398     sideCollapsed: true,
   1399     rightOrder: ["pluginRack"],
   1400     dockBottom: ["onboarding", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
   1401   },
   1402   communityWatch: {
   1403     presetId: "communityWatch",
   1404     label: "Community Watch",
   1405     group: "mod",
   1406     modOnly: true,
   1407     workspaceLeftOrder: ["hives", "moderation"],
   1408     workspaceRightOrder: ["chat", "people"],
   1409     sideOrder: ["profile", "composer"],
   1410     sideCollapsed: true,
   1411     rightOrder: ["pluginRack"],
   1412     dockBottom: ["onboarding", "maps", "library-browser", "library-shelf", "library-reader"],
   1413   },
   1414   serverAdmin: {
   1415     presetId: "serverAdmin",
   1416     label: "Server Admin",
   1417     group: "mod",
   1418     modOnly: true,
   1419     workspaceLeftOrder: ["moderation", "hives"],
   1420     workspaceRightOrder: ["chat", "people"],
   1421     sideOrder: ["composer", "profile"],
   1422     sideCollapsed: true,
   1423     rightOrder: ["pluginRack"],
   1424     dockBottom: ["onboarding", "maps", "library-browser", "library-shelf", "library-reader"],
   1425   },
   1426 };
   1427 
   1428 const PRESET_ALIASES = {
   1429   // Back-compat for older preset ids.
   1430   discordLike: "social",
   1431   onboarding: "onboardingDefault",
   1432   chat: "chatFocus",
   1433   browsing: "browse",
   1434   maps: "mapsSession",
   1435   focus: "quiet",
   1436   clean: "social",
   1437   moderation: "ops",
   1438   reading: "readingNook",
   1439   library: "libraryCurator",
   1440   tour: "tutorial",
   1441 };
   1442 
   1443 function resolvePresetKey(presetId) {
   1444   const raw = String(presetId || "").trim();
   1445   const mapped = Object.prototype.hasOwnProperty.call(PRESET_ALIASES, raw) ? PRESET_ALIASES[raw] : raw;
   1446   return Object.prototype.hasOwnProperty.call(PRESET_DEFS, mapped) ? mapped : "onboardingDefault";
   1447 }
   1448 
   1449 function updateLayoutPresetOptions() {
   1450   if (!layoutPresetEl) return;
   1451   const current = resolvePresetKey(rackLayoutState?.presetId || layoutPresetEl.value || "onboardingDefault");
   1452 
   1453   const defs = Object.values(PRESET_DEFS).filter((d) => d && typeof d === "object");
   1454   const userDefs = defs.filter((d) => d.group === "user");
   1455   const modDefs = defs.filter((d) => d.group === "mod");
   1456 
   1457   const makeOpt = (def) => {
   1458     const opt = document.createElement("option");
   1459     opt.value = String(def.presetId || "");
   1460     opt.textContent = String(def.label || def.presetId || "Preset");
   1461     return opt;
   1462   };
   1463 
   1464   layoutPresetEl.innerHTML = "";
   1465 
   1466   const userGroup = document.createElement("optgroup");
   1467   userGroup.label = "Presets";
   1468   for (const def of userDefs) userGroup.appendChild(makeOpt(def));
   1469   layoutPresetEl.appendChild(userGroup);
   1470 
   1471   if (canModerate) {
   1472     const modGroup = document.createElement("optgroup");
   1473     modGroup.label = "Moderation (mods)";
   1474     for (const def of modDefs) modGroup.appendChild(makeOpt(def));
   1475     layoutPresetEl.appendChild(modGroup);
   1476   }
   1477 
   1478   const nextValue = canModerate ? current : (PRESET_DEFS[current]?.modOnly ? "onboardingDefault" : current);
   1479   layoutPresetEl.value = Object.prototype.hasOwnProperty.call(PRESET_DEFS, nextValue) ? nextValue : "onboardingDefault";
   1480 }
   1481 
   1482 function readRackLayoutEnabled() {
   1483   if (FORCE_RACK_MODE) return true;
   1484   try {
   1485     return localStorage.getItem(RACK_LAYOUT_ENABLED_KEY) === "1";
   1486   } catch {
   1487     return false;
   1488   }
   1489 }
   1490 
   1491 function writeRackLayoutEnabled(enabled) {
   1492   if (FORCE_RACK_MODE) {
   1493     rackLayoutEnabled = true;
   1494     try {
   1495       localStorage.setItem(RACK_LAYOUT_ENABLED_KEY, "1");
   1496     } catch {
   1497       // ignore
   1498     }
   1499     return;
   1500   }
   1501   rackLayoutEnabled = Boolean(enabled);
   1502   try {
   1503     localStorage.setItem(RACK_LAYOUT_ENABLED_KEY, rackLayoutEnabled ? "1" : "0");
   1504   } catch {
   1505     // ignore
   1506   }
   1507 }
   1508 
   1509 /** @returns {RackLayoutState} */
   1510 function loadRackLayoutState() {
   1511   try {
   1512     const raw = localStorage.getItem(RACK_LAYOUT_STATE_KEY);
   1513     if (!raw)
   1514       return {
   1515         version: 2,
   1516         presetId: "onboardingDefault",
   1517         docked: { bottom: [] },
   1518         racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
   1519         pluginRackWidgets: [],
   1520         lastRackByPanelId: {},
   1521         panelSizes: {},
   1522       };
   1523     const parsed = JSON.parse(raw);
   1524     if (!parsed || parsed.version !== 2)
   1525       return {
   1526         version: 2,
   1527         presetId: "onboardingDefault",
   1528         docked: { bottom: [] },
   1529         racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
   1530         pluginRackWidgets: [],
   1531         lastRackByPanelId: {},
   1532         panelSizes: {},
   1533       };
   1534     const bottom = Array.isArray(parsed?.docked?.bottom) ? parsed.docked.bottom.map((x) => String(x || "")).filter(Boolean) : [];
   1535     const pluginRackWidgets = Array.isArray(parsed?.pluginRackWidgets)
   1536       ? parsed.pluginRackWidgets.map((x) => String(x || "")).filter(Boolean)
   1537       : [];
   1538     const presetId = typeof parsed?.presetId === "string" ? parsed.presetId : "onboardingDefault";
   1539     const workspaceLeft = Array.isArray(parsed?.racks?.workspaceLeft) ? parsed.racks.workspaceLeft.map((x) => String(x || "")).filter(Boolean) : [];
   1540     const workspaceRight = Array.isArray(parsed?.racks?.workspaceRight) ? parsed.racks.workspaceRight.map((x) => String(x || "")).filter(Boolean) : [];
   1541     const side = Array.isArray(parsed?.racks?.side) ? parsed.racks.side.map((x) => String(x || "")).filter(Boolean) : [];
   1542     const right = Array.isArray(parsed?.racks?.right) ? parsed.racks.right.map((x) => String(x || "")).filter(Boolean) : [];
   1543     const lastRackByPanelIdRaw = parsed?.lastRackByPanelId && typeof parsed.lastRackByPanelId === "object" ? parsed.lastRackByPanelId : {};
   1544     const lastRackByPanelId = {};
   1545     for (const [k, v] of Object.entries(lastRackByPanelIdRaw)) {
   1546       const id = String(k || "").trim();
   1547       const rackId = typeof v === "string" ? v.trim() : "";
   1548       if (!id || !rackId) continue;
   1549       lastRackByPanelId[id] = rackId;
   1550     }
   1551     const panelSizesRaw = parsed?.panelSizes && typeof parsed.panelSizes === "object" ? parsed.panelSizes : {};
   1552     const panelSizes = {};
   1553     for (const [k, v] of Object.entries(panelSizesRaw)) {
   1554       const id = String(k || "").trim();
   1555       const size = String(v || "").trim().toLowerCase();
   1556       if (!id) continue;
   1557       if (size !== "skinny" && size !== "half" && size !== "full") continue;
   1558       panelSizes[id] = size;
   1559     }
   1560     return { version: 2, presetId, docked: { bottom }, racks: { workspaceLeft, workspaceRight, side, right }, pluginRackWidgets, lastRackByPanelId, panelSizes };
   1561   } catch {
   1562     return {
   1563       version: 2,
   1564       presetId: "onboardingDefault",
   1565       docked: { bottom: [] },
   1566       racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
   1567       pluginRackWidgets: [],
   1568       lastRackByPanelId: {},
   1569       panelSizes: {},
   1570     };
   1571   }
   1572 }
   1573 
   1574 function saveRackLayoutState() {
   1575   try {
   1576     localStorage.setItem(RACK_LAYOUT_STATE_KEY, JSON.stringify(rackLayoutState));
   1577   } catch {
   1578     // ignore
   1579   }
   1580 }
   1581 
   1582 function ensureWorkspaceSlots() {
   1583   const workspace = mainWorkspaceRackEl || document.getElementById("mainWorkspaceRack");
   1584   if (!workspace) return { left: null, right: null };
   1585   if (workspaceInfiniteMode()) {
   1586     const leftSlot = workspace.querySelector?.("#workspaceLeftSlot");
   1587     const rightSlot = workspace.querySelector?.("#workspaceRightSlot");
   1588     for (const slot of [leftSlot, rightSlot]) {
   1589       if (!(slot instanceof HTMLElement)) continue;
   1590       const kids = Array.from(slot.querySelectorAll(":scope > .rackPanel"));
   1591       for (const kid of kids) workspace.appendChild(kid);
   1592       try {
   1593         slot.remove();
   1594       } catch {
   1595         // ignore
   1596       }
   1597     }
   1598     return { left: workspace, right: workspace };
   1599   }
   1600 
   1601   let left = workspace.querySelector?.("#workspaceLeftSlot");
   1602   let right = workspace.querySelector?.("#workspaceRightSlot");
   1603 
   1604   if (!left) {
   1605     left = document.createElement("div");
   1606     left.id = "workspaceLeftSlot";
   1607     left.className = "workspaceSlot workspaceSlotLeft";
   1608     left.setAttribute("aria-label", "Workspace left");
   1609     workspace.prepend(left);
   1610   }
   1611   if (!right) {
   1612     right = document.createElement("div");
   1613     right.id = "workspaceRightSlot";
   1614     right.className = "workspaceSlot workspaceSlotRight";
   1615     right.setAttribute("aria-label", "Workspace right");
   1616     const afterLeft = workspace.querySelector?.("#workspaceLeftSlot");
   1617     if (afterLeft && afterLeft.nextSibling) workspace.insertBefore(right, afterLeft.nextSibling);
   1618     else workspace.appendChild(right);
   1619   }
   1620   return { left, right };
   1621 }
   1622 
   1623 function ensureWorkspaceStripRack() {
   1624   if (!workspaceInfiniteMode()) return null;
   1625   const workspace = ensureMainRack();
   1626   return workspace instanceof HTMLElement ? workspace : null;
   1627 }
   1628 
   1629 function panelTitle(panelId) {
   1630   const entry = panelRegistry.get(panelId);
   1631   if (entry?.title) return entry.title;
   1632   if (panelId === "maps") return "Maps";
   1633   if (panelId === "library") return "Library";
   1634   return String(panelId || "");
   1635 }
   1636 
   1637 function chatRailClass({ fromUser, isModMessage }) {
   1638   const from = String(fromUser || "").trim();
   1639   const isSystem = !from || from.toLowerCase() === "system";
   1640   const isModMsg = Boolean(isModMessage);
   1641   const isYou = Boolean(loggedInUser && from && from === loggedInUser);
   1642   if (isSystem || isModMsg) return "railLeft";
   1643   if (isYou) return "railRight";
   1644   return "railCenter";
   1645 }
   1646 
   1647 function updateChatModToggleVisibility() {
   1648   if (!chatModToggleWrapEl) return;
   1649   const canUse = Boolean(canModerate && activeChatPostId && !activeDmThreadId && !isMapChatActive());
   1650   chatModToggleWrapEl.classList.toggle("hidden", !canUse);
   1651   if (!canUse && chatModToggleEl) chatModToggleEl.checked = false;
   1652 }
   1653 
   1654 function panelIcon(panelId) {
   1655   const entry = panelRegistry.get(panelId);
   1656   if (entry?.icon) return entry.icon;
   1657   if (panelId === "maps") return "πŸ—ΊοΈ";
   1658   if (panelId === "library") return "πŸ“š";
   1659   return "β€’";
   1660 }
   1661 
   1662 function panelRole(panelId) {
   1663   const entry = panelRegistry.get(panelId);
   1664   return typeof entry?.role === "string" ? entry.role : "aux";
   1665 }
   1666 
   1667 function panelCanExpand(panelId) {
   1668   const id = String(panelId || "").trim();
   1669   if (!id) return false;
   1670   if (id.startsWith("chat:")) return true;
   1671   if (panelRole(id) === "primary") return true;
   1672   // Allow a few core panels to take over the workspace even though they aren't "primary" by default.
   1673   return id === "moderation" || id === "composer" || id === "pluginRack";
   1674 }
   1675 
   1676 // Panels that are allowed to live in "skinny" columns (side rack / right rack).
   1677 // These panels should be able to render in a narrow width without breaking layout.
   1678 const SKINNY_CAPABLE_PANELS = new Set(["people", "profile", "composer", "chat", "pluginRack", "dice"]);
   1679 
   1680 function panelIsSkinnyCapable(panelId) {
   1681   const id = String(panelId || "").trim();
   1682   if (!id) return false;
   1683   if (id.startsWith("chat:")) return true;
   1684   return SKINNY_CAPABLE_PANELS.has(id);
   1685 }
   1686 
   1687 function isDocked(panelId) {
   1688   return rackLayoutState.docked.bottom.includes(panelId);
   1689 }
   1690 
   1691 function getPanelElement(panelId) {
   1692   const id = String(panelId || "").trim();
   1693   if (!id) return null;
   1694   const entry = panelRegistry.get(id);
   1695   const el = entry?.element;
   1696   return el instanceof HTMLElement ? el : null;
   1697 }
   1698 
   1699 function rackIdForPanelElement(panelEl) {
   1700   const el = panelEl instanceof HTMLElement ? panelEl : null;
   1701   if (!el) return "";
   1702   const parent = el.parentElement;
   1703   const id = parent && typeof parent.id === "string" ? parent.id : "";
   1704   if (id === "mainWorkspaceRack" || id === "workspaceLeftSlot" || id === "workspaceRightSlot" || id === "mainSideRack" || id === "rightRack")
   1705     return id;
   1706   return "";
   1707 }
   1708 
   1709 function updateSkinnyChatPanels() {
   1710   const applySkinnyState = (panelEl) => {
   1711     if (!(panelEl instanceof HTMLElement)) return;
   1712     const rackId = rackIdForPanelElement(panelEl);
   1713     const inSkinnyRack = rackId === "mainSideRack" || rackId === "rightRack";
   1714     panelEl.classList.toggle("isSkinnyChat", Boolean(rackLayoutEnabled && inSkinnyRack));
   1715   };
   1716 
   1717   applySkinnyState(chatPanelEl);
   1718   for (const panelId of chatPanelInstances.keys()) {
   1719     applySkinnyState(getPanelElement(panelId));
   1720   }
   1721 }
   1722 
   1723 function rememberPanelLastRack(panelId, rackId) {
   1724   const id = String(panelId || "").trim();
   1725   const rack = String(rackId || "").trim();
   1726   if (!id || !rack) return;
   1727   if (!rackLayoutState.lastRackByPanelId || typeof rackLayoutState.lastRackByPanelId !== "object") rackLayoutState.lastRackByPanelId = {};
   1728   rackLayoutState.lastRackByPanelId[id] = rack;
   1729 }
   1730 
   1731 function dockPanel(panelId) {
   1732   const id = String(panelId || "").trim();
   1733   if (!id) return;
   1734   if (rackLayoutEnabled && isRightRackFixedPanel(id)) return;
   1735   // Docking a hosted widget should implicitly un-host it.
   1736   removePanelFromPluginRack(id);
   1737   const el = getPanelElement(id);
   1738   const lastRack = rackIdForPanelElement(el);
   1739   if (lastRack) rememberPanelLastRack(id, lastRack);
   1740   if (!isDocked(id)) rackLayoutState.docked.bottom.push(id);
   1741   saveRackLayoutState();
   1742   applyDockState();
   1743 }
   1744 
   1745 function undockPanel(panelId) {
   1746   const id = String(panelId || "").trim();
   1747   if (!id) return;
   1748   rackLayoutState.docked.bottom = rackLayoutState.docked.bottom.filter((x) => x !== id);
   1749   saveRackLayoutState();
   1750   applyDockState();
   1751 }
   1752 
   1753 function restorePanelFromHotbar(panelId, opts) {
   1754   const id = String(panelId || "").trim();
   1755   if (!id) return;
   1756   if (!rackLayoutEnabled) return;
   1757   const options = opts && typeof opts === "object" ? opts : {};
   1758   const userAdded = options.userAdded === true;
   1759 
   1760   const panelEl = getPanelElement(id);
   1761   if (!panelEl) return;
   1762   if (workspaceInfiniteMode() && isRightRackFixedPanel(id)) {
   1763     const rightRack = ensureRightRack();
   1764     if (!(rightRack instanceof HTMLElement)) return;
   1765     undockPanel(id);
   1766     rightRack.appendChild(panelEl);
   1767     rememberPanelLastRack(id, rightRack.id);
   1768     setRightCollapsed(false);
   1769     saveRackLayoutState();
   1770     syncRackStateFromDom();
   1771     enforceWorkspaceRules();
   1772     return;
   1773   }
   1774   if (workspaceInfiniteMode()) {
   1775     const workspace = ensureWorkspaceStripRack();
   1776     if (!(workspace instanceof HTMLElement)) return;
   1777     undockPanel(id);
   1778     const spacer = ensureWorkspaceMinSpacer();
   1779     if (spacer instanceof HTMLElement && spacer.parentElement === workspace) workspace.insertBefore(panelEl, spacer);
   1780     else workspace.appendChild(panelEl);
   1781     rememberPanelLastRack(id, workspace.id);
   1782     applyPanelWorkspaceSize(panelEl);
   1783     saveRackLayoutState();
   1784     syncRackStateFromDom();
   1785     enforceWorkspaceRules();
   1786     if (userAdded) {
   1787       requestAnimationFrame(() => {
   1788         focusWorkspaceArrival(panelEl);
   1789       });
   1790     }
   1791     return;
   1792   }
   1793 
   1794   // Decide where to restore the panel.
   1795   const lastRackId =
   1796     rackLayoutState?.lastRackByPanelId && typeof rackLayoutState.lastRackByPanelId === "object"
   1797       ? String(rackLayoutState.lastRackByPanelId[id] || "")
   1798       : "";
   1799   const lastRack = lastRackId ? document.getElementById(lastRackId) : null;
   1800 
   1801   const leftSlot = ensureWorkspaceLeftRack();
   1802   const rightSlot = ensureWorkspaceRightRack();
   1803   const sideRack = ensureMainSideRack();
   1804   const rightRack = ensureRightRack();
   1805 
   1806   const pickWorkspaceSlot = () => {
   1807     const leftEmpty = leftSlot ? leftSlot.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false;
   1808     const rightEmpty = rightSlot ? rightSlot.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false;
   1809     return leftEmpty ? leftSlot : rightEmpty ? rightSlot : leftSlot;
   1810   };
   1811 
   1812   let targetRack = null;
   1813   if (lastRack instanceof HTMLElement) {
   1814     targetRack = lastRack;
   1815   } else if (panelIsSkinnyCapable(id)) {
   1816     // Heuristic: aux-like panels default to side rack; "right" defaults to the right rack.
   1817     const defRack = String(panelRegistry.get(id)?.defaultRack || "");
   1818     targetRack = defRack === "right" ? rightRack : sideRack;
   1819   } else {
   1820     targetRack = pickWorkspaceSlot();
   1821   }
   1822 
   1823   // If restoring into a collapsed rack, uncollapse it (hotbar acts like a summonable launcher).
   1824   if (targetRack && targetRack.id === "mainSideRack") setSideCollapsed(false);
   1825   if (targetRack && targetRack.id === "rightRack") setRightCollapsed(false);
   1826 
   1827   // If the panel already lives in a rack, keep its place and just reveal it.
   1828   const currentRackId = rackIdForPanelElement(panelEl);
   1829   const currentRack = currentRackId ? document.getElementById(currentRackId) : null;
   1830 
   1831   undockPanel(id);
   1832 
   1833   if (!(currentRack instanceof HTMLElement)) {
   1834     const rack = targetRack instanceof HTMLElement ? targetRack : null;
   1835     if (rack) {
   1836       // Right rack + workspace slots are single-slot: docking the existing occupant is the least surprising behavior.
   1837       const isWorkspaceSlot = rack.id === "workspaceLeftSlot" || rack.id === "workspaceRightSlot";
   1838       const isRightRackSlot = rack.id === "rightRack";
   1839       if (isWorkspaceSlot || isRightRackSlot) {
   1840         const existing = rack.querySelector?.(":scope > .rackPanel:not(.hidden)");
   1841         if (existing instanceof HTMLElement && existing !== panelEl) {
   1842           const existingId = String(existing.dataset.panelId || "").trim();
   1843           if (existingId) dockPanel(existingId);
   1844         }
   1845       }
   1846       rack.appendChild(panelEl);
   1847       rememberPanelLastRack(id, rack.id);
   1848       saveRackLayoutState();
   1849     }
   1850   } else {
   1851     // Ensure the rack is visible if we restored into it.
   1852     if (currentRack.id === "mainSideRack") setSideCollapsed(false);
   1853     if (currentRack.id === "rightRack") setRightCollapsed(false);
   1854   }
   1855 
   1856   syncRackStateFromDom();
   1857   enforceWorkspaceRules();
   1858   if (userAdded) {
   1859     requestAnimationFrame(() => {
   1860       focusWorkspaceArrival(panelEl);
   1861     });
   1862   }
   1863 }
   1864 
   1865 function showHotbar(show) {
   1866   if (!dockHotbarEl) return;
   1867   if (!show && dockHotbarEl.dataset.lockVisible === "1") return;
   1868   dockHotbarEl.classList.toggle("hidden", !show);
   1869   dockHotbarEl.classList.toggle("show", Boolean(show));
   1870   if (appRoot) appRoot.classList.toggle("hotbarVisible", Boolean(show));
   1871 }
   1872 
   1873 function renderHotbar() {
   1874   if (!dockHotbarEl) return;
   1875   const items = rackLayoutState.docked.bottom.slice().filter((id) => getPanelElement(id));
   1876   const includePlus = Boolean(rackLayoutEnabled);
   1877   if (!items.length && !includePlus) {
   1878     dockHotbarEl.classList.add("hidden");
   1879     dockHotbarEl.classList.remove("show");
   1880     dockHotbarEl.innerHTML = "";
   1881     if (appRoot) appRoot.classList.remove("hotbarVisible");
   1882     return;
   1883   }
   1884 
   1885   const orbsHtml = items
   1886     .map(
   1887       (id) => `
   1888     <button type="button" class="dockOrb" data-undock="${escapeHtml(id)}" title="Click to restore ${escapeHtml(panelTitle(id))}. Drag to place in a rack.">
   1889       <span class="dockOrbIcon" aria-hidden="true">${escapeHtml(panelIcon(id))}</span>
   1890       <span>${escapeHtml(panelTitle(id))}</span>
   1891     </button>
   1892   `
   1893     )
   1894     .join("");
   1895   const hintHtml = items.length
   1896     ? `
   1897     <div class="hotbarHint" aria-hidden="true">
   1898       <span class="hotbarHintGrip">≑</span>
   1899       <span>Drag to place</span>
   1900       <span class="hotbarHintSep">β€’</span>
   1901       <span>Click to open</span>
   1902     </div>
   1903   `
   1904     : "";
   1905 
   1906   const plusHtml = includePlus
   1907     ? `
   1908     <button type="button" class="dockOrb dockOrbPlus" data-hotbarplus="1" title="Add panel">
   1909       <span class="dockOrbIcon" aria-hidden="true">+</span>
   1910       <span>Add</span>
   1911     </button>
   1912   `
   1913     : "";
   1914 
   1915   dockHotbarEl.innerHTML = `${hintHtml}${orbsHtml}${plusHtml}`;
   1916   dockHotbarEl.classList.remove("hidden");
   1917   requestAnimationFrame(() => showHotbar(true));
   1918 }
   1919 
   1920 let hotbarPlusMenuEl = null;
   1921 let workspaceAddMenuEl = null;
   1922 
   1923 function closeHotbarPlusMenu() {
   1924   if (!hotbarPlusMenuEl) return;
   1925   try {
   1926     hotbarPlusMenuEl.remove();
   1927   } catch {
   1928     // ignore
   1929   }
   1930   hotbarPlusMenuEl = null;
   1931 }
   1932 
   1933 function closeWorkspaceAddMenu() {
   1934   if (!workspaceAddMenuEl) return;
   1935   try {
   1936     workspaceAddMenuEl.remove();
   1937   } catch {
   1938     // ignore
   1939   }
   1940   workspaceAddMenuEl = null;
   1941 }
   1942 
   1943 function workspaceAddCandidates() {
   1944   return Array.from(panelRegistry.keys())
   1945     .filter((id) => Boolean(getPanelElement(id)))
   1946     .filter((id) => !id.startsWith("chat:post:"))
   1947     .filter((id) => !isRightRackFixedPanel(id))
   1948     .filter((id) => id !== "profile")
   1949     .filter((id) => !(id === "moderation" && !canModerate))
   1950     .map((id) => ({
   1951       id,
   1952       title: panelTitle(id),
   1953       icon: panelIcon(id),
   1954       docked: isDocked(id),
   1955     }))
   1956     .sort((a, b) => a.title.localeCompare(b.title));
   1957 }
   1958 
   1959 function restorePanelToWorkspaceSlot(panelId, slotId, opts) {
   1960   const id = String(panelId || "").trim();
   1961   const slot = String(slotId || "").trim();
   1962   if (!id || !slot) return;
   1963   const options = opts && typeof opts === "object" ? opts : {};
   1964   const userAdded = options.userAdded === true;
   1965   if (workspaceInfiniteMode()) {
   1966     restorePanelFromHotbar(id, { userAdded });
   1967     return;
   1968   }
   1969   const target = slot === "workspaceRightSlot" ? ensureWorkspaceRightRack() : ensureWorkspaceLeftRack();
   1970   if (!(target instanceof HTMLElement)) return;
   1971   const panelEl = getPanelElement(id);
   1972   if (!(panelEl instanceof HTMLElement)) return;
   1973   if (isDocked(id)) undockPanel(id);
   1974   const existing = target.querySelector?.(":scope > .rackPanel:not(.hidden)");
   1975   if (existing instanceof HTMLElement && existing !== panelEl) {
   1976     const existingId = String(existing.dataset.panelId || "").trim();
   1977     if (existingId) dockPanel(existingId);
   1978   }
   1979   target.appendChild(panelEl);
   1980   rememberPanelLastRack(id, target.id);
   1981   saveRackLayoutState();
   1982   applyDockState();
   1983   syncRackStateFromDom();
   1984   enforceWorkspaceRules();
   1985   if (userAdded) {
   1986     requestAnimationFrame(() => {
   1987       focusWorkspaceArrival(panelEl);
   1988     });
   1989   }
   1990 }
   1991 
   1992 function openWorkspaceAddMenu(anchorEl, slotId) {
   1993   closeWorkspaceAddMenu();
   1994   if (!(anchorEl instanceof HTMLElement)) return;
   1995   const slot = String(slotId || "").trim();
   1996   if (!slot) return;
   1997   const items = workspaceAddCandidates()
   1998     .map(
   1999       (p) => `<button type="button" class="ghost smallBtn" data-workspaceaddpanel="${escapeHtml(p.id)}" data-workspaceaddslot="${escapeHtml(slot)}">
   2000         ${escapeHtml(p.icon)} ${escapeHtml(p.title)}${p.docked ? " (docked)" : ""}
   2001       </button>`
   2002     )
   2003     .join("");
   2004   const menu = document.createElement("div");
   2005   menu.className = "hotbarAddMenu";
   2006   menu.innerHTML = `<div class="hotbarAddMenuList">${items || `<div class="small muted" style="padding:6px 8px;">No panels available.</div>`}</div>`;
   2007   const rect = anchorEl.getBoundingClientRect();
   2008   menu.style.left = `${Math.max(12, Math.min(window.innerWidth - 272, rect.left - 10))}px`;
   2009   menu.style.top = `${Math.max(12, rect.bottom + 8)}px`;
   2010   menu.addEventListener("click", (e) => {
   2011     const btn = e.target.closest?.("[data-workspaceaddpanel][data-workspaceaddslot]");
   2012     if (!btn) return;
   2013     const id = String(btn.getAttribute("data-workspaceaddpanel") || "").trim();
   2014     const slotIdNext = String(btn.getAttribute("data-workspaceaddslot") || "").trim();
   2015     if (!id || !slotIdNext) return;
   2016     restorePanelToWorkspaceSlot(id, slotIdNext, { userAdded: true });
   2017     closeWorkspaceAddMenu();
   2018   });
   2019   document.body.appendChild(menu);
   2020   workspaceAddMenuEl = menu;
   2021 }
   2022 
   2023 function openHotbarPlusMenu(anchorEl) {
   2024   closeHotbarPlusMenu();
   2025   if (!dockHotbarEl) return;
   2026   if (!(anchorEl instanceof HTMLElement)) return;
   2027 
   2028   const list = sortPosts(Array.from(posts.values())).slice(0, 8);
   2029   const items = list
   2030     .map((p) => {
   2031       const id = String(p?.id || "").trim();
   2032       if (!id) return "";
   2033       const title = postTitle(p);
   2034       return `<button type="button" class="ghost smallBtn" data-addchatpost="${escapeHtml(id)}">${escapeHtml(title)}</button>`;
   2035     })
   2036     .filter(Boolean)
   2037     .join("");
   2038 
   2039   const menu = document.createElement("div");
   2040   menu.className = "hotbarAddMenu";
   2041   menu.innerHTML = `
   2042     <div class="small muted" style="padding:6px 8px 4px;">New chat panel for...</div>
   2043     <div class="hotbarAddMenuList">${items || `<div class="small muted" style="padding:6px 8px;">No hives yet.</div>`}</div>
   2044   `;
   2045 
   2046   const rect = anchorEl.getBoundingClientRect();
   2047   const left = Math.max(12, Math.min(window.innerWidth - 260, rect.left - 200));
   2048   const top = Math.max(12, rect.top - 260);
   2049   menu.style.left = `${left}px`;
   2050   menu.style.top = `${top}px`;
   2051 
   2052   menu.addEventListener("click", (e) => {
   2053     const btn = e.target.closest?.("[data-addchatpost]");
   2054     if (!btn) return;
   2055     const postId = String(btn.getAttribute("data-addchatpost") || "").trim();
   2056     if (!postId) return;
   2057     ensureChatPostPanelInstance(postId, { docked: true });
   2058     try {
   2059       ws.send(JSON.stringify({ type: "getChat", postId }));
   2060     } catch {
   2061       // ignore
   2062     }
   2063     closeHotbarPlusMenu();
   2064     renderHotbar();
   2065   });
   2066 
   2067   document.body.appendChild(menu);
   2068   hotbarPlusMenuEl = menu;
   2069 }
   2070 
   2071 function applyDockState() {
   2072   // For the first implementation phase, we support docking any registered panel that has a DOM element.
   2073   for (const [id, p] of panelRegistry.entries()) {
   2074     const el = p?.element;
   2075     if (!(el instanceof HTMLElement)) continue;
   2076     if (rackLayoutEnabled && isRightRackFixedPanel(id)) {
   2077       el.classList.remove("hidden");
   2078       continue;
   2079     }
   2080     if (id === "moderation" && !canModerate) {
   2081       el.classList.add("hidden");
   2082       continue;
   2083     }
   2084     el.classList.toggle("hidden", isDocked(id));
   2085   }
   2086 
   2087   renderHotbar();
   2088   updateSideRackEmptyState();
   2089   updateSkinnyChatPanels();
   2090   renderWorkspaceSlotAffordances();
   2091   applyAllWorkspacePanelSizes();
   2092 }
   2093 
   2094 function renderWorkspaceSlotAffordances() {
   2095   if (!rackLayoutEnabled) return;
   2096   if (workspaceInfiniteMode()) return;
   2097   const left = ensureWorkspaceLeftRack();
   2098   const right = ensureWorkspaceRightRack();
   2099   for (const slot of [left, right]) {
   2100     if (!(slot instanceof HTMLElement)) continue;
   2101     const hasVisible = Boolean(slot.querySelector?.(":scope > .rackPanel:not(.hidden)"));
   2102     slot.classList.toggle("workspaceSlotEmpty", !hasVisible);
   2103     const existing = slot.querySelector?.(":scope > .workspaceEmptyAdd");
   2104     if (hasVisible) {
   2105       if (existing) existing.remove();
   2106       continue;
   2107     }
   2108     if (existing) continue;
   2109     const btn = document.createElement("button");
   2110     btn.type = "button";
   2111     btn.className = "workspaceEmptyAdd ghost";
   2112     btn.setAttribute("data-workspaceadd", slot.id || "");
   2113     btn.innerHTML = `<span class="workspaceEmptyAddPlus">+</span><span>Add panel</span>`;
   2114     slot.appendChild(btn);
   2115   }
   2116 }
   2117 
   2118 function readRackOrder(rackEl) {
   2119   if (!(rackEl instanceof HTMLElement)) return [];
   2120   return Array.from(rackEl.querySelectorAll(".rackPanel"))
   2121     .filter((el) => el instanceof HTMLElement && !el.classList.contains("hidden"))
   2122     .map((el) => String(el?.dataset?.panelId || "").trim())
   2123     .filter(Boolean);
   2124 }
   2125 
   2126 function applyRackStateToDom() {
   2127   if (!rackLayoutEnabled) return;
   2128   if (workspaceInfiniteMode()) {
   2129     ensurePluginRackPanel();
   2130     const workspace = ensureWorkspaceStripRack();
   2131     const rightRack = ensureRightRack();
   2132     if (!(workspace instanceof HTMLElement)) return;
   2133     const combinedOrder = [
   2134       ...(Array.isArray(rackLayoutState?.racks?.workspaceLeft) ? rackLayoutState.racks.workspaceLeft : []),
   2135       ...(Array.isArray(rackLayoutState?.racks?.workspaceRight) ? rackLayoutState.racks.workspaceRight : []),
   2136       ...(Array.isArray(rackLayoutState?.racks?.side) ? rackLayoutState.racks.side : []),
   2137       ...(Array.isArray(rackLayoutState?.racks?.right) ? rackLayoutState.racks.right : []),
   2138     ];
   2139     for (const panelId of combinedOrder) {
   2140       if (isRightRackFixedPanel(panelId)) continue;
   2141       const el = getPanelElement(panelId);
   2142       if (!el) continue;
   2143       if (isDocked(panelId)) continue;
   2144       workspace.appendChild(el);
   2145       applyPanelWorkspaceSize(el);
   2146     }
   2147     const rightPanel = getPanelElement(RIGHT_RACK_FIXED_PANEL_ID);
   2148     if (
   2149       rightRack instanceof HTMLElement &&
   2150       rightPanel instanceof HTMLElement &&
   2151       !isDocked(RIGHT_RACK_FIXED_PANEL_ID) &&
   2152       rightPanel.parentElement !== rightRack
   2153     ) {
   2154       rightRack.appendChild(rightPanel);
   2155     }
   2156     return;
   2157   }
   2158   // Ensure core "virtual" panels exist before we try to place them.
   2159   ensurePluginRackPanel();
   2160   const left = ensureWorkspaceLeftRack();
   2161   const rightWorkspace = ensureWorkspaceRightRack();
   2162   const side = ensureMainSideRack();
   2163   const right = ensureRightRack();
   2164   if (!left || !rightWorkspace || !side || !right) return;
   2165   const leftOrder = Array.isArray(rackLayoutState?.racks?.workspaceLeft) ? rackLayoutState.racks.workspaceLeft : [];
   2166   const rightOrderW = Array.isArray(rackLayoutState?.racks?.workspaceRight) ? rackLayoutState.racks.workspaceRight : [];
   2167   const sideOrder = Array.isArray(rackLayoutState?.racks?.side) ? rackLayoutState.racks.side : [];
   2168   const rightOrder = Array.isArray(rackLayoutState?.racks?.right) ? rackLayoutState.racks.right : [];
   2169 
   2170   for (const panelId of leftOrder) {
   2171     const el = getPanelElement(panelId);
   2172     if (el) left.appendChild(el);
   2173   }
   2174   for (const panelId of rightOrderW) {
   2175     const el = getPanelElement(panelId);
   2176     if (el) rightWorkspace.appendChild(el);
   2177   }
   2178   for (const panelId of sideOrder) {
   2179     const el = getPanelElement(panelId);
   2180     if (el) side.appendChild(el);
   2181   }
   2182   for (const panelId of rightOrder) {
   2183     const el = getPanelElement(panelId);
   2184     if (el) right.appendChild(el);
   2185   }
   2186 
   2187   // Hosted plugin widgets live inside Plugin Rack, not a top-level rack.
   2188   const widgetsOrder = Array.isArray(rackLayoutState?.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : [];
   2189   const widgetsRack = ensurePluginRackWidgetsRack();
   2190   if (widgetsRack) {
   2191     for (const panelId of widgetsOrder) {
   2192       const el = getPanelElement(panelId);
   2193       if (!el) continue;
   2194       el.classList.add("pluginRackWidget");
   2195       widgetsRack.appendChild(el);
   2196     }
   2197   }
   2198 }
   2199 
   2200 function readWorkspaceActivePrimary() {
   2201   try {
   2202     const raw = localStorage.getItem(WORKSPACE_ACTIVE_PRIMARY_KEY);
   2203     return raw ? String(raw) : "";
   2204   } catch {
   2205     return "";
   2206   }
   2207 }
   2208 
   2209 function writeWorkspaceActivePrimary(panelId) {
   2210   const id = String(panelId || "").trim();
   2211   if (!id) return;
   2212   try {
   2213     localStorage.setItem(WORKSPACE_ACTIVE_PRIMARY_KEY, id);
   2214   } catch {
   2215     // ignore
   2216   }
   2217 }
   2218 
   2219 function enforceWorkspaceRules() {
   2220   if (!rackLayoutEnabled) return;
   2221   if (workspaceInfiniteMode()) {
   2222     const workspace = ensureWorkspaceStripRack();
   2223     const rightRack = ensureRightRack();
   2224     if (!(workspace instanceof HTMLElement)) return;
   2225     if (!(rightRack instanceof HTMLElement)) return;
   2226     const candidatePanels = Array.from(
   2227       appRoot.querySelectorAll(
   2228         "#mainWorkspaceRack > .rackPanel, #workspaceLeftSlot > .rackPanel, #workspaceRightSlot > .rackPanel, #mainSideRack > .rackPanel, #rightRack > .rackPanel"
   2229       )
   2230     );
   2231     for (const panel of candidatePanels) {
   2232       if (!(panel instanceof HTMLElement)) continue;
   2233       const panelId = String(panel.dataset.panelId || "").trim();
   2234       if (!panelId) continue;
   2235       if (isRightRackFixedPanel(panelId)) {
   2236         if (rightRack instanceof HTMLElement && panel.parentElement !== rightRack) rightRack.appendChild(panel);
   2237         panel.classList.remove("hidden");
   2238         continue;
   2239       }
   2240       if (isDocked(panelId)) continue;
   2241       if (panel.parentElement !== workspace) workspace.appendChild(panel);
   2242       applyPanelWorkspaceSize(panel);
   2243     }
   2244     if (rackLayoutState?.docked?.bottom?.includes?.(RIGHT_RACK_FIXED_PANEL_ID)) {
   2245       rackLayoutState.docked.bottom = rackLayoutState.docked.bottom.filter((x) => x !== RIGHT_RACK_FIXED_PANEL_ID);
   2246     }
   2247     const peoplePanel = getPanelElement(RIGHT_RACK_FIXED_PANEL_ID);
   2248     if (rightRack instanceof HTMLElement && peoplePanel instanceof HTMLElement && peoplePanel.parentElement !== rightRack) {
   2249       rightRack.appendChild(peoplePanel);
   2250     }
   2251     if (appRoot) {
   2252       appRoot.classList.remove("workspaceExpandedLeft", "workspaceExpandedRight", "workspaceSingleLeft", "workspaceSingleRight");
   2253     }
   2254     syncRackStateFromDom();
   2255     updateWorkspaceMinView();
   2256     return;
   2257   }
   2258   const left = ensureWorkspaceLeftRack();
   2259   const rightWorkspace = ensureWorkspaceRightRack();
   2260   const side = ensureMainSideRack();
   2261   const rightRack = ensureRightRack();
   2262   if (!left || !rightWorkspace || !side || !rightRack) return;
   2263 
   2264   // Primary panels: allow up to 2 visible (one per workspace slot). Enforce max 1 per slot.
   2265   const cleanupSlot = (slotEl) => {
   2266     const kids = Array.from(slotEl.querySelectorAll(":scope > .rackPanel:not(.hidden)"));
   2267     if (kids.length <= 1) return;
   2268     for (const extra of kids.slice(1)) side.appendChild(extra);
   2269   };
   2270   cleanupSlot(left);
   2271   cleanupSlot(rightWorkspace);
   2272 
   2273   // Side rack and right rack are "skinny columns": only allow skinny-capable panels.
   2274   const enforceSkinny = (rackEl) => {
   2275     const kids = Array.from(rackEl.querySelectorAll(":scope > .rackPanel:not(.hidden)"));
   2276     for (const kid of kids) {
   2277       const id = String(kid?.dataset?.panelId || "").trim();
   2278       if (!id) continue;
   2279       if (!panelIsSkinnyCapable(id)) dockPanel(id);
   2280     }
   2281   };
   2282   enforceSkinny(side);
   2283   enforceSkinny(rightRack);
   2284 
   2285   // Side rack can stack, but keep it compact: at most 2 visible panels.
   2286   const sideKids = Array.from(side.querySelectorAll(":scope > .rackPanel:not(.hidden)"));
   2287   if (sideKids.length > 2) {
   2288     for (const extra of sideKids.slice(2)) {
   2289       const id = String(extra?.dataset?.panelId || "").trim();
   2290       if (id) dockPanel(id);
   2291     }
   2292   }
   2293 
   2294   // Right rack is single-slot: keep at most one visible panel.
   2295   const rightKids = Array.from(rightRack.querySelectorAll(":scope > .rackPanel:not(.hidden)"));
   2296   if (rightKids.length > 1) {
   2297     for (const extra of rightKids.slice(1)) {
   2298       const id = String(extra?.dataset?.panelId || "").trim();
   2299       if (id) dockPanel(id);
   2300     }
   2301   }
   2302 
   2303   // Panels that live in the workspace slots should be "full" by default (especially primaries).
   2304   for (const slot of [left, rightWorkspace]) {
   2305     const panel = slot.querySelector?.(":scope > .rackPanel:not(.hidden)");
   2306     if (!(panel instanceof HTMLElement)) continue;
   2307     const id = String(panel.dataset.panelId || "").trim();
   2308     if (!id) continue;
   2309     panel.classList.remove("panelCollapsed");
   2310     panel.dataset.panelDisplay = "full";
   2311   }
   2312 
   2313   // If only one workspace slot is occupied, allow it to expand to full width to avoid blank space.
   2314   // (We temporarily disable this during drag so the empty slot remains a visible drop target.)
   2315   const leftPanel = left.querySelector?.(":scope > .rackPanel:not(.hidden)");
   2316   const rightPanel = rightWorkspace.querySelector?.(":scope > .rackPanel:not(.hidden)");
   2317   const leftId = String(leftPanel?.dataset?.panelId || "").trim();
   2318   const rightId = String(rightPanel?.dataset?.panelId || "").trim();
   2319 
   2320   // Workspace expansion (explicit maximize for primaries).
   2321   const expandedId = readWorkspaceExpandedPrimary();
   2322   const expandedInLeft = Boolean(expandedId && expandedId === leftId);
   2323   const expandedInRight = Boolean(expandedId && expandedId === rightId);
   2324   const expandedValid = expandedInLeft || expandedInRight;
   2325   if (appRoot) {
   2326     appRoot.classList.toggle("workspaceExpandedLeft", expandedInLeft);
   2327     appRoot.classList.toggle("workspaceExpandedRight", expandedInRight);
   2328     if (!expandedValid) appRoot.classList.remove("workspaceExpandedLeft", "workspaceExpandedRight");
   2329   }
   2330   if (expandedId && !expandedValid) clearWorkspaceExpandedState();
   2331 
   2332   // If expanded and the other slot is occupied, keep it accessible via hotbar.
   2333   if (expandedInLeft && rightId && rightId !== expandedId) {
   2334     if (!readWorkspaceExpandedDisplaced()) writeWorkspaceExpandedDisplaced(rightId);
   2335     dockPanel(rightId);
   2336   }
   2337   if (expandedInRight && leftId && leftId !== expandedId) {
   2338     if (!readWorkspaceExpandedDisplaced()) writeWorkspaceExpandedDisplaced(leftId);
   2339     dockPanel(leftId);
   2340   }
   2341 
   2342   // Auto-expand single-primary only when not explicitly expanded.
   2343   if (appRoot && !appRoot.classList.contains("rackIsDragging") && !expandedValid) {
   2344     const leftOnly = Boolean(leftPanel && !rightPanel);
   2345     const rightOnly = Boolean(!leftPanel && rightPanel);
   2346     appRoot.classList.toggle("workspaceSingleLeft", leftOnly);
   2347     appRoot.classList.toggle("workspaceSingleRight", rightOnly);
   2348   } else if (appRoot) {
   2349     appRoot.classList.remove("workspaceSingleLeft", "workspaceSingleRight");
   2350   }
   2351 
   2352   // Transient panels should live in the side column and be collapsed by default.
   2353   for (const el of Array.from(appRoot.querySelectorAll("#mainWorkspaceRack .rackPanel, #mainSideRack .rackPanel"))) {
   2354     const id = String(el?.dataset?.panelId || "").trim();
   2355     if (!id) continue;
   2356     if (panelRole(id) !== "transient") continue;
   2357     if (el.parentElement !== side) side.appendChild(el);
   2358     el.classList.add("panelCollapsed");
   2359     el.dataset.panelDisplay = "collapsed";
   2360   }
   2361 
   2362   updateSkinnyChatPanels();
   2363   renderWorkspaceSlotAffordances();
   2364   syncRackStateFromDom();
   2365 }
   2366 
   2367 function installWorkspaceInteractions() {
   2368   if (!rackLayoutEnabled) return;
   2369   if (!appRoot) return;
   2370   if (appRoot.dataset.workspaceClicks === "1") return;
   2371   appRoot.dataset.workspaceClicks = "1";
   2372 
   2373   appRoot.addEventListener("click", (e) => {
   2374     if (!rackLayoutEnabled) return;
   2375     const target = e.target;
   2376     const addBtn = target?.closest?.("[data-workspaceadd]");
   2377     if (addBtn instanceof HTMLElement) {
   2378       const slotId = String(addBtn.getAttribute("data-workspaceadd") || "").trim();
   2379       if (!slotId) return;
   2380       if (workspaceAddMenuEl) closeWorkspaceAddMenu();
   2381       else openWorkspaceAddMenu(addBtn, slotId);
   2382       return;
   2383     }
   2384     const interactive = target?.closest?.("button,a,input,select,textarea,label");
   2385     if (interactive) return;
   2386     const panel = target?.closest?.(".rackPanel");
   2387     if (!panel) return;
   2388     if (!(panel instanceof HTMLElement)) return;
   2389     if (!panel.closest?.("#mainRack")) return;
   2390     const panelId = String(panel.dataset.panelId || "").trim();
   2391     if (!panelId) return;
   2392     if (panelRole(panelId) !== "primary") return;
   2393     writeWorkspaceActivePrimary(panelId);
   2394     enforceWorkspaceRules();
   2395   });
   2396 }
   2397 
   2398 function syncRackStateFromDom() {
   2399   if (!rackLayoutEnabled) return;
   2400   if (workspaceInfiniteMode()) {
   2401     const workspace = ensureWorkspaceStripRack();
   2402     const right = ensureRightRack();
   2403     if (!(workspace instanceof HTMLElement)) return;
   2404     const workspaceIds = readRackOrder(workspace).filter((id) => !isRightRackFixedPanel(id));
   2405     const rightIds = right instanceof HTMLElement ? readRackOrder(right).filter((id) => isRightRackFixedPanel(id)) : [];
   2406     rackLayoutState.racks = {
   2407       workspaceLeft: workspaceIds,
   2408       workspaceRight: [],
   2409       side: [],
   2410       right: rightIds,
   2411     };
   2412     rackLayoutState.pluginRackWidgets = readPluginRackWidgetsOrder();
   2413     saveRackLayoutState();
   2414     return;
   2415   }
   2416   const left = ensureWorkspaceLeftRack();
   2417   const rightWorkspace = ensureWorkspaceRightRack();
   2418   const side = ensureMainSideRack();
   2419   const right = ensureRightRack();
   2420   if (!left || !rightWorkspace || !side || !right) return;
   2421   rackLayoutState.racks = {
   2422     workspaceLeft: readRackOrder(left),
   2423     workspaceRight: readRackOrder(rightWorkspace),
   2424     side: readRackOrder(side),
   2425     right: readRackOrder(right),
   2426   };
   2427   rackLayoutState.pluginRackWidgets = readPluginRackWidgetsOrder();
   2428   const hosted = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []);
   2429   for (const [id, entry] of panelRegistry.entries()) {
   2430     const el = entry?.element;
   2431     if (!(el instanceof HTMLElement)) continue;
   2432     if (!el.classList.contains("pluginRackWidget") && hosted.has(id)) el.classList.add("pluginRackWidget");
   2433     if (el.classList.contains("pluginRackWidget") && !hosted.has(id)) el.classList.remove("pluginRackWidget");
   2434   }
   2435   saveRackLayoutState();
   2436 }
   2437 
   2438 function ensureRightRack() {
   2439   if (!appRoot) return null;
   2440   if (rightRackEl && rightRackEl.isConnected) return rightRackEl;
   2441   const el = document.createElement("aside");
   2442   el.id = "rightRack";
   2443   el.className = "rightRack";
   2444   appRoot.appendChild(el);
   2445   rightRackEl = el;
   2446   return el;
   2447 }
   2448 
   2449 function ensureMainRack() {
   2450   // In rack mode, "main rack" is the workspace column inside #mainRack.
   2451   if (mainRack && mainRack.isConnected) return mainRack;
   2452   if (mainWorkspaceRackEl) {
   2453     mainRack = mainWorkspaceRackEl;
   2454     return mainRack;
   2455   }
   2456 
   2457   const wrapper = mainRackEl || document.querySelector("#mainRack") || document.querySelector("main.main");
   2458   if (!wrapper) return null;
   2459 
   2460   let workspace = wrapper.querySelector?.("#mainWorkspaceRack");
   2461   let side = wrapper.querySelector?.("#mainSideRack");
   2462   if (!workspace) {
   2463     const w = document.createElement("div");
   2464     w.id = "mainWorkspaceRack";
   2465     w.className = "workspaceRack";
   2466     w.setAttribute("aria-label", "Workspace");
   2467     wrapper.appendChild(w);
   2468     workspace = w;
   2469   }
   2470   if (!side) {
   2471     const s = document.createElement("div");
   2472     s.id = "mainSideRack";
   2473     s.className = "sideRack";
   2474     s.setAttribute("aria-label", "Side panels");
   2475     wrapper.appendChild(s);
   2476     side = s;
   2477   }
   2478   mainSideRack = side;
   2479   mainRack = workspace;
   2480   return mainRack;
   2481 }
   2482 
   2483 function ensureMainSideRack() {
   2484   if (mainSideRack && mainSideRack.isConnected) return mainSideRack;
   2485   if (mainSideRackEl) {
   2486     mainSideRack = mainSideRackEl;
   2487     return mainSideRack;
   2488   }
   2489   // Ensure the workspace rack exists too (creates both columns if missing).
   2490   ensureMainRack();
   2491   return mainSideRack instanceof HTMLElement ? mainSideRack : null;
   2492 }
   2493 
   2494 function ensureWorkspaceLeftRack() {
   2495   if (workspaceInfiniteMode()) return ensureWorkspaceStripRack();
   2496   const { left } = ensureWorkspaceSlots();
   2497   return left instanceof HTMLElement ? left : null;
   2498 }
   2499 
   2500 function ensureWorkspaceRightRack() {
   2501   if (workspaceInfiniteMode()) return ensureWorkspaceStripRack();
   2502   const { right } = ensureWorkspaceSlots();
   2503   return right instanceof HTMLElement ? right : null;
   2504 }
   2505 
   2506 function enableRackLayoutDom() {
   2507   if (!appRoot) return;
   2508   appRoot.classList.add("rackMode");
   2509   appRoot.classList.toggle("workspaceInfinite", workspaceInfiniteMode());
   2510   const rack = ensureRightRack();
   2511   if (!rack) return;
   2512   const main = ensureMainRack();
   2513   const left = ensureWorkspaceLeftRack();
   2514   const rightWorkspace = ensureWorkspaceRightRack();
   2515   const side = ensureMainSideRack();
   2516   const workspaceStrip = ensureWorkspaceStripRack();
   2517 
   2518   const mark = (el, panelId) => {
   2519     if (!el) return;
   2520     el.classList.add("rackPanel");
   2521     el.dataset.panelId = panelId;
   2522   };
   2523 
   2524   // Move right-side panels into the rack so they become stackable.
   2525   // (This is a stepping stone toward full dockable panels.)
   2526   if (chatPanelEl) {
   2527     mark(chatPanelEl, "chat");
   2528     if (workspaceInfiniteMode()) {
   2529       if (workspaceStrip && chatPanelEl.parentElement !== workspaceStrip) workspaceStrip.appendChild(chatPanelEl);
   2530     } else if (rightWorkspace && chatPanelEl.parentElement !== rightWorkspace) rightWorkspace.appendChild(chatPanelEl);
   2531   }
   2532   if (peopleDrawerEl) {
   2533     mark(peopleDrawerEl, "people");
   2534     if (peopleDrawerEl.parentElement !== rack) rack.appendChild(peopleDrawerEl);
   2535   }
   2536   if (modPanelEl) {
   2537     mark(modPanelEl, "moderation");
   2538     if (workspaceInfiniteMode()) {
   2539       if (workspaceStrip && modPanelEl.parentElement !== workspaceStrip) workspaceStrip.appendChild(modPanelEl);
   2540     } else if (modPanelEl.parentElement !== rack) rack.appendChild(modPanelEl);
   2541   }
   2542 
   2543   // Mark center panels as rack panels too (they already live in mainRack in normal DOM).
   2544   if (main) {
   2545     if (onboardingPanelEl) {
   2546       mark(onboardingPanelEl, "onboarding");
   2547       if (workspaceInfiniteMode()) {
   2548         if (workspaceStrip && onboardingPanelEl.parentElement !== workspaceStrip) workspaceStrip.appendChild(onboardingPanelEl);
   2549       } else if (left && onboardingPanelEl.parentElement !== left) left.appendChild(onboardingPanelEl);
   2550       onboardingPanelEl.classList.remove("hidden");
   2551     }
   2552     if (hivesPanelEl) {
   2553       mark(hivesPanelEl, "hives");
   2554       if (workspaceInfiniteMode()) {
   2555         if (workspaceStrip && hivesPanelEl.parentElement !== workspaceStrip) workspaceStrip.appendChild(hivesPanelEl);
   2556       } else if (left && hivesPanelEl.parentElement !== left) left.appendChild(hivesPanelEl);
   2557     }
   2558     if (profileViewPanel) {
   2559       mark(profileViewPanel, "profile");
   2560       if (workspaceInfiniteMode()) {
   2561         if (workspaceStrip && profileViewPanel.parentElement !== workspaceStrip) workspaceStrip.appendChild(profileViewPanel);
   2562       } else if (side && profileViewPanel.parentElement !== side) side.appendChild(profileViewPanel);
   2563       // In rack mode, profile is its own panel; don't keep it hidden behind the legacy center-view toggle.
   2564       profileViewPanel.classList.remove("hidden");
   2565     }
   2566     if (pollinatePanel) {
   2567       mark(pollinatePanel, "composer");
   2568       if (workspaceInfiniteMode()) {
   2569         if (workspaceStrip && pollinatePanel.parentElement !== workspaceStrip) workspaceStrip.appendChild(pollinatePanel);
   2570       } else if (side && pollinatePanel.parentElement !== side) side.appendChild(pollinatePanel);
   2571     }
   2572   }
   2573 
   2574   // Hide old resizers in rack mode (we'll replace with rack-aware resizing later).
   2575   chatResizeHandle?.classList.add("hidden");
   2576   peopleResizeHandle?.classList.add("hidden");
   2577 
   2578   // People drawer chrome: hide the close button (panel is now a rack item).
   2579   closePeopleBtn?.classList.add("hidden");
   2580   // People drawer toggle button is obsolete in rack mode.
   2581   togglePeopleBtn?.classList.add("hidden");
   2582   // Ensure people panel isn't hidden by legacy state.
   2583   peopleDrawerEl?.classList.remove("hidden");
   2584   peopleOpen = true;
   2585 
   2586   // Profile panel no longer "replaces" the feed in rack mode, so the back button is confusing.
   2587   profileBackBtn?.classList.add("hidden");
   2588   applyAllWorkspacePanelSizes();
   2589   installWorkspaceScrollSnapStep();
   2590   updateWorkspaceMinView();
   2591 }
   2592 
   2593 function disableRackLayoutDom() {
   2594   if (!appRoot) return;
   2595   appRoot.classList.remove("rackMode");
   2596   appRoot.classList.remove("workspaceInfinite");
   2597   // No attempt to move elements back (yet). Disable is meant for page reload use.
   2598 }
   2599 
   2600 function applyPreset(presetId) {
   2601   const key = resolvePresetKey(presetId);
   2602   const def = PRESET_DEFS[key];
   2603   if (!def) return;
   2604   if (def.modOnly && !canModerate) {
   2605     applyPreset("onboardingDefault");
   2606     return;
   2607   }
   2608 
   2609   // Keep hosted widgets alive across preset switches so panel runtime state is preserved.
   2610   closePluginRackAddMenu();
   2611 
   2612   rackLayoutState.presetId = def.presetId || key;
   2613 
   2614   const workspaceLeftOrder = Array.isArray(def.workspaceLeftOrder) ? def.workspaceLeftOrder.map((x) => String(x || "")).filter(Boolean) : [];
   2615   const workspaceRightOrder = Array.isArray(def.workspaceRightOrder) ? def.workspaceRightOrder.map((x) => String(x || "")).filter(Boolean) : [];
   2616   const sideOrder = Array.isArray(def.sideOrder) ? def.sideOrder.map((x) => String(x || "")).filter(Boolean) : [];
   2617   const rightOrderRaw = Array.isArray(def.rightOrder) ? def.rightOrder.map((x) => String(x || "")).filter(Boolean) : [];
   2618   // Right rack is a single skinny-capable panel.
   2619   const rightOrder = rightOrderRaw.length ? [rightOrderRaw[0]] : [];
   2620 
   2621   // Applying a preset should be deterministic even after the user has rearranged panels.
   2622   clearWorkspaceExpandedState();
   2623   const expandedPrimary = typeof def.expandedPrimary === "string" ? def.expandedPrimary.trim() : "";
   2624   if (expandedPrimary) writeWorkspaceExpandedPrimary(expandedPrimary);
   2625 
   2626   if (typeof def.composerOpen === "boolean") setComposerOpen(def.composerOpen);
   2627   setSideCollapsed(Boolean(def.sideCollapsed), { persist: true });
   2628   setRightCollapsed(Boolean(def.rightCollapsed), { persist: true });
   2629 
   2630   const leftRack = ensureWorkspaceLeftRack();
   2631   const rightWorkspaceRack = ensureWorkspaceRightRack();
   2632   const sideRack = ensureMainSideRack();
   2633   const rightRack = ensureRightRack();
   2634   if (!leftRack || !rightWorkspaceRack || !sideRack || !rightRack) return;
   2635 
   2636   if (workspaceInfiniteMode()) {
   2637     const workspace = ensureWorkspaceStripRack();
   2638     if (!(workspace instanceof HTMLElement)) return;
   2639     const order = [...workspaceLeftOrder, ...workspaceRightOrder, ...sideOrder, ...rightOrder].filter((id) => !isRightRackFixedPanel(id));
   2640     const placed = new Set(order);
   2641     const presetDocked = new Set(
   2642       (Array.isArray(def.dockBottom) ? def.dockBottom.map((x) => String(x || "")).filter(Boolean) : []).filter((id) => !isRightRackFixedPanel(id))
   2643     );
   2644     const affectedByPreset = new Set([...placed, ...presetDocked]);
   2645     const docked = new Set(Array.isArray(rackLayoutState?.docked?.bottom) ? rackLayoutState.docked.bottom : []);
   2646     for (const id of affectedByPreset) docked.delete(id);
   2647     for (const id of presetDocked) docked.add(id);
   2648     for (const id of placed) docked.delete(id);
   2649     // Core presets should not unexpectedly surface plugin panels unless explicitly placed.
   2650     for (const [panelId] of panelRegistry.entries()) {
   2651       if (!panelIsPluginOwned(panelId)) continue;
   2652       if (placed.has(panelId) || presetDocked.has(panelId)) continue;
   2653       docked.add(panelId);
   2654     }
   2655     if (!canModerate) docked.add("moderation");
   2656     rackLayoutState.docked.bottom = Array.from(docked);
   2657     saveRackLayoutState();
   2658     applyDockState();
   2659     for (const panelId of order) {
   2660       if (docked.has(panelId)) continue;
   2661       const el = getPanelElement(panelId);
   2662       if (!el) continue;
   2663       workspace.appendChild(el);
   2664       applyPanelWorkspaceSize(el);
   2665     }
   2666     syncRackStateFromDom();
   2667     enforceWorkspaceRules();
   2668     updateLayoutPresetOptions();
   2669     requestAnimationFrame(() => {
   2670       try {
   2671         workspace.scrollTo({ left: 0, behavior: "auto" });
   2672       } catch {
   2673         // ignore
   2674       }
   2675     });
   2676     return;
   2677   }
   2678 
   2679   const placed = new Set([...workspaceLeftOrder, ...workspaceRightOrder, ...sideOrder, ...rightOrder]);
   2680   const presetDocked = new Set(
   2681     (Array.isArray(def.dockBottom) ? def.dockBottom.map((x) => String(x || "")).filter(Boolean) : []).filter((id) => !isRightRackFixedPanel(id))
   2682   );
   2683   const affectedByPreset = new Set([...placed, ...presetDocked]);
   2684   // Apply preset intent only to panels explicitly named by the preset.
   2685   // Dynamic panels (for example chat instances) keep their current dock/placement state.
   2686   const docked = new Set(Array.isArray(rackLayoutState?.docked?.bottom) ? rackLayoutState.docked.bottom : []);
   2687   for (const id of affectedByPreset) docked.delete(id);
   2688   for (const id of presetDocked) docked.add(id);
   2689   for (const id of placed) docked.delete(id);
   2690   // Keep plugin-owned panels out of core layouts unless preset explicitly names them.
   2691   for (const [panelId] of panelRegistry.entries()) {
   2692     if (!panelIsPluginOwned(panelId)) continue;
   2693     if (placed.has(panelId) || presetDocked.has(panelId)) continue;
   2694     docked.add(panelId);
   2695   }
   2696 
   2697   // Moderation panel should not be forced visible for non-mods.
   2698   if (!canModerate) {
   2699     docked.add("moderation");
   2700     // Also ensure moderation isn't placed anywhere.
   2701     workspaceLeftOrder.splice(0, workspaceLeftOrder.length, ...workspaceLeftOrder.filter((x) => x !== "moderation"));
   2702     workspaceRightOrder.splice(0, workspaceRightOrder.length, ...workspaceRightOrder.filter((x) => x !== "moderation"));
   2703     sideOrder.splice(0, sideOrder.length, ...sideOrder.filter((x) => x !== "moderation"));
   2704   }
   2705 
   2706   rackLayoutState.docked.bottom = Array.from(docked);
   2707 
   2708   saveRackLayoutState();
   2709   applyDockState();
   2710 
   2711   if (leftRack) {
   2712     for (const panelId of workspaceLeftOrder) {
   2713       if (docked.has(panelId)) continue;
   2714       const el = getPanelElement(panelId);
   2715       if (el) leftRack.appendChild(el);
   2716     }
   2717   }
   2718   if (rightWorkspaceRack) {
   2719     for (const panelId of workspaceRightOrder) {
   2720       if (docked.has(panelId)) continue;
   2721       const el = getPanelElement(panelId);
   2722       if (el) rightWorkspaceRack.appendChild(el);
   2723     }
   2724   }
   2725   if (sideRack) {
   2726     for (const panelId of sideOrder) {
   2727       if (docked.has(panelId)) continue;
   2728       const el = getPanelElement(panelId);
   2729       if (el) sideRack.appendChild(el);
   2730     }
   2731   }
   2732   if (rightRack) {
   2733     for (const panelId of rightOrder) {
   2734       if (docked.has(panelId)) continue;
   2735       const el = getPanelElement(panelId);
   2736       if (el) rightRack.appendChild(el);
   2737     }
   2738   }
   2739 
   2740   syncRackStateFromDom();
   2741   enforceWorkspaceRules();
   2742   updateLayoutPresetOptions();
   2743 }
   2744 
   2745 function installPanelMinimizeButtons() {
   2746   const addMinBtn = (headerEl, panelId) => {
   2747     if (!headerEl) return;
   2748     const row = headerEl.querySelector(".row") || headerEl.querySelector(".filters") || headerEl;
   2749 
   2750     if (!headerEl.querySelector(`[data-rackdrag="${panelId}"]`)) {
   2751       const drag = document.createElement("button");
   2752       drag.type = "button";
   2753       drag.className = "ghost smallBtn rackDragHandle";
   2754       drag.textContent = "≑";
   2755       drag.title = "Drag to reorder";
   2756       drag.setAttribute("data-rackdrag", panelId);
   2757       row.appendChild(drag);
   2758     }
   2759 
   2760     installPanelSizeButtons(headerEl, panelId);
   2761 
   2762     if (!headerEl.querySelector(`[data-minimize="${panelId}"]`)) {
   2763       const btn = document.createElement("button");
   2764       btn.type = "button";
   2765       btn.className = "ghost smallBtn";
   2766       btn.textContent = "-";
   2767       btn.title = "Minimize to hotbar";
   2768       btn.setAttribute("data-minimize", panelId);
   2769       btn.onclick = () => dockPanel(panelId);
   2770       row.appendChild(btn);
   2771     }
   2772   };
   2773 
   2774   addMinBtn(chatHeaderEl, "chat");
   2775   addMinBtn(modPanelEl?.querySelector(".panelHeader"), "moderation");
   2776   addMinBtn(hivesPanelEl?.querySelector(".panelHeader"), "hives");
   2777   addMinBtn(profileViewPanel?.querySelector(".panelHeader"), "profile");
   2778   addMinBtn(pollinatePanel?.querySelector(".panelHeader"), "composer");
   2779   ensurePluginRackPanel();
   2780   addMinBtn(pluginRackPanelEl?.querySelector(".panelHeader"), "pluginRack");
   2781 }
   2782 
   2783 function ensurePluginPanelShell(panelId, title, icon, defaultRack, role) {
   2784   const wantsMain = String(defaultRack || "").toLowerCase() === "main";
   2785   const isPrimary = String(role || "").toLowerCase() === "primary";
   2786   let preferred = null;
   2787   if (wantsMain && isPrimary) {
   2788     // Primary panels should live inside a workspace slot, not as loose items in the workspace grid.
   2789     const left = ensureWorkspaceLeftRack();
   2790     const right = ensureWorkspaceRightRack();
   2791     const side = ensureMainSideRack();
   2792     const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel").length === 0 : false;
   2793     const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel").length === 0 : false;
   2794     preferred = leftEmpty ? left : rightEmpty ? right : side;
   2795   } else if (wantsMain) {
   2796     preferred = ensureMainSideRack();
   2797   } else {
   2798     preferred = ensureRightRack();
   2799   }
   2800   const rack = preferred || ensureRightRack() || ensureMainSideRack() || ensureWorkspaceLeftRack() || ensureWorkspaceRightRack() || ensureMainRack();
   2801   if (!rack) return null;
   2802 
   2803   const existing = document.querySelector?.(`.panel.pluginPanel[data-panel-id="${CSS.escape(panelId)}"]`);
   2804   if (existing instanceof HTMLElement) {
   2805     if (existing.parentElement !== rack) rack.appendChild(existing);
   2806     return existing;
   2807   }
   2808 
   2809   const shell = document.createElement("section");
   2810   shell.className = "panel panelFill pluginPanel rackPanel";
   2811   shell.dataset.panelId = panelId;
   2812   shell.innerHTML = `
   2813     <div class="panelHeader">
   2814       <div class="panelTitle">${escapeHtml(title || panelId)}</div>
   2815       <div class="row">
   2816         <button type="button" class="ghost smallBtn rackDragHandle" data-rackdrag="${escapeHtml(panelId)}" title="Drag to reorder">≑</button>
   2817         <button type="button" class="ghost smallBtn" data-minimize="${escapeHtml(panelId)}" title="Minimize to hotbar">-</button>
   2818       </div>
   2819     </div>
   2820     <div class="panelBody" data-pluginmount="1"></div>
   2821   `;
   2822   installPanelSizeButtons(shell.querySelector(".panelHeader"), panelId);
   2823   const minBtn = shell.querySelector(`[data-minimize="${panelId}"]`);
   2824   if (minBtn) minBtn.addEventListener("click", () => dockPanel(panelId));
   2825 
   2826   rack.appendChild(shell);
   2827   applyPanelWorkspaceSize(shell);
   2828   return shell;
   2829 }
   2830 
   2831 function ensureChatPostPanelInstance(postId, opts) {
   2832   if (!rackLayoutEnabled) return "";
   2833   const pid = String(postId || "").trim();
   2834   if (!pid) return "";
   2835   const post = posts.get(pid) || null;
   2836   const panelId = chatInstancePanelIdForPost(pid);
   2837   if (!panelId) return "";
   2838 
   2839   if (panelRegistry.has(panelId)) return panelId;
   2840 
   2841   const title = post?.title ? `Chat: ${String(post.title).slice(0, 32)}` : "Chat";
   2842   const shell = document.createElement("section");
   2843   shell.className = "panel panelFill rackPanel chat chatInstance";
   2844   shell.dataset.panelId = panelId;
   2845   shell.innerHTML = `
   2846     <div class="panelHeader">
   2847       <div>
   2848         <div class="panelTitle">${escapeHtml(title)}</div>
   2849         <div class="small muted chatMeta"></div>
   2850       </div>
   2851       <div class="row">
   2852         <button type="button" class="ghost smallBtn rackDragHandle" data-rackdrag="${escapeHtml(panelId)}" title="Drag to reorder">≑</button>
   2853         <div class="panelSizeControls" data-panelsizerow="${escapeHtml(panelId)}">
   2854           <button type="button" class="ghost smallBtn panelSizeBtn" data-panelsize="skinny" data-panelid="${escapeHtml(panelId)}" title="Skinny width">S</button>
   2855           <button type="button" class="ghost smallBtn panelSizeBtn" data-panelsize="half" data-panelid="${escapeHtml(panelId)}" title="Half width">H</button>
   2856           <button type="button" class="ghost smallBtn panelSizeBtn" data-panelsize="full" data-panelid="${escapeHtml(panelId)}" title="Full width">F</button>
   2857         </div>
   2858         <button type="button" class="ghost smallBtn" data-minimize="${escapeHtml(panelId)}" title="Minimize to hotbar">-</button>
   2859       </div>
   2860     </div>
   2861     <div class="chatMessages"></div>
   2862     <div class="typingIndicator small muted"></div>
   2863     <form class="chatForm">
   2864       <div class="chatComposer">
   2865         <div class="toolbar" role="toolbar" aria-label="Chat formatting">
   2866           <button type="button" data-chatcmd="bold"><b>B</b></button>
   2867           <button type="button" data-chatcmd="italic"><i>I</i></button>
   2868           <button type="button" data-chatcmd="underline"><u>U</u></button>
   2869           <button type="button" data-chatcmd="strikeThrough"><s>S</s></button>
   2870           <span class="sep"></span>
   2871           <button type="button" data-chatcmd="insertUnorderedList">List</button>
   2872           <button type="button" data-chatcmd="insertOrderedList">1. List</button>
   2873           <button type="button" data-chatlink="1">Link</button>
   2874           <button type="button" data-chatimg="1">GIF/Image</button>
   2875           <button type="button" data-chataudio="1">Audio</button>
   2876           <button type="button" data-chatemoji="1">Emoji</button>
   2877           <button type="button" data-chatcmd="removeFormat">Clear</button>
   2878         </div>
   2879         <div class="chatInstanceTools">
   2880           <label class="checkRow chatModToggle chatInstModToggle hidden" title="Send as moderator/system message (left rail)">
   2881             <span>Mod</span>
   2882             <input class="chatInstModToggleInput" type="checkbox" />
   2883           </label>
   2884         </div>
   2885         <div class="editor chatEditor" contenteditable="true" aria-label="Chat editor"></div>
   2886       </div>
   2887       <button class="primary" type="submit">Send</button>
   2888     </form>
   2889   `;
   2890 
   2891   const metaEl = shell.querySelector(".chatMeta");
   2892   const messagesEl = shell.querySelector(".chatMessages");
   2893   const typingEl = shell.querySelector(".typingIndicator");
   2894   const formEl = shell.querySelector("form.chatForm");
   2895   const editorEl = shell.querySelector(".chatEditor");
   2896   const modToggleWrapEl = shell.querySelector(".chatInstModToggle");
   2897   const modToggleEl = shell.querySelector(".chatInstModToggleInput");
   2898 
   2899   shell.querySelector(`[data-minimize="${cssEscape(panelId)}"]`)?.addEventListener("click", () => dockPanel(panelId));
   2900   refreshPanelSizeButtons(panelId);
   2901 
   2902   if (formEl && editorEl) {
   2903     formEl.addEventListener("submit", (e) => {
   2904       e.preventDefault();
   2905       const html = String(editorEl.innerHTML || "").trim();
   2906       const text = String(editorEl.innerText || "").trim();
   2907       const hasImg = Boolean(editorEl.querySelector("img"));
   2908       const hasAudio = Boolean(editorEl.querySelector("audio"));
   2909       if (!text && !hasImg && !hasAudio) return;
   2910       if (!loggedInUser) {
   2911         toast("Sign in required", "Sign in to chat.");
   2912         return;
   2913       }
   2914       const currentPost = posts.get(pid) || null;
   2915       if (currentPost && String(currentPost.mode || currentPost.chatMode || "").toLowerCase() === "walkie") {
   2916         toast("Walkie Talkie", "This hive is walkie-only. Hold ~ to talk.");
   2917         return;
   2918       }
   2919       if (currentPost?.readOnly && !isStaffRole(loggedInRole)) {
   2920         toast("Read-only", "This hive is read-only.");
   2921         return;
   2922       }
   2923       if (currentPost?.deleted) {
   2924         toast("Unavailable", "This post was deleted.");
   2925         return;
   2926       }
   2927       const wantsMod = Boolean(canModerate && modToggleEl instanceof HTMLInputElement && modToggleEl.checked);
   2928       ws.send(JSON.stringify({ type: "typing", postId: pid, isTyping: false }));
   2929       ws.send(JSON.stringify({ type: "chatMessage", postId: pid, text, html, replyToId: "", asMod: wantsMod }));
   2930       editorEl.innerHTML = "";
   2931       // Leave global reply-to state alone; this instance panel is independent (MVP).
   2932     });
   2933 
   2934     editorEl.addEventListener("focus", () => {
   2935       chatUploadTargetEditor = editorEl;
   2936     });
   2937 
   2938     editorEl.addEventListener("keydown", (e) => {
   2939       if (!shouldSubmitChatOnEnter(e)) return;
   2940       e.preventDefault();
   2941       formEl.requestSubmit();
   2942     });
   2943 
   2944     // Allow drag/drop uploads in instance chats too.
   2945     try {
   2946       installDropUpload(editorEl, { allowImages: true, allowAudio: true });
   2947     } catch {
   2948       // ignore
   2949     }
   2950   }
   2951 
   2952   if (modToggleWrapEl) modToggleWrapEl.classList.toggle("hidden", !canModerate);
   2953 
   2954   // Register + insert.
   2955   panelRegistry.set(panelId, {
   2956     id: panelId,
   2957     title,
   2958     icon: "πŸ’¬",
   2959     source: "core",
   2960     role: "aux",
   2961     defaultRack: "main",
   2962     element: shell,
   2963   });
   2964   chatPanelInstances.set(panelId, { postId: pid });
   2965 
   2966   const options = opts && typeof opts === "object" ? opts : {};
   2967   const docked = Boolean(options.docked);
   2968   const sideRack = ensureMainSideRack();
   2969   if (docked) {
   2970     // Keep it out of layout; show as orb.
   2971     if (sideRack) sideRack.appendChild(shell);
   2972     dockPanel(panelId);
   2973   } else {
   2974     setSideCollapsed(false);
   2975     if (sideRack) sideRack.prepend(shell);
   2976     rememberPanelLastRack(panelId, "mainSideRack");
   2977     saveRackLayoutState();
   2978     applyDockState();
   2979     syncRackStateFromDom();
   2980     enforceWorkspaceRules();
   2981   }
   2982   applyPanelWorkspaceSize(shell);
   2983 
   2984   renderChatPostPanelInstance(panelId, true);
   2985   return panelId;
   2986 }
   2987 
   2988 function renderTypingIndicatorForPost(postId, targetEl) {
   2989   if (!(targetEl instanceof HTMLElement)) return;
   2990   const id = String(postId || "").trim();
   2991   if (!id) {
   2992     targetEl.textContent = "";
   2993     return;
   2994   }
   2995   const set = typingUsersByPostId.get(id);
   2996   if (!set || set.size === 0) {
   2997     targetEl.textContent = "";
   2998     return;
   2999   }
   3000   const names = Array.from(set.values()).slice(0, 3);
   3001   const more = set.size > names.length ? ` +${set.size - names.length}` : "";
   3002   targetEl.textContent = `${names.map((u) => `@${u}`).join(", ")}${more} typing...`;
   3003 }
   3004 
   3005 function renderChatPostPanelInstance(panelId, forceScroll) {
   3006   const id = String(panelId || "").trim();
   3007   if (!id) return;
   3008   const inst = chatPanelInstances.get(id);
   3009   if (!inst) return;
   3010   const postId = String(inst.postId || "").trim();
   3011   const post = postId ? posts.get(postId) : null;
   3012   const root = getPanelElement(id);
   3013   if (!(root instanceof HTMLElement)) return;
   3014   const metaEl = root.querySelector(".chatMeta");
   3015   const messagesEl = root.querySelector(".chatMessages");
   3016   const typingEl = root.querySelector(".typingIndicator");
   3017   const editorEl = root.querySelector(".chatEditor");
   3018   const sendBtn = root.querySelector("form.chatForm button[type='submit']");
   3019 
   3020   if (metaEl) {
   3021     if (!post) metaEl.textContent = "Hive not found.";
   3022     else {
   3023       const tags = (post.keywords || []).map((k) => `#${k}`).join(" ");
   3024       const author = post.author ? `by @${post.author}` : "";
   3025       const exp = formatCountdown(post.expiresAt);
   3026       const ro = post.readOnly ? " | read-only" : "";
   3027       const mode = normalizePostMode(post.mode || post.chatMode || "");
   3028       const modeMeta =
   3029         mode === "walkie"
   3030           ? " | walkie talkie"
   3031           : mode === "stream"
   3032             ? ` | stream (${streamKindLabel(post.streamKind || "webcam")})`
   3033             : "";
   3034       metaEl.textContent = `${author}${modeMeta}${ro} | ${exp === "permanent" ? "permanent" : `expires in ${exp}`} | ${tags}`.trim();
   3035     }
   3036   }
   3037 
   3038   if (!(messagesEl instanceof HTMLElement)) return;
   3039   const atBottomBefore = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 24;
   3040 
   3041   if (!post) {
   3042     messagesEl.innerHTML = `<div class="small muted">Hive not found.</div>`;
   3043     if (typingEl) typingEl.textContent = "";
   3044     return;
   3045   }
   3046   if (post.deleted) {
   3047     messagesEl.innerHTML = `<div class="small muted">Post was deleted.</div>`;
   3048     if (typingEl) typingEl.textContent = "";
   3049     return;
   3050   }
   3051 
   3052   const isWalkie = String(post.mode || post.chatMode || "").toLowerCase() === "walkie";
   3053   const canChatWrite = Boolean(isStaffRole(loggedInRole) || !post.readOnly);
   3054   if (editorEl) editorEl.contentEditable = String(Boolean(canChatWrite && !isWalkie));
   3055   if (sendBtn instanceof HTMLButtonElement) sendBtn.disabled = !(loggedInUser && canChatWrite && !isWalkie);
   3056 
   3057   const modToggleWrapEl = root.querySelector(".chatInstModToggle");
   3058   const modToggleEl = root.querySelector(".chatInstModToggleInput");
   3059   if (modToggleWrapEl) modToggleWrapEl.classList.toggle("hidden", !canModerate);
   3060   if (!canModerate && modToggleEl instanceof HTMLInputElement) modToggleEl.checked = false;
   3061 
   3062   const messages = chatByPost.get(post.id) || [];
   3063   const ignoreUserSet = new Set(
   3064     [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase())
   3065   );
   3066   const selfLower = String(loggedInUser || "").toLowerCase();
   3067   const visibleMessages = messages.filter((m) => {
   3068     const fromLower = String(m?.fromUser || "").toLowerCase();
   3069     if (!fromLower || fromLower === selfLower) return true;
   3070     return !ignoreUserSet.has(fromLower);
   3071   });
   3072 
   3073   messagesEl.innerHTML = visibleMessages
   3074     .map((m, index) => {
   3075       const isModMsg = Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod";
   3076       const from = isModMsg ? "MOD" : m.fromUser || "";
   3077       const isYou = loggedInUser && from && from === loggedInUser;
   3078       const rail = chatRailClass({ fromUser: from, isModMessage: isModMsg });
   3079       const prev = index > 0 ? visibleMessages[index - 1] : null;
   3080       const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from);
   3081       const mentions = Array.isArray(m.mentions) ? m.mentions.map((u) => String(u || "").toLowerCase()) : [];
   3082       const mentionMe = Boolean(loggedInUser && mentions.includes(loggedInUser));
   3083       const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || "");
   3084       const youTag = !isModMsg && isYou ? `<span class="muted">(you)</span>` : "";
   3085       const time = new Date(m.createdAt).toLocaleTimeString();
   3086       const tint = isModMsg ? "" : tintStylesFromHex(getProfile(from).color);
   3087       const html = typeof m.html === "string" && m.html.trim() ? m.html : "";
   3088       const content = html ? html : highlightMentionsInText(m.text || "");
   3089       const replyMeta = m.replyTo && typeof m.replyTo === "object" ? m.replyTo : null;
   3090       const replyBlock = replyMeta
   3091         ? `<div class="chatReplyRef"><span class="small muted">@${escapeHtml(replyMeta.fromUser || "unknown")}</span><div class="small">${escapeHtml(
   3092             String(replyMeta.text || "[media]").slice(0, 120)
   3093           )}</div></div>`
   3094         : "";
   3095       const reacts = renderReactionButtons({ kind: "chat", id: m.id, reactions: m.reactions || {}, postId: post.id });
   3096       const deletedLine = m.deleted
   3097         ? `<div class="small muted">message deleted${
   3098             m.deletedBy ? ` by @${escapeHtml(m.deletedBy)}` : ""
   3099           } at ${escapeHtml(new Date(Number(m.deletedAt || m.createdAt || Date.now())).toLocaleString())}</div>`
   3100         : "";
   3101       const editedLine =
   3102         !m.deleted && Number(m.editCount || 0) > 0
   3103           ? `<div class="small muted">edited (${Number(m.editCount || 0)}) at ${escapeHtml(
   3104               new Date(Number(m.editedAt || m.createdAt || Date.now())).toLocaleTimeString()
   3105             )}</div>`
   3106           : "";
   3107       const reportAction =
   3108         loggedInUser && !m.deleted
   3109           ? `<button type="button" class="ghost smallBtn" data-reportchat="${escapeHtml(m.id)}" data-postid="${escapeHtml(
   3110               post.id
   3111             )}">Report</button>`
   3112           : "";
   3113       const deleteAction =
   3114         loggedInUser && !m.deleted && (isStaffRole(loggedInRole) || from === loggedInUser)
   3115           ? `<button type="button" class="ghost smallBtn" data-delchat="${escapeHtml(m.id)}" data-postid="${escapeHtml(
   3116               post.id
   3117             )}">Delete</button>`
   3118           : "";
   3119       const actions =
   3120         reportAction || deleteAction
   3121           ? `<div class="chatTools">${reportAction}${deleteAction}</div>`
   3122           : "";
   3123       return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${mentionMe ? "mentionMe" : ""} ${rail} ${isModMsg ? "isModMsg" : ""}" data-msgid="${escapeHtml(
   3124         m.id
   3125       )}" ${tint}>
   3126         <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div>
   3127         ${replyBlock}
   3128         <div class="content">${content}</div>
   3129         ${deletedLine}${editedLine}
   3130         <div class="chatActionsRow">${reacts}${actions}</div>
   3131       </div>`;
   3132     })
   3133     .join("");
   3134 
   3135   for (const contentEl of messagesEl.querySelectorAll(".chatMsg .content")) {
   3136     decorateMentionNodesInElement(contentEl);
   3137     decorateYouTubeEmbedsInElement(contentEl);
   3138   }
   3139 
   3140   renderTypingIndicatorForPost(post.id, typingEl);
   3141 
   3142   if (forceScroll || atBottomBefore) messagesEl.scrollTop = messagesEl.scrollHeight;
   3143 }
   3144 
   3145 function renderChatInstancesForPost(postId) {
   3146   const pid = String(postId || "").trim();
   3147   if (!pid) return;
   3148   for (const [panelId, inst] of chatPanelInstances.entries()) {
   3149     if (String(inst?.postId || "") !== pid) continue;
   3150     renderChatPostPanelInstance(panelId);
   3151   }
   3152 }
   3153 
   3154 function setChatInstancePanelPost(panelId, postId, forceScroll = true) {
   3155   const pid = String(postId || "").trim();
   3156   const id = String(panelId || "").trim();
   3157   if (!pid || !id) return false;
   3158   const inst = chatPanelInstances.get(id);
   3159   if (!inst) return false;
   3160   const post = posts.get(pid);
   3161   if (!post) return false;
   3162   inst.postId = pid;
   3163   chatPanelInstances.set(id, inst);
   3164   const root = getPanelElement(id);
   3165   const titleEl = root?.querySelector?.(".panelTitle");
   3166   if (titleEl) titleEl.textContent = post?.title ? `Chat: ${String(post.title).slice(0, 32)}` : "Chat";
   3167   renderChatPostPanelInstance(id, forceScroll);
   3168   return true;
   3169 }
   3170 
   3171 function nearestVisibleChatInstancePanelId(sourceEl) {
   3172   const anchor = sourceEl instanceof HTMLElement ? sourceEl : null;
   3173   if (!anchor) return "";
   3174   const anchorRect = anchor.getBoundingClientRect();
   3175   const ax = anchorRect.left + anchorRect.width / 2;
   3176   const ay = anchorRect.top + anchorRect.height / 2;
   3177   let bestId = "";
   3178   let bestDist = Number.POSITIVE_INFINITY;
   3179   for (const [panelId] of chatPanelInstances.entries()) {
   3180     const root = getPanelElement(panelId);
   3181     if (!(root instanceof HTMLElement)) continue;
   3182     if (root.classList.contains("hidden")) continue;
   3183     const rect = root.getBoundingClientRect();
   3184     if (rect.width <= 1 || rect.height <= 1) continue;
   3185     const cx = rect.left + rect.width / 2;
   3186     const cy = rect.top + rect.height / 2;
   3187     const dist = Math.hypot(cx - ax, cy - ay);
   3188     if (dist < bestDist) {
   3189       bestDist = dist;
   3190       bestId = panelId;
   3191     }
   3192   }
   3193   return bestId;
   3194 }
   3195 
   3196 function panelIdFromSourceElement(sourceEl) {
   3197   const el = sourceEl instanceof HTMLElement ? sourceEl : null;
   3198   if (!el) return "";
   3199   const panel = el.closest(".rackPanel");
   3200   if (!(panel instanceof HTMLElement)) return "";
   3201   return String(panel.dataset.panelId || "").trim();
   3202 }
   3203 
   3204 function workspaceChatTargetCandidates() {
   3205   if (!rackLayoutEnabled) return [];
   3206   const left = ensureWorkspaceLeftRack();
   3207   const right = ensureWorkspaceRightRack();
   3208   if (!left || !right) return [];
   3209   const slots = [left, right];
   3210   const out = [];
   3211   for (const slot of slots) {
   3212     const panel = slot.querySelector?.(":scope > .rackPanel:not(.hidden)");
   3213     if (!(panel instanceof HTMLElement)) continue;
   3214     const panelId = String(panel.dataset.panelId || "").trim();
   3215     if (!panelId) continue;
   3216     if (panelId === "chat") {
   3217       out.push({ kind: "main", panelId, element: panel });
   3218       continue;
   3219     }
   3220     if (panelId.startsWith("chat:post:") && chatPanelInstances.has(panelId)) {
   3221       out.push({ kind: "instance", panelId, element: panel });
   3222     }
   3223   }
   3224   return out;
   3225 }
   3226 
   3227 function chooseWorkspaceChatTarget(sourceEl) {
   3228   const candidates = workspaceChatTargetCandidates();
   3229   if (!candidates.length) return null;
   3230   const anchor = sourceEl instanceof HTMLElement ? sourceEl : null;
   3231   if (!anchor) return candidates[0] || null;
   3232   const anchorRect = anchor.getBoundingClientRect();
   3233   const ax = anchorRect.left + anchorRect.width / 2;
   3234   const ay = anchorRect.top + anchorRect.height / 2;
   3235   let best = null;
   3236   let bestDist = Number.POSITIVE_INFINITY;
   3237   for (const candidate of candidates) {
   3238     const rect = candidate.element.getBoundingClientRect();
   3239     const cx = rect.left + rect.width / 2;
   3240     const cy = rect.top + rect.height / 2;
   3241     const dist = Math.hypot(cx - ax, cy - ay);
   3242     if (dist < bestDist) {
   3243       bestDist = dist;
   3244       best = candidate;
   3245     }
   3246   }
   3247   return best || candidates[0] || null;
   3248 }
   3249 
   3250 function chooseReferrerPanelForChatSplit(sourceEl) {
   3251   const sourcePanelId = panelIdFromSourceElement(sourceEl);
   3252   if (sourcePanelId && sourcePanelId !== "chat" && !sourcePanelId.startsWith("chat:")) return sourcePanelId;
   3253 
   3254   const left = ensureWorkspaceLeftRack();
   3255   const right = ensureWorkspaceRightRack();
   3256   const inWorkspace = [];
   3257   const leftPanel = left?.querySelector?.(":scope > .rackPanel:not(.hidden)");
   3258   const rightPanel = right?.querySelector?.(":scope > .rackPanel:not(.hidden)");
   3259   if (leftPanel instanceof HTMLElement) inWorkspace.push(String(leftPanel.dataset.panelId || "").trim());
   3260   if (rightPanel instanceof HTMLElement) inWorkspace.push(String(rightPanel.dataset.panelId || "").trim());
   3261   for (const id of inWorkspace) {
   3262     if (!id || id === "chat" || id.startsWith("chat:")) continue;
   3263     return id;
   3264   }
   3265 
   3266   const activePrimary = readWorkspaceActivePrimary();
   3267   if (activePrimary && activePrimary !== "chat" && !activePrimary.startsWith("chat:")) return activePrimary;
   3268 
   3269   if (getPanelElement("hives")) return "hives";
   3270   return "";
   3271 }
   3272 
   3273 function ensureChatWorkspaceSplit(sourceEl) {
   3274   if (!rackLayoutEnabled || isMobileSwipeMode()) return false;
   3275   if (!chatPanelEl) return false;
   3276   const existingTarget = chooseWorkspaceChatTarget(sourceEl);
   3277   if (existingTarget) return true;
   3278 
   3279   const referrerPanelId = chooseReferrerPanelForChatSplit(sourceEl);
   3280   if (referrerPanelId) {
   3281     restorePanelToWorkspaceSlot(referrerPanelId, "workspaceLeftSlot");
   3282     writeWorkspaceActivePrimary(referrerPanelId);
   3283   }
   3284   if (isDocked("chat")) undockPanel("chat");
   3285   restorePanelToWorkspaceSlot("chat", "workspaceRightSlot");
   3286   writeWorkspaceActivePrimary(referrerPanelId || "chat");
   3287   return true;
   3288 }
   3289 
   3290 function applyPluginPresetHint(panelDef) {
   3291   if (!rackLayoutEnabled) return;
   3292   const id = String(panelDef?.id || "").trim();
   3293   if (!id) return;
   3294   if (isDocked(id)) return;
   3295   const presetId = rackLayoutState?.presetId || "";
   3296   const hint = panelDef?.presetHints && typeof panelDef.presetHints === "object" ? panelDef.presetHints[presetId] : null;
   3297   const place = hint && typeof hint === "object" ? String(hint.place || "") : "";
   3298   if (place === "docked.bottom") {
   3299     dockPanel(id);
   3300     return;
   3301   }
   3302   if (place === "main" || place === "right") {
   3303     const rack = place === "main" ? ensureMainSideRack() : ensureRightRack();
   3304     const el = getPanelElement(id);
   3305     if (rack && el) rack.appendChild(el);
   3306   }
   3307 }
   3308 
   3309 function enableRackDnD() {
   3310   if (!rackLayoutEnabled) return;
   3311   const pluginWidgets = ensurePluginRackWidgetsRack();
   3312   const workspaceRack = ensureWorkspaceStripRack();
   3313   const right = ensureRightRack();
   3314   const left = ensureWorkspaceLeftRack();
   3315   const rightWorkspace = ensureWorkspaceRightRack();
   3316   const side = ensureMainSideRack();
   3317   if (!workspaceInfiniteMode() && (!right || !left || !rightWorkspace || !side)) return;
   3318   const racks = workspaceInfiniteMode()
   3319     ? [workspaceRack, pluginWidgets].filter((x) => x instanceof HTMLElement)
   3320     : [left, rightWorkspace, side, right, pluginWidgets].filter((x) => x instanceof HTMLElement);
   3321 
   3322   // Guard against double-install if initRackLayout is called more than once.
   3323   if (appRoot?.dataset?.rackDnd === "1") return;
   3324   if (appRoot) appRoot.dataset.rackDnd = "1";
   3325 
   3326   let draggingEl = null;
   3327   let placeholderEl = null;
   3328   let pointerId = null;
   3329   let dragOffset = { x: 0, y: 0 };
   3330   let draggingPanelId = "";
   3331   let activeRack = null;
   3332   let originRack = null;
   3333   let originBefore = null;
   3334 
   3335   const cancelDrag = () => {
   3336     if (!draggingEl) return;
   3337     cleanup();
   3338     enforceWorkspaceRules();
   3339   };
   3340 
   3341   const cleanup = () => {
   3342     if (appRoot) appRoot.classList.remove("rackIsDragging");
   3343     if (draggingEl) {
   3344       draggingEl.classList.remove("rackDragging");
   3345       draggingEl.style.position = "";
   3346       draggingEl.style.left = "";
   3347       draggingEl.style.top = "";
   3348       draggingEl.style.width = "";
   3349       draggingEl.style.zIndex = "";
   3350       draggingEl.style.pointerEvents = "";
   3351     }
   3352     if (dockHotbarEl) dockHotbarEl.classList.remove("dockTarget");
   3353     if (placeholderEl && placeholderEl.parentElement) placeholderEl.parentElement.removeChild(placeholderEl);
   3354     draggingEl = null;
   3355     placeholderEl = null;
   3356     pointerId = null;
   3357     draggingPanelId = "";
   3358     activeRack = null;
   3359     originRack = null;
   3360     originBefore = null;
   3361   };
   3362 
   3363   const siblings = (rack) => Array.from(rack.querySelectorAll(".rackPanel")).filter((el) => el !== draggingEl && el !== placeholderEl);
   3364 
   3365   const insertPlaceholderAt = (rack, y) => {
   3366     const items = siblings(rack);
   3367     for (const el of items) {
   3368       const r = el.getBoundingClientRect();
   3369       const mid = r.top + r.height / 2;
   3370       if (y < mid) {
   3371         rack.insertBefore(placeholderEl, el);
   3372         return;
   3373       }
   3374     }
   3375     rack.appendChild(placeholderEl);
   3376   };
   3377 
   3378   const rackAtPoint = (x, y) => {
   3379     for (const r of racks) {
   3380       const rect = r.getBoundingClientRect();
   3381       if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return r;
   3382     }
   3383     return null;
   3384   };
   3385 
   3386   const onMove = (e) => {
   3387     if (!draggingEl || e.pointerId !== pointerId) return;
   3388     e.preventDefault();
   3389     const x = e.clientX - dragOffset.x;
   3390     const y = e.clientY - dragOffset.y;
   3391     draggingEl.style.left = `${x}px`;
   3392     draggingEl.style.top = `${y}px`;
   3393 
   3394     const targetRack = rackAtPoint(e.clientX, e.clientY) || activeRack;
   3395     if (targetRack && placeholderEl && placeholderEl.parentElement !== targetRack) {
   3396       targetRack.appendChild(placeholderEl);
   3397     }
   3398     if (targetRack) {
   3399       activeRack = targetRack;
   3400       insertPlaceholderAt(targetRack, e.clientY);
   3401     }
   3402 
   3403     if (dockHotbarEl) {
   3404       const nearBottom = e.clientY > window.innerHeight - 90;
   3405       dockHotbarEl.classList.toggle("dockTarget", Boolean(nearBottom));
   3406       if (nearBottom) showHotbar(true);
   3407     }
   3408   };
   3409 
   3410     const onUp = (e) => {
   3411       if (!draggingEl || e.pointerId !== pointerId) return;
   3412       e.preventDefault();
   3413       const targetRack = placeholderEl?.parentElement || activeRack;
   3414       if (targetRack && placeholderEl && placeholderEl.parentElement === targetRack) {
   3415         if (workspaceInfiniteMode()) {
   3416           targetRack.insertBefore(draggingEl, placeholderEl);
   3417           if (targetRack.id === "pluginRackWidgetsRack") draggingEl.classList.add("pluginRackWidget");
   3418         } else {
   3419         const isWorkspaceSlot = targetRack.id === "workspaceLeftSlot" || targetRack.id === "workspaceRightSlot";
   3420         const isRightRackSlot = targetRack.id === "rightRack";
   3421         const isSideRackSlot = targetRack.id === "mainSideRack";
   3422         const isPluginRackWidgets = targetRack.id === "pluginRackWidgetsRack";
   3423         const isSkinnyRackSlot = isRightRackSlot || isSideRackSlot;
   3424         const skinnyOk = panelIsSkinnyCapable(draggingPanelId);
   3425 
   3426         if (isPluginRackWidgets && !panelIsHostableInPluginRack(draggingPanelId)) {
   3427           toast("Can't place there", `${panelTitle(draggingPanelId)} can't be hosted in Plugin Rack.`);
   3428           if (originRack) {
   3429             if (originBefore && originBefore.parentElement === originRack) originRack.insertBefore(draggingEl, originBefore);
   3430             else originRack.appendChild(draggingEl);
   3431           }
   3432           cleanup();
   3433           syncRackStateFromDom();
   3434           enforceWorkspaceRules();
   3435           return;
   3436         }
   3437 
   3438         // Only skinny-capable panels may live in skinny columns (side / right racks).
   3439         if (isSkinnyRackSlot && !skinnyOk) {
   3440           toast("Can't place there", `${panelTitle(draggingPanelId)} can't be placed in a skinny rack.`);
   3441           if (originRack) {
   3442             if (originBefore && originBefore.parentElement === originRack) originRack.insertBefore(draggingEl, originBefore);
   3443             else originRack.appendChild(draggingEl);
   3444           }
   3445           cleanup();
   3446           syncRackStateFromDom();
   3447           enforceWorkspaceRules();
   3448           return;
   3449         }
   3450 
   3451         if (isWorkspaceSlot || isRightRackSlot) {
   3452           const existing = Array.from(targetRack.querySelectorAll(":scope > .rackPanel")).find((x) => x !== draggingEl);
   3453           targetRack.insertBefore(draggingEl, placeholderEl);
   3454           // Swap if occupied: send the previous occupant back to the origin rack position.
   3455           if (existing && originRack) {
   3456             if (originBefore && originBefore.parentElement === originRack) originRack.insertBefore(existing, originBefore);
   3457             else originRack.appendChild(existing);
   3458           }
   3459         } else {
   3460           targetRack.insertBefore(draggingEl, placeholderEl);
   3461         }
   3462         if (isPluginRackWidgets) draggingEl.classList.add("pluginRackWidget");
   3463         }
   3464       }
   3465     const shouldDock = Boolean(dockHotbarEl && e.clientY > window.innerHeight - 90);
   3466     const dockId = draggingPanelId;
   3467     const droppedEl = draggingEl;
   3468     const droppedRackId = String(targetRack?.id || "");
   3469     cleanup();
   3470     if (shouldDock && dockId) dockPanel(dockId);
   3471     if (!shouldDock && droppedEl instanceof HTMLElement) applyPanelWorkspaceSize(droppedEl);
   3472     syncRackStateFromDom();
   3473     enforceWorkspaceRules();
   3474   };
   3475 
   3476   // Use window-level listeners so cross-rack dragging stays responsive even when the cursor passes over gaps/resizers.
   3477   window.addEventListener("pointermove", onMove);
   3478   window.addEventListener("pointerup", onUp);
   3479   window.addEventListener("pointercancel", onUp);
   3480   // Extra safety: pointer events can fail to deliver pointerup if the mouse is released outside the window.
   3481   window.addEventListener("blur", cancelDrag);
   3482   window.addEventListener("mouseup", cancelDrag);
   3483   window.addEventListener("touchend", cancelDrag, { passive: true });
   3484   document.addEventListener("visibilitychange", () => {
   3485     if (document.visibilityState !== "visible") cancelDrag();
   3486   });
   3487   window.addEventListener("keydown", (e) => {
   3488     if (e.key === "Escape") cancelDrag();
   3489   });
   3490 
   3491   const onDown = (e) => {
   3492     const btn = e.target.closest?.("[data-rackdrag]");
   3493     if (!btn) return;
   3494     const el = btn.closest?.(".rackPanel");
   3495     if (!(el instanceof HTMLElement)) return;
   3496     if (el.classList.contains("hidden")) return;
   3497 
   3498     e.preventDefault();
   3499     // If a drag somehow got stuck, start clean.
   3500     cleanup();
   3501     if (appRoot) appRoot.classList.add("rackIsDragging");
   3502     draggingEl = el;
   3503     draggingPanelId = String(el.dataset.panelId || "");
   3504     pointerId = e.pointerId;
   3505     draggingEl.setPointerCapture?.(pointerId);
   3506 
   3507     activeRack = el.parentElement;
   3508     originRack = activeRack;
   3509     originBefore = draggingEl.nextSibling;
   3510     const rect = draggingEl.getBoundingClientRect();
   3511     dragOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top };
   3512 
   3513     placeholderEl = document.createElement("div");
   3514     placeholderEl.className = "rackPlaceholder";
   3515     placeholderEl.style.height = `${Math.max(40, Math.round(rect.height))}px`;
   3516 
   3517     (activeRack || main).insertBefore(placeholderEl, draggingEl.nextSibling);
   3518 
   3519     draggingEl.classList.add("rackDragging");
   3520     draggingEl.style.position = "fixed";
   3521     draggingEl.style.left = `${rect.left}px`;
   3522     draggingEl.style.top = `${rect.top}px`;
   3523     draggingEl.style.width = `${rect.width}px`;
   3524     draggingEl.style.zIndex = "80";
   3525     draggingEl.style.pointerEvents = "none";
   3526   };
   3527 
   3528   // Delegate to the app root so panels can be dragged regardless of which rack they're currently in.
   3529   (appRoot || document).addEventListener("pointerdown", onDown);
   3530 }
   3531 
   3532 function initRackLayout() {
   3533   rackLayoutEnabled = readRackLayoutEnabled();
   3534   let hadState = false;
   3535   try {
   3536     hadState = Boolean(localStorage.getItem(RACK_LAYOUT_STATE_KEY));
   3537   } catch {
   3538     hadState = false;
   3539   }
   3540   rackLayoutState = loadRackLayoutState();
   3541   // Normalize older preset ids in persisted state.
   3542   rackLayoutState.presetId = resolvePresetKey(rackLayoutState.presetId);
   3543 
   3544   if (toggleRackLayoutEl) {
   3545     toggleRackLayoutEl.checked = rackLayoutEnabled;
   3546     // Hide/disable the toggle while rack mode is forced on.
   3547     if (FORCE_RACK_MODE) {
   3548       toggleRackLayoutEl.checked = true;
   3549       toggleRackLayoutEl.disabled = true;
   3550       const row = toggleRackLayoutEl.closest?.("label");
   3551       if (row) row.classList.add("hidden");
   3552       const toggleBtn = document.getElementById("toggleRackLayoutBtn");
   3553       if (toggleBtn) toggleBtn.classList.add("hidden");
   3554     } else {
   3555       toggleRackLayoutEl.onchange = () => {
   3556         writeRackLayoutEnabled(Boolean(toggleRackLayoutEl.checked));
   3557         // Reload is the simplest safe path while the feature is in flux.
   3558         location.reload();
   3559       };
   3560     }
   3561   }
   3562 
   3563   if (layoutPresetEl) {
   3564     updateLayoutPresetOptions();
   3565     layoutPresetEl.value = resolvePresetKey(rackLayoutState.presetId || "onboardingDefault");
   3566     layoutPresetEl.disabled = !rackLayoutEnabled;
   3567     layoutPresetEl.onchange = () => {
   3568       if (!rackLayoutEnabled) return;
   3569       const next = String(layoutPresetEl.value || "onboardingDefault");
   3570       applyPreset(next);
   3571     };
   3572   }
   3573 
   3574   if (!rackLayoutEnabled) {
   3575     disableRackLayoutDom();
   3576     setSideCollapsed(false, { persist: false, updateControls: false });
   3577     setRightCollapsed(false, { persist: false, updateControls: false });
   3578     toggleSideRackEl && (toggleSideRackEl.disabled = true);
   3579     toggleRightRackEl && (toggleRightRackEl.disabled = true);
   3580     showSideRackBtn?.classList.add("hidden");
   3581     showRightRackBtn?.classList.add("hidden");
   3582     showHotbar(false);
   3583     return;
   3584   }
   3585 
   3586   enableRackLayoutDom();
   3587 
   3588   // Ensure Plugin Rack exists and is accessible (defaults to hotbar unless explicitly placed).
   3589   ensurePluginRackPanel();
   3590   const pluginRackPlaced =
   3591     isDocked("pluginRack") ||
   3592     ["workspaceLeft", "workspaceRight", "side", "right"].some((k) => Array.isArray(rackLayoutState?.racks?.[k]) && rackLayoutState.racks[k].includes("pluginRack"));
   3593   if (!pluginRackPlaced) {
   3594     rackLayoutState.docked.bottom = Array.isArray(rackLayoutState?.docked?.bottom) ? rackLayoutState.docked.bottom : [];
   3595     if (!rackLayoutState.docked.bottom.includes("pluginRack")) rackLayoutState.docked.bottom.push("pluginRack");
   3596     saveRackLayoutState();
   3597   }
   3598 
   3599   // Side racks behave like summonable hotbars: hide/show without changing panel layout state.
   3600   toggleSideRackEl && (toggleSideRackEl.disabled = false);
   3601   toggleRightRackEl && (toggleRightRackEl.disabled = false);
   3602 
   3603   if (showSideRackBtn) {
   3604     showSideRackBtn.classList.remove("hidden");
   3605     showSideRackBtn.onclick = () => setSideCollapsed(false);
   3606   }
   3607   if (showRightRackBtn) {
   3608     showRightRackBtn.classList.remove("hidden");
   3609     showRightRackBtn.onclick = () => setRightCollapsed(false);
   3610   }
   3611 
   3612   if (toggleSideRackEl) {
   3613     toggleSideRackEl.onchange = () => {
   3614       if (!rackLayoutEnabled) return;
   3615       setSideCollapsed(!Boolean(toggleSideRackEl.checked));
   3616     };
   3617   }
   3618   if (toggleRightRackEl) {
   3619     toggleRightRackEl.onchange = () => {
   3620       if (!rackLayoutEnabled) return;
   3621       setRightCollapsed(!Boolean(toggleRightRackEl.checked));
   3622     };
   3623   }
   3624 
   3625   setSideCollapsed(readBoolPref(RACK_SIDE_COLLAPSED_KEY, false), { persist: false });
   3626   setRightCollapsed(readBoolPref(RACK_RIGHT_COLLAPSED_KEY, false), { persist: false });
   3627 
   3628   applyRackStateToDom();
   3629   const hasOnboardingPlacement =
   3630     (Array.isArray(rackLayoutState?.racks?.workspaceLeft) && rackLayoutState.racks.workspaceLeft.includes("onboarding")) ||
   3631     (Array.isArray(rackLayoutState?.racks?.workspaceRight) && rackLayoutState.racks.workspaceRight.includes("onboarding")) ||
   3632     (Array.isArray(rackLayoutState?.racks?.side) && rackLayoutState.racks.side.includes("onboarding")) ||
   3633     (Array.isArray(rackLayoutState?.racks?.right) && rackLayoutState.racks.right.includes("onboarding")) ||
   3634     (Array.isArray(rackLayoutState?.docked?.bottom) && rackLayoutState.docked.bottom.includes("onboarding"));
   3635   if ((rackLayoutState?.presetId || "") === "onboardingDefault" && !hasOnboardingPlacement) {
   3636     applyPreset("onboardingDefault");
   3637   }
   3638   installPanelMinimizeButtons();
   3639   if (appRoot && appRoot.dataset.panelSizeControls !== "1") {
   3640     appRoot.dataset.panelSizeControls = "1";
   3641     appRoot.addEventListener("click", (e) => {
   3642       const btn = e.target?.closest?.("[data-panelsize][data-panelid]");
   3643       if (!(btn instanceof HTMLElement)) return;
   3644       const panelId = String(btn.getAttribute("data-panelid") || "").trim();
   3645       const size = String(btn.getAttribute("data-panelsize") || "").trim().toLowerCase();
   3646       if (!panelId) return;
   3647       setPanelWorkspaceSize(panelId, size);
   3648       applyAllWorkspacePanelSizes();
   3649     });
   3650   }
   3651   enableRackDnD();
   3652   installWorkspaceInteractions();
   3653   enforceWorkspaceRules();
   3654   renderProfilePanel();
   3655 
   3656   // Hotbar interactions
   3657   if (dockHotbarEl) {
   3658     dockHotbarEl.onmouseenter = null;
   3659     dockHotbarEl.onmouseleave = null;
   3660     // Click restores a panel to workspace; drag still supports precise placement.
   3661     dockHotbarEl.onclick = (e) => {
   3662       if (dockHotbarEl.dataset.dragging === "1") return;
   3663       const restoreBtn = e.target.closest?.("[data-undock]");
   3664       if (restoreBtn) {
   3665         const id = String(restoreBtn.getAttribute("data-undock") || "").trim();
   3666         if (id) restorePanelFromHotbar(id, { userAdded: true });
   3667         return;
   3668       }
   3669       const plus = e.target.closest?.("[data-hotbarplus]");
   3670       if (!plus) return;
   3671       if (hotbarPlusMenuEl) closeHotbarPlusMenu();
   3672       else openHotbarPlusMenu(plus);
   3673     };
   3674   }
   3675 
   3676   // Close the "+" menu when clicking elsewhere.
   3677   if (appRoot && appRoot.dataset.hotbarPlusClose !== "1") {
   3678     appRoot.dataset.hotbarPlusClose = "1";
   3679     document.addEventListener("pointerdown", (e) => {
   3680       if (!hotbarPlusMenuEl && !pluginRackAddMenuEl && !workspaceAddMenuEl) return;
   3681       const t = e.target;
   3682       if (t) {
   3683         if (hotbarPlusMenuEl && hotbarPlusMenuEl.contains(t)) return;
   3684         if (pluginRackAddMenuEl && pluginRackAddMenuEl.contains(t)) return;
   3685         if (workspaceAddMenuEl && workspaceAddMenuEl.contains(t)) return;
   3686         if (dockHotbarEl && dockHotbarEl.contains(t)) return;
   3687         if (t.closest?.("[data-workspaceadd]")) return;
   3688       }
   3689       closeHotbarPlusMenu();
   3690       closePluginRackAddMenu();
   3691       closeWorkspaceAddMenu();
   3692     });
   3693   }
   3694 
   3695   // Drag orbs back into the rack to restore (MVP: restore to end of rack).
   3696   if (dockHotbarEl) {
   3697     let orbDragId = "";
   3698     let orbPointer = null;
   3699     let orbStart = null;
   3700     let orbMoved = false;
   3701     let orbPlaceholder = null;
   3702     let orbActiveRack = null;
   3703 
   3704     const lockHotbarVisible = (lock) => {
   3705       dockHotbarEl.dataset.lockVisible = lock ? "1" : "0";
   3706       dockHotbarEl.dataset.dragging = lock ? "1" : "0";
   3707       // While dragging an orb, keep both workspace slots visible as drop targets.
   3708       if (appRoot) {
   3709         if (lock) {
   3710           appRoot.classList.add("rackIsDragging");
   3711           appRoot.dataset.orbDragging = "1";
   3712         } else if (appRoot.dataset.orbDragging === "1") {
   3713           delete appRoot.dataset.orbDragging;
   3714           appRoot.classList.remove("rackIsDragging");
   3715         }
   3716       }
   3717       if (lock) showHotbar(true);
   3718     };
   3719 
   3720     const resolveOrbDropRack = (panelId, rackEl) => {
   3721       const id = String(panelId || "").trim();
   3722       if (!id) return rackEl;
   3723       if (workspaceInfiniteMode()) {
   3724         if (isRightRackFixedPanel(id)) return ensureRightRack() || rackEl;
   3725         if (rackEl && rackEl.id === "pluginRackWidgetsRack" && panelIsHostableInPluginRack(id)) return rackEl;
   3726         return ensureWorkspaceStripRack() || rackEl;
   3727       }
   3728       if (rackEl && rackEl.id === "pluginRackWidgetsRack") {
   3729         if (panelIsHostableInPluginRack(id)) return rackEl;
   3730         const left = ensureWorkspaceLeftRack();
   3731         const right = ensureWorkspaceRightRack();
   3732         const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false;
   3733         const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false;
   3734         return leftEmpty ? left : rightEmpty ? right : left;
   3735       }
   3736       // Skinny racks (side/right) only allow skinny-capable panels.
   3737       if (rackEl && (rackEl.id === "mainSideRack" || rackEl.id === "rightRack")) {
   3738         if (panelIsSkinnyCapable(id)) return rackEl;
   3739         const left = ensureWorkspaceLeftRack();
   3740         const right = ensureWorkspaceRightRack();
   3741         const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false;
   3742         const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false;
   3743         return leftEmpty ? left : rightEmpty ? right : left;
   3744       }
   3745       if (panelRole(id) !== "primary") return rackEl;
   3746       const isWorkspaceSlot = rackEl && (rackEl.id === "workspaceLeftSlot" || rackEl.id === "workspaceRightSlot");
   3747       if (isWorkspaceSlot) return rackEl;
   3748       const left = ensureWorkspaceLeftRack();
   3749       const right = ensureWorkspaceRightRack();
   3750       const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false;
   3751       const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false;
   3752       return leftEmpty ? left : rightEmpty ? right : left;
   3753     };
   3754 
   3755     const insertOrbPlaceholderAt = (rack, y) => {
   3756       if (!(rack instanceof HTMLElement) || !(orbPlaceholder instanceof HTMLElement)) return;
   3757       const items = Array.from(rack.querySelectorAll(":scope > .rackPanel")).filter((el) => el !== orbPlaceholder);
   3758       for (const el of items) {
   3759         const r = el.getBoundingClientRect();
   3760         const mid = r.top + r.height / 2;
   3761         if (y < mid) {
   3762           rack.insertBefore(orbPlaceholder, el);
   3763           return;
   3764         }
   3765       }
   3766       rack.appendChild(orbPlaceholder);
   3767     };
   3768 
   3769     const orbRacks = () => {
   3770       if (workspaceInfiniteMode()) {
   3771         const workspaceRack = ensureWorkspaceStripRack();
   3772         const pluginWidgetsRack = ensurePluginRackWidgetsRack();
   3773         return [workspaceRack, pluginWidgetsRack].filter((x) => x instanceof HTMLElement);
   3774       }
   3775       const leftRack = ensureWorkspaceLeftRack();
   3776       const rightWorkspaceRack = ensureWorkspaceRightRack();
   3777       const sideRack = ensureMainSideRack();
   3778       const rightRack = ensureRightRack();
   3779       const pluginWidgetsRack = ensurePluginRackWidgetsRack();
   3780       return [leftRack, rightWorkspaceRack, sideRack, rightRack, pluginWidgetsRack].filter((x) => x instanceof HTMLElement);
   3781     };
   3782 
   3783     const rackAtPoint = (x, y) => {
   3784       for (const r of orbRacks()) {
   3785         const rect = r.getBoundingClientRect();
   3786         if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return r;
   3787       }
   3788       return null;
   3789     };
   3790 
   3791       const dropOrbIntoRack = (panelId, targetRack, beforeEl) => {
   3792         const id = String(panelId || "").trim();
   3793         if (!id) return;
   3794         const rack = resolveOrbDropRack(id, targetRack);
   3795         if (!(rack instanceof HTMLElement)) return;
   3796         const panelEl = getPanelElement(id);
   3797         if (!panelEl) return;
   3798 
   3799         // Restoring into a collapsed rack should uncollapse it (hotbar is a summonable launcher).
   3800         if (rack.id === "mainSideRack") setSideCollapsed(false);
   3801         if (rack.id === "rightRack") setRightCollapsed(false);
   3802 
   3803         undockPanel(id);
   3804         if (!workspaceInfiniteMode()) {
   3805           const isWorkspaceSlot = rack.id === "workspaceLeftSlot" || rack.id === "workspaceRightSlot";
   3806           const isRightRackSlot = rack.id === "rightRack";
   3807           if (isWorkspaceSlot) {
   3808             const existing = rack.querySelector?.(":scope > .rackPanel:not(.hidden)");
   3809             if (existing instanceof HTMLElement && existing !== panelEl) {
   3810               const existingId = String(existing.dataset.panelId || "").trim();
   3811               if (existingId) dockPanel(existingId);
   3812             }
   3813           }
   3814           if (isRightRackSlot) {
   3815             const existing = rack.querySelector?.(":scope > .rackPanel:not(.hidden)");
   3816             if (existing instanceof HTMLElement && existing !== panelEl) {
   3817               const existingId = String(existing.dataset.panelId || "").trim();
   3818               if (existingId) dockPanel(existingId);
   3819             }
   3820           }
   3821         }
   3822 
   3823         const insertBefore =
   3824           beforeEl instanceof HTMLElement && beforeEl.parentElement === rack && beforeEl.classList.contains("rackPanel")
   3825             ? beforeEl
   3826             : null;
   3827         if (panelEl.parentElement !== rack) {
   3828           if (insertBefore) rack.insertBefore(panelEl, insertBefore);
   3829           else rack.appendChild(panelEl);
   3830         }
   3831         applyPanelWorkspaceSize(panelEl);
   3832         if (rack.id === "pluginRackWidgetsRack") panelEl.classList.add("pluginRackWidget");
   3833         rememberPanelLastRack(id, rack.id);
   3834         saveRackLayoutState();
   3835         syncRackStateFromDom();
   3836         enforceWorkspaceRules();
   3837         if (isWorkspaceRackId(rack.id)) {
   3838           requestAnimationFrame(() => {
   3839             focusWorkspaceArrival(panelEl);
   3840           });
   3841         }
   3842       };
   3843 
   3844     dockHotbarEl.addEventListener("pointerdown", (e) => {
   3845       const orb = e.target.closest?.("[data-undock]");
   3846       if (!orb) return;
   3847       orbDragId = String(orb.getAttribute("data-undock") || "");
   3848       if (!orbDragId) return;
   3849       orbPointer = e.pointerId;
   3850       orbStart = { x: e.clientX, y: e.clientY };
   3851       orbMoved = false;
   3852       orbActiveRack = null;
   3853       orb.classList.add("dragging");
   3854       orb.setPointerCapture?.(orbPointer);
   3855       lockHotbarVisible(true);
   3856       e.preventDefault();
   3857 
   3858       // Placeholder shows drop position while dragging.
   3859       orbPlaceholder = document.createElement("div");
   3860       orbPlaceholder.className = "rackPlaceholder";
   3861       orbPlaceholder.style.height = "52px";
   3862     });
   3863     window.addEventListener("pointermove", (e) => {
   3864       if (!orbDragId || e.pointerId !== orbPointer) return;
   3865       if (!orbStart) return;
   3866       const dx = Math.abs(e.clientX - orbStart.x);
   3867       const dy = Math.abs(e.clientY - orbStart.y);
   3868       if (dx + dy > 6) orbMoved = true;
   3869 
   3870       if (orbMoved && orbPlaceholder) {
   3871         const r = rackAtPoint(e.clientX, e.clientY) || orbActiveRack;
   3872         if (r && orbPlaceholder.parentElement !== r) r.appendChild(orbPlaceholder);
   3873         if (r) {
   3874           orbActiveRack = r;
   3875           insertOrbPlaceholderAt(r, e.clientY);
   3876         }
   3877       }
   3878     });
   3879     dockHotbarEl.addEventListener("pointerup", (e) => {
   3880       if (!orbDragId || e.pointerId !== orbPointer) return;
   3881       const orb = dockHotbarEl.querySelector(`[data-undock="${CSS.escape(orbDragId)}"]`);
   3882       if (orb) orb.classList.remove("dragging");
   3883       const targetRack = orbMoved ? (rackAtPoint(e.clientX, e.clientY) || orbActiveRack) : null;
   3884       const beforeEl =
   3885         orbMoved && orbPlaceholder && targetRack instanceof HTMLElement && orbPlaceholder.parentElement === targetRack
   3886           ? orbPlaceholder.nextSibling
   3887           : null;
   3888       if (orbMoved && targetRack) dropOrbIntoRack(orbDragId, targetRack, beforeEl);
   3889       orbDragId = "";
   3890       orbPointer = null;
   3891       orbStart = null;
   3892       orbMoved = false;
   3893       orbActiveRack = null;
   3894       if (orbPlaceholder && orbPlaceholder.parentElement) orbPlaceholder.parentElement.removeChild(orbPlaceholder);
   3895       orbPlaceholder = null;
   3896       lockHotbarVisible(false);
   3897     });
   3898     dockHotbarEl.addEventListener("pointercancel", () => {
   3899       orbDragId = "";
   3900       orbPointer = null;
   3901       orbStart = null;
   3902       orbMoved = false;
   3903       orbActiveRack = null;
   3904       if (orbPlaceholder && orbPlaceholder.parentElement) orbPlaceholder.parentElement.removeChild(orbPlaceholder);
   3905       orbPlaceholder = null;
   3906       lockHotbarVisible(false);
   3907       dockHotbarEl.querySelectorAll(".dockOrb.dragging").forEach((x) => x.classList.remove("dragging"));
   3908     });
   3909   }
   3910 
   3911   // Keep hotbar visible in rack mode to avoid layout jank from auto-hide.
   3912   if (rackLayoutEnabled) showHotbar(true);
   3913 
   3914   // First enable: seed state from the selected preset so users immediately get a sensible layout.
   3915   if (!hadState) {
   3916     const preset = resolvePresetKey(rackLayoutState.presetId || (layoutPresetEl ? String(layoutPresetEl.value || "") : "") || "onboardingDefault");
   3917     applyPreset(preset);
   3918   }
   3919 
   3920   applyDockState();
   3921   enforceWorkspaceRules();
   3922 }
   3923 let activeProfileUsername = "";
   3924 let activeProfile = null;
   3925 let lastRequestedProfileUsername = "";
   3926 let isEditingProfile = false;
   3927 let replyToMessage = null;
   3928 let chatResizeDragging = false;
   3929 let chatResizeStartX = 0;
   3930 let chatResizeStartWidth = 0;
   3931 const CHAT_WIDTH_KEY = "bzl_chatWidth";
   3932 const CHAT_WIDTH_DEFAULT = 640;
   3933 let sidebarResizeDragging = false;
   3934 let sidebarResizeStartX = 0;
   3935 let sidebarResizeStartWidth = 0;
   3936 const SIDEBAR_WIDTH_KEY = "bzl_sidebarWidth";
   3937 const SIDEBAR_WIDTH_DEFAULT = 320;
   3938 let modResizeDragging = false;
   3939 let modResizeStartX = 0;
   3940 let modResizeStartWidth = 0;
   3941 const MOD_WIDTH_KEY = "bzl_modWidth";
   3942 const MOD_WIDTH_DEFAULT = 360;
   3943 let peopleResizeDragging = false;
   3944 let peopleResizeStartX = 0;
   3945 let peopleResizeStartWidth = 0;
   3946 const PEOPLE_WIDTH_KEY = "bzl_peopleWidth";
   3947 const PEOPLE_WIDTH_DEFAULT = 360;
   3948 let editContext = null;
   3949 let mentionState = { open: false, query: "", selected: 0, items: [], anchorRect: null };
   3950 
   3951 const STAY_CONNECTED_KEY = "bzl_stayConnected";
   3952 function readStayConnectedPref() {
   3953   return readBoolPref(STAY_CONNECTED_KEY, false);
   3954 }
   3955 function writeStayConnectedPref(on) {
   3956   writeBoolPref(STAY_CONNECTED_KEY, Boolean(on));
   3957 }
   3958 const NOTIF_SOUND_KEY = "bzl_notif_sound";
   3959 const NOTIF_NEW_HIVE_KEY = "bzl_notif_new_hive";
   3960 const NOTIF_REPLY_PING_KEY = "bzl_notif_reply_ping";
   3961 const NOTIF_MY_HIVE_CHAT_KEY = "bzl_notif_my_hive_chat";
   3962 const NOTIF_RECENT_HIVE_CHAT_KEY = "bzl_notif_recent_hive_chat";
   3963 const RECENT_HIVE_CHAT_WINDOW_MS = 6 * 60 * 60 * 1000;
   3964 function readNotifSoundPref() {
   3965   return readBoolPref(NOTIF_SOUND_KEY, true);
   3966 }
   3967 function writeNotifSoundPref(on) {
   3968   writeBoolPref(NOTIF_SOUND_KEY, Boolean(on));
   3969 }
   3970 function readNotifNewHivePref() {
   3971   return readBoolPref(NOTIF_NEW_HIVE_KEY, true);
   3972 }
   3973 function writeNotifNewHivePref(on) {
   3974   writeBoolPref(NOTIF_NEW_HIVE_KEY, Boolean(on));
   3975 }
   3976 function readNotifReplyPingPref() {
   3977   return readBoolPref(NOTIF_REPLY_PING_KEY, true);
   3978 }
   3979 function writeNotifReplyPingPref(on) {
   3980   writeBoolPref(NOTIF_REPLY_PING_KEY, Boolean(on));
   3981 }
   3982 function readNotifMyHiveChatPref() {
   3983   return readBoolPref(NOTIF_MY_HIVE_CHAT_KEY, true);
   3984 }
   3985 function writeNotifMyHiveChatPref(on) {
   3986   writeBoolPref(NOTIF_MY_HIVE_CHAT_KEY, Boolean(on));
   3987 }
   3988 function readNotifRecentHiveChatPref() {
   3989   return readBoolPref(NOTIF_RECENT_HIVE_CHAT_KEY, true);
   3990 }
   3991 function writeNotifRecentHiveChatPref(on) {
   3992   writeBoolPref(NOTIF_RECENT_HIVE_CHAT_KEY, Boolean(on));
   3993 }
   3994 function noteOwnRecentChat(postId, atMs) {
   3995   const id = String(postId || "").trim();
   3996   if (!id) return;
   3997   const at = Number(atMs || Date.now());
   3998   ownRecentChatByPostId.set(id, Number.isFinite(at) ? at : Date.now());
   3999 }
   4000 function hasOwnRecentChat(postId) {
   4001   const id = String(postId || "").trim();
   4002   if (!id) return false;
   4003   const at = Number(ownRecentChatByPostId.get(id) || 0);
   4004   if (!at) return false;
   4005   if (Date.now() - at > RECENT_HIVE_CHAT_WINDOW_MS) {
   4006     ownRecentChatByPostId.delete(id);
   4007     return false;
   4008   }
   4009   return true;
   4010 }
   4011 function hydrateOwnRecentChatFromHistory(postId, messages) {
   4012   const id = String(postId || "").trim();
   4013   if (!id || !Array.isArray(messages) || !messages.length) return;
   4014   const selfLower = String(loggedInUser || "").toLowerCase();
   4015   if (!selfLower) return;
   4016   for (let i = messages.length - 1; i >= 0; i -= 1) {
   4017     const m = messages[i];
   4018     const fromLower = String(m?.fromUser || "").toLowerCase();
   4019     if (!fromLower || fromLower !== selfLower) continue;
   4020     noteOwnRecentChat(id, m?.createdAt);
   4021     return;
   4022   }
   4023 }
   4024 const ENABLE_HINTS_KEY = "bzl_enableHints";
   4025 const CHAT_ENTER_MODE_KEY = "bzl_chatEnterMode"; // "ctrlEnter" | "enter"
   4026 function readHintsEnabledPref() {
   4027   const raw = localStorage.getItem(ENABLE_HINTS_KEY);
   4028   if (raw == null) return true;
   4029   return raw !== "0";
   4030 }
   4031 function writeHintsEnabledPref(on) {
   4032   const enabled = Boolean(on);
   4033   localStorage.setItem(ENABLE_HINTS_KEY, enabled ? "1" : "0");
   4034   appRoot?.classList.toggle("hintsEnabled", enabled);
   4035 }
   4036 
   4037 function readChatEnterModePref() {
   4038   const raw = readStringPref(CHAT_ENTER_MODE_KEY, "ctrlEnter");
   4039   return raw === "enter" ? "enter" : "ctrlEnter";
   4040 }
   4041 
   4042 function writeChatEnterModePref(mode) {
   4043   const next = String(mode || "").trim().toLowerCase();
   4044   writeStringPref(CHAT_ENTER_MODE_KEY, next === "enter" ? "enter" : "ctrlEnter");
   4045 }
   4046 
   4047 let instanceBranding = { title: "Bzl", subtitle: "Ephemeral hives + chat", allowMemberPermanentPosts: false, appearance: {} };
   4048 let userAppearanceOverride = null;
   4049 let onboardingState = {
   4050   enabled: true,
   4051   rulesVersion: 1,
   4052   requireAcceptance: false,
   4053   blockReadUntilAccepted: false,
   4054   acceptedRulesVersion: 0,
   4055   acceptedAt: 0,
   4056   tutorialVersion: 1,
   4057   tutorialCompletedVersion: 0,
   4058   selectedRoleIds: [],
   4059   needsAcceptance: false,
   4060 };
   4061 let serverInfo = null;
   4062 let serverHealth = null;
   4063 let serverInfoStatus = { loading: false, at: 0, error: "" };
   4064 let pluginAdminStatus = "";
   4065 let pluginAdminBusy = false;
   4066 const pluginEnableInFlight = new Set();
   4067 
   4068 const THEME_PRESETS = [
   4069   {
   4070     id: "bzl_original",
   4071     name: "Bzl (Original)",
   4072     appearance: {
   4073       bg: "#060611",
   4074       panel: "#0c0c18",
   4075       text: "#f6f0ff",
   4076       accent: "#ff3ea5",
   4077       accent2: "#b84bff",
   4078       good: "#3ddc97",
   4079       bad: "#ff4d8a",
   4080       fontBody: "system",
   4081       fontMono: "mono",
   4082       mutedPct: 65,
   4083       linePct: 10,
   4084       panel2Pct: 2,
   4085     },
   4086   },
   4087   {
   4088     id: "midnight_cyan",
   4089     name: "Midnight Cyan",
   4090     appearance: {
   4091       bg: "#060a12",
   4092       panel: "#0a1220",
   4093       text: "#eaf4ff",
   4094       accent: "#2bf5d6",
   4095       accent2: "#4aa0ff",
   4096       good: "#2bf5d6",
   4097       bad: "#ff5c8a",
   4098       fontBody: "clean",
   4099       fontMono: "mono",
   4100       mutedPct: 64,
   4101       linePct: 10,
   4102       panel2Pct: 2,
   4103     },
   4104   },
   4105   {
   4106     id: "warm_amber",
   4107     name: "Warm Amber",
   4108     appearance: {
   4109       bg: "#0b0706",
   4110       panel: "#17100e",
   4111       text: "#fff2ea",
   4112       accent: "#ffb020",
   4113       accent2: "#ff6b3d",
   4114       good: "#56dba3",
   4115       bad: "#ff5b6a",
   4116       fontBody: "serif",
   4117       fontMono: "mono",
   4118       mutedPct: 66,
   4119       linePct: 11,
   4120       panel2Pct: 3,
   4121     },
   4122   },
   4123   {
   4124     id: "terminal_green",
   4125     name: "Terminal Green",
   4126     appearance: {
   4127       bg: "#040805",
   4128       panel: "#070f08",
   4129       text: "#d7ffe6",
   4130       accent: "#2bff88",
   4131       accent2: "#20d3ff",
   4132       good: "#2bff88",
   4133       bad: "#ff4d8a",
   4134       fontBody: "mono",
   4135       fontMono: "mono",
   4136       mutedPct: 58,
   4137       linePct: 12,
   4138       panel2Pct: 2,
   4139     },
   4140   },
   4141   {
   4142     id: "high_contrast",
   4143     name: "High Contrast",
   4144     appearance: {
   4145       bg: "#000000",
   4146       panel: "#0a0a0a",
   4147       text: "#ffffff",
   4148       accent: "#ffd300",
   4149       accent2: "#00d3ff",
   4150       good: "#00ff85",
   4151       bad: "#ff2d55",
   4152       fontBody: "condensed",
   4153       fontMono: "mono",
   4154       mutedPct: 70,
   4155       linePct: 16,
   4156       panel2Pct: 3,
   4157     },
   4158   },
   4159   {
   4160     id: "paper_ink",
   4161     name: "Paper Ink",
   4162     appearance: {
   4163       bg: "#f2ecdf",
   4164       panel: "#e7decd",
   4165       text: "#2e251d",
   4166       accent: "#1d5eff",
   4167       accent2: "#b44f2b",
   4168       good: "#2f9f63",
   4169       bad: "#c84545",
   4170       fontBody: "slab",
   4171       fontMono: "mono",
   4172       mutedPct: 46,
   4173       linePct: 14,
   4174       panel2Pct: 5,
   4175     },
   4176   },
   4177   {
   4178     id: "sunset_neon",
   4179     name: "Sunset Neon",
   4180     appearance: {
   4181       bg: "#13070f",
   4182       panel: "#1f0c18",
   4183       text: "#ffeaf7",
   4184       accent: "#ff7a00",
   4185       accent2: "#ff2ea8",
   4186       good: "#2be7b0",
   4187       bad: "#ff4f70",
   4188       fontBody: "rounded",
   4189       fontMono: "mono",
   4190       mutedPct: 62,
   4191       linePct: 11,
   4192       panel2Pct: 4,
   4193     },
   4194   },
   4195   {
   4196     id: "ocean_depth",
   4197     name: "Ocean Depth",
   4198     appearance: {
   4199       bg: "#031119",
   4200       panel: "#082130",
   4201       text: "#d7f2ff",
   4202       accent: "#28c7ff",
   4203       accent2: "#3d78ff",
   4204       good: "#28e6a1",
   4205       bad: "#ff5e84",
   4206       fontBody: "humanist",
   4207       fontMono: "clean",
   4208       mutedPct: 61,
   4209       linePct: 10,
   4210       panel2Pct: 3,
   4211     },
   4212   },
   4213   {
   4214     id: "retro_arcade",
   4215     name: "Retro Arcade",
   4216     appearance: {
   4217       bg: "#09040f",
   4218       panel: "#13081c",
   4219       text: "#f7dcff",
   4220       accent: "#2dff89",
   4221       accent2: "#ffea00",
   4222       good: "#32ff9f",
   4223       bad: "#ff4f8b",
   4224       fontBody: "condensed",
   4225       fontMono: "mono",
   4226       mutedPct: 60,
   4227       linePct: 13,
   4228       panel2Pct: 3,
   4229     },
   4230   },
   4231   {
   4232     id: "forest_moss",
   4233     name: "Forest Moss",
   4234     appearance: {
   4235       bg: "#08120b",
   4236       panel: "#102016",
   4237       text: "#e8f7ea",
   4238       accent: "#80c96a",
   4239       accent2: "#2fae95",
   4240       good: "#5fd48b",
   4241       bad: "#e6626f",
   4242       fontBody: "serif",
   4243       fontMono: "humanist",
   4244       mutedPct: 60,
   4245       linePct: 9,
   4246       panel2Pct: 4,
   4247     },
   4248   },
   4249   {
   4250     id: "noir_redline",
   4251     name: "Noir Redline",
   4252     appearance: {
   4253       bg: "#0a090a",
   4254       panel: "#151114",
   4255       text: "#f8f4f6",
   4256       accent: "#ff3d52",
   4257       accent2: "#9aa0a6",
   4258       good: "#4dd79c",
   4259       bad: "#ff3d52",
   4260       fontBody: "clean",
   4261       fontMono: "mono",
   4262       mutedPct: 67,
   4263       linePct: 14,
   4264       panel2Pct: 3,
   4265     },
   4266   },
   4267   {
   4268     id: "mist_ui",
   4269     name: "Mist UI",
   4270     appearance: {
   4271       bg: "#f3f7fb",
   4272       panel: "#e6edf5",
   4273       text: "#1d2833",
   4274       accent: "#2e76ff",
   4275       accent2: "#34b3ff",
   4276       good: "#21a56d",
   4277       bad: "#cf4d66",
   4278       fontBody: "clean",
   4279       fontMono: "system",
   4280       mutedPct: 48,
   4281       linePct: 12,
   4282       panel2Pct: 6,
   4283     },
   4284   },
   4285   {
   4286     id: "calculator_lcd",
   4287     name: "Calculator LCD",
   4288     appearance: {
   4289       bg: "#0a110e",
   4290       panel: "#121c18",
   4291       text: "#d8ffe8",
   4292       accent: "#98ff9a",
   4293       accent2: "#62d2a2",
   4294       good: "#98ff9a",
   4295       bad: "#ff6b7a",
   4296       fontBody: "lcd",
   4297       fontMono: "lcd",
   4298       mutedPct: 55,
   4299       linePct: 12,
   4300       panel2Pct: 3,
   4301     },
   4302   },
   4303   {
   4304     id: "digital_radio",
   4305     name: "Digital Radio",
   4306     appearance: {
   4307       bg: "#041018",
   4308       panel: "#0a1c26",
   4309       text: "#dff8ff",
   4310       accent: "#38e8ff",
   4311       accent2: "#6bd0ff",
   4312       good: "#43ffd0",
   4313       bad: "#ff6d95",
   4314       fontBody: "lcd",
   4315       fontMono: "lcd",
   4316       mutedPct: 57,
   4317       linePct: 11,
   4318       panel2Pct: 3,
   4319     },
   4320   },
   4321 ];
   4322 
   4323 const THEME_PRESET_GROUP_ORDER = ["Dark", "Light", "Retro", "High-Contrast", "Reading"];
   4324 const THEME_PRESET_GROUP_BY_ID = {
   4325   bzl_original: "Dark",
   4326   midnight_cyan: "Dark",
   4327   ocean_depth: "Dark",
   4328   noir_redline: "Dark",
   4329   forest_moss: "Dark",
   4330   sunset_neon: "Dark",
   4331   mist_ui: "Light",
   4332   paper_ink: "Reading",
   4333   terminal_green: "Retro",
   4334   retro_arcade: "Retro",
   4335   calculator_lcd: "Retro",
   4336   digital_radio: "Retro",
   4337   warm_amber: "Reading",
   4338   high_contrast: "High-Contrast",
   4339 };
   4340 
   4341 function groupedThemePresetOptionsHtml() {
   4342   const groups = new Map(THEME_PRESET_GROUP_ORDER.map((label) => [label, []]));
   4343   groups.set("Other", []);
   4344   for (const preset of THEME_PRESETS) {
   4345     const id = String(preset?.id || "").trim();
   4346     if (!id) continue;
   4347     const group = THEME_PRESET_GROUP_BY_ID[id] || "Other";
   4348     if (!groups.has(group)) groups.set(group, []);
   4349     groups.get(group).push(preset);
   4350   }
   4351   return [...groups.entries()]
   4352     .map(([groupLabel, presets]) => {
   4353       if (!Array.isArray(presets) || !presets.length) return "";
   4354       const opts = presets.map((p) => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)}</option>`).join("");
   4355       return `<optgroup label="${escapeHtml(groupLabel)}">${opts}</optgroup>`;
   4356     })
   4357     .join("");
   4358 }
   4359 
   4360 const SFX = {
   4361   open: "/assets/sfx/Select_B7.wav",
   4362   post: "/assets/sfx/Select_B7.wav",
   4363   notif: "/assets/sfx/Select_B7.wav",
   4364   ping: "/assets/sfx/Select_C3.wav",
   4365 };
   4366 const sfxCache = new Map();
   4367 let pendingOpenSfx = true;
   4368 let lastSfxAt = 0;
   4369 
   4370 function getSfx(url) {
   4371   const key = String(url || "");
   4372   if (!key) return null;
   4373   if (sfxCache.has(key)) return sfxCache.get(key);
   4374   const a = new Audio(key);
   4375   a.preload = "auto";
   4376   sfxCache.set(key, a);
   4377   return a;
   4378 }
   4379 
   4380 async function playSfx(name, { volume = 0.32 } = {}) {
   4381   const url = SFX[name];
   4382   if (!url) return false;
   4383   const now = Date.now();
   4384   if (now - lastSfxAt < 120) return false;
   4385   lastSfxAt = now;
   4386 
   4387   const a = getSfx(url);
   4388   if (!a) return false;
   4389   try {
   4390     a.pause();
   4391     a.currentTime = 0;
   4392     a.volume = Math.max(0, Math.min(1, Number(volume) || 0.32));
   4393     await a.play();
   4394     return true;
   4395   } catch {
   4396     return false;
   4397   }
   4398 }
   4399 
   4400 function normalizeInstanceBranding(raw) {
   4401   const title = String(raw?.title || "").replace(/\s+/g, " ").trim().slice(0, 32);
   4402   const subtitle = String(raw?.subtitle || "").replace(/\s+/g, " ").trim().slice(0, 80);
   4403   const allowMemberPermanentPosts = Boolean(raw?.allowMemberPermanentPosts);
   4404   const appearanceRaw = raw?.appearance && typeof raw.appearance === "object" ? raw.appearance : {};
   4405   const bg = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.bg || "")) ? String(appearanceRaw.bg).toLowerCase() : "#060611";
   4406   const panel = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.panel || "")) ? String(appearanceRaw.panel).toLowerCase() : "#0c0c18";
   4407   const text = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.text || "")) ? String(appearanceRaw.text).toLowerCase() : "#f6f0ff";
   4408   const accent = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.accent || "")) ? String(appearanceRaw.accent).toLowerCase() : "#ff3ea5";
   4409   const accent2 = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.accent2 || "")) ? String(appearanceRaw.accent2).toLowerCase() : "#b84bff";
   4410   const good = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.good || "")) ? String(appearanceRaw.good).toLowerCase() : "#3ddc97";
   4411   const bad = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.bad || "")) ? String(appearanceRaw.bad).toLowerCase() : "#ff4d8a";
   4412   const fontBody = ["system", "serif", "mono", "humanist", "rounded", "condensed", "slab", "clean", "lcd"].includes(
   4413     String(appearanceRaw.fontBody || "")
   4414   )
   4415     ? String(appearanceRaw.fontBody)
   4416     : "system";
   4417   const fontMono = ["mono", "system", "humanist", "rounded", "clean", "lcd"].includes(String(appearanceRaw.fontMono || ""))
   4418     ? String(appearanceRaw.fontMono)
   4419     : "mono";
   4420   const clampPct = (n, fallback) => {
   4421     const v = Math.floor(Number(n));
   4422     if (!Number.isFinite(v)) return fallback;
   4423     return Math.max(0, Math.min(100, v));
   4424   };
   4425   const mutedPct = clampPct(appearanceRaw.mutedPct, 65);
   4426   const linePct = clampPct(appearanceRaw.linePct, 10);
   4427   const panel2Pct = clampPct(appearanceRaw.panel2Pct, 2);
   4428   const onboardingRaw = raw?.onboarding && typeof raw.onboarding === "object" ? raw.onboarding : {};
   4429   const aboutRaw = onboardingRaw.about && typeof onboardingRaw.about === "object" ? onboardingRaw.about : {};
   4430   const rulesRaw = onboardingRaw.rules && typeof onboardingRaw.rules === "object" ? onboardingRaw.rules : {};
   4431   const roleSelectRaw = onboardingRaw.roleSelect && typeof onboardingRaw.roleSelect === "object" ? onboardingRaw.roleSelect : {};
   4432   const tutorialRaw = onboardingRaw.tutorial && typeof onboardingRaw.tutorial === "object" ? onboardingRaw.tutorial : {};
   4433   const ruleItems = Array.isArray(rulesRaw.items)
   4434     ? rulesRaw.items
   4435         .map((r, idx) => ({
   4436           id: String(r?.id || `r${idx + 1}`).trim().slice(0, 40),
   4437           order: Number.isFinite(Number(r?.order)) ? Math.max(1, Math.floor(Number(r.order))) : idx + 1,
   4438           name: String(r?.name || "").trim().slice(0, 60),
   4439           shortDescription: String(r?.shortDescription || "").trim().slice(0, 180),
   4440           description: typeof r?.description === "string" ? r.description : "",
   4441           severity: ["info", "warn", "critical"].includes(String(r?.severity || "").trim().toLowerCase())
   4442             ? String(r.severity).trim().toLowerCase()
   4443             : "info",
   4444         }))
   4445         .filter((r) => r.id)
   4446         .slice(0, 200)
   4447         .sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || String(a.id || "").localeCompare(String(b.id || "")))
   4448     : [];
   4449   return {
   4450     title: title || "Bzl",
   4451     subtitle: subtitle || "Ephemeral hives + chat",
   4452     allowMemberPermanentPosts,
   4453     onboarding: {
   4454       enabled: Object.prototype.hasOwnProperty.call(onboardingRaw, "enabled") ? Boolean(onboardingRaw.enabled) : true,
   4455       about: {
   4456         content: typeof aboutRaw.content === "string" ? aboutRaw.content : "",
   4457         updatedAt: Number(aboutRaw.updatedAt || 0) || 0,
   4458         updatedBy: String(aboutRaw.updatedBy || "").trim().toLowerCase(),
   4459       },
   4460       rules: {
   4461         version: Math.max(1, Math.floor(Number(rulesRaw.version || 1))),
   4462         requireAcceptance: Boolean(rulesRaw.requireAcceptance),
   4463         blockReadUntilAccepted: Boolean(rulesRaw.blockReadUntilAccepted),
   4464         items: ruleItems,
   4465       },
   4466       roleSelect: {
   4467         enabled: Object.prototype.hasOwnProperty.call(roleSelectRaw, "enabled") ? Boolean(roleSelectRaw.enabled) : true,
   4468         selfAssignableRoleIds: Array.isArray(roleSelectRaw.selfAssignableRoleIds)
   4469           ? roleSelectRaw.selfAssignableRoleIds.map((x) => String(x || "").trim().toLowerCase()).filter(Boolean).slice(0, 64)
   4470           : [],
   4471       },
   4472       tutorial: {
   4473         enabled: Object.prototype.hasOwnProperty.call(tutorialRaw, "enabled") ? Boolean(tutorialRaw.enabled) : true,
   4474         version: Math.max(1, Math.floor(Number(tutorialRaw.version || 1))),
   4475       },
   4476     },
   4477     appearance: { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct },
   4478   };
   4479 }
   4480 
   4481 function normalizeOnboardingState(raw) {
   4482   const src = raw && typeof raw === "object" ? raw : {};
   4483   return {
   4484     enabled: Object.prototype.hasOwnProperty.call(src, "enabled") ? Boolean(src.enabled) : Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.enabled),
   4485     rulesVersion: Math.max(1, Math.floor(Number(src.rulesVersion || normalizeInstanceBranding(instanceBranding).onboarding?.rules?.version || 1))),
   4486     requireAcceptance: Object.prototype.hasOwnProperty.call(src, "requireAcceptance")
   4487       ? Boolean(src.requireAcceptance)
   4488       : Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.rules?.requireAcceptance),
   4489     blockReadUntilAccepted: Object.prototype.hasOwnProperty.call(src, "blockReadUntilAccepted")
   4490       ? Boolean(src.blockReadUntilAccepted)
   4491       : Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.rules?.blockReadUntilAccepted),
   4492     acceptedRulesVersion: Math.max(0, Math.floor(Number(src.acceptedRulesVersion || 0))),
   4493     acceptedAt: Number(src.acceptedAt || 0) || 0,
   4494     tutorialVersion: Math.max(1, Math.floor(Number(src.tutorialVersion || normalizeInstanceBranding(instanceBranding).onboarding?.tutorial?.version || 1))),
   4495     tutorialCompletedVersion: Math.max(0, Math.floor(Number(src.tutorialCompletedVersion || 0))),
   4496     selectedRoleIds: Array.isArray(src.selectedRoleIds) ? src.selectedRoleIds.map((x) => String(x || "").trim().toLowerCase()).filter(Boolean).slice(0, 64) : [],
   4497     needsAcceptance: Boolean(src.needsAcceptance),
   4498   };
   4499 }
   4500 
   4501 function loadUserAppearanceOverride() {
   4502   try {
   4503     const raw = localStorage.getItem(USER_APPEARANCE_KEY);
   4504     if (!raw) return null;
   4505     const parsed = JSON.parse(raw);
   4506     if (!parsed || typeof parsed !== "object") return null;
   4507     return normalizeInstanceBranding({ appearance: parsed }).appearance;
   4508   } catch {
   4509     return null;
   4510   }
   4511 }
   4512 
   4513 function saveUserAppearanceOverride(appearance) {
   4514   try {
   4515     localStorage.setItem(USER_APPEARANCE_KEY, JSON.stringify(normalizeInstanceBranding({ appearance }).appearance));
   4516   } catch {
   4517     // ignore
   4518   }
   4519 }
   4520 
   4521 function clearUserAppearanceOverride() {
   4522   try {
   4523     localStorage.removeItem(USER_APPEARANCE_KEY);
   4524   } catch {
   4525     // ignore
   4526   }
   4527 }
   4528 
   4529 function effectiveAppearanceForUi() {
   4530   const base = normalizeInstanceBranding(instanceBranding).appearance || {};
   4531   if (!userAppearanceOverride || typeof userAppearanceOverride !== "object") return base;
   4532   return normalizeInstanceBranding({ appearance: { ...base, ...userAppearanceOverride } }).appearance;
   4533 }
   4534 
   4535 function setAppearanceStatus(msg) {
   4536   if (!(appearanceStatusEl instanceof HTMLElement)) return;
   4537   appearanceStatusEl.textContent = String(msg || "");
   4538 }
   4539 
   4540 function syncAppearanceControlsFromCurrent() {
   4541   const a = effectiveAppearanceForUi();
   4542   if (appearanceBgEl) appearanceBgEl.value = a.bg || "#060611";
   4543   if (appearancePanelEl) appearancePanelEl.value = a.panel || "#0c0c18";
   4544   if (appearanceTextEl) appearanceTextEl.value = a.text || "#f6f0ff";
   4545   if (appearanceAccentEl) appearanceAccentEl.value = a.accent || "#ff3ea5";
   4546   if (appearanceAccent2El) appearanceAccent2El.value = a.accent2 || "#b84bff";
   4547   if (appearanceGoodEl) appearanceGoodEl.value = a.good || "#3ddc97";
   4548   if (appearanceBadEl) appearanceBadEl.value = a.bad || "#ff4d8a";
   4549   if (appearanceMutedPctEl) appearanceMutedPctEl.value = String(Number(a.mutedPct ?? 65));
   4550   if (appearanceLinePctEl) appearanceLinePctEl.value = String(Number(a.linePct ?? 10));
   4551   if (appearancePanel2PctEl) appearancePanel2PctEl.value = String(Number(a.panel2Pct ?? 2));
   4552   if (appearanceFontBodyEl) appearanceFontBodyEl.value = a.fontBody || "system";
   4553   if (appearanceFontMonoEl) appearanceFontMonoEl.value = a.fontMono || "mono";
   4554 }
   4555 
   4556 function readAppearanceFromControls() {
   4557   return normalizeInstanceBranding({
   4558     appearance: {
   4559       bg: String(appearanceBgEl?.value || "").trim(),
   4560       panel: String(appearancePanelEl?.value || "").trim(),
   4561       text: String(appearanceTextEl?.value || "").trim(),
   4562       accent: String(appearanceAccentEl?.value || "").trim(),
   4563       accent2: String(appearanceAccent2El?.value || "").trim(),
   4564       good: String(appearanceGoodEl?.value || "").trim(),
   4565       bad: String(appearanceBadEl?.value || "").trim(),
   4566       fontBody: String(appearanceFontBodyEl?.value || "system").trim(),
   4567       fontMono: String(appearanceFontMonoEl?.value || "mono").trim(),
   4568       mutedPct: String(appearanceMutedPctEl?.value || "").trim(),
   4569       linePct: String(appearanceLinePctEl?.value || "").trim(),
   4570       panel2Pct: String(appearancePanel2PctEl?.value || "").trim(),
   4571     },
   4572   }).appearance;
   4573 }
   4574 
   4575 function initAppearanceControls() {
   4576   if (!(appearancePresetEl instanceof HTMLSelectElement)) return;
   4577   if (appearancePresetEl.dataset.ready === "1") return;
   4578   appearancePresetEl.dataset.ready = "1";
   4579   appearancePresetEl.innerHTML = `<option value="">(choose...)</option>${groupedThemePresetOptionsHtml()}`;
   4580   userAppearanceOverride = loadUserAppearanceOverride();
   4581   syncAppearanceControlsFromCurrent();
   4582   applyInstanceAppearance();
   4583 
   4584   appearanceApplyPresetBtn?.addEventListener("click", () => {
   4585     const id = String(appearancePresetEl.value || "").trim();
   4586     const preset = THEME_PRESETS.find((p) => p.id === id) || null;
   4587     if (!preset) return;
   4588     const a = normalizeInstanceBranding({ appearance: preset.appearance || {} }).appearance;
   4589     userAppearanceOverride = a;
   4590     syncAppearanceControlsFromCurrent();
   4591     applyInstanceAppearance(a);
   4592     setAppearanceStatus(`Preset "${preset.name}" applied (preview).`);
   4593   });
   4594 
   4595   appearanceResetPreviewBtn?.addEventListener("click", () => {
   4596     userAppearanceOverride = loadUserAppearanceOverride();
   4597     syncAppearanceControlsFromCurrent();
   4598     applyInstanceAppearance();
   4599     setAppearanceStatus("Reset to current saved look.");
   4600   });
   4601 
   4602   appearanceSaveBtn?.addEventListener("click", () => {
   4603     const a = readAppearanceFromControls();
   4604     userAppearanceOverride = a;
   4605     saveUserAppearanceOverride(a);
   4606     applyInstanceAppearance();
   4607     setAppearanceStatus("Saved personal look.");
   4608   });
   4609 
   4610   appearanceClearBtn?.addEventListener("click", () => {
   4611     userAppearanceOverride = null;
   4612     clearUserAppearanceOverride();
   4613     syncAppearanceControlsFromCurrent();
   4614     applyInstanceAppearance();
   4615     setAppearanceStatus("Using server default look.");
   4616   });
   4617 
   4618   const previewInputs = [
   4619     appearanceBgEl,
   4620     appearancePanelEl,
   4621     appearanceTextEl,
   4622     appearanceAccentEl,
   4623     appearanceAccent2El,
   4624     appearanceGoodEl,
   4625     appearanceBadEl,
   4626     appearanceMutedPctEl,
   4627     appearanceLinePctEl,
   4628     appearancePanel2PctEl,
   4629     appearanceFontBodyEl,
   4630     appearanceFontMonoEl,
   4631   ];
   4632   for (const input of previewInputs) {
   4633     input?.addEventListener("input", () => {
   4634       const a = readAppearanceFromControls();
   4635       userAppearanceOverride = a;
   4636       applyInstanceAppearance(a);
   4637       setAppearanceStatus("Previewing changes. Click Save look to keep.");
   4638     });
   4639     input?.addEventListener("change", () => {
   4640       const a = readAppearanceFromControls();
   4641       userAppearanceOverride = a;
   4642       applyInstanceAppearance(a);
   4643       setAppearanceStatus("Previewing changes. Click Save look to keep.");
   4644     });
   4645   }
   4646 }
   4647 
   4648 function applyInstanceAppearance(appearanceOverride = null) {
   4649   const override =
   4650     appearanceOverride && typeof appearanceOverride === "object"
   4651       ? appearanceOverride
   4652       : userAppearanceOverride && typeof userAppearanceOverride === "object"
   4653         ? userAppearanceOverride
   4654         : null;
   4655   const b = normalizeInstanceBranding(override ? { ...instanceBranding, appearance: { ...(instanceBranding?.appearance || {}), ...override } } : instanceBranding);
   4656   const a = b.appearance || {};
   4657   const fontStacks = {
   4658     system:
   4659       'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"',
   4660     serif: 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif',
   4661     mono: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
   4662     humanist: '"Trebuchet MS", "Segoe UI", Tahoma, Verdana, sans-serif',
   4663     rounded: '"Avenir Next Rounded", "Arial Rounded MT Bold", "Nunito", "Quicksand", "Trebuchet MS", sans-serif',
   4664     condensed: '"Roboto Condensed", "Arial Narrow", "Liberation Sans Narrow", "Helvetica Neue Condensed", sans-serif',
   4665     slab: '"Rockwell", "Roboto Slab", "Bitter", "Courier New", serif',
   4666     clean: '"Inter", "Public Sans", "Noto Sans", "Segoe UI", sans-serif',
   4667     lcd: '"Orbitron", "Eurostile", "Bank Gothic", "OCR A Std", "Consolas", "Courier New", monospace',
   4668   };
   4669   const fontBodyStack = fontStacks[a.fontBody] || fontStacks.system;
   4670   const fontMonoStack = fontStacks[a.fontMono] || fontStacks.mono;
   4671   document.documentElement.style.setProperty("--bg", a.bg || "#060611");
   4672   document.documentElement.style.setProperty("--panel", a.panel || "#0c0c18");
   4673   document.documentElement.style.setProperty("--text", a.text || "#f6f0ff");
   4674   document.documentElement.style.setProperty("--accent", a.accent || "#ff3ea5");
   4675   document.documentElement.style.setProperty("--accent2", a.accent2 || "#b84bff");
   4676   document.documentElement.style.setProperty("--good", a.good || "#3ddc97");
   4677   document.documentElement.style.setProperty("--bad", a.bad || "#ff4d8a");
   4678   document.documentElement.style.setProperty("--font-body", fontBodyStack);
   4679   document.documentElement.style.setProperty("--font-mono", fontMonoStack);
   4680   document.documentElement.style.setProperty("--muted-pct", String(Number(a.mutedPct ?? 65)));
   4681   document.documentElement.style.setProperty("--line-pct", String(Number(a.linePct ?? 10)));
   4682   document.documentElement.style.setProperty("--panel2-pct", String(Number(a.panel2Pct ?? 2)));
   4683   if (appearancePresetEl instanceof HTMLSelectElement) syncAppearanceControlsFromCurrent();
   4684 }
   4685 
   4686 function renderInstanceBranding() {
   4687   const b = normalizeInstanceBranding(instanceBranding);
   4688   if (instanceTitleEl) instanceTitleEl.textContent = b.title;
   4689   if (instanceSubtitleEl) instanceSubtitleEl.textContent = b.subtitle;
   4690 }
   4691 
   4692 function renderPoweredByVersion() {
   4693   if (!poweredByVersionEl) return;
   4694   const version = String(serverHealth?.version || "").trim();
   4695   poweredByVersionEl.textContent = version ? `v${version}` : "";
   4696 }
   4697 
   4698 function formatLocalTime(ts) {
   4699   const n = Number(ts || 0);
   4700   if (!n) return "";
   4701   try {
   4702     return new Date(n).toLocaleString();
   4703   } catch {
   4704     return "";
   4705   }
   4706 }
   4707 
   4708 async function requestServerInfo() {
   4709   if (serverInfoStatus.loading) return;
   4710   serverInfoStatus = { loading: true, at: Date.now(), error: "" };
   4711   renderModPanel();
   4712   try {
   4713     const [infoRes, healthRes] = await Promise.all([
   4714       fetch("/api/info", { cache: "no-store" }),
   4715       fetch("/api/health", { cache: "no-store" })
   4716     ]);
   4717     if (!infoRes.ok) throw new Error(`Failed to load /api/info (${infoRes.status})`);
   4718     if (!healthRes.ok) throw new Error(`Failed to load /api/health (${healthRes.status})`);
   4719     serverInfo = await infoRes.json();
   4720     serverHealth = await healthRes.json();
   4721     serverInfoStatus = { loading: false, at: Date.now(), error: "" };
   4722     renderPoweredByVersion();
   4723     renderModPanel();
   4724   } catch (e) {
   4725     serverInfoStatus = { loading: false, at: Date.now(), error: e?.message || "Failed to load server info." };
   4726     renderPoweredByVersion();
   4727     renderModPanel();
   4728   }
   4729 }
   4730 
   4731 function normalizeDmThread(raw) {
   4732   if (!raw || typeof raw !== "object") return null;
   4733   const id = String(raw.id || "").trim();
   4734   const other = String(raw.other || "").trim().toLowerCase();
   4735   const status = String(raw.status || "").trim();
   4736   if (!id || !other) return null;
   4737   return {
   4738     id,
   4739     other,
   4740     status: status || "unknown",
   4741     requestedBy: String(raw.requestedBy || ""),
   4742     pendingFor: String(raw.pendingFor || ""),
   4743     createdAt: Number(raw.createdAt || 0),
   4744     updatedAt: Number(raw.updatedAt || 0),
   4745     lastMessageAt: Number(raw.lastMessageAt || 0),
   4746   };
   4747 }
   4748 
   4749 function normalizeDmMessage(raw) {
   4750   if (!raw || typeof raw !== "object") return null;
   4751   const id = String(raw.id || "").trim();
   4752   if (!id) return null;
   4753   return {
   4754     id,
   4755     fromUser: String(raw.fromUser || raw.from || "").trim().toLowerCase(),
   4756     asMod: Boolean(raw.asMod) || String(raw.fromUser || raw.from || "").trim().toLowerCase() === "mod",
   4757     createdAt: Number(raw.createdAt || 0),
   4758     text: typeof raw.text === "string" ? raw.text : "",
   4759     html: typeof raw.html === "string" ? raw.html : "",
   4760   };
   4761 }
   4762 
   4763 function dmActivityAt(thread) {
   4764   if (!thread) return 0;
   4765   return Math.max(Number(thread.lastMessageAt || 0), Number(thread.updatedAt || 0), Number(thread.createdAt || 0));
   4766 }
   4767 
   4768 function shortTimeAgo(ts) {
   4769   const t = Number(ts || 0);
   4770   if (!Number.isFinite(t) || t <= 0) return "";
   4771   const deltaMs = Math.max(0, Date.now() - t);
   4772   const mins = Math.floor(deltaMs / 60000);
   4773   if (mins < 1) return "now";
   4774   if (mins < 60) return `${mins}m`;
   4775   const hours = Math.floor(mins / 60);
   4776   if (hours < 24) return `${hours}h`;
   4777   const days = Math.floor(hours / 24);
   4778   return `${days}d`;
   4779 }
   4780 
   4781 function postChatActivityAt(postId, post) {
   4782   const id = String(postId || "").trim();
   4783   const list = id ? chatByPost.get(id) : null;
   4784   const lastChatAt =
   4785     Array.isArray(list) && list.length
   4786       ? Math.max(
   4787           ...list.map((m) => Math.max(Number(m?.createdAt || 0), Number(m?.editedAt || 0), Number(m?.deletedAt || 0)))
   4788         )
   4789       : 0;
   4790   return Math.max(lastChatAt, Number(post?.createdAt || 0), Number(post?.updatedAt || 0));
   4791 }
   4792 
   4793 function pushRecentUnique(list, id, limit = CHAT_RECENTS_LIMIT) {
   4794   const value = String(id || "").trim();
   4795   if (!value) return list;
   4796   const next = [value, ...list.filter((x) => x !== value)];
   4797   if (next.length > limit) next.length = limit;
   4798   return next;
   4799 }
   4800 
   4801 function touchRecentHiveChat(postId) {
   4802   const id = String(postId || "").trim();
   4803   if (!id) return;
   4804   recentHiveChatIds = pushRecentUnique(recentHiveChatIds, id);
   4805 }
   4806 
   4807 function touchRecentDmChat(threadId) {
   4808   const id = String(threadId || "").trim();
   4809   if (!id) return;
   4810   recentDmChatThreadIds = pushRecentUnique(recentDmChatThreadIds, id);
   4811 }
   4812 
   4813 function activeDmThreadsSorted() {
   4814   return dmThreads
   4815     .filter((t) => t && String(t.status || "") === "active")
   4816     .sort((a, b) => dmActivityAt(b) - dmActivityAt(a));
   4817 }
   4818 
   4819 function blurFocusedChatComposer() {
   4820   const activeEl = document.activeElement;
   4821   if (!(activeEl instanceof HTMLElement)) return;
   4822   if (activeEl === chatEditor || activeEl.closest?.(".chatEditor")) activeEl.blur();
   4823 }
   4824 
   4825 function openChatContextValue(rawValue, opts = null) {
   4826   const raw = String(rawValue || "").trim();
   4827   if (!raw) return false;
   4828   const options = opts && typeof opts === "object" ? opts : {};
   4829   const preserveFocus = Boolean(options.preserveFocus);
   4830   if (raw.startsWith("dm:")) {
   4831     const id = raw.slice(3);
   4832     if (!id) return false;
   4833     openDmThread(id, { preserveFocus });
   4834     return true;
   4835   }
   4836   if (raw.startsWith("post:")) {
   4837     const id = raw.slice(5);
   4838     if (!id) return false;
   4839     openChat(id, { preserveFocus });
   4840     return true;
   4841   }
   4842   return false;
   4843 }
   4844 
   4845 function renderChatContextSelect() {
   4846   if (!(chatContextSelectEl instanceof HTMLSelectElement)) return;
   4847   const prevValue = String(chatContextSelectEl.value || "").trim();
   4848   const dmThreadsActive = activeDmThreadsSorted();
   4849   const dmById = new Map(dmThreadsActive.map((t) => [t.id, t]));
   4850   recentDmChatThreadIds = recentDmChatThreadIds.filter((id) => dmById.has(id));
   4851   const dmRecent = [activeDmThreadId, ...recentDmChatThreadIds]
   4852     .map((id) => dmById.get(String(id || "")))
   4853     .filter(Boolean)
   4854     .filter((t, i, arr) => arr.findIndex((x) => x.id === t.id) === i);
   4855 
   4856   const postsById = new Map(Array.from(posts.values()).map((p) => [String(p.id), p]));
   4857   const openPanelPostIds = Array.from(chatPanelInstances.values())
   4858     .map((inst) => String(inst?.postId || "").trim())
   4859     .filter(Boolean);
   4860   recentHiveChatIds = recentHiveChatIds.filter((id) => {
   4861     const p = postsById.get(String(id));
   4862     return Boolean(p && !p.deleted);
   4863   });
   4864   const knownChatPostIds = Array.from(chatByPost.keys()).map((id) => String(id || "").trim()).filter(Boolean);
   4865   const postRecent = [activeChatPostId, ...openPanelPostIds, ...recentHiveChatIds, ...knownChatPostIds]
   4866     .map((id) => postsById.get(String(id || "")))
   4867     .filter((p) => p && !p.deleted)
   4868     .filter((p, i, arr) => arr.findIndex((x) => String(x.id) === String(p.id)) === i);
   4869 
   4870   const hasAny = Boolean(dmRecent.length || postRecent.length || activeDmThreadId || activeChatPostId);
   4871   if (!hasAny) {
   4872     chatContextSelectEl.classList.add("hidden");
   4873     chatContextSelectEl.innerHTML = "";
   4874     return;
   4875   }
   4876 
   4877   const activeDmValue = activeDmThreadId ? `dm:${activeDmThreadId}` : "";
   4878   const activePostValue = activeChatPostId ? `post:${activeChatPostId}` : "";
   4879   const selected = activeDmValue || activePostValue || prevValue;
   4880 
   4881   syncingChatContextSelect = true;
   4882   chatContextSelectEl.classList.remove("hidden");
   4883   chatContextSelectEl.replaceChildren();
   4884   const topPlaceholder = document.createElement("option");
   4885   topPlaceholder.value = "";
   4886   topPlaceholder.textContent = "Open chats...";
   4887   chatContextSelectEl.appendChild(topPlaceholder);
   4888 
   4889   if (dmRecent.length) {
   4890     const dmGroup = document.createElement("optgroup");
   4891     dmGroup.label = "DMs";
   4892     for (const thread of dmRecent) {
   4893       const opt = document.createElement("option");
   4894       opt.value = `dm:${String(thread.id || "").trim()}`;
   4895       const when = shortTimeAgo(dmActivityAt(thread));
   4896       opt.textContent = `@${String(thread.other || "unknown")}${when ? ` β€’ ${when}` : ""}`;
   4897       dmGroup.appendChild(opt);
   4898     }
   4899     chatContextSelectEl.appendChild(dmGroup);
   4900   }
   4901 
   4902   if (postRecent.length) {
   4903     const postGroup = document.createElement("optgroup");
   4904     postGroup.label = "Hive Chats";
   4905     for (const post of postRecent) {
   4906       const postId = String(post.id || "").trim();
   4907       if (!postId) continue;
   4908       const opt = document.createElement("option");
   4909       opt.value = `post:${postId}`;
   4910       const unread = Number(unreadByPostId.get(postId) || 0);
   4911       const unreadLabel = unread > 0 ? ` (${unread})` : "";
   4912       const when = shortTimeAgo(postChatActivityAt(postId, post));
   4913       const mode = normalizePostMode(post.mode || post.chatMode || "");
   4914       const streamLabel = mode === "stream" ? " [stream]" : "";
   4915       opt.textContent = `${postTitle(post)}${streamLabel}${unreadLabel}${when ? ` β€’ ${when}` : ""}${post.author ? ` - @${String(post.author || "")}` : ""}`;
   4916       postGroup.appendChild(opt);
   4917     }
   4918     chatContextSelectEl.appendChild(postGroup);
   4919   }
   4920 
   4921   chatContextSelectEl.value =
   4922     selected && chatContextSelectEl.querySelector(`option[value="${cssEscape(selected)}"]`) ? selected : "";
   4923   syncingChatContextSelect = false;
   4924 }
   4925 
   4926 function setDmThreads(list) {
   4927   dmThreads = Array.isArray(list) ? list.map(normalizeDmThread).filter(Boolean) : [];
   4928   dmThreadsById = new Map(dmThreads.map((t) => [t.id, t]));
   4929   if (pendingOpenDmThreadId) {
   4930     const pending = dmThreadsById.get(pendingOpenDmThreadId) || null;
   4931     if (pending && String(pending.status || "") === "active") {
   4932       openDmThread(pending.id);
   4933     }
   4934   }
   4935   if (activeDmThreadId && !dmThreadsById.has(activeDmThreadId)) {
   4936     activeDmThreadId = null;
   4937   }
   4938   renderPeoplePanel();
   4939   renderChatPanel();
   4940 }
   4941 
   4942 function applyChatDock() {
   4943   if (!appRoot) return;
   4944   appRoot.classList.toggle("chatRight", chatDock === "right");
   4945 }
   4946 
   4947 function upsertDmThread(rawThread) {
   4948   const t = normalizeDmThread(rawThread);
   4949   if (!t) return;
   4950   dmThreadsById.set(t.id, t);
   4951   dmThreads = dmThreads.filter((x) => x.id !== t.id);
   4952   dmThreads.push(t);
   4953   dmThreads.sort((a, b) => dmActivityAt(b) - dmActivityAt(a));
   4954   renderPeoplePanel();
   4955   renderChatPanel();
   4956 }
   4957 
   4958 function setModModalOpen(open) {
   4959   if (!modModal) return;
   4960   modModal.classList.toggle("hidden", !open);
   4961   if (!open) {
   4962     modModalContext = null;
   4963     if (modModalBody) modModalBody.innerHTML = "";
   4964     if (modModalStatus) modModalStatus.textContent = "";
   4965     if (modModalPrimary) modModalPrimary.classList.remove("hidden");
   4966   }
   4967 }
   4968 
   4969 function setMediaModalOpen(open) {
   4970   if (!mediaModal) return;
   4971   mediaModal.classList.toggle("hidden", !open);
   4972   if (!open) {
   4973     if (mediaModalImg) mediaModalImg.src = "";
   4974     if (mediaModalOpenLink) mediaModalOpenLink.href = "#";
   4975     if (mediaModalStatus) mediaModalStatus.textContent = "";
   4976     if (mediaModalTitle) mediaModalTitle.textContent = "Media";
   4977   }
   4978 }
   4979 
   4980 function setShortcutHelpOpen(open) {
   4981   if (!shortcutHelpModal) return;
   4982   shortcutHelpModal.classList.toggle("hidden", !open);
   4983 }
   4984 
   4985 function openMediaModal(url) {
   4986   const src = String(url || "").trim();
   4987   if (!src) return;
   4988   if (!mediaModalImg) return;
   4989   mediaModalImg.src = src;
   4990   if (mediaModalOpenLink) mediaModalOpenLink.href = src;
   4991   if (mediaModalStatus) mediaModalStatus.textContent = "";
   4992   setMediaModalOpen(true);
   4993 }
   4994 
   4995 function gateTokenLabel(token) {
   4996   const t = String(token || "").trim().toLowerCase();
   4997   if (!t) return { label: "", color: "" };
   4998   if (t === "owner" || t === "moderator" || t === "member") return { label: t, color: "" };
   4999   if (t.startsWith("role:")) {
   5000     const key = t.slice("role:".length);
   5001     const def = roleDefByKey(key);
   5002     if (def) return { label: def.label || `role:${key}`, color: def.color || "" };
   5003     return { label: `role:${key}`, color: "" };
   5004   }
   5005   return { label: t, color: "" };
   5006 }
   5007 
   5008 function openCollectionGateModal(collectionId) {
   5009   if (!canModerate) return;
   5010   const id = String(collectionId || "");
   5011   const col = collections.find((c) => c.id === id);
   5012   if (!col) {
   5013     toast("Collections", "Collection not found.");
   5014     return;
   5015   }
   5016   modModalContext = { kind: "collectionGate", collectionId: id };
   5017   if (modModalTitle) modModalTitle.textContent = `Gate /${col.name || col.id}`;
   5018   if (modModalStatus) modModalStatus.textContent = "";
   5019   if (modModalPrimary) modModalPrimary.textContent = "Save";
   5020 
   5021   const visibility = col.visibility === "gated" ? "gated" : "public";
   5022   const allowed = new Set(Array.isArray(col.allowedRoles) ? col.allowedRoles : []);
   5023   const tokens = availableGateTokens();
   5024 
   5025   const optionsHtml = tokens
   5026     .map((token) => {
   5027       const meta = gateTokenLabel(token);
   5028       const swatch = meta.color ? `<span class="roleSwatch" style="background:${escapeHtml(meta.color)}"></span>` : "";
   5029       const checked = allowed.has(token) ? "checked" : "";
   5030       return `<label class="gateOption">
   5031         <span class="gateOptionLeft">${swatch}<span>${escapeHtml(meta.label)}</span></span>
   5032         <input type="checkbox" data-gatetoken="${escapeHtml(token)}" ${checked} />
   5033       </label>`;
   5034     })
   5035     .join("");
   5036 
   5037   if (modModalBody) {
   5038     modModalBody.innerHTML = `
   5039       <div class="row" style="gap:12px;align-items:center">
   5040         <label class="gateOption" style="flex:1">
   5041           <span>Public</span>
   5042           <input type="radio" name="gateVisibility" value="public" ${visibility === "public" ? "checked" : ""} />
   5043         </label>
   5044         <label class="gateOption" style="flex:1">
   5045           <span>Gated</span>
   5046           <input type="radio" name="gateVisibility" value="gated" ${visibility === "gated" ? "checked" : ""} />
   5047         </label>
   5048       </div>
   5049       <div class="small muted">If gated, pick one or more roles that can view this collection.</div>
   5050       <div class="gateList" id="gateListWrap">${optionsHtml || `<div class="muted">No roles available.</div>`}</div>
   5051     `;
   5052   }
   5053   setModModalOpen(true);
   5054   updateGateModalVisibility();
   5055 }
   5056 
   5057 function openUserRolesModal(username) {
   5058   if (!canModerate) return;
   5059   const target = String(username || "").toLowerCase();
   5060   if (!target) return;
   5061   const member = (peopleMembers || []).find((m) => m && m.username === target);
   5062   const assigned = new Set(Array.isArray(member?.customRoles) ? member.customRoles : []);
   5063   modModalContext = { kind: "userRoles", username: target };
   5064   if (modModalTitle) modModalTitle.textContent = `Custom roles for @${target}`;
   5065   if (modModalPrimary) modModalPrimary.classList.add("hidden");
   5066   if (modModalStatus) modModalStatus.textContent = "Toggles apply immediately.";
   5067 
   5068   const rows = customRoles.length
   5069     ? customRoles
   5070         .map((r) => {
   5071           const checked = assigned.has(r.key) ? "checked" : "";
   5072           const swatch = r.color ? `<span class="roleSwatch" style="background:${escapeHtml(r.color)}"></span>` : "";
   5073           return `<label class="gateOption">
   5074             <span class="gateOptionLeft">${swatch}<span>${escapeHtml(r.label)}</span> <span class="roleKey">${escapeHtml(
   5075               r.key
   5076             )}</span></span>
   5077             <input type="checkbox" data-userrolekey="${escapeHtml(r.key)}" ${checked} />
   5078           </label>`;
   5079         })
   5080         .join("")
   5081     : `<div class="muted">No custom roles created yet.</div>`;
   5082 
   5083   if (modModalBody) modModalBody.innerHTML = `<div class="gateList">${rows}</div>`;
   5084   setModModalOpen(true);
   5085 }
   5086 
   5087 function openCollectionCreateModal() {
   5088   if (!canModerate) return;
   5089   modModalContext = { kind: "collectionCreate" };
   5090   if (modModalTitle) modModalTitle.textContent = "Create collection";
   5091   if (modModalPrimary) modModalPrimary.textContent = "Create";
   5092   if (modModalStatus) modModalStatus.textContent = "";
   5093   if (modModalBody) {
   5094     modModalBody.innerHTML = `
   5095       <label>
   5096         <span>Name</span>
   5097         <input id="modModalCollectionName" maxlength="40" placeholder="Example: music" />
   5098       </label>
   5099       <div class="small muted">Collections appear as tabs and can be gated.</div>
   5100     `;
   5101   }
   5102   setModModalOpen(true);
   5103   setTimeout(() => document.getElementById("modModalCollectionName")?.focus(), 0);
   5104 }
   5105 
   5106 function updateGateModalVisibility() {
   5107   const listWrap = document.getElementById("gateListWrap");
   5108   if (!listWrap) return;
   5109   const v = String(modModalBody?.querySelector("input[name='gateVisibility']:checked")?.value || "public");
   5110   listWrap.classList.toggle("hidden", v !== "gated");
   5111 }
   5112 
   5113 function getSessionToken() {
   5114   try {
   5115     return localStorage.getItem(SESSION_TOKEN_KEY) || "";
   5116   } catch {
   5117     return "";
   5118   }
   5119 }
   5120 
   5121 function setSessionToken(token) {
   5122   try {
   5123     if (!token) localStorage.removeItem(SESSION_TOKEN_KEY);
   5124     else localStorage.setItem(SESSION_TOKEN_KEY, token);
   5125   } catch {
   5126     // ignore
   5127   }
   5128 }
   5129 
   5130 function fallbackPeopleFromProfiles() {
   5131   const out = [];
   5132   for (const [username, p] of Object.entries(profiles || {})) {
   5133     if (!username) continue;
   5134     out.push({
   5135       username,
   5136       image: typeof p?.image === "string" ? p.image : "",
   5137       color: typeof p?.color === "string" ? p.color : "",
   5138       role: "member",
   5139       online: false,
   5140       status: "offline"
   5141     });
   5142   }
   5143   if (loggedInUser && !out.some((m) => m.username === loggedInUser)) {
   5144     const me = getProfile(loggedInUser);
   5145     out.push({
   5146       username: loggedInUser,
   5147       image: me.image || "",
   5148       color: me.color || "",
   5149       role: loggedInRole || "member",
   5150       online: true,
   5151       status: "online"
   5152     });
   5153   }
   5154   out.sort((a, b) => a.username.localeCompare(b.username));
   5155   return out;
   5156 }
   5157 
   5158 function ensurePeopleFallback() {
   5159   if (Array.isArray(peopleMembers) && peopleMembers.length > 0) return;
   5160   peopleMembers = fallbackPeopleFromProfiles();
   5161 }
   5162 
   5163 const toastHost = (() => {
   5164   const el = document.createElement("div");
   5165   el.className = "toastHost";
   5166   document.body.appendChild(el);
   5167   return el;
   5168 })();
   5169 
   5170 /** @type {Set<string>} */
   5171 const newPostAnimIds = new Set();
   5172 /** @type {Map<string, number>} */
   5173 const buzzTimers = new Map();
   5174 const TTL_PRESET_VALUES = [5, 30, 60, 120, 720, 1440];
   5175 
   5176 function syncProtectedUi() {
   5177   if (!isProtectedEl || !postPasswordEl) return;
   5178   const on = Boolean(isProtectedEl.checked);
   5179   postPasswordEl.disabled = !on;
   5180   if (!on) postPasswordEl.value = "";
   5181 }
   5182 
   5183 function syncComposerModeUi() {
   5184   const mode = normalizePostMode(postModeEl?.value || "text");
   5185   if (postModeEl && postModeEl.value !== mode) postModeEl.value = mode;
   5186   const showStreamKind = mode === "stream";
   5187   if (streamKindRowEl) streamKindRowEl.classList.toggle("hidden", !showStreamKind);
   5188   if (streamKindEl) streamKindEl.value = normalizeStreamKind(streamKindEl.value || "webcam");
   5189 }
   5190 
   5191 function userCanCreatePermanentHive() {
   5192   return Boolean(loggedInUser) && (isStaffRole(loggedInRole) || Boolean(normalizeInstanceBranding(instanceBranding).allowMemberPermanentPosts));
   5193 }
   5194 
   5195 function syncTtlUiFromMinutes() {
   5196   const minutes = Number(ttlMinutesEl?.value || 60);
   5197   const canPermanent = userCanCreatePermanentHive();
   5198   const isPermanent = Number.isFinite(minutes) && minutes <= 0 && canPermanent;
   5199   if (ttlPermanentEl instanceof HTMLInputElement) ttlPermanentEl.checked = isPermanent;
   5200   if (ttlPresetEl instanceof HTMLSelectElement) {
   5201     const exact = TTL_PRESET_VALUES.find((v) => v === Math.floor(minutes));
   5202     if (exact) ttlPresetEl.value = String(exact);
   5203   }
   5204 }
   5205 
   5206 syncProtectedUi();
   5207 isProtectedEl?.addEventListener("change", () => {
   5208   syncProtectedUi();
   5209   if (isProtectedEl?.checked) postPasswordEl?.focus();
   5210 });
   5211 syncComposerModeUi();
   5212 postModeEl?.addEventListener("change", () => syncComposerModeUi());
   5213 ttlPresetEl?.addEventListener("change", () => {
   5214   const next = Number(ttlPresetEl.value || 60);
   5215   if (Number.isFinite(next) && ttlMinutesEl instanceof HTMLInputElement) ttlMinutesEl.value = String(Math.max(1, Math.min(2880, Math.floor(next))));
   5216   if (ttlPermanentEl instanceof HTMLInputElement) ttlPermanentEl.checked = false;
   5217 });
   5218 ttlPermanentEl?.addEventListener("change", () => {
   5219   if (!(ttlMinutesEl instanceof HTMLInputElement)) return;
   5220   if (ttlPermanentEl.checked && userCanCreatePermanentHive()) {
   5221     ttlMinutesEl.value = "0";
   5222     return;
   5223   }
   5224   if (Number(ttlMinutesEl.value || 0) <= 0) ttlMinutesEl.value = String(Number(ttlPresetEl?.value || 60) || 60);
   5225 });
   5226 ttlMinutesEl?.addEventListener("input", () => syncTtlUiFromMinutes());
   5227 
   5228 function setSidebarHidden(hidden) {
   5229   if (!appRoot) return;
   5230   appRoot.classList.toggle("sidebarHidden", hidden);
   5231   if (toggleSidebarBtn) {
   5232     toggleSidebarBtn.textContent = "Hide";
   5233     toggleSidebarBtn.title = "Hide sidebar";
   5234   }
   5235   if (showSidebarBtn) {
   5236     showSidebarBtn.classList.toggle("hidden", !hidden);
   5237     showSidebarBtn.textContent = "Show";
   5238     showSidebarBtn.title = "Show sidebar";
   5239   }
   5240   try {
   5241     localStorage.setItem("bzl_sidebarHidden", hidden ? "1" : "0");
   5242   } catch {
   5243     // ignore
   5244   }
   5245 }
   5246 
   5247 function getSidebarHidden() {
   5248   try {
   5249     return localStorage.getItem("bzl_sidebarHidden") === "1";
   5250   } catch {
   5251     return false;
   5252   }
   5253 }
   5254 
   5255 function setPeopleOpen(open) {
   5256   const inRackMode = Boolean(appRoot?.classList.contains("rackMode"));
   5257   peopleOpen = inRackMode ? true : Boolean(open);
   5258   if (!peopleDrawerEl) return;
   5259   // In rack mode, Members list is anchored to the right rail.
   5260   peopleDrawerEl.classList.toggle("hidden", !peopleOpen && !inRackMode);
   5261   if (togglePeopleBtn) {
   5262     if (inRackMode) {
   5263       togglePeopleBtn.classList.add("hidden");
   5264     } else {
   5265       togglePeopleBtn.classList.remove("hidden");
   5266       togglePeopleBtn.textContent = peopleOpen ? "Hide members" : "Members";
   5267       togglePeopleBtn.title = peopleOpen ? "Hide members list" : "Show members list";
   5268     }
   5269   }
   5270   if (peopleOpen && ws.readyState === WebSocket.OPEN) {
   5271     ws.send(JSON.stringify({ type: "peopleList" }));
   5272   }
   5273   if (inRackMode) return;
   5274   try {
   5275     localStorage.setItem("bzl_peopleOpen", peopleOpen ? "1" : "0");
   5276   } catch {
   5277     // ignore
   5278   }
   5279 }
   5280 
   5281 function getPeopleOpen() {
   5282   try {
   5283     return localStorage.getItem("bzl_peopleOpen") === "1";
   5284   } catch {
   5285     return false;
   5286   }
   5287 }
   5288 
   5289 function setComposerOpen(open) {
   5290   composerOpen = Boolean(open);
   5291   if (pollinatePanel) pollinatePanel.classList.toggle("composerCollapsed", !composerOpen);
   5292   if (toggleComposerBtn) {
   5293     toggleComposerBtn.textContent = composerOpen ? "Hide Creator" : "New Hive";
   5294     toggleComposerBtn.title = composerOpen ? "Hide hive creator" : "Open hive creator";
   5295   }
   5296   renderCenterPanels();
   5297   updateSideRackEmptyState();
   5298   try {
   5299     localStorage.setItem("bzl_composerOpen", composerOpen ? "1" : "0");
   5300   } catch {
   5301     // ignore
   5302   }
   5303 }
   5304 
   5305 function getComposerOpen() {
   5306   try {
   5307     return localStorage.getItem("bzl_composerOpen") === "1";
   5308   } catch {
   5309     return false;
   5310   }
   5311 }
   5312 
   5313 function readStoredChatWidth() {
   5314   try {
   5315     const raw = Number(localStorage.getItem(CHAT_WIDTH_KEY) || 0);
   5316     return Number.isFinite(raw) && raw > 0 ? raw : CHAT_WIDTH_DEFAULT;
   5317   } catch {
   5318     return CHAT_WIDTH_DEFAULT;
   5319   }
   5320 }
   5321 
   5322 function readStoredSidebarWidth() {
   5323   try {
   5324     const raw = Number(localStorage.getItem(SIDEBAR_WIDTH_KEY) || 0);
   5325     return Number.isFinite(raw) && raw > 0 ? raw : SIDEBAR_WIDTH_DEFAULT;
   5326   } catch {
   5327     return SIDEBAR_WIDTH_DEFAULT;
   5328   }
   5329 }
   5330 
   5331 function readStoredModWidth() {
   5332   try {
   5333     const raw = Number(localStorage.getItem(MOD_WIDTH_KEY) || 0);
   5334     return Number.isFinite(raw) && raw > 0 ? raw : MOD_WIDTH_DEFAULT;
   5335   } catch {
   5336     return MOD_WIDTH_DEFAULT;
   5337   }
   5338 }
   5339 
   5340 function readStoredPeopleWidth() {
   5341   try {
   5342     const raw = Number(localStorage.getItem(PEOPLE_WIDTH_KEY) || 0);
   5343     return Number.isFinite(raw) && raw > 0 ? raw : PEOPLE_WIDTH_DEFAULT;
   5344   } catch {
   5345     return PEOPLE_WIDTH_DEFAULT;
   5346   }
   5347 }
   5348 
   5349 function clampChatWidth(px) {
   5350   const maxByViewport = Math.floor(window.innerWidth * 0.72);
   5351   return Math.max(380, Math.min(maxByViewport, Math.floor(Number(px || CHAT_WIDTH_DEFAULT))));
   5352 }
   5353 
   5354 function clampSidebarWidth(px) {
   5355   const maxByViewport = Math.floor(window.innerWidth * 0.42);
   5356   return Math.max(240, Math.min(maxByViewport, Math.floor(Number(px || SIDEBAR_WIDTH_DEFAULT))));
   5357 }
   5358 
   5359 function clampModWidth(px) {
   5360   const maxByViewport = Math.floor(window.innerWidth * 0.44);
   5361   return Math.max(280, Math.min(maxByViewport, Math.floor(Number(px || MOD_WIDTH_DEFAULT))));
   5362 }
   5363 
   5364 function clampPeopleWidth(px) {
   5365   const maxByViewport = Math.floor(window.innerWidth * 0.62);
   5366   return Math.max(320, Math.min(maxByViewport, Math.floor(Number(px || PEOPLE_WIDTH_DEFAULT))));
   5367 }
   5368 
   5369 function applyChatWidth(px, persist = true) {
   5370   if (!appRoot) return;
   5371   const next = clampChatWidth(px);
   5372   appRoot.style.setProperty("--chat-width", `${next}px`);
   5373   if (persist) {
   5374     try {
   5375       localStorage.setItem(CHAT_WIDTH_KEY, String(next));
   5376     } catch {
   5377       // ignore
   5378     }
   5379   }
   5380 }
   5381 
   5382 function applySidebarWidth(px, persist = true) {
   5383   if (!appRoot) return;
   5384   const next = clampSidebarWidth(px);
   5385   appRoot.style.setProperty("--sidebar-width", `${next}px`);
   5386   if (persist) {
   5387     try {
   5388       localStorage.setItem(SIDEBAR_WIDTH_KEY, String(next));
   5389     } catch {
   5390       // ignore
   5391     }
   5392   }
   5393 }
   5394 
   5395 function applyModWidth(px, persist = true) {
   5396   if (!appRoot) return;
   5397   const next = clampModWidth(px);
   5398   appRoot.style.setProperty("--mod-width", `${next}px`);
   5399   if (persist) {
   5400     try {
   5401       localStorage.setItem(MOD_WIDTH_KEY, String(next));
   5402     } catch {
   5403       // ignore
   5404     }
   5405   }
   5406 }
   5407 
   5408 function applyPeopleWidth(px, persist = true) {
   5409   const next = clampPeopleWidth(px);
   5410   document.documentElement.style.setProperty("--people-width", `${next}px`);
   5411   if (persist) {
   5412     try {
   5413       localStorage.setItem(PEOPLE_WIDTH_KEY, String(next));
   5414     } catch {
   5415       // ignore
   5416     }
   5417   }
   5418 }
   5419 
   5420 function canResizeChatNow() {
   5421   return !isMobileSwipeMode();
   5422 }
   5423 
   5424 function canResizeSidebarNow() {
   5425   return !isMobileSwipeMode();
   5426 }
   5427 
   5428 function canResizeModNow() {
   5429   return !isMobileSwipeMode() && canModerate;
   5430 }
   5431 
   5432 function canResizePeopleNow() {
   5433   return !isMobileSwipeMode();
   5434 }
   5435 
   5436 function stopAnyPanelResize() {
   5437   if (!chatResizeDragging && !sidebarResizeDragging && !modResizeDragging && !peopleResizeDragging) return;
   5438   chatResizeDragging = false;
   5439   sidebarResizeDragging = false;
   5440   modResizeDragging = false;
   5441   peopleResizeDragging = false;
   5442   appRoot?.classList.remove("isResizing");
   5443 }
   5444 
   5445 function startChatResize(clientX) {
   5446   if (!canResizeChatNow() || !chatPanelEl) return false;
   5447   chatResizeDragging = true;
   5448   chatResizeStartX = clientX;
   5449   chatResizeStartWidth = chatPanelEl.getBoundingClientRect().width || readStoredChatWidth();
   5450   appRoot?.classList.add("isResizing");
   5451   return true;
   5452 }
   5453 
   5454 function startSidebarResize(clientX) {
   5455   if (!canResizeSidebarNow() || !sidebarPanelEl || appRoot?.classList.contains("sidebarHidden")) return false;
   5456   sidebarResizeDragging = true;
   5457   sidebarResizeStartX = clientX;
   5458   sidebarResizeStartWidth = sidebarPanelEl.getBoundingClientRect().width || readStoredSidebarWidth();
   5459   appRoot?.classList.add("isResizing");
   5460   return true;
   5461 }
   5462 
   5463 function startModResize(clientX) {
   5464   if (!canResizeModNow() || !modPanelEl || modPanelEl.classList.contains("hidden")) return false;
   5465   modResizeDragging = true;
   5466   modResizeStartX = clientX;
   5467   modResizeStartWidth = modPanelEl.getBoundingClientRect().width || readStoredModWidth();
   5468   appRoot?.classList.add("isResizing");
   5469   return true;
   5470 }
   5471 
   5472 function startPeopleResize(clientX) {
   5473   if (!canResizePeopleNow() || !peopleDrawerEl || peopleDrawerEl.classList.contains("hidden")) return false;
   5474   peopleResizeDragging = true;
   5475   peopleResizeStartX = clientX;
   5476   peopleResizeStartWidth = peopleDrawerEl.getBoundingClientRect().width || readStoredPeopleWidth();
   5477   appRoot?.classList.add("isResizing");
   5478   return true;
   5479 }
   5480 
   5481 function setEditModalOpen(open) {
   5482   if (!editModal) return;
   5483   editModal.classList.toggle("hidden", !open);
   5484   if (editModalStatus) editModalStatus.textContent = "";
   5485   if (!open) {
   5486     editContext = null;
   5487     if (editModalEditor) editModalEditor.innerHTML = "";
   5488     if (editModalPostTitleInput) editModalPostTitleInput.value = "";
   5489     if (editModalPostMeta) editModalPostMeta.classList.add("hidden");
   5490     if (editModalKeywordsInput) editModalKeywordsInput.value = "";
   5491     if (editModalCollectionSelect) editModalCollectionSelect.innerHTML = "";
   5492     if (editModalProtectedToggle) editModalProtectedToggle.checked = false;
   5493     if (editModalModeSelect) editModalModeSelect.value = "text";
   5494     if (editModalStreamKindSelect) editModalStreamKindSelect.value = "webcam";
   5495     if (editModalStreamKindRow) editModalStreamKindRow.classList.add("hidden");
   5496     if (editModalPasswordInput) editModalPasswordInput.value = "";
   5497     if (editModalPasswordRow) editModalPasswordRow.classList.add("hidden");
   5498   }
   5499 }
   5500 
   5501 function parseKeywordsInput(value) {
   5502   const raw = String(value || "")
   5503     .split(",")
   5504     .map((x) => x.trim().toLowerCase())
   5505     .filter(Boolean);
   5506   const out = [];
   5507   for (const k of raw) {
   5508     const cleaned = k.replace(/[^a-z0-9_-]/g, "").slice(0, 20);
   5509     if (!cleaned) continue;
   5510     if (!out.includes(cleaned)) out.push(cleaned);
   5511     if (out.length >= 6) break;
   5512   }
   5513   return out;
   5514 }
   5515 
   5516 function fillCollectionSelect(selectEl, currentId) {
   5517   if (!selectEl) return;
   5518   const active = activeCollections();
   5519   const current = String(currentId || "") || "general";
   5520   const list = active.length ? active : [{ id: "general", name: "General" }];
   5521   const hasCurrent = list.some((c) => c.id === current);
   5522   selectEl.innerHTML =
   5523     (hasCurrent ? "" : `<option value="${escapeHtml(current)}">${escapeHtml(current)}</option>`) +
   5524     list.map((c) => `<option value="${escapeHtml(c.id)}">${escapeHtml(c.name || c.id)}</option>`).join("");
   5525   selectEl.value = current;
   5526 }
   5527 
   5528 function syncEditModalModeUi() {
   5529   const mode = normalizePostMode(editModalModeSelect?.value || "text");
   5530   if (editModalModeSelect && editModalModeSelect.value !== mode) editModalModeSelect.value = mode;
   5531   if (editModalStreamKindRow) editModalStreamKindRow.classList.toggle("hidden", mode !== "stream");
   5532   if (editModalStreamKindSelect) editModalStreamKindSelect.value = normalizeStreamKind(editModalStreamKindSelect.value || "webcam");
   5533 }
   5534 
   5535 function openEditModalForPost(post) {
   5536   if (!post || post.deleted || post.locked) return;
   5537   if (!loggedInUser || post.author !== loggedInUser) return;
   5538   editContext = { kind: "post", postId: post.id };
   5539   if (editModalTitle) editModalTitle.textContent = "Edit post";
   5540   if (editModalPostTitleRow) editModalPostTitleRow.classList.remove("hidden");
   5541   if (editModalPostMeta) editModalPostMeta.classList.remove("hidden");
   5542   if (editModalPostTitleInput) editModalPostTitleInput.value = String(post.title || "").slice(0, 96);
   5543   if (editModalKeywordsInput) editModalKeywordsInput.value = (post.keywords || []).join(", ");
   5544   fillCollectionSelect(editModalCollectionSelect, String(post.collectionId || "general"));
   5545   if (editModalProtectedToggle) editModalProtectedToggle.checked = Boolean(post.protected);
   5546   if (editModalModeSelect) editModalModeSelect.value = normalizePostMode(post.mode || post.chatMode || "");
   5547   if (editModalStreamKindSelect) editModalStreamKindSelect.value = normalizeStreamKind(post.streamKind || "webcam");
   5548   syncEditModalModeUi();
   5549   if (editModalPasswordRow) editModalPasswordRow.classList.toggle("hidden", !Boolean(post.protected));
   5550   if (editModalPasswordInput) editModalPasswordInput.value = "";
   5551   if (editModalEditor) editModalEditor.innerHTML = String(post.contentHtml || "").trim() || escapeHtml(post.content || "");
   5552   setEditModalOpen(true);
   5553   setTimeout(() => editModalEditor?.focus(), 0);
   5554 }
   5555 
   5556 function openEditModalForChatMessage(message, postId) {
   5557   if (!message || message.deleted) return;
   5558   if (!loggedInUser || message.fromUser !== loggedInUser) return;
   5559   editContext = { kind: "chat", messageId: message.id, postId };
   5560   if (editModalTitle) editModalTitle.textContent = "Edit message";
   5561   if (editModalPostTitleRow) editModalPostTitleRow.classList.add("hidden");
   5562   if (editModalPostTitleInput) editModalPostTitleInput.value = "";
   5563   if (editModalPostMeta) editModalPostMeta.classList.add("hidden");
   5564   if (editModalKeywordsInput) editModalKeywordsInput.value = "";
   5565   if (editModalCollectionSelect) editModalCollectionSelect.innerHTML = "";
   5566   if (editModalProtectedToggle) editModalProtectedToggle.checked = false;
   5567   if (editModalModeSelect) editModalModeSelect.value = "text";
   5568   if (editModalStreamKindSelect) editModalStreamKindSelect.value = "webcam";
   5569   syncEditModalModeUi();
   5570   if (editModalPasswordInput) editModalPasswordInput.value = "";
   5571   if (editModalPasswordRow) editModalPasswordRow.classList.add("hidden");
   5572   if (editModalEditor) editModalEditor.innerHTML = String(message.html || "").trim() || escapeHtml(message.text || "");
   5573   setEditModalOpen(true);
   5574   setTimeout(() => editModalEditor?.focus(), 0);
   5575 }
   5576 
   5577 editModalProtectedToggle?.addEventListener("change", () => {
   5578   const on = Boolean(editModalProtectedToggle?.checked);
   5579   if (editModalPasswordRow) editModalPasswordRow.classList.toggle("hidden", !on);
   5580   if (!on && editModalPasswordInput) editModalPasswordInput.value = "";
   5581 });
   5582 editModalModeSelect?.addEventListener("change", () => syncEditModalModeUi());
   5583 
   5584 function collectEditorPayload(targetEditor) {
   5585   const html = String(targetEditor?.innerHTML || "").trim();
   5586   const text = String(targetEditor?.innerText || "")
   5587     .replace(/\s+/g, " ")
   5588     .trim();
   5589   const hasImg = Boolean(targetEditor?.querySelector?.("img"));
   5590   const hasAudio = Boolean(targetEditor?.querySelector?.("audio"));
   5591   return { html, text, hasImg, hasAudio };
   5592 }
   5593 
   5594 function syncProfileSongPreview(url) {
   5595   if (!profileThemeSongPreview || !profileThemeSongUrlInput) return;
   5596   const safe = asProfileLink(url) || (String(url || "").startsWith("/uploads/") ? String(url || "") : "");
   5597   if (!safe) {
   5598     profileThemeSongPreview.classList.add("hidden");
   5599     profileThemeSongPreview.removeAttribute("src");
   5600     profileThemeSongUrlInput.value = "";
   5601     return;
   5602   }
   5603   profileThemeSongPreview.classList.remove("hidden");
   5604   profileThemeSongPreview.src = safe;
   5605   profileThemeSongPreview.load();
   5606   profileThemeSongUrlInput.value = safe;
   5607 }
   5608 
   5609 function renderProfileLinksEditor(links) {
   5610   if (!profileLinksEditor) return;
   5611   const list = normalizeProfileLinks(links);
   5612   if (!list.length) {
   5613     profileLinksEditor.innerHTML = `<div class="small muted">No links yet.</div>`;
   5614     return;
   5615   }
   5616   profileLinksEditor.innerHTML = list
   5617     .map(
   5618       (entry, index) => `<div class="profileLinkEditRow">
   5619       <input data-linklabel="${index}" value="${escapeHtml(entry.label)}" maxlength="40" placeholder="Label" />
   5620       <input data-linkurl="${index}" value="${escapeHtml(entry.url)}" maxlength="280" placeholder="https://..." />
   5621       <button type="button" class="ghost smallBtn" data-linkremove="${index}">Remove</button>
   5622     </div>`
   5623     )
   5624     .join("");
   5625 }
   5626 
   5627 function profileLinksFromEditor() {
   5628   if (!profileLinksEditor) return [];
   5629   const rows = Array.from(profileLinksEditor.querySelectorAll(".profileLinkEditRow"));
   5630   if (!rows.length) return [];
   5631   const out = [];
   5632   for (const row of rows) {
   5633     const label = String(row.querySelector("[data-linklabel]")?.value || "")
   5634       .replace(/\s+/g, " ")
   5635       .trim()
   5636       .slice(0, 40);
   5637     const url = asProfileLink(row.querySelector("[data-linkurl]")?.value || "");
   5638     if (!url) continue;
   5639     out.push({ label: label || "Link", url });
   5640     if (out.length >= 8) break;
   5641   }
   5642   return out;
   5643 }
   5644 
   5645 function renderProfileCard() {
   5646   if (!profileCard) return;
   5647   if (!activeProfile || !activeProfile.username) {
   5648     profileCard.innerHTML = `<div class="small muted">Profile unavailable.</div>`;
   5649     return;
   5650   }
   5651   const p = normalizeProfileData(activeProfile);
   5652   const headerStyle = p.color ? ` style="--profile-accent:${escapeHtml(p.color)}"` : "";
   5653   const pronouns = p.pronouns ? `<div class="small muted pronouns">${escapeHtml(p.pronouns)}</div>` : "";
   5654   const usernameLower = String(p.username || "").toLowerCase();
   5655   const selfLower = String(loggedInUser || "").toLowerCase();
   5656   const canDm = Boolean(loggedInUser && usernameLower && usernameLower !== selfLower);
   5657   const ignored = prefSet("ignoredUsers").has(usernameLower);
   5658   const blocked = prefSet("blockedUsers").has(usernameLower);
   5659   const dmBtn = canDm
   5660     ? `<button type="button" class="primary smallBtn" data-dmrequest="${escapeHtml(p.username)}" ${blocked ? "disabled" : ""}>DM</button>`
   5661     : "";
   5662   const modDmBtn = canModerate && canDm
   5663     ? `<button type="button" class="ghost smallBtn" data-moddm="${escapeHtml(p.username)}">Mod DM</button>`
   5664     : "";
   5665   const member = peopleMembers.find((m) => String(m.username || "").toLowerCase() === usernameLower) || null;
   5666   const role = roleLabel(member?.role);
   5667   const isStaff = role === "owner" || role === "moderator";
   5668   const canMuteUser = Boolean(loggedInUser && usernameLower && usernameLower !== selfLower && !isStaff);
   5669   const ignoreBtn = canMuteUser
   5670     ? ignored
   5671       ? `<button type="button" class="ghost smallBtn" data-unignoreuser="${escapeHtml(p.username)}">Unignore</button>`
   5672       : `<button type="button" class="ghost smallBtn" data-ignoreuser="${escapeHtml(p.username)}">Ignore</button>`
   5673     : "";
   5674   const blockBtn = canMuteUser
   5675     ? blocked
   5676       ? `<button type="button" class="ghost smallBtn" data-unblockuser="${escapeHtml(p.username)}">Unblock</button>`
   5677       : `<button type="button" class="ghost smallBtn" data-blockuser="${escapeHtml(p.username)}">Block</button>`
   5678     : "";
   5679   const blockNote = canDm && blocked ? `<div class="small muted">Blocked: DMs + content hidden.</div>` : "";
   5680   const bio = p.bioHtml ? `<div class="profileBio">${p.bioHtml}</div>` : `<div class="small muted">No bio yet.</div>`;
   5681   const theme = p.themeSongUrl ? `<audio controls preload="none" src="${escapeHtml(p.themeSongUrl)}"></audio>` : `<div class="small muted">No theme song set.</div>`;
   5682   const links = p.links.length
   5683     ? p.links
   5684         .map(
   5685           (entry) =>
   5686             `<a class="tag profileLinkTag" href="${escapeHtml(entry.url)}" target="_blank" rel="noopener noreferrer nofollow">${escapeHtml(entry.label)}</a>`
   5687         )
   5688         .join("")
   5689     : `<div class="small muted">No links yet.</div>`;
   5690   profileCard.innerHTML = `<div class="profileHeader"${headerStyle}>
   5691     <span class="pfp profileHeroPfp">${p.image ? `<img alt="" src="${escapeHtml(p.image)}" />` : ""}</span>
   5692     <div class="profileIdentity">
   5693       <div class="profileHandle" ${p.color ? `style="color:${escapeHtml(safeTextColorFromHex(p.color))}"` : ""}>@${escapeHtml(p.username)}</div>
   5694       ${pronouns}
   5695     </div>
   5696     ${dmBtn || modDmBtn || ignoreBtn || blockBtn ? `<div class="profileActions">${dmBtn}${modDmBtn}${ignoreBtn}${blockBtn}</div>` : ""}
   5697   </div>
   5698   ${blockNote}
   5699   <div class="profileSection">
   5700     <div class="small muted">Bio</div>
   5701     ${bio}
   5702   </div>
   5703   <div class="profileSection">
   5704     <div class="small muted">Theme song</div>
   5705     ${theme}
   5706   </div>
   5707   <div class="profileSection">
   5708     <div class="small muted">Links</div>
   5709     <div class="profileLinksWrap">${links}</div>
   5710   </div>`;
   5711   const bioEl = profileCard.querySelector(".profileBio");
   5712   if (bioEl) decorateYouTubeEmbedsInElement(bioEl);
   5713 }
   5714 
   5715 function renderProfileEditor() {
   5716   const canEdit = Boolean(loggedInUser && activeProfile && activeProfile.username === loggedInUser);
   5717   if (profileEditToggleBtn) profileEditToggleBtn.classList.toggle("hidden", !canEdit);
   5718   if (!profileEditPanel || !profilePronounsInput || !profileBioEditor) return;
   5719   profileEditPanel.classList.toggle("hidden", !(canEdit && isEditingProfile));
   5720   if (!canEdit || !activeProfile) return;
   5721   profilePronounsInput.value = String(activeProfile.pronouns || "");
   5722   profileBioEditor.innerHTML = String(activeProfile.bioHtml || "");
   5723   renderProfileLinksEditor(activeProfile.links);
   5724   syncProfileSongPreview(activeProfile.themeSongUrl || "");
   5725 }
   5726 
   5727 function renderCenterPanels() {
   5728   // In rack mode, panels are independent. Profile shouldn't "replace" the Hives panel.
   5729   if (rackLayoutEnabled) {
   5730     if (pollinatePanel) {
   5731       pollinatePanel.classList.remove("hidden");
   5732       pollinatePanel.classList.toggle("panelCollapsed", !composerOpen);
   5733       pollinatePanel.dataset.panelDisplay = composerOpen ? "full" : "collapsed";
   5734     }
   5735     renderProfilePanel();
   5736     updateSideRackEmptyState();
   5737     return;
   5738   }
   5739 
   5740   const profileMode = centerView === "profile";
   5741   if (profileViewPanel) profileViewPanel.classList.toggle("hidden", !profileMode);
   5742   if (feedEl?.closest("section")) feedEl.closest("section").classList.toggle("hidden", profileMode);
   5743   if (pollinatePanel) {
   5744     if (profileMode) pollinatePanel.classList.add("hidden");
   5745     else pollinatePanel.classList.toggle("hidden", !composerOpen);
   5746   }
   5747   if (!profileMode) return;
   5748   renderProfilePanel();
   5749 }
   5750 
   5751 function renderProfilePanel() {
   5752   if (!profileViewPanel) return;
   5753   if (!activeProfileUsername && !activeProfile && loggedInUser) {
   5754     activeProfileUsername = String(loggedInUser || "").trim().toLowerCase();
   5755   }
   5756 
   5757   const username = String(activeProfile?.username || activeProfileUsername || "")
   5758     .trim()
   5759     .toLowerCase();
   5760 
   5761   if (username) {
   5762     // Ensure we always have *some* profile data to show immediately.
   5763     if (!activeProfile || String(activeProfile.username || "").toLowerCase() !== username) {
   5764       const basic = getProfile(username);
   5765       activeProfile = normalizeProfileData({ username, image: basic.image || "", color: basic.color || "" });
   5766     }
   5767 
   5768     // Pull the full profile from the server (bio/links/song) once per username selection.
   5769     try {
   5770       if (ws?.readyState === WebSocket.OPEN && lastRequestedProfileUsername !== username) {
   5771         lastRequestedProfileUsername = username;
   5772         ws.send(JSON.stringify({ type: "getUserProfile", username }));
   5773       }
   5774     } catch {
   5775       // ignore
   5776     }
   5777   }
   5778 
   5779   if (profileViewTitle) profileViewTitle.textContent = username ? `@${username}` : "Profile";
   5780   if (profileViewMeta) profileViewMeta.textContent = username === loggedInUser ? "Your profile" : "Community profile";
   5781   renderProfileCard();
   5782   renderProfileEditor();
   5783 }
   5784 
   5785 function setCenterView(next, username = "") {
   5786   if (rackLayoutEnabled) {
   5787     // Keep the legacy centerView on "hives" in rack mode; just update profile context.
   5788     const wantsProfile = next === "profile";
   5789     if (wantsProfile) {
   5790       activeProfileUsername = String(username || activeProfileUsername || "")
   5791         .trim()
   5792         .toLowerCase();
   5793       isEditingProfile = false;
   5794       if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile";
   5795 
   5796       // Make sure the profile panel is actually visible as its own panel.
   5797       undockPanel("profile");
   5798       profileViewPanel.classList.remove("panelCollapsed");
   5799       profileViewPanel.dataset.panelDisplay = "full";
   5800       enforceWorkspaceRules();
   5801       renderProfilePanel();
   5802     } else {
   5803       activeProfileUsername = "";
   5804       activeProfile = null;
   5805       isEditingProfile = false;
   5806       if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile";
   5807       renderProfilePanel();
   5808     }
   5809     return;
   5810   }
   5811 
   5812   centerView = next === "profile" ? "profile" : "hives";
   5813   if (centerView === "hives") {
   5814     activeProfileUsername = "";
   5815     activeProfile = null;
   5816     isEditingProfile = false;
   5817     if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile";
   5818   } else {
   5819     activeProfileUsername = String(username || activeProfileUsername || "")
   5820       .trim()
   5821       .toLowerCase();
   5822     isEditingProfile = false;
   5823     if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile";
   5824   }
   5825   renderCenterPanels();
   5826 }
   5827 
   5828 function openUserProfile(username) {
   5829   const normalized = String(username || "")
   5830     .trim()
   5831     .toLowerCase();
   5832   if (!normalized) return;
   5833   const basic = getProfile(normalized);
   5834   activeProfile = normalizeProfileData({ username: normalized, image: basic.image || "", color: basic.color || "" });
   5835   setCenterView("profile", normalized);
   5836   requestAnimationFrame(() => {
   5837     const profilePanel = getPanelElement("profile");
   5838     if (profilePanel instanceof HTMLElement) focusWorkspaceArrival(profilePanel);
   5839   });
   5840   ws.send(JSON.stringify({ type: "getUserProfile", username: normalized }));
   5841   if (isMobileSwipeMode()) setMobileScreen("profile");
   5842 }
   5843 
   5844 function isMobileSwipeMode() {
   5845   // Mobile UX should kick in for touch-first devices, including landscape phones.
   5846   // (Many phones exceed 760px in landscape, so max-width alone is not sufficient.)
   5847   const mqNarrow = "(max-width: 760px)";
   5848   const mqPortrait = "(hover: none) and (pointer: coarse) and (max-width: 900px)";
   5849   const mqLandscape = "(hover: none) and (pointer: coarse) and (max-height: 520px)";
   5850   return window.matchMedia(mqNarrow).matches || window.matchMedia(mqPortrait).matches || window.matchMedia(mqLandscape).matches;
   5851 }
   5852 
   5853 function isMobileScreenMode() {
   5854   // Keep this consistent with CSS mobile screen media queries.
   5855   const mqNarrow = "(max-width: 760px)";
   5856   const mqPortrait = "(hover: none) and (pointer: coarse) and (max-width: 900px)";
   5857   const mqLandscape = "(hover: none) and (pointer: coarse) and (max-height: 520px)";
   5858   return window.matchMedia(mqNarrow).matches || window.matchMedia(mqPortrait).matches || window.matchMedia(mqLandscape).matches;
   5859 }
   5860 
   5861 function loadMobileLayout() {
   5862   const defaults = () => {
   5863     const pinned = ["account", "hives", "chat", "people", "profile"];
   5864     const onboardingEnabled = Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.enabled);
   5865     const active = onboardingEnabled ? "onboarding" : pinned[0] || "account";
   5866     return { version: 1, pinned, active, history: [], tools: { composerOpen: false, profileOpen: false, pluginRackOpen: false } };
   5867   };
   5868   const sanitizeId = (id) => {
   5869     const raw = String(id || "")
   5870       .trim()
   5871       .toLowerCase();
   5872     if (!raw) return "";
   5873     if (raw === "maps" || raw === "library") return "";
   5874     if (raw === "mod") return canModerate ? "moderation" : "";
   5875     if (raw === "sidebar") return "account";
   5876     if (raw === "main" || raw === "workspace") return "hives";
   5877     if (raw === "account" || raw === "hives" || raw === "chat" || raw === "people" || raw === "profile" || raw === "onboarding") return raw;
   5878     if (raw === "moderation") return canModerate ? "moderation" : "";
   5879     if (panelRegistry.has(raw)) return raw;
   5880     return "";
   5881   };
   5882   try {
   5883     const raw = localStorage.getItem(MOBILE_LAYOUT_KEY);
   5884     if (!raw) return defaults();
   5885     const parsed = JSON.parse(raw);
   5886     const pinned = Array.isArray(parsed?.pinned) ? parsed.pinned.map((x) => sanitizeId(x)).filter(Boolean) : null;
   5887     const active = sanitizeId(parsed?.active);
   5888     const history = Array.isArray(parsed?.history) ? parsed.history.map((x) => sanitizeId(x)).filter(Boolean) : [];
   5889     const base = defaults();
   5890     if (pinned && pinned.length) base.pinned = pinned.slice(0, 5);
   5891     if (active) base.active = active;
   5892     base.history = history.slice(0, 12);
   5893     return base;
   5894   } catch {
   5895     return defaults();
   5896   }
   5897 }
   5898 
   5899 function saveMobileLayout(layout) {
   5900   try {
   5901     localStorage.setItem(MOBILE_LAYOUT_KEY, JSON.stringify(layout));
   5902   } catch {
   5903     // ignore
   5904   }
   5905 }
   5906 
   5907 function availableMobileScreens() {
   5908   const out = [];
   5909   out.push({ id: "account", title: "Account", core: true });
   5910   if (Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.enabled)) out.push({ id: "onboarding", title: "Onboarding", core: true });
   5911   out.push({ id: "hives", title: "Hives", core: true });
   5912   out.push({ id: "chat", title: "Chat", core: true });
   5913   out.push({ id: "people", title: "Members list", core: true });
   5914   out.push({ id: "profile", title: "Profile", core: true });
   5915   if (canModerate) out.push({ id: "moderation", title: "Moderation", core: true });
   5916 
   5917   // Plugin screens: include primary-ish panels that exist.
   5918   for (const [id, entry] of panelRegistry.entries()) {
   5919     if (!id || typeof id !== "string") continue;
   5920     if (id === "maps" || id === "library") continue;
   5921     if (id === "hives" || id === "chat" || id === "people" || id === "moderation" || id === "profile" || id === "composer" || id === "pluginRack") continue;
   5922     const role = typeof entry?.role === "string" ? entry.role : "";
   5923     if (role && role !== "primary") continue;
   5924     const hasElement = entry?.element instanceof HTMLElement;
   5925     const canRender = typeof pluginPanelDefsByPanelId.get(id)?.render === "function";
   5926     if (!hasElement && !canRender) continue;
   5927     out.push({ id, title: panelTitle(id), core: false });
   5928   }
   5929 
   5930   // Prefer stable ordering.
   5931   const byTitle = (a, b) => String(a.title || "").localeCompare(String(b.title || ""));
   5932   const core = out.filter((x) => x.core).sort(byTitle);
   5933   const plugins = out.filter((x) => !x.core).sort(byTitle);
   5934   return { core, plugins };
   5935 }
   5936 
   5937 function mobileScreenFromLegacyPanel(next) {
   5938   const raw = String(next || "").trim();
   5939   if (!raw) return "hives";
   5940   if (raw === "maps" || raw === "library") return "hives";
   5941   if (raw === "sidebar") return "account";
   5942   if (raw === "main" || raw === "workspace") return "hives";
   5943   if (raw === "chat") return "chat";
   5944   if (raw === "people") return "people";
   5945   if (raw === "profile") return "profile";
   5946   if (raw === "onboarding") return "onboarding";
   5947   if (raw === "moderation" || raw === "mod") return canModerate ? "moderation" : "hives";
   5948   if (raw === "hives" || raw === "account" || raw === "people" || raw === "profile" || raw === "onboarding" || raw === "moderation") return raw;
   5949   // Plugin panel id can be treated as a screen.
   5950   if (panelRegistry.has(raw)) return raw;
   5951   return "hives";
   5952 }
   5953 
   5954 function setMobileMoreOpen(open) {
   5955   mobileMoreOpen = Boolean(open);
   5956   if (mobileMoreSheetEl) mobileMoreSheetEl.classList.toggle("hidden", !mobileMoreOpen);
   5957   if (mobileNavEl) {
   5958     const moreBtn = mobileNavEl.querySelector?.('[data-mobilescreen="more"]');
   5959     if (moreBtn instanceof HTMLElement) {
   5960       moreBtn.classList.toggle("primary", mobileMoreOpen);
   5961       moreBtn.classList.toggle("ghost", !mobileMoreOpen);
   5962     }
   5963   }
   5964 }
   5965 
   5966 function restoreHostedPanelIfAny() {
   5967   const ids = Array.from(mobileHostedPanelIds);
   5968   if (mobileHostPanelId && !ids.includes(mobileHostPanelId)) ids.push(mobileHostPanelId);
   5969   if (!ids.length) return;
   5970   mobileHostedPanelIds.clear();
   5971   mobileHostPanelId = "";
   5972   for (const id of ids) {
   5973     const el = getPanelElement(id);
   5974     const parent = mobileHostRestoreParentByPanelId.get(id) || null;
   5975     mobileHostRestoreParentByPanelId.delete(id);
   5976     if (!(el instanceof HTMLElement)) continue;
   5977     if (!parent && mobileHostEphemeralPanelIds.has(id)) {
   5978       mobileHostEphemeralPanelIds.delete(id);
   5979       try {
   5980         el.remove();
   5981       } catch {
   5982         // ignore
   5983       }
   5984       const prev = panelRegistry.get(id);
   5985       if (prev) panelRegistry.set(id, { ...prev, element: null });
   5986       continue;
   5987     }
   5988     if (parent instanceof HTMLElement && parent.isConnected) {
   5989       parent.appendChild(el);
   5990       continue;
   5991     }
   5992     const def = panelRegistry.get(id);
   5993     const wantsMain = String(def?.defaultRack || "").toLowerCase() === "main";
   5994     const rack = wantsMain ? ensureMainSideRack() : ensureRightRack();
   5995     if (rack) rack.appendChild(el);
   5996   }
   5997 }
   5998 
   5999 function ensureMobileHostedPluginPanel(panelId) {
   6000   const id = String(panelId || "").trim();
   6001   if (!id) return null;
   6002   const existing = getPanelElement(id);
   6003   if (existing instanceof HTMLElement) return existing;
   6004   const entry = panelRegistry.get(id);
   6005   const src = typeof entry?.source === "string" ? entry.source : "";
   6006   if (!src.startsWith("plugin:")) return null;
   6007   const def = pluginPanelDefsByPanelId.get(id);
   6008   const render = def?.render;
   6009   if (typeof render !== "function") return null;
   6010 
   6011   const shell = document.createElement("section");
   6012   shell.className = "panel panelFill pluginPanel mobileHostedPluginPanel";
   6013   shell.dataset.panelId = id;
   6014   shell.innerHTML = `
   6015     <div class="panelHeader">
   6016       <div class="panelTitle">${escapeHtml(def?.title || id)}</div>
   6017       <div class="row"></div>
   6018     </div>
   6019     <div class="panelBody" data-pluginmount="1"></div>
   6020   `;
   6021 
   6022   const mount = shell.querySelector("[data-pluginmount]");
   6023   if (mount instanceof HTMLElement) {
   6024     const pluginId = String(def?.pluginId || "").trim();
   6025     const api = {
   6026       toast,
   6027       send: (eventName, payload) => {
   6028         const ev = String(eventName || "").trim();
   6029         if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false;
   6030         const wsRef = window.__bzlWs;
   6031         if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false;
   6032         const msg = payload && typeof payload === "object" ? payload : {};
   6033         wsRef.send(JSON.stringify({ ...msg, type: `plugin:${pluginId}:${ev}` }));
   6034         return true;
   6035       },
   6036       getUser: () => loggedInUser,
   6037       getRole: () => loggedInRole,
   6038       storage: {
   6039         get(key) {
   6040           try {
   6041             return localStorage.getItem(`bzl_panel_${id}_${String(key || "")}`);
   6042           } catch {
   6043             return null;
   6044           }
   6045         },
   6046         set(key, value) {
   6047           try {
   6048             localStorage.setItem(`bzl_panel_${id}_${String(key || "")}`, String(value ?? ""));
   6049             return true;
   6050           } catch {
   6051             return false;
   6052           }
   6053         }
   6054       }
   6055     };
   6056     try {
   6057       const cleanup = render(mount, api);
   6058       if (typeof cleanup === "function") shell.__panelCleanup = cleanup;
   6059     } catch (e) {
   6060       console.warn(`Plugin ${pluginId} panel render failed:`, e?.message || e);
   6061       mount.textContent = `Failed to render panel "${id}".`;
   6062     }
   6063   }
   6064 
   6065   panelRegistry.set(id, {
   6066     ...(entry || { id, title: def?.title || id, icon: def?.icon || "", source: `plugin:${def?.pluginId || ""}`, role: def?.role || "aux", defaultRack: def?.defaultRack || "right" }),
   6067     title: def?.title || (entry?.title || id),
   6068     icon: def?.icon || (entry?.icon || ""),
   6069     role: def?.role || (entry?.role || "aux"),
   6070     defaultRack: def?.defaultRack || (entry?.defaultRack || "right"),
   6071     element: shell
   6072   });
   6073   mobileHostEphemeralPanelIds.add(id);
   6074   return shell;
   6075 }
   6076 
   6077 function hostPanelInMobileScreen(panelId) {
   6078   const id = String(panelId || "").trim();
   6079   if (!id) return false;
   6080   if (!(mobileScreenHostEl instanceof HTMLElement)) return false;
   6081   if (rackLayoutEnabled && isDocked(id)) {
   6082     undockPanel(id);
   6083     applyDockState();
   6084   }
   6085   let el = getPanelElement(id);
   6086   if (!(el instanceof HTMLElement)) el = ensureMobileHostedPluginPanel(id);
   6087   if (!(el instanceof HTMLElement)) return false;
   6088   el.classList.remove("hidden");
   6089 
   6090   restoreHostedPanelIfAny();
   6091   const parent = el.parentElement;
   6092   if (parent instanceof HTMLElement) mobileHostRestoreParentByPanelId.set(id, parent);
   6093   mobileHostPanelId = id;
   6094   mobileHostedPanelIds.clear();
   6095   mobileHostedPanelIds.add(id);
   6096   mobileScreenHostEl.innerHTML = "";
   6097   mobileScreenHostEl.appendChild(el);
   6098   return true;
   6099 }
   6100 
   6101 function hostHivesInMobileScreen() {
   6102   if (!(mobileScreenHostEl instanceof HTMLElement)) return false;
   6103   if (rackLayoutEnabled) {
   6104     if (isDocked("hives")) undockPanel("hives");
   6105     applyDockState();
   6106   }
   6107   const hivesEl = getPanelElement("hives");
   6108   if (!(hivesEl instanceof HTMLElement)) return false;
   6109 
   6110   restoreHostedPanelIfAny();
   6111 
   6112   const hivesParent = hivesEl.parentElement;
   6113   if (hivesParent instanceof HTMLElement) mobileHostRestoreParentByPanelId.set("hives", hivesParent);
   6114 
   6115   mobileScreenHostEl.innerHTML = "";
   6116   hivesEl.classList.remove("hidden");
   6117   mobileScreenHostEl.appendChild(hivesEl);
   6118 
   6119   mobileHostedPanelIds.clear();
   6120   mobileHostedPanelIds.add("hives");
   6121   mobileHostPanelId = "hives";
   6122 
   6123   return true;
   6124 }
   6125 
   6126 function setMobileScreen(screenId, { pushHistory = true } = {}) {
   6127   if (!appRoot) return;
   6128   const screen = mobileScreenFromLegacyPanel(screenId);
   6129   if (onboardingNeedsAcceptanceNow() && screen !== "onboarding" && screen !== "account") {
   6130     setMobileScreen("onboarding", { pushHistory: false });
   6131     return;
   6132   }
   6133   const nextIsMore = screen === "more";
   6134   if (nextIsMore) {
   6135     setMobileMoreOpen(true);
   6136     return;
   6137   }
   6138 
   6139   if (pushHistory) {
   6140     const current = String(appRoot.getAttribute("data-mobile-screen") || "").trim();
   6141     if (current && current !== "more" && current !== screen) {
   6142       const layout = loadMobileLayout();
   6143       layout.history = [current, ...(layout.history || [])].filter((x, idx, arr) => x && arr.indexOf(x) === idx).slice(0, 12);
   6144       saveMobileLayout(layout);
   6145     }
   6146   }
   6147 
   6148   setMobileMoreOpen(false);
   6149 
   6150   // Core screens map directly.
   6151   if (screen === "people") {
   6152     setPeopleOpen(true);
   6153     peopleDrawerEl?.classList.remove("hidden");
   6154     renderPeoplePanel();
   6155     if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "peopleList" }));
   6156   } else {
   6157     setPeopleOpen(false);
   6158   }
   6159 
   6160   if (screen === "moderation" && !canModerate) {
   6161     appRoot.setAttribute("data-mobile-screen", "hives");
   6162     return;
   6163   }
   6164 
   6165   if (screen === "account") {
   6166     restoreHostedPanelIfAny();
   6167     appRoot.setAttribute("data-mobile-screen", screen);
   6168     return;
   6169   }
   6170 
   6171   if (screen === "people") {
   6172     const hosted = hostPanelInMobileScreen("people");
   6173     appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "people");
   6174     return;
   6175   }
   6176 
   6177   if (screen === "profile") {
   6178     const target = String(activeProfileUsername || loggedInUser || "").trim().toLowerCase();
   6179     if (target) setCenterView("profile", target);
   6180     else renderProfilePanel();
   6181     const hosted = hostPanelInMobileScreen("profile");
   6182     appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives");
   6183     return;
   6184   }
   6185 
   6186   if (screen === "onboarding") {
   6187     const hosted = hostPanelInMobileScreen("onboarding");
   6188     appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives");
   6189     return;
   6190   }
   6191 
   6192   if (screen === "hives") {
   6193     const hosted = hostHivesInMobileScreen();
   6194     appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives");
   6195     return;
   6196   }
   6197 
   6198   const hostableCorePanelId = screen === "chat" ? "chat" : screen === "moderation" ? "moderation" : "";
   6199   if (hostableCorePanelId) {
   6200     const hosted = hostPanelInMobileScreen(hostableCorePanelId);
   6201     appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives");
   6202     return;
   6203   }
   6204 
   6205   // Plugin screen: host it.
   6206   const hosted = hostPanelInMobileScreen(screen);
   6207   appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives");
   6208 }
   6209 
   6210 function setMobilePanel(next) {
   6211   if (!appRoot) return;
   6212   // Back-compat shim: old callers still call setMobilePanel("chat"/"main"/etc).
   6213   if (!isMobileScreenMode()) return;
   6214   mobilePanel = mobileScreenFromLegacyPanel(next);
   6215   setMobileScreen(mobilePanel, { pushHistory: true });
   6216 }
   6217 
   6218 function applyMobileMode() {
   6219   if (!appRoot) return;
   6220   const wasMobile = appRoot.classList.contains("mobileScreens");
   6221   const mobile = isMobileScreenMode();
   6222   appRoot.classList.toggle("mobileScreens", mobile);
   6223   if (mobileNavEl) mobileNavEl.classList.toggle("hidden", !mobile);
   6224   if (mobile) stopAnyPanelResize();
   6225 
   6226   if (!mobile) {
   6227     setMobileMoreOpen(false);
   6228     restoreHostedPanelIfAny();
   6229     return;
   6230   }
   6231 
   6232   if (mobileFourthBtn instanceof HTMLElement) {
   6233     mobileFourthBtn.textContent = "People";
   6234     mobileFourthBtn.setAttribute("data-mobilescreen", "people");
   6235   }
   6236 
   6237   // Apply persisted layout only when entering mobile mode (avoid resetting state on keyboard/URL-bar resizes).
   6238   const current = String(appRoot.getAttribute("data-mobile-screen") || "").trim();
   6239   if (!wasMobile || !current) {
   6240     const layout = loadMobileLayout();
   6241     const desired = onboardingNeedsAcceptanceNow() ? "onboarding" : mobileScreenFromLegacyPanel(layout.active || "hives");
   6242     setMobileScreen(desired, { pushHistory: false });
   6243   }
   6244   renderMobileNav();
   6245   if (mobileMoreOpen) renderMobileMoreList();
   6246 
   6247   if (!wasMobile) {
   6248     if (canResizeSidebarNow()) applySidebarWidth(readStoredSidebarWidth(), false);
   6249     if (canResizeChatNow()) applyChatWidth(readStoredChatWidth(), false);
   6250     if (canResizeModNow()) applyModWidth(readStoredModWidth(), false);
   6251     if (canResizePeopleNow()) applyPeopleWidth(readStoredPeopleWidth(), false);
   6252     setComposerOpen(composerOpen);
   6253   }
   6254 }
   6255 
   6256 function shiftMobilePanel(delta) {
   6257   if (!isMobileScreenMode()) return;
   6258   const order = canModerate
   6259     ? ["account", "onboarding", "hives", "chat", "people", "profile", "moderation"]
   6260     : ["account", "onboarding", "hives", "chat", "people", "profile"];
   6261   const current = mobileScreenFromLegacyPanel(appRoot?.getAttribute("data-mobile-screen") || "hives");
   6262   const idx = order.indexOf(current);
   6263   const at = idx >= 0 ? idx : 0;
   6264   const nextIdx = Math.max(0, Math.min(order.length - 1, at + delta));
   6265   setMobileScreen(order[nextIdx]);
   6266   const layout = loadMobileLayout();
   6267   layout.active = order[nextIdx];
   6268   saveMobileLayout(layout);
   6269   renderMobileNav();
   6270 }
   6271 
   6272 function renderMobileNav() {
   6273   if (!(mobileNavEl instanceof HTMLElement)) return;
   6274   if (!appRoot) return;
   6275   const active = String(appRoot.getAttribute("data-mobile-screen") || "hives").trim();
   6276   const buttons = Array.from(mobileNavEl.querySelectorAll("[data-mobilescreen]"));
   6277   for (const btn of buttons) {
   6278     const id = String(btn.getAttribute("data-mobilescreen") || "").trim();
   6279     const on = id !== "more" && (active === id || (active === "host" && id === mobileHostPanelId));
   6280     btn.classList.toggle("primary", on);
   6281     btn.classList.toggle("ghost", !on);
   6282   }
   6283 }
   6284 
   6285 function toast(title, body, timeoutMs = 2800) {
   6286   const el = document.createElement("div");
   6287   el.className = "toast";
   6288   el.innerHTML = `<div class="toastTitle">${escapeHtml(title)}</div><div class="toastBody">${escapeHtml(body)}</div>`;
   6289   toastHost.appendChild(el);
   6290   setTimeout(() => el.remove(), timeoutMs);
   6291 }
   6292 
   6293 function sendDevLog(level, scope, message, data) {
   6294   try {
   6295     if (!canModerate) return false;
   6296     const wsRef = window.__bzlWs;
   6297     if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false;
   6298     wsRef.send(JSON.stringify({ type: "devLogClient", level, scope, message, data }));
   6299     return true;
   6300   } catch {
   6301     return false;
   6302   }
   6303 }
   6304 
   6305 window.bzlDevLog = sendDevLog;
   6306 
   6307 // Plugin event handlers: pluginId -> eventName -> Set<fn(msg)>
   6308 const pluginClientHandlers = new Map();
   6309 // Moderation plugin tabs: fullTabId -> { title, ownerOnly, render(mount, api), pluginId }
   6310 const modPluginTabs = new Map();
   6311 // Plugin panels by panelId (so mobile can render plugin screens even when rack layout is off).
   6312 const pluginPanelDefsByPanelId = new Map();
   6313 
   6314 // Minimal plugin host (client-side). Plugins are trusted by the owner who installs them.
   6315 // Plugin scripts can call `window.BzlPluginHost.register("pluginId", (ctx) => { ... })`.
   6316 if (!window.BzlPluginHost) {
   6317   const pluginInits = new Map();
   6318   window.BzlPluginHost = {
   6319     apiVersion: 3,
   6320     register(pluginId, initFn) {
   6321       const id = String(pluginId || "").trim().toLowerCase();
   6322       if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(id)) throw new Error("Invalid plugin id");
   6323       if (typeof initFn !== "function") throw new Error("init must be a function");
   6324       if (pluginInits.has(id)) return false;
   6325       pluginInits.set(id, initFn);
   6326       try {
   6327         initFn({
   6328           id,
   6329           toast,
   6330           getUser: () => loggedInUser,
   6331           getRole: () => loggedInRole,
   6332           on(eventName, handler) {
   6333             const ev = String(eventName || "").trim();
   6334             if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) throw new Error("Invalid event name");
   6335             if (typeof handler !== "function") throw new Error("handler must be a function");
   6336             let byEvent = pluginClientHandlers.get(id);
   6337             if (!byEvent) {
   6338               byEvent = new Map();
   6339               pluginClientHandlers.set(id, byEvent);
   6340             }
   6341             let set = byEvent.get(ev);
   6342             if (!set) {
   6343               set = new Set();
   6344               byEvent.set(ev, set);
   6345             }
   6346             set.add(handler);
   6347             return () => {
   6348               try {
   6349                 set.delete(handler);
   6350               } catch {
   6351                 // ignore
   6352               }
   6353             };
   6354           },
   6355           ui: {
   6356             registerModTab(tabDef) {
   6357               const tabId = String(tabDef?.id || id).trim().toLowerCase();
   6358               if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(tabId)) throw new Error("Invalid tab id");
   6359               const title = typeof tabDef?.title === "string" ? tabDef.title.trim().slice(0, 22) : tabId;
   6360               const ownerOnly = Boolean(tabDef?.ownerOnly);
   6361               const render = tabDef?.render;
   6362               if (typeof render !== "function") throw new Error("render must be a function");
   6363 
   6364               const fullId = `plugin:${id}:${tabId}`;
   6365               modPluginTabs.set(fullId, { title, ownerOnly, render, pluginId: id });
   6366 
   6367               const tabsEl = modPanelEl?.querySelector?.(".modTabs");
   6368               if (tabsEl && !tabsEl.querySelector(`[data-modtab="${CSS.escape(fullId)}"]`)) {
   6369                 const btn = document.createElement("button");
   6370                 btn.type = "button";
   6371                 btn.className = "ghost";
   6372                 btn.textContent = title;
   6373                 btn.setAttribute("data-modtab", fullId);
   6374                 btn.dataset.ownerOnly = ownerOnly ? "1" : "0";
   6375                 tabsEl.appendChild(btn);
   6376               }
   6377 
   6378               // If the tab isn't visible for this user, don't allow it to become active.
   6379               if (ownerOnly && loggedInRole !== "owner" && modTab === fullId) {
   6380                 modTab = "server";
   6381                 renderModPanel();
   6382               }
   6383               return true;
   6384             },
   6385             registerPanel(panelDef) {
   6386               const panelId = String(panelDef?.id || id).trim().toLowerCase();
   6387               if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(panelId)) throw new Error("Invalid panel id");
   6388               const title = typeof panelDef?.title === "string" ? panelDef.title.trim().slice(0, 40) : panelId;
   6389               const icon = typeof panelDef?.icon === "string" ? panelDef.icon.trim().slice(0, 10) : "";
   6390               const defaultRack =
   6391                 typeof panelDef?.defaultRack === "string" && /^(main|right)$/i.test(panelDef.defaultRack)
   6392                   ? panelDef.defaultRack.toLowerCase()
   6393                   : "right";
   6394               const role =
   6395                 typeof panelDef?.role === "string" && /^(primary|aux|transient|utility)$/i.test(panelDef.role)
   6396                   ? panelDef.role.toLowerCase()
   6397                   : "aux";
   6398               const source = `plugin:${id}`;
   6399               const render = typeof panelDef?.render === "function" ? panelDef.render : null;
   6400 
   6401               pluginPanelDefsByPanelId.set(panelId, { pluginId: id, panelId, title, icon, defaultRack, role, render });
   6402 
   6403               // Create a visible shell only when rack layout is enabled (for now).
   6404               // Otherwise, plugins should continue using their existing DOM hooks.
   6405               let element = null;
   6406               if (rackLayoutEnabled) {
   6407                 const shell = ensurePluginPanelShell(panelId, title, icon, defaultRack, role);
   6408                 element = shell;
   6409                 const mount = shell ? shell.querySelector("[data-pluginmount]") : null;
   6410                 if (mount) {
   6411                   mount.innerHTML = "";
   6412                   const api = {
   6413                     toast,
   6414                     send: (eventName, payload) => {
   6415                       const ev = String(eventName || "").trim();
   6416                       if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false;
   6417                       const wsRef = window.__bzlWs;
   6418                       if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false;
   6419                       const msg = payload && typeof payload === "object" ? payload : {};
   6420                       wsRef.send(JSON.stringify({ ...msg, type: `plugin:${id}:${ev}` }));
   6421                       return true;
   6422                     },
   6423                     getUser: () => loggedInUser,
   6424                     getRole: () => loggedInRole,
   6425                     storage: {
   6426                       get(key) {
   6427                         try {
   6428                           return localStorage.getItem(`bzl_panel_${panelId}_${String(key || "")}`);
   6429                         } catch {
   6430                           return null;
   6431                         }
   6432                       },
   6433                       set(key, value) {
   6434                         try {
   6435                           localStorage.setItem(`bzl_panel_${panelId}_${String(key || "")}`, String(value ?? ""));
   6436                           return true;
   6437                         } catch {
   6438                           return false;
   6439                         }
   6440                       },
   6441                     },
   6442                   };
   6443                   try {
   6444                     const cleanup = render ? render(mount, api) : null;
   6445                     if (typeof cleanup === "function") {
   6446                       // Store cleanup on the shell so future hot-reload / uninstall can call it.
   6447                       shell.__panelCleanup = cleanup;
   6448                     }
   6449                   } catch (e) {
   6450                     console.warn(`Plugin ${id} panel render failed:`, e?.message || e);
   6451                     mount.textContent = `Failed to render panel "${panelId}".`;
   6452                   }
   6453                 }
   6454 
   6455                 enableRackDnD();
   6456               }
   6457 
   6458               panelRegistry.set(panelId, { id: panelId, title, icon, source, role, defaultRack, element });
   6459               applyPluginPresetHint(panelDef);
   6460               applyDockState();
   6461               syncRackStateFromDom();
   6462               return true;
   6463             },
   6464           },
   6465           devLog: (level, message, data) => sendDevLog(level, `plugin:${id}`, message, data),
   6466           send(eventName, payload) {
   6467             const ev = String(eventName || "").trim();
   6468             if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false;
   6469             const wsRef = window.__bzlWs;
   6470             if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false;
   6471             const msg = payload && typeof payload === "object" ? payload : {};
   6472             wsRef.send(JSON.stringify({ ...msg, type: `plugin:${id}:${ev}` }));
   6473             return true;
   6474           },
   6475         });
   6476       } catch (e) {
   6477         console.warn(`Plugin ${id} init failed:`, e?.message || e);
   6478         toast("Plugin error", `Failed to init "${id}".`);
   6479       }
   6480       return true;
   6481     },
   6482   };
   6483 }
   6484 
   6485 function renderTypingIndicator() {
   6486   if (!typingIndicator) return;
   6487   if (!activeChatPostId) {
   6488     typingIndicator.textContent = "";
   6489     return;
   6490   }
   6491   const set = typingUsersByPostId.get(activeChatPostId);
   6492   if (!set || set.size === 0) {
   6493     typingIndicator.textContent = "";
   6494     return;
   6495   }
   6496   const names = Array.from(set.values());
   6497   let text = "";
   6498   if (names.length === 1) text = `@${names[0]} is typing`;
   6499   else if (names.length === 2) text = `@${names[0]} and @${names[1]} are typing`;
   6500   else text = `@${names[0]}, @${names[1]} and ${names.length - 2} others are typing`;
   6501   typingIndicator.innerHTML = `${escapeHtml(text)} <span class="typingDots"><span>.</span><span>.</span><span>.</span></span>`;
   6502 }
   6503 
   6504 function highlightMentionsInText(text) {
   6505   const escaped = escapeHtml(text || "");
   6506   if (!escaped) return "";
   6507   return escaped.replace(/(^|[\s(>])@([a-z0-9][a-z0-9_.-]{0,31})/gi, (full, lead, name) => {
   6508     const normalized = String(name || "").toLowerCase();
   6509     const mine = loggedInUser && normalized === loggedInUser ? " mentionTokenMe" : "";
   6510     return `${lead}<span class="mentionToken${mine}">@${escapeHtml(name)}</span>`;
   6511   });
   6512 }
   6513 
   6514 function decorateMentionNodesInElement(rootEl) {
   6515   if (!rootEl) return;
   6516   const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT);
   6517   const targets = [];
   6518   for (let node = walker.nextNode(); node; node = walker.nextNode()) {
   6519     const parent = node.parentElement;
   6520     if (!parent) continue;
   6521     if (parent.closest(".mentionToken")) continue;
   6522     if (parent.closest("a")) continue;
   6523     const text = String(node.nodeValue || "");
   6524     if (!/@[a-z0-9_][a-z0-9_.-]{0,31}/i.test(text)) continue;
   6525     targets.push(node);
   6526   }
   6527   for (const node of targets) {
   6528     const text = String(node.nodeValue || "");
   6529     const re = /(^|[\s(>])@([a-z0-9_][a-z0-9_.-]{0,31})/gi;
   6530     let match;
   6531     let last = 0;
   6532     const frag = document.createDocumentFragment();
   6533     let changed = false;
   6534     while ((match = re.exec(text))) {
   6535       const start = match.index;
   6536       const lead = match[1] || "";
   6537       const rawName = match[2] || "";
   6538       const mentionStart = start + lead.length;
   6539       if (mentionStart > last) {
   6540         frag.appendChild(document.createTextNode(text.slice(last, mentionStart)));
   6541       }
   6542       const normalized = String(rawName).toLowerCase();
   6543       const span = document.createElement("span");
   6544       span.className = `mentionToken${loggedInUser && normalized === loggedInUser ? " mentionTokenMe" : ""}`;
   6545       span.textContent = `@${rawName}`;
   6546       frag.appendChild(span);
   6547       last = mentionStart + 1 + rawName.length;
   6548       changed = true;
   6549     }
   6550     if (!changed) continue;
   6551     if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last)));
   6552     node.parentNode?.replaceChild(frag, node);
   6553   }
   6554 }
   6555 
   6556 function youtubeVideoIdFromUrl(rawUrl) {
   6557   const raw = String(rawUrl || "").trim();
   6558   if (!raw) return "";
   6559   const urlText = /^https?:\/\//i.test(raw) ? raw : `https://${raw.replace(/^\/+/, "")}`;
   6560   let url;
   6561   try {
   6562     url = new URL(urlText);
   6563   } catch {
   6564     return "";
   6565   }
   6566 
   6567   const host = String(url.hostname || "").toLowerCase();
   6568   const path = String(url.pathname || "");
   6569   const isYouTube =
   6570     host === "youtu.be" ||
   6571     host.endsWith(".youtu.be") ||
   6572     host === "youtube.com" ||
   6573     host.endsWith(".youtube.com") ||
   6574     host === "youtube-nocookie.com" ||
   6575     host.endsWith(".youtube-nocookie.com");
   6576   if (!isYouTube) return "";
   6577 
   6578   let id = "";
   6579   if (host.includes("youtu.be")) {
   6580     id = path.split("/").filter(Boolean)[0] || "";
   6581   } else {
   6582     const v = url.searchParams.get("v");
   6583     if (v) id = v;
   6584     if (!id) {
   6585       const parts = path.split("/").filter(Boolean);
   6586       if (parts[0] === "shorts") id = parts[1] || "";
   6587       if (!id && parts[0] === "embed") id = parts[1] || "";
   6588     }
   6589   }
   6590 
   6591   id = String(id || "").trim();
   6592   if (!/^[a-zA-Z0-9_-]{11}$/.test(id)) return "";
   6593   return id;
   6594 }
   6595 
   6596 function buildYouTubeEmbedEl(videoId) {
   6597   const id = String(videoId || "").trim();
   6598   if (!/^[a-zA-Z0-9_-]{11}$/.test(id)) return null;
   6599   const wrap = document.createElement("div");
   6600   wrap.className = "ytEmbed";
   6601   const iframe = document.createElement("iframe");
   6602   iframe.setAttribute("title", "YouTube video");
   6603   iframe.setAttribute("loading", "lazy");
   6604   iframe.setAttribute("allowfullscreen", "true");
   6605   iframe.setAttribute(
   6606     "allow",
   6607     "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
   6608   );
   6609   iframe.setAttribute("referrerpolicy", "strict-origin-when-cross-origin");
   6610   iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-presentation allow-popups");
   6611   iframe.src = `https://www.youtube-nocookie.com/embed/${id}`;
   6612   wrap.appendChild(iframe);
   6613   return wrap;
   6614 }
   6615 
   6616 function decorateYouTubeEmbedsInElement(rootEl) {
   6617   if (!rootEl) return;
   6618   const existing = rootEl.querySelectorAll(".ytEmbed iframe[src*=\"youtube-nocookie.com/embed/\"]");
   6619   if (existing && existing.length) return;
   6620 
   6621   const anchors = Array.from(rootEl.querySelectorAll("a[href]"));
   6622   for (const a of anchors) {
   6623     const href = a.getAttribute("href") || "";
   6624     const id = youtubeVideoIdFromUrl(href);
   6625     if (!id) continue;
   6626     const next = a.nextElementSibling;
   6627     if (next && next.classList.contains("ytEmbed")) continue;
   6628     const embed = buildYouTubeEmbedEl(id);
   6629     if (!embed) continue;
   6630     a.insertAdjacentElement("afterend", embed);
   6631   }
   6632 
   6633   const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT);
   6634   const nodes = [];
   6635   for (let node = walker.nextNode(); node; node = walker.nextNode()) {
   6636     const parent = node.parentElement;
   6637     if (!parent) continue;
   6638     if (parent.closest("a")) continue;
   6639     if (parent.closest(".ytEmbed")) continue;
   6640     const text = String(node.nodeValue || "");
   6641     if (!/(youtu\.be\/|youtube\.com\/|youtube-nocookie\.com\/)/i.test(text)) continue;
   6642     nodes.push(node);
   6643   }
   6644 
   6645   for (const node of nodes) {
   6646     const text = String(node.nodeValue || "");
   6647     const re = /(https?:\/\/[^\s<]+|www\.[^\s<]+)/gi;
   6648     let match;
   6649     let last = 0;
   6650     const frag = document.createDocumentFragment();
   6651     let changed = false;
   6652     while ((match = re.exec(text))) {
   6653       const urlToken = String(match[0] || "");
   6654       const start = match.index;
   6655       if (start > last) frag.appendChild(document.createTextNode(text.slice(last, start)));
   6656       const id = youtubeVideoIdFromUrl(urlToken);
   6657       if (!id) {
   6658         frag.appendChild(document.createTextNode(urlToken));
   6659         last = start + urlToken.length;
   6660         continue;
   6661       }
   6662       changed = true;
   6663       const a = document.createElement("a");
   6664       const href = /^https?:\/\//i.test(urlToken) ? urlToken : `https://${urlToken}`;
   6665       a.href = href;
   6666       a.target = "_blank";
   6667       a.rel = "noopener noreferrer nofollow";
   6668       a.textContent = urlToken;
   6669       frag.appendChild(a);
   6670       frag.appendChild(buildYouTubeEmbedEl(id));
   6671       last = start + urlToken.length;
   6672     }
   6673     if (!changed) continue;
   6674     if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last)));
   6675     node.parentNode?.replaceChild(frag, node);
   6676   }
   6677 }
   6678 
   6679 function findChatMessage(postId, messageId) {
   6680   const list = chatByPost.get(postId) || [];
   6681   return list.find((m) => m && m.id === messageId) || null;
   6682 }
   6683 
   6684 function setReplyToMessage(message) {
   6685   replyToMessage = message || null;
   6686   if (!chatReplyBanner || !chatReplyWho || !chatReplyText) return;
   6687   if (!replyToMessage) {
   6688     chatReplyBanner.classList.add("hidden");
   6689     chatReplyWho.textContent = "";
   6690     chatReplyText.textContent = "";
   6691     return;
   6692   }
   6693   chatReplyBanner.classList.remove("hidden");
   6694   const who = replyToMessage.fromUser ? `@${replyToMessage.fromUser}` : "unknown";
   6695   chatReplyWho.textContent = who;
   6696   const text = String(replyToMessage.text || "").replace(/\s+/g, " ").trim();
   6697   chatReplyText.textContent = text ? `- ${text.slice(0, 96)}` : "- [media]";
   6698 }
   6699 
   6700 function listMentionCandidates(query) {
   6701   const q = String(query || "")
   6702     .trim()
   6703     .toLowerCase()
   6704     .replace(/^@+/, "");
   6705   const list = Array.isArray(peopleMembers) && peopleMembers.length ? peopleMembers : fallbackPeopleFromProfiles();
   6706   const filtered = list
   6707     .map((m) => String(m.username || "").toLowerCase())
   6708     .filter(Boolean)
   6709     .filter((u) => (q ? u.includes(q) : true))
   6710     .slice(0, 8);
   6711   return Array.from(new Set(filtered));
   6712 }
   6713 
   6714 function getCaretRect() {
   6715   const sel = window.getSelection();
   6716   if (!sel || sel.rangeCount === 0) return null;
   6717   const range = sel.getRangeAt(0).cloneRange();
   6718   range.collapse(true);
   6719   const rects = range.getClientRects();
   6720   if (rects && rects.length) return rects[0];
   6721   const node = range.startContainer && range.startContainer.parentElement ? range.startContainer.parentElement : null;
   6722   return node ? node.getBoundingClientRect() : null;
   6723 }
   6724 
   6725 function renderMentionMenu() {
   6726   if (!mentionMenuEl) return;
   6727   const open = Boolean(mentionState.open && mentionState.items.length);
   6728   mentionMenuEl.classList.toggle("hidden", !open);
   6729   if (!open) {
   6730     mentionMenuEl.innerHTML = "";
   6731     return;
   6732   }
   6733   const rect = mentionState.anchorRect || getCaretRect();
   6734   if (rect) {
   6735     const top = Math.min(window.innerHeight - 180, rect.bottom + 6);
   6736     const left = Math.min(window.innerWidth - 220, rect.left);
   6737     mentionMenuEl.style.top = `${Math.max(10, top)}px`;
   6738     mentionMenuEl.style.left = `${Math.max(10, left)}px`;
   6739   }
   6740   mentionMenuEl.innerHTML = mentionState.items
   6741     .map((u, idx) => {
   6742       const on = idx === mentionState.selected;
   6743       return `<div class="mentionItem ${on ? "isOn" : ""}" role="option" data-mentionpick="${escapeHtml(u)}">@${escapeHtml(u)}</div>`;
   6744     })
   6745     .join("");
   6746 }
   6747 
   6748 mentionMenuEl?.addEventListener("mousedown", (e) => {
   6749   const item = e.target.closest("[data-mentionpick]");
   6750   if (!item) return;
   6751   e.preventDefault(); // keep focus in editor
   6752   const picked = item.getAttribute("data-mentionpick") || "";
   6753   if (!picked) return;
   6754   replaceCurrentMentionToken(picked);
   6755   closeMentionMenu();
   6756   chatEditor?.focus();
   6757 });
   6758 
   6759 function closeMentionMenu() {
   6760   mentionState = { open: false, query: "", selected: 0, items: [], anchorRect: null };
   6761   renderMentionMenu();
   6762 }
   6763 
   6764 function replaceCurrentMentionToken(username) {
   6765   const sel = window.getSelection();
   6766   if (!sel || sel.rangeCount === 0) return;
   6767   const range = sel.getRangeAt(0);
   6768   if (!range.collapsed) return;
   6769   const node = range.startContainer;
   6770   if (!node || node.nodeType !== Node.TEXT_NODE) return;
   6771   const text = String(node.nodeValue || "");
   6772   const caret = range.startOffset;
   6773   const before = text.slice(0, caret);
   6774   const after = text.slice(caret);
   6775   const atIndex = before.lastIndexOf("@");
   6776   if (atIndex < 0) return;
   6777   const prefix = before.slice(0, atIndex);
   6778   const next = `${prefix}@${username} ${after}`;
   6779   node.nodeValue = next;
   6780   const newOffset = (prefix + `@${username} `).length;
   6781   const newRange = document.createRange();
   6782   newRange.setStart(node, Math.min(newOffset, node.nodeValue.length));
   6783   newRange.collapse(true);
   6784   sel.removeAllRanges();
   6785   sel.addRange(newRange);
   6786 }
   6787 
   6788 function wsUrl() {
   6789   const isHttps = location.protocol === "https:";
   6790   const proto = isHttps ? "wss:" : "ws:";
   6791   return `${proto}//${location.host}/ws`;
   6792 }
   6793 
   6794 function setConn(state) {
   6795   if (state === "open") {
   6796     connBadge.textContent = "Connected";
   6797     connBadge.className = "badge badge-good";
   6798   } else if (state === "closed") {
   6799     connBadge.textContent = "Disconnected";
   6800     connBadge.className = "badge badge-bad";
   6801   } else {
   6802     connBadge.textContent = "Connecting...";
   6803     connBadge.className = "badge badge-warn";
   6804   }
   6805 }
   6806 
   6807 function escapeHtml(str) {
   6808   return String(str)
   6809     .replaceAll("&", "&amp;")
   6810     .replaceAll("<", "&lt;")
   6811     .replaceAll(">", "&gt;")
   6812     .replaceAll('"', "&quot;")
   6813     .replaceAll("'", "&#039;");
   6814 }
   6815 
   6816 function cssEscape(str) {
   6817   const raw = String(str ?? "");
   6818   if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(raw);
   6819   return raw.replace(/[^a-zA-Z0-9_-]/g, (m) => `\\${m}`);
   6820 }
   6821 
   6822 function parseKeywords(str) {
   6823   if (!str) return [];
   6824   const parts = str
   6825     .split(",")
   6826     .map((s) => s.trim().toLowerCase())
   6827     .filter(Boolean);
   6828   return Array.from(new Set(parts)).slice(0, 6);
   6829 }
   6830 
   6831 function normalizePostMode(mode) {
   6832   const m = String(mode || "").trim().toLowerCase();
   6833   if (m === "walkie") return "walkie";
   6834   if (m === "stream") return "stream";
   6835   return "text";
   6836 }
   6837 
   6838 function normalizeStreamKind(kind) {
   6839   const k = String(kind || "").trim().toLowerCase();
   6840   if (k === "screen" || k === "audio") return k;
   6841   return "webcam";
   6842 }
   6843 
   6844 function streamKindLabel(kind) {
   6845   const k = normalizeStreamKind(kind);
   6846   if (k === "screen") return "Screen share";
   6847   if (k === "audio") return "Audio only";
   6848   return "Webcam";
   6849 }
   6850 
   6851 function isStreamPost(post) {
   6852   return normalizePostMode(post?.mode || post?.chatMode || "") === "stream";
   6853 }
   6854 
   6855 function formatCountdown(expiresAt) {
   6856   if (!Number(expiresAt || 0) || Number(expiresAt) <= 0) return "permanent";
   6857   const ms = expiresAt - Date.now();
   6858   if (ms <= 0) return "expired";
   6859   const totalSeconds = Math.floor(ms / 1000);
   6860   const seconds = totalSeconds % 60;
   6861   const totalMinutes = Math.floor(totalSeconds / 60);
   6862   const minutes = totalMinutes % 60;
   6863   const hours = Math.floor(totalMinutes / 60);
   6864   if (hours > 0) return `${hours}h ${minutes}m`;
   6865   if (minutes > 0) return `${minutes}m ${seconds}s`;
   6866   return `${seconds}s`;
   6867 }
   6868 
   6869 function formatBoostRemaining(boostUntil) {
   6870   const ms = boostUntil - Date.now();
   6871   if (ms <= 0) return "";
   6872   const totalSeconds = Math.floor(ms / 1000);
   6873   const seconds = totalSeconds % 60;
   6874   const totalMinutes = Math.floor(totalSeconds / 60);
   6875   const minutes = totalMinutes % 60;
   6876   const hours = Math.floor(totalMinutes / 60);
   6877   if (hours > 0) return `${hours}h ${minutes}m`;
   6878   if (minutes > 0) return `${minutes}m ${seconds}s`;
   6879   return `${seconds}s`;
   6880 }
   6881 
   6882 function rankTime(post) {
   6883   return Math.max(Number(post.lastActivityAt || post.createdAt || 0), Number(post.boostUntil || 0));
   6884 }
   6885 
   6886 function normalizePrefs(raw) {
   6887   const starred = Array.isArray(raw?.starredPostIds) ? raw.starredPostIds.filter((x) => typeof x === "string" && x) : [];
   6888   const hidden = Array.isArray(raw?.hiddenPostIds) ? raw.hiddenPostIds.filter((x) => typeof x === "string" && x) : [];
   6889   const ignored = Array.isArray(raw?.ignoredUsers) ? raw.ignoredUsers.filter((x) => typeof x === "string" && x) : [];
   6890   const blocked = Array.isArray(raw?.blockedUsers) ? raw.blockedUsers.filter((x) => typeof x === "string" && x) : [];
   6891   const cleanUsers = (list) =>
   6892     [...new Set(list.map((u) => String(u).trim().toLowerCase().replace(/^@+/, "")).filter(Boolean))].slice(0, 400);
   6893   return {
   6894     starredPostIds: [...new Set(starred)],
   6895     hiddenPostIds: [...new Set(hidden)],
   6896     ignoredUsers: cleanUsers(ignored),
   6897     blockedUsers: cleanUsers(blocked),
   6898   };
   6899 }
   6900 
   6901 function setUserPrefs(raw) {
   6902   userPrefs = normalizePrefs(raw || {});
   6903   if (!loggedInUser && activeHiveView !== "all") activeHiveView = "all";
   6904 }
   6905 
   6906 function normalizeCollections(rawList) {
   6907   const list = Array.isArray(rawList) ? rawList : [];
   6908   const out = [];
   6909   for (const item of list) {
   6910     if (!item || typeof item !== "object") continue;
   6911     const id = String(item.id || "").trim();
   6912     const name = String(item.name || "").trim();
   6913     if (!id || !name) continue;
   6914     out.push({
   6915       id,
   6916       name,
   6917       order: Number(item.order || 0) || 0,
   6918       archived: Boolean(item.archived),
   6919       visibility: item.visibility === "gated" ? "gated" : "public",
   6920       allowedRoles: Array.isArray(item.allowedRoles) ? item.allowedRoles.map((x) => String(x || "").toLowerCase()).filter(Boolean) : []
   6921     });
   6922   }
   6923   out.sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || a.name.localeCompare(b.name));
   6924   return out;
   6925 }
   6926 
   6927 function activeCollections() {
   6928   return collections.filter((c) => !c.archived);
   6929 }
   6930 
   6931 function ensureActiveCollectionView() {
   6932   if (!String(activeHiveView).startsWith("collection:")) return;
   6933   const id = String(activeHiveView).slice("collection:".length);
   6934   if (!activeCollections().some((c) => c.id === id)) activeHiveView = "all";
   6935 }
   6936 
   6937 function renderCollectionSelect() {
   6938   if (!postCollectionEl) return;
   6939   const list = activeCollections();
   6940   const opts = list.map((c) => `<option value="${escapeHtml(c.id)}">${escapeHtml(c.name)}</option>`).join("");
   6941   postCollectionEl.innerHTML = opts;
   6942   if (!postCollectionEl.value && list.length) postCollectionEl.value = list[0].id;
   6943 }
   6944 
   6945 function normalizeRoleDefs(rawList) {
   6946   const list = Array.isArray(rawList) ? rawList : [];
   6947   const out = [];
   6948   for (const item of list) {
   6949     if (!item || typeof item !== "object") continue;
   6950     const key = String(item.key || "").trim().toLowerCase();
   6951     const label = String(item.label || "").trim();
   6952     if (!key || !label) continue;
   6953     out.push({
   6954       key,
   6955       label,
   6956       color: /^#[0-9a-f]{6}$/i.test(String(item.color || "")) ? String(item.color).toLowerCase() : "",
   6957       order: Number(item.order || 0) || 0
   6958     });
   6959   }
   6960   out.sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || a.label.localeCompare(b.label));
   6961   return out;
   6962 }
   6963 
   6964 function normalizePlugins(rawList) {
   6965   const list = Array.isArray(rawList) ? rawList : [];
   6966   const out = [];
   6967   for (const item of list) {
   6968     if (!item || typeof item !== "object") continue;
   6969     const id = String(item.id || "").trim().toLowerCase();
   6970     if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(id)) continue;
   6971     out.push({
   6972       id,
   6973       name: String(item.name || id).trim().slice(0, 64) || id,
   6974       version: String(item.version || "0.0.0").trim().slice(0, 32),
   6975       description: String(item.description || "").trim().slice(0, 240),
   6976       enabled: Boolean(item.enabled),
   6977       entryClient: String(item.entryClient || "").trim(),
   6978       entryServer: String(item.entryServer || "").trim(),
   6979       permissions: Array.isArray(item.permissions)
   6980         ? item.permissions.filter((p) => typeof p === "string" && p.trim()).map((p) => p.trim().slice(0, 64)).slice(0, 24)
   6981         : [],
   6982       error: String(item.error || "").trim().slice(0, 280),
   6983     });
   6984   }
   6985   out.sort((a, b) => a.name.localeCompare(b.name));
   6986   return out;
   6987 }
   6988 
   6989 function isOwnerUser() {
   6990   return Boolean(loggedInUser && isOwnerRole(loggedInRole));
   6991 }
   6992 
   6993 function canManagePlugins() {
   6994   return Boolean(loggedInUser && canManagePluginsRole(loggedInRole));
   6995 }
   6996 
   6997 function renderPluginsAdminHtml() {
   6998   if (!canManagePlugins()) return `<div class="muted small">Admin/owner only.</div>`;
   6999   const status = pluginAdminStatus ? `<div class="small muted">${escapeHtml(pluginAdminStatus)}</div>` : "";
   7000   const busyLine = pluginAdminBusy ? `<div class="small muted">Working...</div>` : "";
   7001   const listHtml = !plugins.length
   7002     ? `<div class="muted small">No plugins installed yet.</div>`
   7003     : plugins
   7004     .map((p) => {
   7005       const badges = [];
   7006       if (p.entryClient) badges.push(`<span class="pluginBadge">client</span>`);
   7007       if (p.entryServer) badges.push(`<span class="pluginBadge">server</span>`);
   7008       for (const perm of p.permissions || []) badges.push(`<span class="pluginBadge">${escapeHtml(perm)}</span>`);
   7009       const err = p.error ? `<div class="pluginError">${escapeHtml(p.error)}</div>` : "";
   7010       return `<div class="pluginRow">
   7011         <div class="pluginLeft">
   7012           <div class="pluginName">${escapeHtml(p.name)} <span class="muted small">v${escapeHtml(p.version)}</span></div>
   7013           ${p.description ? `<div class="pluginDesc">${escapeHtml(p.description)}</div>` : ""}
   7014           ${badges.length ? `<div class="pluginBadges">${badges.join("")}</div>` : ""}
   7015           ${err}
   7016         </div>
   7017         <div class="pluginRight">
   7018           <label class="checkRow" style="justify-content:flex-end; gap:10px">
   7019             <span>Enabled</span>
   7020             <input type="checkbox" data-pluginenable="${escapeHtml(p.id)}" ${p.enabled ? "checked" : ""} ${
   7021               pluginEnableInFlight.has(p.id) || pluginAdminBusy ? "disabled" : ""
   7022             } />
   7023           </label>
   7024           <button type="button" class="danger smallBtn" data-pluginuninstall="${escapeHtml(p.id)}">Uninstall</button>
   7025         </div>
   7026       </div>`;
   7027     })
   7028     .join("");
   7029   return `
   7030     <div class="small muted">Admin/owner only. Install optional plugins to extend your instance.</div>
   7031     <div class="pluginInstallRow" style="margin-top:10px">
   7032       <input data-pluginzip="1" type="file" accept=".zip,application/zip" />
   7033       <button data-plugininstall="1" class="ghost" type="button">Install</button>
   7034       <button data-pluginreload="1" class="ghost" type="button">Reload</button>
   7035     </div>
   7036     ${busyLine}
   7037     ${status}
   7038     <div class="pluginsList">${listHtml}</div>
   7039   `;
   7040 }
   7041 
   7042 function ensureEnabledPluginClientScripts() {
   7043   if (!Array.isArray(plugins) || !plugins.length) return;
   7044   for (const p of plugins) {
   7045     if (!p || !p.enabled) continue;
   7046     if (!p.entryClient) continue;
   7047     const wantVersion = String(p.version || "0");
   7048     const loadedVersion = loadedPluginClientVersionById.get(p.id) || "";
   7049     if (loadedVersion && loadedVersion === wantVersion) continue;
   7050     const src = `/plugins/${encodeURIComponent(p.id)}/${p.entryClient}?v=${encodeURIComponent(p.version || "0")}`;
   7051     const script = document.createElement("script");
   7052     script.src = src;
   7053     script.defer = true;
   7054     script.onload = () => {
   7055       loadedPluginClientVersionById.set(p.id, wantVersion);
   7056     };
   7057     script.onerror = () => {
   7058       pluginAdminStatus = `Failed to load plugin "${p.id}".`;
   7059       toast("Plugins", pluginAdminStatus);
   7060       renderModPanel();
   7061     };
   7062     document.head.appendChild(script);
   7063   }
   7064 }
   7065 
   7066 function setPlugins(rawList) {
   7067   plugins = normalizePlugins(rawList);
   7068   ensureEnabledPluginClientScripts();
   7069   if (canModerate && modTab === "server") renderModPanel();
   7070 }
   7071 
   7072 function roleDefByKey(key) {
   7073   return customRoles.find((r) => r.key === key) || null;
   7074 }
   7075 
   7076 function roleTokenLabel(token) {
   7077   const t = String(token || "");
   7078   if (t === "owner" || t === "admin" || t === "moderator" || t === "member") return t;
   7079   if (t.startsWith("role:")) {
   7080     const key = t.slice("role:".length);
   7081     const found = roleDefByKey(key);
   7082     return found ? found.label : key;
   7083   }
   7084   return t;
   7085 }
   7086 
   7087 function userCustomRoleKeys(username) {
   7088   const member = (peopleMembers || []).find((m) => m && m.username === username);
   7089   const keys = Array.isArray(member?.customRoles) ? member.customRoles : [];
   7090   return keys.filter((x) => typeof x === "string" && x);
   7091 }
   7092 
   7093 function renderCustomRoleBadges(username) {
   7094   const keys = userCustomRoleKeys(username);
   7095   if (!keys.length) return "";
   7096   const parts = keys
   7097     .map((key) => {
   7098       const def = roleDefByKey(key);
   7099       if (!def) return `<span class="modStatus">${escapeHtml(key)}</span>`;
   7100       const style = def.color ? ` style="border-color:${escapeHtml(def.color)}66;color:${escapeHtml(def.color)}"` : "";
   7101       return `<span class="modStatus"${style}>${escapeHtml(def.label)}</span>`;
   7102     })
   7103     .join(" ");
   7104   return `<span class="customRoleRow">${parts}</span>`;
   7105 }
   7106 
   7107 function availableGateTokens() {
   7108   const base = ["member", "moderator", "admin", "owner"];
   7109   const custom = customRoles.map((r) => `role:${r.key}`);
   7110   return [...base, ...custom];
   7111 }
   7112 
   7113 function prefSet(key) {
   7114   return new Set(Array.isArray(userPrefs?.[key]) ? userPrefs[key] : []);
   7115 }
   7116 
   7117 function totalReactions(post) {
   7118   const reactions = post?.reactions && typeof post.reactions === "object" ? post.reactions : {};
   7119   let total = 0;
   7120   for (const count of Object.values(reactions)) total += Number(count || 0);
   7121   return total;
   7122 }
   7123 
   7124 function sortPosts(list) {
   7125   const mode = String(sortByEl?.value || "activity");
   7126   if (mode === "popular") {
   7127     return list.sort((a, b) => totalReactions(b) - totalReactions(a) || rankTime(b) - rankTime(a) || b.createdAt - a.createdAt);
   7128   }
   7129   if (mode === "expiring") {
   7130     const exp = (p) => {
   7131       const t = Number(p?.expiresAt || 0) || 0;
   7132       return t > 0 ? t : Number.MAX_SAFE_INTEGER;
   7133     };
   7134     return list.sort((a, b) => exp(a) - exp(b) || rankTime(b) - rankTime(a));
   7135   }
   7136   return list.sort((a, b) => rankTime(b) - rankTime(a) || b.createdAt - a.createdAt);
   7137 }
   7138 
   7139 function currentSortMode() {
   7140   return String(sortByEl?.value || "activity");
   7141 }
   7142 
   7143 function updateMobileSortCycleLabel() {
   7144   if (!(mobileSortCycleBtn instanceof HTMLElement)) return;
   7145   const mode = currentSortMode();
   7146   const label = mode === "popular" ? "Popular" : mode === "expiring" ? "Ending" : "Recent";
   7147   mobileSortCycleBtn.textContent = label;
   7148 }
   7149 
   7150 function getProfile(username) {
   7151   if (!username) return { image: "", color: "" };
   7152   const p = profiles[username] || {};
   7153   return { image: p.image || "", color: p.color || "" };
   7154 }
   7155 
   7156 function normalizeProfileLinks(list) {
   7157   if (!Array.isArray(list)) return [];
   7158   const out = [];
   7159   for (const item of list) {
   7160     if (!item || typeof item !== "object") continue;
   7161     const label = String(item.label || "")
   7162       .replace(/\s+/g, " ")
   7163       .trim()
   7164       .slice(0, 40);
   7165     const url = String(item.url || "").trim().slice(0, 280);
   7166     if (!/^https?:\/\//i.test(url)) continue;
   7167     out.push({ label: label || "Link", url });
   7168     if (out.length >= 8) break;
   7169   }
   7170   return out;
   7171 }
   7172 
   7173 function normalizeProfileData(raw, fallbackUsername = "") {
   7174   const username = String(raw?.username || fallbackUsername || "")
   7175     .trim()
   7176     .toLowerCase();
   7177   const image = typeof raw?.image === "string" ? raw.image : getProfile(username).image || "";
   7178   const colorRaw = typeof raw?.color === "string" ? raw.color : getProfile(username).color || "";
   7179   const color = /^#[0-9a-f]{6}$/i.test(colorRaw) ? colorRaw.toLowerCase() : "";
   7180   const pronouns = String(raw?.pronouns || "")
   7181     .replace(/\s+/g, " ")
   7182     .trim()
   7183     .slice(0, 40);
   7184   const bioHtml = typeof raw?.bioHtml === "string" ? raw.bioHtml : "";
   7185   const themeSongUrl = typeof raw?.themeSongUrl === "string" ? raw.themeSongUrl.trim() : "";
   7186   const links = normalizeProfileLinks(raw?.links);
   7187   return { username, image, color, pronouns, bioHtml, themeSongUrl, links };
   7188 }
   7189 
   7190 function asProfileLink(url) {
   7191   const value = String(url || "").trim();
   7192   if (!/^https?:\/\//i.test(value)) return "";
   7193   return value;
   7194 }
   7195 
   7196 function renderUserPill(username) {
   7197   if (!username) return `<span class="muted small">anon</span>`;
   7198   const p = getProfile(username);
   7199   const image = typeof p.image === "string" ? p.image : "";
   7200   const color = p.color && /^#[0-9a-f]{6}$/i.test(p.color) ? p.color : "";
   7201   const safeTextColor = color ? safeTextColorFromHex(color) : "";
   7202   const style = safeTextColor ? `style="color:${escapeHtml(safeTextColor)}"` : "";
   7203   const img = image ? `<img alt="" src="${escapeHtml(image)}" />` : "";
   7204   const extra = renderCustomRoleBadges(username);
   7205   const normalized = String(username || "").trim().toLowerCase();
   7206   return `<button type="button" class="userPill userPillLink" data-viewprofile="${escapeHtml(
   7207     normalized
   7208   )}" title="View profile"><span class="pfp">${img}</span><span ${style}>@${escapeHtml(username)}</span>${extra}</button>`;
   7209 }
   7210 
   7211 function hexToRgb(hex) {
   7212   const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex || "");
   7213   if (!m) return null;
   7214   return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) };
   7215 }
   7216 
   7217 function srgbToLinear(x) {
   7218   const c = x / 255;
   7219   if (c <= 0.04045) return c / 12.92;
   7220   return Math.pow((c + 0.055) / 1.055, 2.4);
   7221 }
   7222 
   7223 function relativeLuminanceFromRgb(rgb) {
   7224   if (!rgb) return 1;
   7225   const r = srgbToLinear(rgb.r);
   7226   const g = srgbToLinear(rgb.g);
   7227   const b = srgbToLinear(rgb.b);
   7228   return 0.2126 * r + 0.7152 * g + 0.0722 * b;
   7229 }
   7230 
   7231 function rgbToHex(rgb) {
   7232   const clamp = (n) => Math.max(0, Math.min(255, Math.round(n)));
   7233   const to2 = (n) => clamp(n).toString(16).padStart(2, "0");
   7234   return `#${to2(rgb.r)}${to2(rgb.g)}${to2(rgb.b)}`;
   7235 }
   7236 
   7237 function mixRgb(a, b, t) {
   7238   const k = Math.max(0, Math.min(1, Number(t) || 0));
   7239   return {
   7240     r: a.r + (b.r - a.r) * k,
   7241     g: a.g + (b.g - a.g) * k,
   7242     b: a.b + (b.b - a.b) * k,
   7243   };
   7244 }
   7245 
   7246 function safeTextColorFromHex(hex) {
   7247   const rgb = hexToRgb(hex);
   7248   if (!rgb) return "";
   7249   const baseLum = relativeLuminanceFromRgb(rgb);
   7250   if (baseLum >= 0.38) return rgbToHex(rgb);
   7251   const white = { r: 255, g: 255, b: 255 };
   7252   let best = rgb;
   7253   for (let t = 0.10; t <= 0.85; t += 0.08) {
   7254     const mixed = mixRgb(rgb, white, t);
   7255     if (relativeLuminanceFromRgb(mixed) >= 0.42) {
   7256       best = mixed;
   7257       break;
   7258     }
   7259     best = mixed;
   7260   }
   7261   return rgbToHex(best);
   7262 }
   7263 
   7264 function tintStylesFromHex(hex) {
   7265   const rgb = hexToRgb(hex);
   7266   if (!rgb) return "";
   7267   const bg = `rgba(${rgb.r},${rgb.g},${rgb.b},0.10)`;
   7268   const border = `rgba(${rgb.r},${rgb.g},${rgb.b},0.22)`;
   7269   return `style="background:${bg};border-color:${border}"`;
   7270 }
   7271 
   7272 function cardTintStylesFromHex(hex) {
   7273   const rgb = hexToRgb(hex);
   7274   if (!rgb) return "";
   7275   const bg = `linear-gradient(180deg, rgba(${rgb.r},${rgb.g},${rgb.b},0.11), rgba(${rgb.r},${rgb.g},${rgb.b},0.03) 48%), var(--panel2)`;
   7276   const border = `rgba(${rgb.r},${rgb.g},${rgb.b},0.34)`;
   7277   const glow = `0 10px 24px rgba(${rgb.r},${rgb.g},${rgb.b},0.14)`;
   7278   return `style="background:${bg};border-color:${border};box-shadow:${glow}"`;
   7279 }
   7280 
   7281 function matchesFilter(post, filterSet, authorQuery, hiddenSet, starredSet, ignoreUserSet, visibleCollectionIds) {
   7282   if (visibleCollectionIds && !visibleCollectionIds.has(String(post.collectionId || ""))) return false;
   7283   if (hiddenSet.has(post.id) && activeHiveView !== "hidden") return false;
   7284   if (activeHiveView === "starred" && !starredSet.has(post.id)) return false;
   7285   if (activeHiveView === "hidden" && !hiddenSet.has(post.id)) return false;
   7286   const author = String(post.author || "").toLowerCase();
   7287   if (author && ignoreUserSet && ignoreUserSet.has(author) && (!loggedInUser || author !== String(loggedInUser).toLowerCase())) return false;
   7288   if (String(activeHiveView).startsWith("collection:")) {
   7289     const collectionId = String(activeHiveView).slice("collection:".length);
   7290     if ((post.collectionId || "") !== collectionId) return false;
   7291   }
   7292   if (filterSet.size > 0) {
   7293     let matched = false;
   7294     for (const kw of post.keywords || []) {
   7295       if (filterSet.has(kw)) {
   7296         matched = true;
   7297         break;
   7298       }
   7299     }
   7300     if (!matched) return false;
   7301   }
   7302   if (authorQuery && !author.includes(authorQuery)) return false;
   7303   return true;
   7304 }
   7305 
   7306 function postTitle(post) {
   7307   if (post.locked) return "Protected post";
   7308   const text = String(post.title || post.content || "").replace(/\s+/g, " ").trim();
   7309   if (!text) return "(untitled)";
   7310   return text.length > 96 ? `${text.slice(0, 96)}...` : text;
   7311 }
   7312 
   7313 function myReactKey(kind, id, emoji) {
   7314   return `${kind}:${id}:${emoji}`;
   7315 }
   7316 
   7317 function toggleMyReact(kind, id, emoji) {
   7318   const key = myReactKey(kind, id, emoji);
   7319   if (myReacts.has(key)) myReacts.delete(key);
   7320   else myReacts.add(key);
   7321 }
   7322 
   7323 function markReactPulse(kind, id, emoji) {
   7324   const key = myReactKey(kind, id, emoji);
   7325   reactPulseByKey.set(key, Date.now());
   7326 }
   7327 
   7328 function renderReactionButtons({ kind, id, reactions, postId }) {
   7329   if (!showReactions) return "";
   7330   const r = reactions && typeof reactions === "object" ? reactions : {};
   7331   const emojis = kind === "post" ? allowedPostReactions : allowedChatReactions;
   7332   return `<div class="reactionsRow">
   7333     ${emojis
   7334       .map((emoji) => {
   7335         const count = Number(r[emoji] || 0);
   7336         const key = myReactKey(kind, id, emoji);
   7337         const isOn = myReacts.has(key);
   7338         const pulseAt = reactPulseByKey.get(key) || 0;
   7339         const pulse = pulseAt && Date.now() - pulseAt < 650;
   7340         if (pulseAt && !pulse) reactPulseByKey.delete(key);
   7341         const cls = `${isOn ? "reactBtn isOn" : "reactBtn"}${pulse ? " pulse" : ""}`;
   7342         const attrs =
   7343           kind === "post"
   7344             ? `data-react="1" data-kind="post" data-postid="${escapeHtml(id)}" data-emoji="${escapeHtml(emoji)}"`
   7345             : `data-react="1" data-kind="chat" data-postid="${escapeHtml(postId || "")}" data-msgid="${escapeHtml(
   7346                 id
   7347               )}" data-emoji="${escapeHtml(emoji)}"`;
   7348         return `<span class="${cls}" ${attrs}>${escapeHtml(emoji)} <span class="count">${count || ""}</span></span>`;
   7349       })
   7350       .join("")}
   7351   </div>`;
   7352 }
   7353 
   7354 function markRead(postId) {
   7355   if (!postId) return;
   7356   unreadByPostId.delete(postId);
   7357 }
   7358 
   7359 function bumpUnread(postId) {
   7360   const current = unreadByPostId.get(postId) || 0;
   7361   unreadByPostId.set(postId, Math.min(99, current + 1));
   7362 }
   7363 
   7364 function notifSupported() {
   7365   return typeof window.Notification !== "undefined";
   7366 }
   7367 
   7368 function notifState() {
   7369   if (!notifSupported()) return "unsupported";
   7370   return Notification.permission; // default | denied | granted
   7371 }
   7372 
   7373 function updateNotifUi() {
   7374   if (!enableNotifsBtn || !notifStatus) return;
   7375   const state = notifState();
   7376   const secure = location.protocol === "https:";
   7377   const hint = secure ? "" : " (requires HTTPS: use tunnel)";
   7378   const activeRules = [];
   7379   if (readNotifReplyPingPref()) activeRules.push("replies/pings");
   7380   if (readNotifMyHiveChatPref()) activeRules.push("my hive chat");
   7381   if (readNotifRecentHiveChatPref()) activeRules.push("recent hive chat");
   7382   if (readNotifNewHivePref()) activeRules.push("new hives");
   7383   const rulesLabel = activeRules.length ? activeRules.join(", ") : "no active rules";
   7384 
   7385   if (state === "unsupported") {
   7386     enableNotifsBtn.classList.add("hidden");
   7387     notifStatus.textContent = `Browser notifications not supported. Alerts: ${rulesLabel}.`;
   7388     return;
   7389   }
   7390 
   7391   enableNotifsBtn.classList.remove("hidden");
   7392   if (!secure) {
   7393     enableNotifsBtn.disabled = true;
   7394     notifStatus.textContent = `Browser notifications disabled on HTTP${hint}. Alerts: ${rulesLabel}.`;
   7395     return;
   7396   }
   7397 
   7398   enableNotifsBtn.disabled = state === "granted";
   7399   enableNotifsBtn.textContent = state === "granted" ? "Notifications enabled" : "Enable notifications";
   7400   notifStatus.textContent =
   7401     state === "granted"
   7402       ? `Browser alerts enabled. Active rules: ${rulesLabel}.`
   7403       : state === "denied"
   7404         ? `Browser alerts blocked in settings. Active rules: ${rulesLabel}.`
   7405         : `Active rules: ${rulesLabel}.`;
   7406 }
   7407 
   7408 function maybeNotify(title, body, data) {
   7409   if (notifState() !== "granted") return;
   7410   if (windowFocused && !document.hidden) return;
   7411   try {
   7412     const n = new Notification(title, { body, data });
   7413     n.onclick = () => {
   7414       window.focus();
   7415       if (data?.postId) openChat(data.postId);
   7416       if (data?.threadId) openDmThread(data.threadId);
   7417       n.close();
   7418     };
   7419   } catch {
   7420     // ignore
   7421   }
   7422 }
   7423 
   7424 function renderLanHint() {
   7425   if (!lanHint) return;
   7426   if (!canModerate || !Array.isArray(lanUrls) || lanUrls.length === 0) {
   7427     lanHint.textContent = "";
   7428     return;
   7429   }
   7430   lanHint.innerHTML = `LAN: <span class="muted">${lanUrls.map(escapeHtml).join(" | ")}</span>`;
   7431 }
   7432 
   7433 function renderFeed() {
   7434   const filter = parseKeywords(filterKeywordsEl.value);
   7435   const filterSet = new Set(filter);
   7436   const authorQuery = String(filterAuthorEl?.value || "")
   7437     .trim()
   7438     .replace(/^@+/, "")
   7439     .toLowerCase();
   7440   ensureActiveCollectionView();
   7441   const hiddenSet = prefSet("hiddenPostIds");
   7442   const starredSet = prefSet("starredPostIds");
   7443   const ignoreUserSet = new Set([...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase()));
   7444   const visibleCollectionIds = new Set(activeCollections().map((c) => c.id));
   7445   if (!loggedInUser && activeHiveView !== "all") activeHiveView = "all";
   7446   if (hiveTabsEl) {
   7447     const collectionTabs = activeCollections()
   7448       .map((c) => {
   7449         const view = `collection:${c.id}`;
   7450         const on = view === activeHiveView;
   7451         const cls = on ? "primary" : "ghost";
   7452         return `<button type="button" data-hiveview="${escapeHtml(view)}" class="${cls}">${escapeHtml(c.name)}</button>`;
   7453       })
   7454       .join("");
   7455     const allOn = activeHiveView === "all";
   7456     const starredOn = activeHiveView === "starred";
   7457     const hiddenOn = activeHiveView === "hidden";
   7458     hiveTabsEl.innerHTML = `
   7459       <button type="button" data-hiveview="all" class="${allOn ? "primary" : "ghost"}">All</button>
   7460       ${collectionTabs}
   7461       <button type="button" data-hiveview="starred" class="${starredOn ? "primary" : "ghost"}" ${loggedInUser ? "" : "disabled"}>Starred</button>
   7462       <button type="button" data-hiveview="hidden" class="${hiddenOn ? "primary" : "ghost"}" ${loggedInUser ? "" : "disabled"}>Hidden</button>
   7463     `;
   7464   }
   7465 
   7466   const list = sortPosts(Array.from(posts.values())).filter((p) =>
   7467     matchesFilter(p, filterSet, authorQuery, hiddenSet, starredSet, ignoreUserSet, visibleCollectionIds)
   7468   );
   7469 
   7470   if (list.length === 0) {
   7471     feedEl.innerHTML = `<div class="small muted">No active posts in this view/filter.</div><div class="uiHint">Tap <b>New Hive</b> to create one, or clear filters to widen results.</div>`;
   7472     return;
   7473   }
   7474 
   7475   feedEl.innerHTML = list
   7476     .map((p) => {
   7477       const tags = (p.keywords || []).map((k) => `<span class="tag">#${escapeHtml(k)}</span>`).join("");
   7478       const collectionName = activeCollections().find((c) => c.id === p.collectionId)?.name || "General";
   7479       const collectionTag = `<span class="tag">/${escapeHtml(collectionName)}</span>`;
   7480       const postedLine = `<div class="small muted">posted ${escapeHtml(new Date(p.createdAt).toLocaleString())}</div>`;
   7481       const editedLine =
   7482         Number(p.editCount || 0) > 0
   7483           ? `<div class="small muted">edited (${Number(p.editCount || 0)}) at ${escapeHtml(
   7484               new Date(Number(p.editedAt || p.createdAt)).toLocaleString()
   7485             )}</div>`
   7486           : "";
   7487       const deletedLine = p.deleted
   7488         ? `<div class="small muted">Post was deleted${
   7489             p.deletedBy ? ` by @${escapeHtml(p.deletedBy)}` : ""
   7490           } at ${escapeHtml(new Date(Number(p.deletedAt || Date.now())).toLocaleString())}${
   7491             p.deleteReason ? ` (${escapeHtml(p.deleteReason)})` : ""
   7492           }</div>`
   7493         : "";
   7494       const authorLine = p.author ? `<div class="small postAuthor">${renderUserPill(p.author)}</div>` : "";
   7495       const boostText = formatBoostRemaining(Number(p.boostUntil || 0));
   7496       const boostLine = boostText ? `<div class="countdown boost" data-boost="${p.id}">boost ${boostText}</div>` : "";
   7497 
   7498       const canBoost = Boolean(loggedInUser && !p.locked && !p.deleted && p.author && loggedInUser !== p.author);
   7499       const canManageOwnPost = Boolean(loggedInUser && !p.locked && !p.deleted && p.author && loggedInUser === p.author);
   7500       const streamMode = normalizePostMode(p.mode || p.chatMode || "");
   7501       const isStream = streamMode === "stream";
   7502       const streamLive = Boolean(streamLiveByPostId.get(p.id) ?? p.streamLive);
   7503       const streamKind = normalizeStreamKind(p.streamKind || "webcam");
   7504       const streamLine = isStream
   7505         ? `<div class="small muted postStreamLine">Stream Β· ${escapeHtml(streamKindLabel(streamKind))}${streamLive ? " Β· live now" : ""}</div>`
   7506         : "";
   7507       const boostControls = canBoost
   7508         ? `<div class="boostRow">
   7509              <select data-boostsel="${p.id}">
   7510                <option value="300000">+5m</option>
   7511                <option value="900000">+15m</option>
   7512                <option value="1800000">+30m</option>
   7513                <option value="3600000" selected>+1h</option>
   7514                <option value="7200000">+2h</option>
   7515              </select>
   7516              <button type="button" data-boostbtn="${p.id}">Boost</button>
   7517            </div>`
   7518         : "";
   7519 
   7520       const reactionsHtml = p.locked || p.deleted ? "" : renderReactionButtons({ kind: "post", id: p.id, reactions: p.reactions || {} });
   7521       const isHidden = hiddenSet.has(p.id);
   7522       const menuItems = `
   7523         ${canManageOwnPost ? `<button type="button" class="ghost" data-editpost="${p.id}">Edit</button>` : ""}
   7524         ${canManageOwnPost ? `<button type="button" class="ghost danger" data-deletepost="${p.id}">Delete</button>` : ""}
   7525         ${loggedInUser ? `<button type="button" class="ghost" data-hidepost="${p.id}">${isHidden ? "Unhide" : "Hide"}</button>` : ""}
   7526         ${loggedInUser && !p.deleted ? `<button type="button" class="ghost" data-reportpost="${p.id}">Report</button>` : ""}
   7527       `.trim();
   7528       const hasMenu = Boolean(menuItems);
   7529       const kebabBtn = hasMenu
   7530         ? `<button type="button" class="ghost smallBtn kebabBtn" data-postmenu="${p.id}" aria-haspopup="menu" aria-expanded="false" title="More">&#8942;</button>`
   7531         : "";
   7532       const postMenu = hasMenu
   7533         ? `<div class="postMenu hidden" role="menu" data-postmenu-panel="${p.id}">${menuItems}</div>`
   7534         : "";
   7535 
   7536       const unread = unreadByPostId.get(p.id) || 0;
   7537       const unreadDot = unread ? `<span class="badgeDot" title="${unread} unread"></span>` : "";
   7538       const unreadClass = unread ? " isUnread" : "";
   7539       const newClass = newPostAnimIds.has(p.id) ? " isNew" : "";
   7540       const buzzClass = buzzTimers.has(p.id) ? " isBuzz" : "";
   7541       const lockLine = p.locked ? `<div class="small muted">πŸ”’ password protected</div>` : "";
   7542       const cardTint = p.author ? cardTintStylesFromHex(getProfile(p.author).color) : "";
   7543       const contentHtml = typeof p.contentHtml === "string" && p.contentHtml.trim() ? p.contentHtml : "";
   7544       const contentText = typeof p.content === "string" && p.content.trim() ? escapeHtml(p.content) : "";
   7545       const content = contentHtml ? contentHtml : contentText ? `<div class="muted">${contentText}</div>` : "";
   7546       const contentBlock = content ? `<div class="postContent">${content}</div>` : "";
   7547       const lastChat = (chatByPost.get(p.id) || []).filter((m) => !m?.deleted).slice(-1)[0] || null;
   7548       const lastChatFrom = lastChat ? String(lastChat.fromUser || "").trim() : "";
   7549       const lastChatText = lastChat ? String(lastChat.text || "").replace(/\s+/g, " ").trim().slice(0, 92) : "";
   7550       const lastChatWho = lastChat
   7551         ? (lastChatFrom && lastChatFrom.toLowerCase() === "mod" ? "MOD" : `@${escapeHtml(lastChatFrom || "unknown")}`)
   7552         : "";
   7553       const lastChatLine = lastChat
   7554         ? `<div class="small muted postLastChat">Last chat: ${lastChatWho}${lastChatText ? ` β€” ${escapeHtml(lastChatText)}` : ""}</div>`
   7555         : "";
   7556       const typersSet = typingUsersByPostId.get(p.id);
   7557       const typingUsers = typersSet ? Array.from(typersSet.values()).slice(0, 2) : [];
   7558       const typingMore = typersSet && typersSet.size > typingUsers.length ? ` +${typersSet.size - typingUsers.length}` : "";
   7559       const typingLine = typingUsers.length
   7560         ? `<div class="small muted postTypingLine">${typingUsers.map((u) => `@${escapeHtml(u)}`).join(", ")}${typingMore} typing...</div>`
   7561         : "";
   7562 
   7563       return `
   7564       <article class="post${unreadClass}${newClass}${buzzClass}" data-id="${p.id}" ${cardTint}>
   7565         <div class="postTop">
   7566           <div class="postTitleRow">
   7567             <div class="postTitle">${escapeHtml(postTitle(p))}</div>
   7568             ${postedLine}
   7569             ${authorLine}
   7570             ${lockLine}
   7571           </div>
   7572           <div class="rightCol">
   7573             ${unreadDot}
   7574             <div class="countdown" data-countdown="${p.id}">${formatCountdown(p.expiresAt)}</div>
   7575             ${boostLine}
   7576             ${boostControls}
   7577             <div class="postActionsRow">
   7578               <button type="button" data-chat="${p.id}">${p.locked ? "Unlock" : p.deleted ? "View" : isStream ? "Watch" : "Chat"}</button>
   7579               ${kebabBtn}
   7580               ${postMenu}
   7581             </div>
   7582           </div>
   7583         </div>
   7584         ${deletedLine}
   7585         ${editedLine}
   7586         ${streamLine}
   7587         ${contentBlock}
   7588         ${typingLine}
   7589         ${lastChatLine}
   7590         <div class="postMeta">${collectionTag}${tags ? ` ${tags}` : ""}</div>
   7591         ${reactionsHtml}
   7592       </article>`;
   7593     })
   7594     .join("");
   7595 
   7596   try {
   7597     feedEl.querySelectorAll?.(".postContent").forEach((el) => decorateYouTubeEmbedsInElement(el));
   7598   } catch {
   7599     // ignore
   7600   }
   7601 }
   7602 
   7603 function isMobileChatScreenActive() {
   7604   if (!isMobileScreenMode() || !appRoot) return false;
   7605   const screen = String(appRoot.getAttribute("data-mobile-screen") || "").trim();
   7606   return screen === "chat" || (screen === "host" && mobileHostPanelId === "chat");
   7607 }
   7608 
   7609 function renderMobileChatListHtml() {
   7610   const dmActive = activeDmThreadsSorted().slice(0, 30);
   7611   const recentPostIds = recentHiveChatIds.slice(0, 24);
   7612   const recentPosts = recentPostIds.map((id) => posts.get(id)).filter((p) => p && !p.deleted);
   7613   const recentPostIdSet = new Set(recentPosts.map((p) => String(p.id)));
   7614   const availablePosts = sortPosts(Array.from(posts.values()))
   7615     .filter((p) => p && !p.deleted && !recentPostIdSet.has(String(p.id)))
   7616     .slice(0, 60);
   7617 
   7618   if (!dmActive.length && !recentPosts.length && !availablePosts.length) {
   7619     return `<div class="small muted">No active hives available for chat.</div><div class="uiHint">Create a hive in Hives first, then return here to chat.</div>`;
   7620   }
   7621 
   7622   const dmSection = dmActive.length
   7623     ? `<div class="mobileChatSection">
   7624         <div class="small muted">DMs</div>
   7625         ${dmActive
   7626           .map((t) => {
   7627             const who = `@${escapeHtml(String(t.other || "unknown"))}`;
   7628             const when = dmActivityAt(t) ? new Date(dmActivityAt(t)).toLocaleTimeString() : "active";
   7629             return `<button type="button" class="ghost mobileChatListItem" data-dmopen="${escapeHtml(t.id)}">
   7630               <span class="mobileChatListTop">${who}</span>
   7631               <span class="mobileChatListMeta">private chat Β· ${escapeHtml(when)}</span>
   7632             </button>`;
   7633           })
   7634           .join("")}
   7635       </div>`
   7636     : "";
   7637 
   7638   const postItem = (p) => {
   7639     const title = escapeHtml(postTitle(p));
   7640     const author = p.author ? `@${escapeHtml(String(p.author || ""))}` : "anon";
   7641     const exp = formatCountdown(p.expiresAt);
   7642     const lock = p.locked ? " Β· locked" : "";
   7643     const mode = normalizePostMode(p.mode || p.chatMode || "");
   7644     const streamLive = mode === "stream" && Boolean(streamLiveByPostId.get(p.id) ?? p.streamLive);
   7645     const streamTag = mode === "stream" ? ` Β· stream${streamLive ? " live" : ""}` : "";
   7646     return `<button type="button" class="ghost mobileChatListItem" data-mobilechatopen="${escapeHtml(p.id)}">
   7647       <span class="mobileChatListTop">${title}</span>
   7648       <span class="mobileChatListMeta">${author} Β· ${escapeHtml(exp)}${lock}${streamTag}</span>
   7649     </button>`;
   7650   };
   7651 
   7652   const recentSection = recentPosts.length
   7653     ? `<div class="mobileChatSection">
   7654         <div class="small muted">Recent Hive Chats</div>
   7655         ${recentPosts.map(postItem).join("")}
   7656       </div>`
   7657     : "";
   7658 
   7659   const hivesSection = availablePosts.length
   7660     ? `<div class="mobileChatSection">
   7661         <div class="small muted">Available Hives</div>
   7662         ${availablePosts.map(postItem).join("")}
   7663       </div>`
   7664     : "";
   7665 
   7666   return `<div class="mobileChatList">${dmSection}${recentSection}${hivesSection}</div>`;
   7667 }
   7668 
   7669 function onboardingRequiresAcceptance() {
   7670   return Boolean(onboardingState.enabled && onboardingState.requireAcceptance);
   7671 }
   7672 
   7673 function onboardingNeedsAcceptanceNow() {
   7674   if (!onboardingRequiresAcceptance()) return false;
   7675   return Boolean(onboardingState.needsAcceptance || Number(onboardingState.acceptedRulesVersion || 0) < Number(onboardingState.rulesVersion || 1));
   7676 }
   7677 
   7678 function onboardingBlocksReadingNow() {
   7679   return Boolean(loggedInUser && onboardingNeedsAcceptanceNow() && onboardingState.blockReadUntilAccepted);
   7680 }
   7681 
   7682 function authGateShouldLock() {
   7683   return !loggedInUser || onboardingNeedsAcceptanceNow();
   7684 }
   7685 
   7686 function updateSplashProgress() {
   7687   if (!(bzlSplashProgressFill instanceof HTMLElement)) return;
   7688   if (!(splashAudio instanceof HTMLAudioElement) || !Number.isFinite(Number(splashAudio.duration)) || Number(splashAudio.duration) <= 0) {
   7689     bzlSplashProgressFill.style.transform = "scaleX(0)";
   7690     return;
   7691   }
   7692   const duration = Number(splashAudio.duration || 0);
   7693   const current = Math.max(0, Math.min(duration, Number(splashAudio.currentTime || 0)));
   7694   const pct = duration > 0 ? current / duration : 0;
   7695   bzlSplashProgressFill.style.transform = `scaleX(${Math.max(0, Math.min(1, pct))})`;
   7696 }
   7697 
   7698 function pickSplashTip() {
   7699   if (!SPLASH_TIPS.length) return "Loading your hive...";
   7700   if (SPLASH_TIPS.length === 1) return SPLASH_TIPS[0];
   7701   let next = SPLASH_TIPS[Math.floor(Math.random() * SPLASH_TIPS.length)];
   7702   let guard = 0;
   7703   while (next === splashLastTip && guard < 8) {
   7704     next = SPLASH_TIPS[Math.floor(Math.random() * SPLASH_TIPS.length)];
   7705     guard += 1;
   7706   }
   7707   splashLastTip = next;
   7708   return next;
   7709 }
   7710 
   7711 function showNextSplashTip() {
   7712   if (!(bzlSplashTipEl instanceof HTMLElement)) return;
   7713   bzlSplashTipEl.textContent = pickSplashTip();
   7714 }
   7715 
   7716 function startSplashTipRotation() {
   7717   stopSplashTipRotation();
   7718   showNextSplashTip();
   7719   splashTipTimer = setInterval(() => {
   7720     if (!splashVisible) {
   7721       stopSplashTipRotation();
   7722       return;
   7723     }
   7724     showNextSplashTip();
   7725   }, 5200);
   7726 }
   7727 
   7728 function stopSplashTipRotation() {
   7729   if (!splashTipTimer) return;
   7730   clearInterval(splashTipTimer);
   7731   splashTipTimer = 0;
   7732 }
   7733 
   7734 function queueSplashProgressTick() {
   7735   if (!splashVisible) return;
   7736   if (splashProgressRaf) cancelAnimationFrame(splashProgressRaf);
   7737   splashProgressRaf = requestAnimationFrame(() => {
   7738     splashProgressRaf = 0;
   7739     updateSplashProgress();
   7740     queueSplashProgressTick();
   7741   });
   7742 }
   7743 
   7744 function finishSplashIfReady() {
   7745   if (!splashVisible) return;
   7746   if (!splashMinDone || !splashAudioDone) return;
   7747   splashVisible = false;
   7748   if (splashProgressRaf) {
   7749     cancelAnimationFrame(splashProgressRaf);
   7750     splashProgressRaf = 0;
   7751   }
   7752   stopSplashTipRotation();
   7753   if (bzlSplashProgressFill instanceof HTMLElement) bzlSplashProgressFill.style.transform = "scaleX(1)";
   7754   if (bzlSplashEl instanceof HTMLElement) bzlSplashEl.classList.add("hidden");
   7755 }
   7756 
   7757 function markSplashAudioDone() {
   7758   splashAudioDone = true;
   7759   splashNeedsGesture = false;
   7760   bzlSplashStartBtn?.classList.add("hidden");
   7761   finishSplashIfReady();
   7762 }
   7763 
   7764 function tryPlaySplashAudio({ fromGesture = false } = {}) {
   7765   if (!(splashAudio instanceof HTMLAudioElement)) {
   7766     markSplashAudioDone();
   7767     return;
   7768   }
   7769   const p = splashAudio.play();
   7770   if (!p || typeof p.then !== "function") return;
   7771   p.then(() => {
   7772     splashNeedsGesture = false;
   7773     bzlSplashStartBtn?.classList.add("hidden");
   7774   }).catch((err) => {
   7775     const name = String(err?.name || "");
   7776     const autoplayBlocked = name === "NotAllowedError" || /notallowed/i.test(String(err?.message || ""));
   7777     if (autoplayBlocked && !fromGesture) {
   7778       splashNeedsGesture = true;
   7779       bzlSplashStartBtn?.classList.remove("hidden");
   7780       return;
   7781     }
   7782     markSplashAudioDone();
   7783   });
   7784 }
   7785 
   7786 function initSplashSequence() {
   7787   if (!(bzlSplashEl instanceof HTMLElement)) {
   7788     splashVisible = false;
   7789     splashMinDone = true;
   7790     splashAudioDone = true;
   7791     return;
   7792   }
   7793   splashStartedAt = Date.now();
   7794   splashVisible = true;
   7795   splashMinDone = false;
   7796   splashAudioDone = false;
   7797   splashNeedsGesture = false;
   7798   bzlSplashEl.classList.remove("hidden");
   7799   bzlSplashStartBtn?.classList.add("hidden");
   7800   startSplashTipRotation();
   7801   splashAudio = new Audio(SPLASH_SFX_URL);
   7802   splashAudio.preload = "auto";
   7803   splashAudio.addEventListener("ended", () => {
   7804     markSplashAudioDone();
   7805   });
   7806   splashAudio.addEventListener("error", () => {
   7807     markSplashAudioDone();
   7808   });
   7809   splashAudio.addEventListener("loadedmetadata", () => updateSplashProgress());
   7810   splashAudio.addEventListener("timeupdate", () => updateSplashProgress());
   7811   tryPlaySplashAudio();
   7812   queueSplashProgressTick();
   7813   setTimeout(() => {
   7814     splashMinDone = true;
   7815     finishSplashIfReady();
   7816   }, SPLASH_MIN_MS);
   7817 }
   7818 
   7819 function renderAuthGateOnboarding() {
   7820   if (!(authGateOnboardingBodyEl instanceof HTMLElement)) return;
   7821   if (authGateEl instanceof HTMLElement) {
   7822     const buttons = Array.from(authGateEl.querySelectorAll("button[data-authgate-tab]"));
   7823     for (const btn of buttons) {
   7824       const on = String(btn.getAttribute("data-authgate-tab") || "") === authGateOnboardingTab;
   7825       btn.classList.toggle("primary", on);
   7826       btn.classList.toggle("ghost", !on);
   7827     }
   7828   }
   7829   const cfg = normalizeInstanceBranding(instanceBranding).onboarding || {};
   7830   if (!cfg.enabled) {
   7831     authGateOnboardingBodyEl.innerHTML = `<div class="small muted">Onboarding is disabled on this server.</div>`;
   7832     if (authGateAcceptBtn instanceof HTMLButtonElement) authGateAcceptBtn.classList.add("hidden");
   7833     return;
   7834   }
   7835   const tab = ["about", "rules", "roles"].includes(authGateOnboardingTab) ? authGateOnboardingTab : "about";
   7836   const rules = onboardingRuleListFromConfig(cfg);
   7837   const about = typeof cfg?.about?.content === "string" ? cfg.about.content.trim() : "";
   7838   const roleIds = Array.isArray(cfg?.roleSelect?.selfAssignableRoleIds) ? cfg.roleSelect.selfAssignableRoleIds : [];
   7839   const roleItems = roleIds
   7840     .map((key) => customRoles.find((r) => String(r?.key || "") === String(key)))
   7841     .filter(Boolean)
   7842     .map((r) => `<span class="tag">${escapeHtml(String(r.label || r.key || ""))}</span>`)
   7843     .join(" ");
   7844   authGateOnboardingBodyEl.innerHTML =
   7845     tab === "about"
   7846       ? about
   7847         ? `<div class="onboardingAbout">${about}</div>`
   7848         : `<div class="small muted">No About content published yet.</div>`
   7849       : tab === "rules"
   7850         ? rules.length
   7851           ? `<div class="onbRuleList">${rules
   7852               .map(
   7853                 (r) => `<article class="onbRuleViewerCard">
   7854                     <div class="row" style="justify-content:space-between;align-items:center;">
   7855                       <b>${escapeHtml(r.name || "Rule")}</b>
   7856                       ${onboardingSeverityBadge(r.severity)}
   7857                     </div>
   7858                     ${r.shortDescription ? `<div class="small muted">${escapeHtml(r.shortDescription)}</div>` : ""}
   7859                     ${r.description ? `<div class="small">${r.description}</div>` : ""}
   7860                   </article>`
   7861               )
   7862               .join("")}</div>`
   7863           : `<div class="small muted">No rules configured.</div>`
   7864         : cfg?.roleSelect?.enabled
   7865           ? roleItems
   7866             ? `<div class="row" style="flex-wrap:wrap;gap:8px;">${roleItems}</div>`
   7867             : `<div class="small muted">No self-assignable roles configured.</div>`
   7868           : `<div class="small muted">Role select is disabled.</div>`;
   7869   if (authGateAcceptBtn instanceof HTMLButtonElement) {
   7870     const needs = onboardingNeedsAcceptanceNow();
   7871     const showAccept = onboardingRequiresAcceptance() && tab === "rules";
   7872     authGateAcceptBtn.classList.toggle("hidden", !showAccept);
   7873     authGateAcceptBtn.disabled = !loggedInUser || !needs;
   7874     authGateAcceptBtn.textContent = needs ? "Accept rules and continue" : "Accepted";
   7875   }
   7876 }
   7877 
   7878 function renderAuthGate() {
   7879   if (!(authGateEl instanceof HTMLElement)) return;
   7880   const locked = authGateShouldLock();
   7881   authGateEl.classList.toggle("hidden", !locked);
   7882   authGateEl.setAttribute("aria-hidden", locked ? "false" : "true");
   7883   if (!(appRoot instanceof HTMLElement)) return;
   7884   appRoot.classList.toggle("authLockedWorkspace", locked);
   7885   if (!locked) return;
   7886   const needsRules = Boolean(loggedInUser && onboardingNeedsAcceptanceNow());
   7887   if (needsRules) authGateOnboardingTab = "rules";
   7888   if (authGateHintEl instanceof HTMLElement) {
   7889     authGateHintEl.textContent = needsRules
   7890       ? "Accept this server's rules on the Rules tab to enter."
   7891       : registrationEnabled
   7892         ? "Create an account or sign in to enter this server."
   7893         : canRegisterFirstUser
   7894           ? "No users exist yet. Create the first account to enter."
   7895           : "Sign in to enter this server.";
   7896   }
   7897   if (authGateCodeRowEl instanceof HTMLElement) authGateCodeRowEl.classList.toggle("hidden", !registrationEnabled);
   7898   if (authGateRegisterEl instanceof HTMLButtonElement) {
   7899     authGateRegisterEl.classList.toggle("hidden", !(registrationEnabled || canRegisterFirstUser));
   7900   }
   7901   renderAuthGateOnboarding();
   7902 }
   7903 
   7904 function openOnboardingView() {
   7905   onboardingViewerTab = "about";
   7906   renderOnboardingPanel();
   7907   if (isMobileScreenMode()) {
   7908     const layout = loadMobileLayout();
   7909     layout.active = "onboarding";
   7910     saveMobileLayout(layout);
   7911     setMobileScreen("onboarding");
   7912     renderMobileNav();
   7913     return;
   7914   }
   7915   if (rackLayoutEnabled) {
   7916     try {
   7917       if (
   7918         layoutPresetEl instanceof HTMLSelectElement &&
   7919         Array.from(layoutPresetEl.options || []).some((opt) => String(opt.value || "") === "onboardingDefault")
   7920       ) {
   7921         if (layoutPresetEl.value !== "onboardingDefault") layoutPresetEl.value = "onboardingDefault";
   7922         applyPreset("onboardingDefault");
   7923       }
   7924       restorePanelToWorkspaceSlot("onboarding", "workspaceLeftSlot");
   7925       restorePanelToWorkspaceSlot("hives", "workspaceRightSlot");
   7926     } catch {
   7927       // ignore layout failures
   7928     }
   7929   }
   7930   try {
   7931     onboardingPanelEl?.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
   7932   } catch {
   7933     // ignore
   7934   }
   7935 }
   7936 
   7937 function renderOnboardingGateHint() {
   7938   if (!(onboardingGateHintEl instanceof HTMLElement)) return;
   7939   const show = onboardingBlocksReadingNow();
   7940   onboardingGateHintEl.classList.toggle("hidden", !show);
   7941   if (!show) {
   7942     onboardingGateHintEl.innerHTML = "";
   7943     return;
   7944   }
   7945   onboardingGateHintEl.innerHTML = `
   7946     <div class="onboardingGateText">This instance requires that you read and accept its guidelines before you can view posts.</div>
   7947     <button type="button" class="ghost smallBtn" data-onboarding-open="1">Go to Onboarding</button>
   7948   `;
   7949 }
   7950 
   7951 function onboardingSeverityLabel(severity) {
   7952   const s = String(severity || "").toLowerCase();
   7953   if (s === "critical") return "Critical";
   7954   if (s === "warn") return "Warn";
   7955   return "Info";
   7956 }
   7957 
   7958 function onboardingSeverityBadge(severity) {
   7959   const s = String(severity || "info").toLowerCase();
   7960   const cls = s === "critical" ? "onbSeverityCritical" : s === "warn" ? "onbSeverityWarn" : "onbSeverityInfo";
   7961   return `<span class="tag ${cls}">${escapeHtml(onboardingSeverityLabel(s))}</span>`;
   7962 }
   7963 
   7964 function onboardingRuleListFromConfig(cfg) {
   7965   const list = Array.isArray(cfg?.rules?.items) ? cfg.rules.items : [];
   7966   return list
   7967     .map((r, index) => ({
   7968       id: String(r?.id || `r${index + 1}`).trim().slice(0, 40) || `r${index + 1}`,
   7969       order: Number.isFinite(Number(r?.order)) ? Math.max(1, Math.floor(Number(r.order))) : index + 1,
   7970       name: String(r?.name || "").trim().slice(0, 60) || `Rule ${index + 1}`,
   7971       shortDescription: String(r?.shortDescription || "").trim().slice(0, 180),
   7972       description: String(r?.description || "").slice(0, 6000),
   7973       severity: ["info", "warn", "critical"].includes(String(r?.severity || "").toLowerCase())
   7974         ? String(r.severity).toLowerCase()
   7975         : "info",
   7976     }))
   7977     .sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || String(a.id || "").localeCompare(String(b.id || "")));
   7978 }
   7979 
   7980 function onboardingDraftStampFromConfig(cfg) {
   7981   return JSON.stringify({
   7982     enabled: Boolean(cfg?.enabled),
   7983     aboutUpdatedAt: Number(cfg?.about?.updatedAt || 0),
   7984     rulesVersion: Number(cfg?.rules?.version || 1),
   7985     itemCount: Array.isArray(cfg?.rules?.items) ? cfg.rules.items.length : 0,
   7986     roleSelectEnabled: Boolean(cfg?.roleSelect?.enabled),
   7987     selfAssignableCount: Array.isArray(cfg?.roleSelect?.selfAssignableRoleIds) ? cfg.roleSelect.selfAssignableRoleIds.length : 0,
   7988   });
   7989 }
   7990 
   7991 function syncOnboardingAdminDraft(force = false) {
   7992   const cfg = normalizeInstanceBranding(instanceBranding).onboarding || {};
   7993   const stamp = onboardingDraftStampFromConfig(cfg);
   7994   if (!force && stamp === onboardingAdminDraftStamp) return;
   7995   onboardingAdminDraft = {
   7996     enabled: Boolean(cfg?.enabled),
   7997     aboutContent: String(cfg?.about?.content || ""),
   7998     requireAcceptance: Boolean(cfg?.rules?.requireAcceptance),
   7999     blockReadUntilAccepted: Boolean(cfg?.rules?.blockReadUntilAccepted),
   8000     roleSelectEnabled: Boolean(cfg?.roleSelect?.enabled),
   8001     selfAssignableRoleIds: Array.isArray(cfg?.roleSelect?.selfAssignableRoleIds)
   8002       ? cfg.roleSelect.selfAssignableRoleIds.map((x) => String(x || "").trim().toLowerCase()).filter(Boolean)
   8003       : [],
   8004     rules: onboardingRuleListFromConfig(cfg),
   8005   };
   8006   onboardingAdminDraftStamp = stamp;
   8007   onboardingAdminExpandedRuleIds.clear();
   8008   if (onboardingAdminDraft.rules[0]?.id) onboardingAdminExpandedRuleIds.add(onboardingAdminDraft.rules[0].id);
   8009 }
   8010 
   8011 function normalizeOnboardingDraftRules() {
   8012   onboardingAdminDraft.rules = (Array.isArray(onboardingAdminDraft.rules) ? onboardingAdminDraft.rules : [])
   8013     .map((r, index) => ({
   8014       id: String(r?.id || `r${index + 1}`).trim().slice(0, 40) || `r${index + 1}`,
   8015       order: index + 1,
   8016       name: String(r?.name || "").trim().slice(0, 60) || `Rule ${index + 1}`,
   8017       shortDescription: String(r?.shortDescription || "").trim().slice(0, 180),
   8018       description: String(r?.description || "").slice(0, 6000),
   8019       severity: ["info", "warn", "critical"].includes(String(r?.severity || "").toLowerCase())
   8020         ? String(r.severity).toLowerCase()
   8021         : "info",
   8022     }))
   8023     .slice(0, 200);
   8024 }
   8025 
   8026 function renderOnboardingPanel() {
   8027   if (!(onboardingPanelEl instanceof HTMLElement) || !(onboardingPanelBodyEl instanceof HTMLElement)) return;
   8028   const cfg = normalizeInstanceBranding(instanceBranding).onboarding || {};
   8029   if (!cfg.enabled) {
   8030     onboardingPanelEl.classList.add("hidden");
   8031     onboardingPanelBodyEl.innerHTML = `<div class="small muted">Onboarding is disabled for this server.</div>`;
   8032     if (onboardingPanelAcceptBtn instanceof HTMLButtonElement) onboardingPanelAcceptBtn.classList.add("hidden");
   8033     return;
   8034   }
   8035 
   8036   onboardingPanelEl.classList.remove("hidden");
   8037   const needs = onboardingNeedsAcceptanceNow();
   8038   const rules = onboardingRuleListFromConfig(cfg);
   8039   const about = typeof cfg?.about?.content === "string" ? cfg.about.content.trim() : "";
   8040   const roleIds = Array.isArray(cfg?.roleSelect?.selfAssignableRoleIds) ? cfg.roleSelect.selfAssignableRoleIds : [];
   8041   const roleItems = roleIds
   8042     .map((key) => customRoles.find((r) => String(r?.key || "") === String(key)))
   8043     .filter(Boolean)
   8044     .map((r) => `<span class="tag">${escapeHtml(String(r.label || r.key || ""))}</span>`)
   8045     .join(" ");
   8046 
   8047   onboardingPanelBodyEl.innerHTML = `
   8048     <div class="onbTabs">
   8049       <button type="button" class="${onboardingViewerTab === "about" ? "primary" : "ghost"} smallBtn" data-onbtab="about">About</button>
   8050       <button type="button" class="${onboardingViewerTab === "rules" ? "primary" : "ghost"} smallBtn" data-onbtab="rules">Rules</button>
   8051       <button type="button" class="${onboardingViewerTab === "roles" ? "primary" : "ghost"} smallBtn" data-onbtab="roles">Roles</button>
   8052     </div>
   8053     ${
   8054       onboardingViewerTab === "about"
   8055         ? about
   8056           ? `<div class="onboardingAbout">${about}</div>`
   8057           : `<div class="small muted">No About content published yet.</div>`
   8058         : onboardingViewerTab === "rules"
   8059           ? rules.length
   8060             ? `<div class="onbRuleList">${rules
   8061                 .map(
   8062                   (r) => `<article class="onbRuleViewerCard">
   8063                       <div class="row" style="justify-content:space-between;align-items:center;">
   8064                         <b>${escapeHtml(r.name || "Rule")}</b>
   8065                         ${onboardingSeverityBadge(r.severity)}
   8066                       </div>
   8067                       ${r.shortDescription ? `<div class="small muted">${escapeHtml(r.shortDescription)}</div>` : ""}
   8068                       ${r.description ? `<div class="small">${r.description}</div>` : ""}
   8069                     </article>`
   8070                 )
   8071                 .join("")}</div>`
   8072             : `<div class="small muted">No rules configured.</div>`
   8073           : cfg?.roleSelect?.enabled
   8074             ? roleItems
   8075               ? `<div class="row" style="flex-wrap:wrap;gap:8px;">${roleItems}</div>`
   8076               : `<div class="small muted">No self-assignable roles configured.</div>`
   8077             : `<div class="small muted">Role select is disabled.</div>`
   8078     }
   8079     <div class="small ${needs ? "badText" : "goodText"}" style="margin-top:10px;">
   8080       ${
   8081         onboardingRequiresAcceptance()
   8082           ? needs
   8083             ? "Rules acceptance required before posting/chat."
   8084             : `Rules accepted${onboardingState.acceptedAt ? ` at ${escapeHtml(formatLocalTime(onboardingState.acceptedAt))}` : "."}`
   8085           : "Rules acceptance is optional on this server."
   8086       }
   8087     </div>`;
   8088 
   8089   if (onboardingPanelAcceptBtn instanceof HTMLButtonElement) {
   8090     const showAccept = onboardingRequiresAcceptance() && onboardingViewerTab === "rules";
   8091     onboardingPanelAcceptBtn.classList.toggle("hidden", !showAccept);
   8092     onboardingPanelAcceptBtn.disabled = !loggedInUser || !needs;
   8093     onboardingPanelAcceptBtn.textContent = needs ? "Accept and continue" : "Accepted";
   8094   }
   8095 }
   8096 
   8097 function renderOnboardingCard() {
   8098   if (!(onboardingCard instanceof HTMLElement) || !(onboardingBody instanceof HTMLElement)) return;
   8099   // Onboarding now lives as a first-class workspace panel; keep the old account card hidden.
   8100   onboardingCard.classList.add("hidden");
   8101   onboardingBody.innerHTML = "";
   8102   if (onboardingAcceptBtn instanceof HTMLButtonElement) {
   8103     onboardingAcceptBtn.classList.add("hidden");
   8104     onboardingAcceptBtn.disabled = true;
   8105   }
   8106   renderOnboardingPanel();
   8107   return;
   8108 
   8109   const cfg = normalizeInstanceBranding(instanceBranding).onboarding || {};
   8110   if (!cfg.enabled) {
   8111     onboardingCard.classList.add("hidden");
   8112     onboardingBody.innerHTML = "";
   8113     return;
   8114   }
   8115   onboardingCard.classList.remove("hidden");
   8116   const needs = onboardingNeedsAcceptanceNow();
   8117   const rules = onboardingRuleListFromConfig(cfg).slice(0, 6);
   8118   const about = typeof cfg?.about?.content === "string" ? cfg.about.content.trim() : "";
   8119   const aboutBlock = about ? `<div class="onboardingAbout">${about}</div>` : `<div class="small muted">No About text set yet.</div>`;
   8120   const rulesBlock = rules.length
   8121     ? `<ol class="onboardingRules">${rules
   8122         .map(
   8123           (r) =>
   8124             `<li><b>${escapeHtml(r.name || "Rule")}</b>${r.shortDescription ? `<div class="small muted">${escapeHtml(r.shortDescription)}</div>` : ""}</li>`
   8125         )
   8126         .join("")}</ol>`
   8127     : `<div class="small muted">No rules published yet.</div>`;
   8128   onboardingBody.innerHTML = `
   8129     ${aboutBlock}
   8130     <div class="small" style="margin-top:10px;"><b>Rules</b></div>
   8131     ${rulesBlock}
   8132     ${
   8133       onboardingRequiresAcceptance()
   8134         ? `<div class="small ${needs ? "badText" : "goodText"}" style="margin-top:10px;">
   8135              ${needs ? "Rules acceptance required before posting/chat." : `Rules accepted${onboardingState.acceptedAt ? ` at ${escapeHtml(formatLocalTime(onboardingState.acceptedAt))}` : "."}`}
   8136            </div>`
   8137         : `<div class="small muted" style="margin-top:10px;">Rules acceptance is optional on this server.</div>`
   8138     }
   8139   `;
   8140   if (onboardingAcceptBtn instanceof HTMLButtonElement) {
   8141     const showAccept = onboardingRequiresAcceptance() && onboardingViewerTab === "rules";
   8142     onboardingAcceptBtn.classList.toggle("hidden", !showAccept);
   8143     onboardingAcceptBtn.disabled = !loggedInUser || !needs;
   8144     onboardingAcceptBtn.textContent = needs ? "Accept and continue" : "Accepted";
   8145   }
   8146   renderOnboardingPanel();
   8147 }
   8148 
   8149 function setAuthUi() {
   8150   if (loggedInUser) {
   8151     guestAuthPanelRevealed = false;
   8152     userLabel.innerHTML = renderUserPill(loggedInUser);
   8153     logoutBtn.classList.remove("hidden");
   8154     const roleText = loggedInRole && loggedInRole !== "member" ? ` (${loggedInRole})` : "";
   8155     authHint.textContent = onboardingNeedsAcceptanceNow()
   8156       ? `Signed in${roleText}. Accept server rules to unlock posting/chat.`
   8157       : `Signed in${roleText}. You can post, chat, and boost others.`;
   8158   } else {
   8159     userLabel.textContent = "Signed out";
   8160     logoutBtn.classList.add("hidden");
   8161     authHint.textContent = registrationEnabled
   8162       ? "Sign in or create an account with the registration code."
   8163       : canRegisterFirstUser
   8164         ? "No users exist yet. Create the first user from this computer."
   8165         : "Sign in to post, chat, and boost.";
   8166   }
   8167   applyInstanceAppearance();
   8168 
   8169   const canMakePermanent = userCanCreatePermanentHive();
   8170   if (ttlMinutesEl) {
   8171     ttlMinutesEl.min = canMakePermanent ? "0" : "1";
   8172     if (!canMakePermanent && Number(ttlMinutesEl.value || 0) <= 0) ttlMinutesEl.value = "60";
   8173   }
   8174   if (ttlPermanentEl instanceof HTMLInputElement) {
   8175     ttlPermanentEl.disabled = !canMakePermanent;
   8176     if (!canMakePermanent) ttlPermanentEl.checked = false;
   8177   }
   8178   syncTtlUiFromMinutes();
   8179 
   8180   codeRow.classList.toggle("hidden", !registrationEnabled);
   8181   registerBtn.classList.toggle("hidden", !(registrationEnabled || canRegisterFirstUser));
   8182   if (authGateUserEl instanceof HTMLInputElement && !authGateUserEl.value && authUser instanceof HTMLInputElement) {
   8183     authGateUserEl.value = authUser.value || "";
   8184   }
   8185   if (authGateCodeEl instanceof HTMLInputElement && !authGateCodeEl.value && authCode instanceof HTMLInputElement) {
   8186     authGateCodeEl.value = authCode.value || "";
   8187   }
   8188   if (loggedInUser && authGatePassEl instanceof HTMLInputElement) authGatePassEl.value = "";
   8189   renderOnboardingGateHint();
   8190   renderOnboardingCard();
   8191   renderAuthGate();
   8192   renderModPanel();
   8193   if (tourBtn instanceof HTMLButtonElement) {
   8194     tourBtn.textContent = shouldAutoShowGuidedTour(loggedInUser) ? "Tour" : "Tour (replay)";
   8195   }
   8196 }
   8197 
   8198 function normalizeTourUser(raw) {
   8199   return String(raw || "")
   8200     .trim()
   8201     .toLowerCase()
   8202     .replace(/[^a-z0-9_.-]/g, "")
   8203     .slice(0, 64);
   8204 }
   8205 
   8206 function guidedTourStorageKey(user = loggedInUser) {
   8207   const who = normalizeTourUser(user) || "guest";
   8208   return `bzl_guidedTour_pref_v${TOUR_SEEN_VERSION}:${location.host}:${who}`;
   8209 }
   8210 
   8211 function readGuidedTourPref(user = loggedInUser) {
   8212   const fallback = { completed: false, dontShow: false };
   8213   try {
   8214     const raw = localStorage.getItem(guidedTourStorageKey(user));
   8215     if (!raw) return fallback;
   8216     const parsed = JSON.parse(raw);
   8217     if (!parsed || typeof parsed !== "object") return fallback;
   8218     return {
   8219       completed: Boolean(parsed.completed),
   8220       dontShow: Boolean(parsed.dontShow),
   8221     };
   8222   } catch {
   8223     return fallback;
   8224   }
   8225 }
   8226 
   8227 function writeGuidedTourPref(user = loggedInUser, patch = {}) {
   8228   const current = readGuidedTourPref(user);
   8229   const next = {
   8230     completed: Object.prototype.hasOwnProperty.call(patch, "completed") ? Boolean(patch.completed) : current.completed,
   8231     dontShow: Object.prototype.hasOwnProperty.call(patch, "dontShow") ? Boolean(patch.dontShow) : current.dontShow,
   8232   };
   8233   try {
   8234     localStorage.setItem(guidedTourStorageKey(user), JSON.stringify(next));
   8235   } catch {
   8236     // ignore
   8237   }
   8238 }
   8239 
   8240 function shouldAutoShowGuidedTour(user = loggedInUser) {
   8241   const pref = readGuidedTourPref(user);
   8242   return !pref.completed && !pref.dontShow;
   8243 }
   8244 
   8245 function guidedTourViewportAnchor(targetEl) {
   8246   const target = targetEl instanceof HTMLElement ? targetEl : null;
   8247   if (!(target instanceof HTMLElement)) return null;
   8248   const panel = target.closest?.(".rackPanel");
   8249   if (panel instanceof HTMLElement) {
   8250     const rackId = rackIdForPanelElement(panel);
   8251     if (isWorkspaceRackId(rackId)) return panel;
   8252   }
   8253   return target;
   8254 }
   8255 
   8256 function focusGuidedTourTarget(targetEl) {
   8257   const target = targetEl instanceof HTMLElement ? targetEl : null;
   8258   if (!(target instanceof HTMLElement)) return;
   8259   const anchor = guidedTourViewportAnchor(target);
   8260   if (!(anchor instanceof HTMLElement)) return;
   8261   try {
   8262     const panel = anchor.closest?.(".rackPanel");
   8263     if (panel instanceof HTMLElement && anchor === panel && isWorkspaceRackId(rackIdForPanelElement(panel))) {
   8264       followWorkspacePanel(panel);
   8265     } else {
   8266       anchor.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
   8267     }
   8268   } catch {
   8269     // ignore
   8270   }
   8271   requestAnimationFrame(() => updateGuidedTourSpotlight());
   8272   setTimeout(() => updateGuidedTourSpotlight(), 140);
   8273   setTimeout(() => updateGuidedTourSpotlight(), 320);
   8274 }
   8275 
   8276 function revealAuthPanelForGuests() {
   8277   if (loggedInUser) return;
   8278   if (guestAuthPanelRevealed) return;
   8279   const target = accountPanel instanceof HTMLElement ? accountPanel : authForm;
   8280   if (!(target instanceof HTMLElement)) return;
   8281   try {
   8282     if (sidebarScrollEl instanceof HTMLElement) {
   8283       const top = Math.max(0, target.offsetTop - 16);
   8284       sidebarScrollEl.scrollTo({ top, behavior: "smooth" });
   8285     } else {
   8286       target.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
   8287     }
   8288     guestAuthPanelRevealed = true;
   8289   } catch {
   8290     // ignore
   8291   }
   8292 }
   8293 
   8294 function clearTourContextDim() {
   8295   for (const el of Array.from(document.querySelectorAll(".rackPanel.tourContextDim"))) {
   8296     el.classList.remove("tourContextDim");
   8297   }
   8298 }
   8299 
   8300 function setTourContextDimForTarget(targetEl) {
   8301   clearTourContextDim();
   8302   const target = targetEl instanceof HTMLElement ? targetEl : null;
   8303   if (!(target instanceof HTMLElement)) return;
   8304   const targetPanel = target.closest?.(".rackPanel");
   8305   if (!(targetPanel instanceof HTMLElement)) return;
   8306   const panels = Array.from(document.querySelectorAll(".rackPanel"));
   8307   for (const panel of panels) {
   8308     if (!(panel instanceof HTMLElement)) continue;
   8309     if (panel === targetPanel) continue;
   8310     panel.classList.add("tourContextDim");
   8311   }
   8312 }
   8313 
   8314 function clearGuidedTourTarget() {
   8315   if (guidedTourTargetEl instanceof HTMLElement) {
   8316     guidedTourTargetEl.classList.remove("tourTargetPulse");
   8317   }
   8318   clearTourContextDim();
   8319   guidedTourTargetEl = null;
   8320   if (guidedTourOverlayEl instanceof HTMLElement) {
   8321     delete guidedTourOverlayEl.dataset.cardpos;
   8322     guidedTourOverlayEl.style.removeProperty("--tour-x");
   8323     guidedTourOverlayEl.style.removeProperty("--tour-y");
   8324     guidedTourOverlayEl.style.removeProperty("--tour-r");
   8325     guidedTourOverlayEl.style.removeProperty("--tour-left");
   8326     guidedTourOverlayEl.style.removeProperty("--tour-top");
   8327     guidedTourOverlayEl.style.removeProperty("--tour-w");
   8328     guidedTourOverlayEl.style.removeProperty("--tour-h");
   8329     guidedTourOverlayEl.style.removeProperty("--tour-br");
   8330   }
   8331   if (guidedTourFocusEl instanceof HTMLElement) guidedTourFocusEl.classList.add("hidden");
   8332 }
   8333 
   8334 function clearGuidedTourTimers() {
   8335   if (guidedTourTaskTimer) {
   8336     clearInterval(guidedTourTaskTimer);
   8337     guidedTourTaskTimer = null;
   8338   }
   8339   if (guidedTourAutoAdvanceTimer) {
   8340     clearTimeout(guidedTourAutoAdvanceTimer);
   8341     guidedTourAutoAdvanceTimer = null;
   8342   }
   8343 }
   8344 
   8345 function updateGuidedTourSpotlight() {
   8346   if (!(guidedTourOverlayEl instanceof HTMLElement) || !(guidedTourTargetEl instanceof HTMLElement)) return;
   8347   setTourContextDimForTarget(guidedTourTargetEl);
   8348 }
   8349 
   8350 function setGuidedTourCardPlacement(targetEl) {
   8351   if (!(guidedTourOverlayEl instanceof HTMLElement)) return;
   8352   if (window.innerWidth <= 780) {
   8353     guidedTourOverlayEl.dataset.cardpos = "bottom";
   8354     return;
   8355   }
   8356   const target = targetEl instanceof HTMLElement ? targetEl : null;
   8357   if (!(target instanceof HTMLElement)) {
   8358     guidedTourOverlayEl.dataset.cardpos = "bottom";
   8359     return;
   8360   }
   8361   const rect = target.getBoundingClientRect();
   8362   let pos = "bottom";
   8363   if (rect.top > window.innerHeight * 0.58) pos = "top";
   8364   else if (rect.bottom < window.innerHeight * 0.42) pos = "bottom";
   8365   else if (rect.left > window.innerWidth * 0.58) pos = "left";
   8366   else if (rect.right < window.innerWidth * 0.42) pos = "right";
   8367   guidedTourOverlayEl.dataset.cardpos = pos;
   8368 }
   8369 
   8370 function ensureGuidedTourUi() {
   8371   if (guidedTourOverlayEl instanceof HTMLElement) return;
   8372 
   8373   const overlay = document.createElement("div");
   8374   overlay.id = "guidedTourOverlay";
   8375   overlay.className = "guidedTourOverlay hidden";
   8376   overlay.innerHTML = `
   8377     <div class="guidedTourShade"></div>
   8378     <div class="guidedTourFocus hidden" id="guidedTourFocus"></div>
   8379     <div class="guidedTourCard">
   8380       <div class="guidedTourStep" id="guidedTourStep"></div>
   8381       <div class="guidedTourTitle" id="guidedTourTitle"></div>
   8382       <div class="guidedTourBody small" id="guidedTourBody"></div>
   8383       <div class="guidedTourTask" id="guidedTourTask"></div>
   8384       <div class="guidedTourStatus small muted" id="guidedTourStatus"></div>
   8385       <label class="guidedTourDontShow small muted">
   8386         <input type="checkbox" id="guidedTourDontShow" />
   8387         <span>Don't show this tour again</span>
   8388       </label>
   8389       <div class="row guidedTourActions">
   8390         <button type="button" class="ghost smallBtn" id="guidedTourPrev">Back</button>
   8391         <button type="button" class="primary smallBtn" id="guidedTourNext">Next</button>
   8392         <button type="button" class="ghost smallBtn" id="guidedTourSkip">End tour</button>
   8393       </div>
   8394     </div>
   8395   `;
   8396   document.body.appendChild(overlay);
   8397 
   8398   guidedTourOverlayEl = overlay;
   8399   guidedTourCardEl = overlay.querySelector(".guidedTourCard");
   8400   guidedTourFocusEl = overlay.querySelector("#guidedTourFocus");
   8401   guidedTourStepEl = overlay.querySelector("#guidedTourStep");
   8402   guidedTourTitleEl = overlay.querySelector("#guidedTourTitle");
   8403   guidedTourBodyEl = overlay.querySelector("#guidedTourBody");
   8404   guidedTourTaskEl = overlay.querySelector("#guidedTourTask");
   8405   guidedTourStatusEl = overlay.querySelector("#guidedTourStatus");
   8406   guidedTourDontShowEl = overlay.querySelector("#guidedTourDontShow");
   8407   guidedTourPrevBtn = overlay.querySelector("#guidedTourPrev");
   8408   guidedTourNextBtn = overlay.querySelector("#guidedTourNext");
   8409   guidedTourSkipBtn = overlay.querySelector("#guidedTourSkip");
   8410 
   8411   guidedTourPrevBtn?.addEventListener("click", () => guidedTourGo(-1));
   8412   guidedTourNextBtn?.addEventListener("click", () => guidedTourGo(1));
   8413   guidedTourSkipBtn?.addEventListener("click", () => stopGuidedTour({ completed: false }));
   8414   window.addEventListener("resize", () => {
   8415     if (guidedTourState.active) updateGuidedTourSpotlight();
   8416   });
   8417   window.addEventListener(
   8418     "scroll",
   8419     () => {
   8420       if (guidedTourState.active) updateGuidedTourSpotlight();
   8421     },
   8422     true
   8423   );
   8424 }
   8425 
   8426 function tourCanMakePermanent() {
   8427   return Boolean(loggedInUser) && (isStaffRole(loggedInRole) || Boolean(normalizeInstanceBranding(instanceBranding).allowMemberPermanentPosts));
   8428 }
   8429 
   8430 function buildGuidedTourSteps({ startedSignedIn = Boolean(loggedInUser) } = {}) {
   8431   const canPermanent = tourCanMakePermanent();
   8432   const durationNote = canPermanent
   8433     ? "Use Quick duration for common timers, or toggle Keep forever for a permanent hive."
   8434     : "Use Quick duration for common timers. Keep forever is available when enabled by server staff.";
   8435   const workspaceVisibleCount = () => {
   8436     const rack = document.getElementById("mainWorkspaceRack");
   8437     if (!(rack instanceof HTMLElement)) return 0;
   8438     return rack.querySelectorAll(":scope > .rackPanel:not(.hidden)").length;
   8439   };
   8440 
   8441   return [
   8442     ...(!startedSignedIn
   8443       ? [
   8444           {
   8445             title: "Sign in first",
   8446             selector: "#authGate",
   8447             body: "You need an account before using the workstation. Sign in or create one here.",
   8448             taskLabel: "Required: sign in or create an account.",
   8449             requireTask: true,
   8450             taskCheck: () => Boolean(loggedInUser),
   8451           },
   8452         ]
   8453       : []),
   8454     {
   8455       title: "Start with New Hive",
   8456       selector: "#toggleComposer",
   8457       body: "Tutorial mode keeps only core panels visible. Start by opening the composer.",
   8458       taskLabel: "Required: press New Hive.",
   8459       requireTask: true,
   8460       onEnter: () => {
   8461         try {
   8462           if (rackLayoutEnabled && layoutPresetEl instanceof HTMLSelectElement) {
   8463             const canUse = Array.from(layoutPresetEl.options || []).some((opt) => String(opt.value || "") === "tutorial");
   8464             if (canUse) {
   8465               if (layoutPresetEl.value !== "tutorial") layoutPresetEl.value = "tutorial";
   8466               applyPreset("tutorial");
   8467             }
   8468           }
   8469         } catch {
   8470           // ignore layout failures
   8471         }
   8472       },
   8473       taskCheck: () => Boolean(composerOpen),
   8474     },
   8475     {
   8476       title: "Create your first post",
   8477       selector: "#newPostForm",
   8478       body: "Enter a title and body, then send your first hive post.",
   8479       taskLabel: "Required: publish one hive post.",
   8480       requireTask: true,
   8481       onEnter: () => {
   8482         setComposerOpen(true);
   8483         guidedTourStepContext.firstPostCutoff = Date.now();
   8484       },
   8485       taskCheck: () => {
   8486         const me = String(loggedInUser || "").trim().toLowerCase();
   8487         if (!me) return false;
   8488         const cutoff = Number(guidedTourStepContext.firstPostCutoff || 0);
   8489         for (const post of posts.values()) {
   8490           if (!post || typeof post !== "object") continue;
   8491           if (String(post.author || "").trim().toLowerCase() !== me) continue;
   8492           const createdAt = Number(post.createdAt || 0);
   8493           if (createdAt > 0 && createdAt >= cutoff) return true;
   8494         }
   8495         return false;
   8496       },
   8497     },
   8498     {
   8499       title: "Post duration",
   8500       selector: "#ttlPreset",
   8501       body:
   8502         `TTL is now called Post duration. ${durationNote} You can still type exact minutes when needed.`,
   8503       taskLabel: "Optional: change Quick duration or toggle Keep forever.",
   8504       onEnter: () => {
   8505         setComposerOpen(true);
   8506         guidedTourStepContext.durationBaseline = `${String(ttlPresetEl?.value || "60")}|${ttlPermanentEl?.checked ? "1" : "0"}`;
   8507       },
   8508       taskCheck: () => {
   8509         const current = `${String(ttlPresetEl?.value || "60")}|${ttlPermanentEl?.checked ? "1" : "0"}`;
   8510         return current !== String(guidedTourStepContext.durationBaseline || "");
   8511       },
   8512     },
   8513     {
   8514       title: "Composer toggles",
   8515       selector: "#pollinatePanel",
   8516       body:
   8517         "Before you post, check toggles: Protected adds a password, Hive mode selects text/walkie/stream, and Post duration controls expiry.",
   8518       taskLabel: "",
   8519       onEnter: () => {
   8520         setComposerOpen(true);
   8521       },
   8522       taskCheck: null,
   8523     },
   8524     {
   8525       title: "Open a hive chat room",
   8526       selector: "#feed",
   8527       body: "Each hive card is its own chat room. Open a hive and tap Chat to join that room.",
   8528       taskLabel: "Required: open a hive chat.",
   8529       requireTask: true,
   8530       taskCheck: () => Boolean(activeChatPostId),
   8531     },
   8532     {
   8533       title: "Chat panel basics",
   8534       selector: ".chat",
   8535       body:
   8536         "This panel is your active room. Header shows the current chat, the message list stays in the middle, and the composer at the bottom supports text, images, audio, replies, and mentions.",
   8537       taskLabel: "",
   8538       taskCheck: null,
   8539     },
   8540     {
   8541       title: "Members and profile",
   8542       selector: "#peopleDrawer",
   8543       body: "Members list is on the right rail. Click your own name to open your profile panel.",
   8544       taskLabel: "Required: open your profile from Members list.",
   8545       requireTask: true,
   8546       onEnter: () => {
   8547         peopleTab = "members";
   8548         renderPeoplePanel();
   8549         setRightCollapsed(false);
   8550       },
   8551       taskCheck: () => String(activeProfileUsername || "").toLowerCase() === String(loggedInUser || "").toLowerCase(),
   8552     },
   8553     {
   8554       title: "Minimize all workspace panels",
   8555       selector: "#mainWorkspaceRack",
   8556       body: "Minimize everything in the workspace so the hotbar workflow is clear.",
   8557       taskLabel: "Required: minimize all workspace panels.",
   8558       requireTask: true,
   8559       taskCheck: () => workspaceVisibleCount() === 0,
   8560     },
   8561     {
   8562       title: "Hotbar and shortcuts",
   8563       selector: "#dockHotbar",
   8564       body:
   8565         "Use the hotbar to restore panels (click, drag, or keyboard). Keyboard: Up restores from hotbar, Down docks hovered workspace panel, Left/Right reorders, Up cycles size.",
   8566       taskLabel: "Required: add one panel back from the hotbar.",
   8567       requireTask: true,
   8568       onEnter: () => {
   8569         guidedTourStepContext.workspaceCountBaseline = workspaceVisibleCount();
   8570       },
   8571       taskCheck: () => workspaceVisibleCount() > Number(guidedTourStepContext.workspaceCountBaseline || 0),
   8572     },
   8573     {
   8574       title: "Sizes and reorder",
   8575       selector: "#mainWorkspaceRack",
   8576       body: "Panels can be skinny, half, or full width. Add another panel, then try resizing and reordering.",
   8577       taskLabel: "Required: get at least 2 workspace panels and change their order once.",
   8578       requireTask: true,
   8579       onEnter: () => {
   8580         const rack = document.getElementById("mainWorkspaceRack");
   8581         const ids = rack instanceof HTMLElement
   8582           ? Array.from(rack.querySelectorAll(":scope > .rackPanel:not(.hidden)")).map((el) => String(el.dataset.panelId || ""))
   8583           : [];
   8584         guidedTourStepContext.reorderBaseline = ids.join("|");
   8585       },
   8586       taskCheck: () => {
   8587         const rack = document.getElementById("mainWorkspaceRack");
   8588         if (!(rack instanceof HTMLElement)) return false;
   8589         const ids = Array.from(rack.querySelectorAll(":scope > .rackPanel:not(.hidden)")).map((el) => String(el.dataset.panelId || ""));
   8590         if (ids.length < 2) return false;
   8591         return ids.join("|") !== String(guidedTourStepContext.reorderBaseline || "");
   8592       },
   8593     },
   8594     {
   8595       title: "User bar",
   8596       selector: "#accountPanel",
   8597       body: "This user bar shows sign-in state, account controls, and quick access to personal settings.",
   8598       taskLabel: "",
   8599       taskCheck: null,
   8600     },
   8601     {
   8602       title: "You're ready",
   8603       selector: "#poweredByTileLink",
   8604       body:
   8605         'Bzl is open source: <a href="https://github.com/bzlapp/Bzl/" target="_blank" rel="noopener noreferrer">github.com/bzlapp/Bzl</a><br><br>Have fun building your workspace.',
   8606       taskLabel: "",
   8607       taskCheck: null,
   8608     },
   8609   ];
   8610 }
   8611 
   8612 function guidedTourTaskSatisfied(step) {
   8613   if (!step || typeof step.taskCheck !== "function") return false;
   8614   try {
   8615     return Boolean(step.taskCheck());
   8616   } catch {
   8617     return false;
   8618   }
   8619 }
   8620 
   8621 function guidedTourGo(delta) {
   8622   if (!guidedTourState.active) return;
   8623   const current = guidedTourCurrentStep();
   8624   if (Number(delta || 0) > 0 && current?.requireTask && !guidedTourTaskSatisfied(current)) {
   8625     if (guidedTourStatusEl) guidedTourStatusEl.textContent = "Complete the required step to continue.";
   8626     return;
   8627   }
   8628   const next = Math.max(0, Math.min(guidedTourState.steps.length - 1, guidedTourState.index + Number(delta || 0)));
   8629   if (next === guidedTourState.index && delta > 0 && next >= guidedTourState.steps.length - 1) {
   8630     stopGuidedTour({ completed: true });
   8631     return;
   8632   }
   8633   guidedTourState.index = next;
   8634   renderGuidedTourStep();
   8635 }
   8636 
   8637 function guidedTourCurrentStep() {
   8638   if (!guidedTourState.active) return null;
   8639   return guidedTourState.steps[guidedTourState.index] || null;
   8640 }
   8641 
   8642 function renderGuidedTourStep() {
   8643   const step = guidedTourCurrentStep();
   8644   if (!step) return;
   8645 
   8646   clearGuidedTourTimers();
   8647   clearGuidedTourTarget();
   8648   guidedTourStepContext = {};
   8649 
   8650   if (typeof step.onEnter === "function") {
   8651     try {
   8652       step.onEnter();
   8653     } catch {
   8654       // ignore task baseline errors
   8655     }
   8656   }
   8657 
   8658   if (guidedTourStepEl) guidedTourStepEl.textContent = `Step ${guidedTourState.index + 1} / ${guidedTourState.steps.length}`;
   8659   if (guidedTourTitleEl) guidedTourTitleEl.textContent = step.title || "Tour";
   8660   if (guidedTourBodyEl) guidedTourBodyEl.innerHTML = step.body || "";
   8661   if (guidedTourTaskEl) guidedTourTaskEl.textContent = step.taskLabel || "";
   8662   if (guidedTourStatusEl) {
   8663     guidedTourStatusEl.textContent = step.taskLabel
   8664       ? step.requireTask
   8665         ? "This step is required to continue."
   8666         : "Do the task or press Next."
   8667       : "";
   8668   }
   8669 
   8670   const selector = String(step.selector || "").trim();
   8671   const target = selector ? document.querySelector(selector) : null;
   8672   setGuidedTourCardPlacement(target instanceof HTMLElement ? target : null);
   8673   if (target instanceof HTMLElement) {
   8674     guidedTourTargetEl = target;
   8675     guidedTourTargetEl.classList.add("tourTargetPulse");
   8676     focusGuidedTourTarget(guidedTourTargetEl);
   8677   }
   8678 
   8679   if (guidedTourPrevBtn) guidedTourPrevBtn.disabled = guidedTourState.index <= 0;
   8680   if (guidedTourNextBtn) {
   8681     guidedTourNextBtn.textContent = guidedTourState.index >= guidedTourState.steps.length - 1 ? "Finish" : "Next";
   8682     guidedTourNextBtn.disabled = Boolean(step.requireTask && !guidedTourTaskSatisfied(step));
   8683   }
   8684 
   8685   if (typeof step.taskCheck === "function") {
   8686     guidedTourTaskTimer = setInterval(() => {
   8687       if (!guidedTourState.active) return;
   8688       const current = guidedTourCurrentStep();
   8689       if (!current || current !== step) return;
   8690       const ok = guidedTourTaskSatisfied(step);
   8691       if (guidedTourNextBtn) guidedTourNextBtn.disabled = Boolean(step.requireTask && !ok);
   8692       if (!ok) return;
   8693       clearGuidedTourTimers();
   8694       if (guidedTourStatusEl) guidedTourStatusEl.textContent = "Task complete. Moving on...";
   8695       guidedTourAutoAdvanceTimer = setTimeout(() => guidedTourGo(1), TOUR_AUTO_ADVANCE_MS);
   8696     }, TOUR_TASK_POLL_MS);
   8697   }
   8698 }
   8699 
   8700 function startGuidedTour({ auto = false } = {}) {
   8701   if (!loggedInUser) {
   8702     toast("Tour", "Sign in first to start the tutorial.");
   8703     renderAuthGate();
   8704     return;
   8705   }
   8706   if (onboardingNeedsAcceptanceNow()) {
   8707     toast("Tour", "Accept server rules first, then start the tutorial.");
   8708     renderAuthGate();
   8709     return;
   8710   }
   8711   try {
   8712     if (rackLayoutEnabled && layoutPresetEl instanceof HTMLSelectElement) {
   8713       const canUse = Array.from(layoutPresetEl.options || []).some((opt) => String(opt.value || "") === "tutorial");
   8714       if (canUse) {
   8715         if (layoutPresetEl.value !== "tutorial") layoutPresetEl.value = "tutorial";
   8716         applyPreset("tutorial");
   8717       }
   8718     }
   8719   } catch {
   8720     // ignore preset setup failures
   8721   }
   8722   ensureGuidedTourUi();
   8723   const startedSignedIn = Boolean(loggedInUser);
   8724   guidedTourState = { active: true, index: 0, steps: buildGuidedTourSteps({ startedSignedIn }), startedSignedIn };
   8725   if (guidedTourOverlayEl instanceof HTMLElement) guidedTourOverlayEl.classList.remove("hidden");
   8726   document.body.classList.add("tourActive");
   8727   if (guidedTourDontShowEl instanceof HTMLInputElement) guidedTourDontShowEl.checked = false;
   8728   if (!auto) toast("Tour", "Tour started. Complete optional tasks or press Next.");
   8729   renderGuidedTourStep();
   8730 }
   8731 
   8732 function stopGuidedTour({ completed = false, seenUser = loggedInUser } = {}) {
   8733   const wasActive = Boolean(guidedTourState.active);
   8734   clearGuidedTourTimers();
   8735   clearGuidedTourTarget();
   8736   if (guidedTourOverlayEl instanceof HTMLElement) guidedTourOverlayEl.classList.add("hidden");
   8737   document.body.classList.remove("tourActive");
   8738   guidedTourState = { active: false, index: 0, steps: [], startedSignedIn: false };
   8739   if (wasActive) {
   8740     const suppressFuture = Boolean(guidedTourDontShowEl instanceof HTMLInputElement && guidedTourDontShowEl.checked);
   8741     if (completed) {
   8742       writeGuidedTourPref(seenUser, { completed: true, dontShow: false });
   8743     } else if (suppressFuture) {
   8744       writeGuidedTourPref(seenUser, { dontShow: true });
   8745     }
   8746     if (tourBtn instanceof HTMLButtonElement) {
   8747       tourBtn.textContent = shouldAutoShowGuidedTour(seenUser) ? "Tour" : "Tour (replay)";
   8748     }
   8749     if (completed) toast("Tour", "Tour complete. Welcome to Bzl.");
   8750   }
   8751 }
   8752 
   8753 function maybeAutoStartGuidedTour() {
   8754   if (!loggedInUser) return;
   8755   if (onboardingNeedsAcceptanceNow()) return;
   8756   const user = normalizeTourUser(loggedInUser || "guest");
   8757   if (!user) return;
   8758   if (guidedTourState.active) return;
   8759   if (!shouldAutoShowGuidedTour(user)) return;
   8760   if (guidedTourAutoStartedForUser === user) return;
   8761   guidedTourAutoStartedForUser = user;
   8762   setTimeout(() => {
   8763     const currentUser = normalizeTourUser(loggedInUser || "guest");
   8764     if (!currentUser || currentUser !== user) return;
   8765     if (!guidedTourState.active) startGuidedTour({ auto: true });
   8766   }, 550);
   8767 }
   8768 
   8769 function roleLabel(role) {
   8770   const r = String(role || "member").toLowerCase();
   8771   return r === "owner" || r === "admin" || r === "moderator" ? r : "member";
   8772 }
   8773 
   8774 function peopleOnlineCardStyle(member) {
   8775   if (!member?.online) return "";
   8776   const rgb = hexToRgb(member.color || "");
   8777   if (!rgb) {
   8778     return `style="border-color:rgba(255,62,165,0.35);box-shadow:0 10px 24px rgba(255,62,165,0.12);"`;
   8779   }
   8780   return `style="border-color:rgba(${rgb.r},${rgb.g},${rgb.b},0.45);background:linear-gradient(180deg, rgba(${rgb.r},${rgb.g},${rgb.b},0.13), rgba(${rgb.r},${rgb.g},${rgb.b},0.04) 55%), rgba(255,255,255,0.02);box-shadow:0 10px 24px rgba(${rgb.r},${rgb.g},${rgb.b},0.17);"`;
   8781 }
   8782 
   8783 function renderPeoplePanel() {
   8784   if (!peopleDrawerEl || !peopleListEl) return;
   8785   ensurePeopleFallback();
   8786   const membersTabOn = peopleTab === "members";
   8787   peopleMembersTabBtn?.classList.toggle("primary", membersTabOn);
   8788   peopleMembersTabBtn?.classList.toggle("ghost", !membersTabOn);
   8789   peopleDmsTabBtn?.classList.toggle("primary", !membersTabOn);
   8790   peopleDmsTabBtn?.classList.toggle("ghost", membersTabOn);
   8791   peopleMembersViewEl?.classList.toggle("hidden", !membersTabOn);
   8792   peopleDmsViewEl?.classList.toggle("hidden", membersTabOn);
   8793   if (!membersTabOn) {
   8794     if (!peopleDmsViewEl) return;
   8795     if (!loggedInUser) {
   8796       peopleDmsViewEl.innerHTML = `<div class="muted">Sign in to use DMs.</div><div class="uiHint">After signing in, open a DM request and accept it to start chatting.</div>`;
   8797       return;
   8798     }
   8799 
   8800     const blockedSet = prefSet("blockedUsers");
   8801     const eligibleMembers = peopleMembers
   8802       .filter((m) => m?.username && String(m.username).toLowerCase() !== String(loggedInUser).toLowerCase())
   8803       .filter((m) => !blockedSet.has(String(m.username || "").toLowerCase()))
   8804       .map((m) => String(m.username))
   8805       .sort((a, b) => a.localeCompare(b))
   8806       .slice(0, 250);
   8807 
   8808     const picker =
   8809       eligibleMembers.length > 0
   8810         ? `<div class="dmNewRow">
   8811             <select class="dmToSelect" data-dmto="1">
   8812               <option value="">New DM...</option>
   8813               ${eligibleMembers.map((u) => `<option value="${escapeHtml(u)}">@${escapeHtml(u)}</option>`).join("")}
   8814             </select>
   8815             <button type="button" class="primary" data-dmrequestfromselect="1">Request</button>
   8816           </div>`
   8817         : `<div class="muted">No other members yet.</div>`;
   8818 
   8819     const threads = Array.isArray(dmThreads) ? [...dmThreads].sort((a, b) => dmActivityAt(b) - dmActivityAt(a)) : [];
   8820     const listHtml = threads.length
   8821       ? threads
   8822           .map((t) => {
   8823             const other = String(t.other || "");
   8824             const isBlocked = blockedSet.has(other.toLowerCase());
   8825             const status = String(t.status || "unknown");
   8826             const when = dmActivityAt(t);
   8827             const whenTxt = when ? new Date(when).toLocaleString() : "";
   8828             const statusBadge =
   8829               status === "incoming"
   8830                 ? `<span class="tag dmTag dmTagIncoming">request</span>`
   8831                 : status === "outgoing"
   8832                   ? `<span class="tag dmTag dmTagPending">pending</span>`
   8833                   : status === "active"
   8834                     ? `<span class="tag dmTag dmTagActive">active</span>`
   8835                     : status === "declined"
   8836                       ? `<span class="tag dmTag dmTagDeclined">declined</span>`
   8837                       : `<span class="tag dmTag">unknown</span>`;
   8838 
   8839             const blockedBadge = isBlocked ? `<span class="tag dmTag dmTagDeclined">blocked</span>` : "";
   8840 
   8841             let actions =
   8842               status === "incoming"
   8843                 ? `<div class="row" style="gap:8px;justify-content:flex-end">
   8844                     <button type="button" class="primary smallBtn" data-dmaccept="${escapeHtml(t.id)}">Accept</button>
   8845                     <button type="button" class="ghost smallBtn" data-dmdecline="${escapeHtml(t.id)}">Decline</button>
   8846                   </div>`
   8847                 : status === "active"
   8848                   ? `<button type="button" class="primary smallBtn" data-dmopen="${escapeHtml(t.id)}">Open</button>`
   8849                   : status === "declined"
   8850                     ? `<button type="button" class="ghost smallBtn" data-dmrequest="${escapeHtml(other)}">Request again</button>`
   8851                     : `<span class="muted small">Waiting...</span>`;
   8852 
   8853             if (isBlocked) {
   8854               actions =
   8855                 status === "active"
   8856                   ? `<button type="button" class="primary smallBtn" data-dmopen="${escapeHtml(t.id)}" disabled>Open</button>`
   8857                   : `<span class="muted small">Blocked</span>`;
   8858             }
   8859             if (canModerate && other) {
   8860               actions += ` <button type="button" class="ghost smallBtn" data-moddm="${escapeHtml(other)}">Mod DM</button>`;
   8861             }
   8862 
   8863             return `<div class="dmThreadCard">
   8864               <div class="dmThreadTop">
   8865                 <div class="dmThreadLeft">
   8866                   ${renderUserPill(other)}
   8867                   ${statusBadge}
   8868                   ${blockedBadge}
   8869                 </div>
   8870                 <div class="dmThreadRight">${actions}</div>
   8871               </div>
   8872               <div class="small muted">${whenTxt ? `Last activity: ${escapeHtml(whenTxt)}` : "No messages yet."} <span class="muted">β€’</span> DMs purge daily.</div>
   8873             </div>`;
   8874           })
   8875           .join("")
   8876       : `<div class="muted">No DMs yet. Start one from the Members tab or a profile.</div>`;
   8877 
   8878     peopleDmsViewEl.innerHTML = `
   8879       <div class="dmHeader">
   8880         <div class="small muted">Private 1:1 chats (encrypted at rest). Incoming requests must be accepted.</div>
   8881         ${picker}
   8882       </div>
   8883       <div class="dmThreadList">${listHtml}</div>
   8884     `;
   8885     return;
   8886   }
   8887 
   8888   const q = (peopleSearchEl?.value || "").trim().toLowerCase();
   8889   const list = peopleMembers
   8890     .filter((m) => (q ? String(m.username || "").toLowerCase().includes(q) : true))
   8891     .sort((a, b) => Number(Boolean(b.online)) - Number(Boolean(a.online)) || String(a.username).localeCompare(String(b.username)));
   8892 
   8893   if (!list.length) {
   8894     peopleListEl.innerHTML = `<div class="muted">No members found.</div><div class="uiHint">Try clearing the search filter or check back when more members are online.</div>`;
   8895     return;
   8896   }
   8897   peopleListEl.innerHTML = list
   8898     .map((m) => {
   8899       const username = String(m.username || "");
   8900       const status = String(m.status || (m.online ? "online" : "offline"));
   8901       const role = roleLabel(m.role);
   8902       const statusText = `${status}${m.online ? "" : ""}`;
   8903       const cardStyle = peopleOnlineCardStyle(m);
   8904       const canDm = Boolean(loggedInUser && username && String(username).toLowerCase() !== String(loggedInUser).toLowerCase());
   8905       const canModDm = Boolean(canModerate && username && String(username).toLowerCase() !== String(loggedInUser || "").toLowerCase());
   8906       return `<div class="peopleCard" data-viewprofile="${escapeHtml(username)}" ${cardStyle}>
   8907         <div class="peopleCardTop">
   8908           <div>${renderUserPill(username)} <span class="modStatus">${escapeHtml(role)}</span></div>
   8909           <div class="peopleStatus">${escapeHtml(statusText)}</div>
   8910         </div>
   8911         <div class="peopleCardActions">
   8912           <button type="button" class="ghost smallBtn" data-viewprofile="${escapeHtml(username)}">Profile</button>
   8913           <button type="button" class="ghost smallBtn" data-dmrequest="${escapeHtml(username)}" ${canDm ? "" : "disabled"}>DM</button>
   8914           ${canModDm ? `<button type="button" class="ghost smallBtn" data-moddm="${escapeHtml(username)}">Mod DM</button>` : ""}
   8915         </div>
   8916       </div>`;
   8917     })
   8918     .join("");
   8919 }
   8920 
   8921 function statusBadge(status) {
   8922   const s = String(status || "");
   8923   if (!s) return `<span class="muted">-</span>`;
   8924   return `<span class="modStatus">${escapeHtml(s)}</span>`;
   8925 }
   8926 
   8927 function userStateText(user) {
   8928   const t = Date.now();
   8929   if (user.banned) return "banned";
   8930   if (Number(user.suspendedUntil || 0) > t) return `suspended until ${new Date(user.suspendedUntil).toLocaleString()}`;
   8931   if (Number(user.mutedUntil || 0) > t) return `muted until ${new Date(user.mutedUntil).toLocaleString()}`;
   8932   return "active";
   8933 }
   8934 
   8935 function promptReason(actionLabel) {
   8936   const value = prompt(`Reason for ${actionLabel}:`);
   8937   if (!value) return "";
   8938   return value.trim();
   8939 }
   8940 
   8941 function requestModData() {
   8942   if (!canModerate) return;
   8943   ws.send(JSON.stringify({ type: "modListUsers", limit: 200 }));
   8944   ws.send(JSON.stringify({ type: "modListLog", limit: 200 }));
   8945   ws.send(JSON.stringify({ type: "devLogList", limit: 300 }));
   8946   const status = modReportStatusEl ? modReportStatusEl.value : "open";
   8947   ws.send(JSON.stringify({ type: "modListReports", status, limit: 200 }));
   8948 }
   8949 
   8950 function renderModPanel() {
   8951   if (!modPanelEl || !modBodyEl) return;
   8952   modPanelEl.classList.toggle("hidden", !canModerate);
   8953   if (appRoot) appRoot.classList.toggle("hasMod", canModerate);
   8954   if (!canModerate) {
   8955     modBodyEl.innerHTML = "";
   8956     if (isMobileScreenMode() && appRoot?.getAttribute("data-mobile-screen") === "moderation") setMobileScreen("hives", { pushHistory: false });
   8957     return;
   8958   }
   8959   if (modReportStatusEl) modReportStatusEl.classList.toggle("hidden", modTab !== "reports");
   8960 
   8961   const tabs = Array.from(modPanelEl.querySelectorAll("[data-modtab]"));
   8962   for (const btn of tabs) {
   8963     const on = btn.getAttribute("data-modtab") === modTab;
   8964     btn.classList.toggle("primary", on);
   8965     btn.classList.toggle("ghost", !on);
   8966     // Owner-only plugin tabs should not show for non-owners.
   8967     const ownerOnly = btn.dataset.ownerOnly === "1";
   8968     btn.classList.toggle("hidden", Boolean(ownerOnly && !isOwnerRole(loggedInRole) && !isAdminRole(loggedInRole)));
   8969   }
   8970 
   8971   // Plugin-provided moderation tabs (render into modBody).
   8972   if (modPluginTabs.has(modTab)) {
   8973     const def = modPluginTabs.get(modTab);
   8974     if (def?.ownerOnly && !isOwnerRole(loggedInRole) && !isAdminRole(loggedInRole)) {
   8975       modTab = "server";
   8976       renderModPanel();
   8977       return;
   8978     }
   8979     modBodyEl.innerHTML = `
   8980       <div class="modCard">
   8981         <div class="modRowTop"><div><b>${escapeHtml(def?.title || "Plugin")}</b></div></div>
   8982         <div id="modPluginMount" class="modActions"></div>
   8983       </div>
   8984     `;
   8985     const mount = modBodyEl.querySelector("#modPluginMount");
   8986     if (mount) {
   8987       const api = {
   8988         toast,
   8989         send: (eventName, payload) => {
   8990           const ev = String(eventName || "").trim();
   8991           if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false;
   8992           const wsRef = window.__bzlWs;
   8993           if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false;
   8994           const msg = payload && typeof payload === "object" ? payload : {};
   8995           wsRef.send(JSON.stringify({ ...msg, type: `plugin:${def.pluginId}:${ev}` }));
   8996           return true;
   8997         },
   8998         getUser: () => loggedInUser,
   8999         getRole: () => loggedInRole,
   9000       };
   9001       try {
   9002         def.render(mount, api);
   9003       } catch (e) {
   9004         mount.textContent = "Failed to render plugin tab.";
   9005         console.warn(`Plugin tab render failed (${modTab}):`, e?.message || e);
   9006       }
   9007     }
   9008     return;
   9009   }
   9010 
   9011   if (modTab === "server") {
   9012     const isOwner = isOwnerRole(loggedInRole) || isAdminRole(loggedInRole);
   9013     const b = normalizeInstanceBranding(instanceBranding);
   9014     const loading = Boolean(serverInfoStatus.loading);
   9015     const err = String(serverInfoStatus.error || "");
   9016     const info = serverInfo && typeof serverInfo === "object" ? serverInfo : null;
   9017     const health = serverHealth && typeof serverHealth === "object" ? serverHealth : null;
   9018     const stats = health?.stats && typeof health.stats === "object" ? health.stats : null;
   9019     const rl = info?.config?.rateLimits && typeof info.config.rateLimits === "object" ? info.config.rateLimits : null;
   9020     const updatedAt = serverInfoStatus.at ? formatLocalTime(serverInfoStatus.at) : "";
   9021 
   9022     const statusLine = loading
   9023       ? `<span class="muted">Loading...</span>`
   9024       : err
   9025         ? `<span class="bad">${escapeHtml(err)}</span>`
   9026         : updatedAt
   9027           ? `<span class="muted">Updated: ${escapeHtml(updatedAt)}</span>`
   9028           : `<span class="muted">Not loaded yet.</span>`;
   9029 
   9030     const instanceOwnerControls = `<label>
   9031            <span>Title</span>
   9032            <input data-instance-title maxlength="32" value="${escapeHtml(b.title)}" />
   9033          </label>
   9034          <label>
   9035            <span>Subtitle</span>
   9036            <input data-instance-subtitle maxlength="80" value="${escapeHtml(b.subtitle)}" />
   9037          </label>
   9038          <label class="row" style="gap:10px; align-items:center">
   9039            <input data-instance-allowpermanent type="checkbox" ${b.allowMemberPermanentPosts ? "checked" : ""} />
   9040            <span>Allow members to create permanent hives</span>
   9041          </label>`;
   9042 
   9043     const instanceControls = isOwner
   9044       ? `${instanceOwnerControls}
   9045          <div class="small muted">Look & feel moved to View β†’ Advanced display. Users can now set personal themes there.</div>
   9046          <div class="row" style="gap:8px">
   9047            <button type="button" class="primary" data-instance-save="1">Save</button>
   9048            <button type="button" class="ghost" data-server-refresh="1">Refresh server</button>
   9049          </div>`
   9050       : `<div class="small muted">Only the owner/admin can edit core instance settings.</div>
   9051            <div class="small">Title: <b>${escapeHtml(b.title)}</b></div>
   9052            <div class="small">Subtitle: <b>${escapeHtml(b.subtitle)}</b></div>
   9053            <div class="small">Members can create permanent hives: <b>${b.allowMemberPermanentPosts ? "yes" : "no"}</b></div>
   9054            <div class="small muted" style="margin-top:6px;">Look & feel moved to View β†’ Advanced display.</div>
   9055            <div class="row" style="gap:8px; margin-top:8px">
   9056              <button type="button" class="ghost" data-server-refresh="1">Refresh server</button>
   9057            </div>`;
   9058 
   9059     const serverLines = [
   9060       info?.port ? `Port: ${Number(info.port)}` : "",
   9061       typeof info?.registrationEnabled === "boolean" ? `Registration enabled: ${info.registrationEnabled ? "yes" : "no"}` : "",
   9062       typeof health?.uptimeSec === "number" ? `Uptime: ${Math.floor(health.uptimeSec)}s` : "",
   9063       typeof stats?.sockets === "number" ? `Sockets: ${Math.floor(stats.sockets)}` : "",
   9064       typeof stats?.activePosts === "number" ? `Active hives: ${Math.floor(stats.activePosts)}` : "",
   9065       typeof stats?.users === "number" ? `Users: ${Math.floor(stats.users)}` : "",
   9066       typeof stats?.activeRateLimitBuckets === "number" ? `Active rate limit buckets: ${Math.floor(stats.activeRateLimitBuckets)}` : "",
   9067     ].filter(Boolean);
   9068 
   9069     const rlLines = rl
   9070       ? [
   9071           `Mod actions: ${rl.mod?.max ?? "?"} / ${rl.mod?.windowMs ?? "?"}ms`,
   9072           `Login: ${rl.login?.max ?? "?"} / ${rl.login?.windowMs ?? "?"}ms`,
   9073           `Register: ${rl.register?.max ?? "?"} / ${rl.register?.windowMs ?? "?"}ms`,
   9074           `Resume: ${rl.resume?.max ?? "?"} / ${rl.resume?.windowMs ?? "?"}ms`,
   9075           `Reports: ${rl.report?.max ?? "?"} / ${rl.report?.windowMs ?? "?"}ms`,
   9076         ]
   9077       : [];
   9078 
   9079     modBodyEl.innerHTML = `
   9080       <div class="modCard">
   9081         <div class="modRowTop">
   9082           <div><b>Server</b></div>
   9083           <div class="small">${statusLine}</div>
   9084         </div>
   9085         <div class="small muted">Server status, appearance, and plugins.</div>
   9086       </div>
   9087       <div class="modCard">
   9088         <div class="modRowTop"><div><b>Instance settings</b></div></div>
   9089         <div class="modActions">${instanceControls}</div>
   9090       </div>
   9091       <div class="modCard">
   9092         <div class="modRowTop"><div><b>Plugins</b></div></div>
   9093         <div class="modActions">${renderPluginsAdminHtml()}</div>
   9094       </div>
   9095       <div class="modCard">
   9096         <div class="modRowTop"><div><b>Runtime</b></div></div>
   9097         <div class="small">${serverLines.length ? serverLines.map((x) => `<div>${escapeHtml(x)}</div>`).join("") : `<div class="muted">No data yet.</div>`}</div>
   9098         ${
   9099           rlLines.length
   9100             ? `<div class="small muted" style="margin-top:10px">Rate limits</div>
   9101                <div class="small">${rlLines.map((x) => `<div>${escapeHtml(x)}</div>`).join("")}</div>`
   9102             : ""
   9103         }
   9104       </div>
   9105     `;
   9106     return;
   9107   }
   9108 
   9109   if (modTab === "onboarding") {
   9110     const isOwner = isOwnerRole(loggedInRole) || isAdminRole(loggedInRole);
   9111     const canEdit = isStaffRole(loggedInRole);
   9112     syncOnboardingAdminDraft(false);
   9113     normalizeOnboardingDraftRules();
   9114     const roleOptions = customRoles
   9115       .map(
   9116         (r) =>
   9117           `<label class="checkRow">
   9118             <span>${escapeHtml(String(r.label || r.key || ""))}</span>
   9119             <input type="checkbox" data-onboarding-rolecheck="${escapeHtml(String(r.key || ""))}" ${
   9120               onboardingAdminDraft.selfAssignableRoleIds.includes(String(r.key || "")) ? "checked" : ""
   9121             } />
   9122           </label>`
   9123       )
   9124       .join("");
   9125     const rulesCards = onboardingAdminDraft.rules.length
   9126       ? onboardingAdminDraft.rules
   9127           .map((r, idx) => {
   9128             const expanded = onboardingAdminExpandedRuleIds.has(r.id);
   9129             return `<article class="onbRuleEditorCard" data-onb-ruleid="${escapeHtml(r.id)}">
   9130                 <div class="row" style="justify-content:space-between;align-items:center;">
   9131                   <button type="button" class="ghost smallBtn" data-onb-ruletoggle="${escapeHtml(r.id)}">${expanded ? "β–Ύ" : "β–Έ"} Rule ${idx + 1}</button>
   9132                   <div class="row" style="gap:6px;">
   9133                     <button type="button" class="ghost smallBtn" data-onb-ruleup="${escapeHtml(r.id)}" ${idx <= 0 ? "disabled" : ""}>↑</button>
   9134                     <button type="button" class="ghost smallBtn" data-onb-ruledown="${escapeHtml(r.id)}" ${
   9135                       idx >= onboardingAdminDraft.rules.length - 1 ? "disabled" : ""
   9136                     }>↓</button>
   9137                     <button type="button" class="ghost smallBtn" data-onb-ruledelete="${escapeHtml(r.id)}">Delete</button>
   9138                   </div>
   9139                 </div>
   9140                 ${
   9141                   expanded
   9142                     ? `<div class="onbRuleEditorBody">
   9143                          <label><span>Name</span><input data-onb-rulefield="name" data-onb-ruleid="${escapeHtml(r.id)}" value="${escapeHtml(
   9144                            r.name
   9145                          )}" maxlength="60" /></label>
   9146                          <label><span>Short description</span><input data-onb-rulefield="shortDescription" data-onb-ruleid="${escapeHtml(
   9147                            r.id
   9148                          )}" value="${escapeHtml(r.shortDescription)}" maxlength="180" /></label>
   9149                          <label><span>Full description</span><textarea data-onb-rulefield="description" data-onb-ruleid="${escapeHtml(
   9150                            r.id
   9151                          )}" rows="4">${escapeHtml(r.description)}</textarea></label>
   9152                          <label><span>Severity</span>
   9153                            <select data-onb-rulefield="severity" data-onb-ruleid="${escapeHtml(r.id)}">
   9154                              <option value="info" ${r.severity === "info" ? "selected" : ""}>Info</option>
   9155                              <option value="warn" ${r.severity === "warn" ? "selected" : ""}>Warn</option>
   9156                              <option value="critical" ${r.severity === "critical" ? "selected" : ""}>Critical</option>
   9157                            </select>
   9158                          </label>
   9159                        </div>`
   9160                     : ""
   9161                 }
   9162               </article>`;
   9163           })
   9164           .join("")
   9165       : `<div class="small muted">No rules yet. Add your first rule.</div>`;
   9166 
   9167     modBodyEl.innerHTML = `
   9168       <div class="modCard">
   9169         <div class="modRowTop"><div><b>Onboarding</b></div></div>
   9170         <div class="small muted">Configure About, Rules, and Role Select.</div>
   9171         <div class="onbTabs" style="margin-top:8px;">
   9172           <button type="button" class="${onboardingAdminTab === "about" ? "primary" : "ghost"} smallBtn" data-onb-admin-tab="about">About</button>
   9173           <button type="button" class="${onboardingAdminTab === "rules" ? "primary" : "ghost"} smallBtn" data-onb-admin-tab="rules">Rules</button>
   9174           <button type="button" class="${onboardingAdminTab === "roles" ? "primary" : "ghost"} smallBtn" data-onb-admin-tab="roles">Roles</button>
   9175         </div>
   9176       </div>
   9177       <div class="modCard">
   9178         ${
   9179           onboardingAdminTab === "about"
   9180             ? `<label class="checkRow">
   9181                  <span>Enable onboarding panel</span>
   9182                  <input type="checkbox" data-onboarding-enabled ${onboardingAdminDraft.enabled ? "checked" : ""} ${canEdit ? "" : "disabled"} />
   9183                </label>
   9184                <label>
   9185                  <span>About (rich text allowed)</span>
   9186                  <textarea data-onboarding-about rows="10" ${canEdit ? "" : "disabled"}>${escapeHtml(onboardingAdminDraft.aboutContent)}</textarea>
   9187                </label>
   9188                <div class="small muted">Updated by: ${escapeHtml(String(normalizeInstanceBranding(instanceBranding).onboarding?.about?.updatedBy || "n/a"))}</div>
   9189                <div class="small muted">Updated at: ${escapeHtml(
   9190                  formatLocalTime(normalizeInstanceBranding(instanceBranding).onboarding?.about?.updatedAt || 0) || "n/a"
   9191                )}</div>`
   9192             : onboardingAdminTab === "rules"
   9193               ? `<label class="checkRow">
   9194                    <span>Require rules acceptance before posting/chat</span>
   9195                    <input type="checkbox" data-onboarding-require ${onboardingAdminDraft.requireAcceptance ? "checked" : ""} ${
   9196                   canEdit ? "" : "disabled"
   9197                 } />
   9198                  </label>
   9199                  <label class="checkRow">
   9200                    <span>Block reading hives until accepted ${isOwner ? "" : "(owner only)"}</span>
   9201                    <input type="checkbox" data-onboarding-blockread ${onboardingAdminDraft.blockReadUntilAccepted ? "checked" : ""} ${
   9202                   canEdit && isOwner ? "" : "disabled"
   9203                 } />
   9204                  </label>
   9205                  <div class="row" style="justify-content:space-between;align-items:center;margin:8px 0;">
   9206                    <div><b>Rules</b></div>
   9207                    <button type="button" class="primary smallBtn" data-onb-ruleadd="1" ${canEdit ? "" : "disabled"}>+ Add Rule</button>
   9208                  </div>
   9209                  <div class="onbRuleEditorList">${rulesCards}</div>`
   9210               : `<label class="checkRow">
   9211                    <span>Enable custom role select in onboarding</span>
   9212                    <input type="checkbox" data-onboarding-roleenabled ${onboardingAdminDraft.roleSelectEnabled ? "checked" : ""} ${
   9213                   canEdit ? "" : "disabled"
   9214                 } />
   9215                  </label>
   9216                  <div class="small muted">Choose self-assignable roles:</div>
   9217                  <div class="onbRoleGrid">${roleOptions || `<div class="small muted">No custom roles defined.</div>`}</div>`
   9218         }
   9219       </div>
   9220       <div class="modCard">
   9221         <div class="row" style="gap:8px;">
   9222           <button type="button" class="primary" data-onboarding-save="1" ${canEdit ? "" : "disabled"}>Save</button>
   9223           <button type="button" class="ghost" data-onboarding-publish="1" ${canEdit ? "" : "disabled"}>Publish</button>
   9224           <button type="button" class="ghost" data-onboarding-refresh="1">Reload</button>
   9225         </div>
   9226       </div>
   9227     `;
   9228     return;
   9229   }
   9230 
   9231   if (modTab === "users") {
   9232     const roleList = customRoles.length
   9233       ? customRoles
   9234           .map((r) => {
   9235             const swatch = r.color ? `<span class="roleSwatch" style="background:${escapeHtml(r.color)}"></span>` : "";
   9236             return `<div class="roleRow">
   9237               <div class="roleRowLeft">
   9238                 ${swatch}
   9239                 <div class="roleMeta">
   9240                   <div><b>${escapeHtml(r.label)}</b></div>
   9241                   <div class="roleKey">${escapeHtml(r.key)}</div>
   9242                 </div>
   9243               </div>
   9244               <div class="row" style="gap:8px">
   9245                 <button type="button" class="ghost smallBtn" data-rolearchive="${escapeHtml(r.key)}">Archive</button>
   9246               </div>
   9247             </div>`;
   9248           })
   9249           .join("")
   9250       : `<div class="muted">No custom roles yet.</div>`;
   9251 
   9252     const roleAdminCard = `<div class="modCard">
   9253       <div class="modRowTop">
   9254         <div><b>Custom roles</b></div>
   9255       </div>
   9256       <div class="roleCreateRow" style="margin-bottom:10px">
   9257         <label>
   9258           <span>Key</span>
   9259           <input data-rolekey maxlength="18" placeholder="vip" />
   9260         </label>
   9261         <label>
   9262           <span>Label</span>
   9263           <input data-rolelabel maxlength="24" placeholder="VIP" />
   9264         </label>
   9265         <label>
   9266           <span>Color</span>
   9267           <input data-rolecolor type="color" value="#ff3ea5" />
   9268         </label>
   9269         <button type="button" data-rolecreate="1">Create</button>
   9270       </div>
   9271       <div class="small muted" style="margin-bottom:8px">Tip: gate collections with <span class="tag">member</span>, <span class="tag">moderator</span>, <span class="tag">admin</span>, <span class="tag">owner</span>, or <span class="tag">role:yourkey</span>.</div>
   9272       <div class="gateList">${roleList}</div>
   9273     </div>`;
   9274     if (!modUsers.length) {
   9275       modBodyEl.innerHTML = `${roleAdminCard}<div class="muted">No users found.</div>`;
   9276       return;
   9277     }
   9278     modBodyEl.innerHTML =
   9279       roleAdminCard +
   9280       modUsers
   9281       .map((u) => {
   9282         const role = u.role || "member";
   9283         const status = userStateText(u);
   9284         const canPromote = loggedInRole === "owner" && u.username !== loggedInUser;
   9285         const canManageCustomRoles = canModerate && u.username !== loggedInUser;
   9286         const canResetPassword =
   9287           canModerate &&
   9288           u.username !== loggedInUser &&
   9289           role !== "owner" &&
   9290           (role !== "admin" || isOwnerRole(loggedInRole)) &&
   9291           (role !== "moderator" || isOwnerRole(loggedInRole) || isAdminRole(loggedInRole));
   9292         const customBadges = renderCustomRoleBadges(u.username);
   9293         return `<div class="modCard">
   9294           <div class="modRowTop">
   9295             <div><b>@${escapeHtml(u.username)}</b> ${statusBadge(role)}</div>
   9296             <div class="muted">${escapeHtml(status)}</div>
   9297           </div>
   9298           <div class="small muted">custom roles: ${customBadges || `<span class="muted">(none)</span>`}</div>
   9299           <div class="modActions">
   9300             <button type="button" data-modaction="user_mute" data-targettype="user" data-targetid="${escapeHtml(u.username)}" data-minutes="30">Mute 30m</button>
   9301             <button type="button" data-modaction="user_unmute" data-targettype="user" data-targetid="${escapeHtml(u.username)}">Unmute</button>
   9302             <button type="button" data-modaction="user_suspend" data-targettype="user" data-targetid="${escapeHtml(u.username)}" data-minutes="120">Suspend 2h</button>
   9303             <button type="button" data-modaction="user_unsuspend" data-targettype="user" data-targetid="${escapeHtml(u.username)}">Unsuspend</button>
   9304             <button type="button" data-modaction="user_ban" data-targettype="user" data-targetid="${escapeHtml(u.username)}">Ban</button>
   9305             <button type="button" data-modaction="user_unban" data-targettype="user" data-targetid="${escapeHtml(u.username)}">Unban</button>
   9306             ${canResetPassword ? `<button type="button" data-modaction="user_password_reset" data-targettype="user" data-targetid="${escapeHtml(u.username)}">Reset password</button>` : ""}
   9307             ${
   9308               canPromote && role === "member"
   9309                 ? `<button type="button" data-modaction="user_role_set" data-targettype="user" data-targetid="${escapeHtml(
   9310                     u.username
   9311                   )}" data-role="moderator">Make mod</button>`
   9312                 : ""
   9313             }
   9314             ${
   9315               canPromote && role === "moderator"
   9316                 ? `<button type="button" data-modaction="user_role_set" data-targettype="user" data-targetid="${escapeHtml(
   9317                     u.username
   9318                   )}" data-role="admin">Make admin</button>`
   9319                 : ""
   9320             }
   9321             ${
   9322               canPromote && role === "admin"
   9323                 ? `<button type="button" class="danger" data-modaction="user_role_set" data-targettype="user" data-targetid="${escapeHtml(
   9324                     u.username
   9325                   )}" data-role="moderator">Remove admin</button>`
   9326                 : ""
   9327             }
   9328             ${
   9329               canPromote && role === "moderator"
   9330                 ? `<button type="button" class="danger" data-modaction="user_role_set" data-targettype="user" data-targetid="${escapeHtml(
   9331                     u.username
   9332                   )}" data-role="member">Remove mod</button>`
   9333                 : ""
   9334             }
   9335             ${
   9336               canManageCustomRoles
   9337                 ? `<button type="button" data-usermanageroles="${escapeHtml(u.username)}">Manage custom roles</button>`
   9338                 : ""
   9339             }
   9340           </div>
   9341         </div>`;
   9342       })
   9343       .join("");
   9344     return;
   9345   }
   9346 
   9347   if (modTab === "hives") {
   9348     const hives = Array.from(posts.values()).sort((a, b) => rankTime(b) - rankTime(a) || b.createdAt - a.createdAt);
   9349     const collectionControls = canModerate
   9350       ? `<div class="modCard">
   9351            <div class="modRowTop">
   9352              <div><b>Collections</b></div>
   9353              <button type="button" data-createcollection="1">Create collection</button>
   9354            </div>
   9355            <div class="modActions">
   9356              ${activeCollections()
   9357                .map((c) => {
   9358                  const canArchive = c.id !== "general";
   9359                   const gateLabel =
   9360                     c.visibility === "gated"
   9361                       ? `gated: ${(c.allowedRoles || []).map((t) => roleTokenLabel(t)).join(", ") || "(none)"}`
   9362                       : "public";
   9363                   return `<span class="tag">/${escapeHtml(c.name)}</span>${
   9364                     c.id !== "general"
   9365                       ? `<button type="button" data-collectiongate="${escapeHtml(c.id)}">Gate...</button>
   9366                          <button type="button" data-collectionpublic="${escapeHtml(c.id)}">Make public</button>`
   9367                       : ""
   9368                   }
   9369                   <span class="small muted">${escapeHtml(gateLabel)}</span>${
   9370                    canArchive
   9371                      ? `<button type="button" data-archivecollection="${escapeHtml(c.id)}">Archive ${escapeHtml(c.name)}</button>`
   9372                      : ""
   9373                  }`;
   9374                })
   9375                .join(" ")}
   9376            </div>
   9377          </div>`
   9378       : "";
   9379     if (!hives.length) {
   9380       modBodyEl.innerHTML = `${collectionControls}<div class="muted">No active hives.</div>`;
   9381       return;
   9382     }
   9383     modBodyEl.innerHTML =
   9384       collectionControls +
   9385       hives
   9386       .map((p) => {
   9387         const title = postTitle(p);
   9388         const author = p.author ? `@${p.author}` : "unknown";
   9389         const collection = activeCollections().find((c) => c.id === p.collectionId)?.name || "General";
   9390         const openReports = modReports.filter(
   9391           (r) => r && r.status === "open" && (r.postId === p.id || (r.targetType === "post" && r.targetId === p.id))
   9392         ).length;
   9393         return `<div class="modCard">
   9394           <div class="modRowTop">
   9395             <div><b>${escapeHtml(title)}</b></div>
   9396             <div class="muted">${formatCountdown(p.expiresAt)}</div>
   9397           </div>
   9398           <div class="small">author: ${escapeHtml(author)} | collection: /${escapeHtml(collection)} | id: ${escapeHtml(p.id)}</div>
   9399           <div class="small muted">open reports: ${openReports}</div>
   9400           <div class="modActions">
   9401             <button type="button" data-chat="${p.id}">Open chat</button>
   9402             <button type="button" data-modaction="post_ttl_set" data-targettype="post" data-targetid="${escapeHtml(
   9403               p.id
   9404             )}" data-ttl="0">Permanent</button>
   9405             <button type="button" data-modaction="post_ttl_set" data-targettype="post" data-targetid="${escapeHtml(
   9406               p.id
   9407             )}" data-ttl="60">TTL 1h</button>
   9408             <button type="button" data-modaction="post_ttl_set" data-targettype="post" data-targetid="${escapeHtml(
   9409               p.id
   9410             )}" data-ttl="1440">TTL 1d</button>
   9411             <button type="button" data-modaction="post_ttl_set" data-targettype="post" data-targetid="${escapeHtml(
   9412               p.id
   9413             )}" data-ttlprompt="1">Set TTL...</button>
   9414             ${
   9415               p.readOnly
   9416                 ? `<button type="button" data-modaction="post_readonly_set" data-targettype="post" data-targetid="${escapeHtml(
   9417                     p.id
   9418                   )}" data-readonly="0">Make writable</button>`
   9419                 : `<button type="button" data-modaction="post_readonly_set" data-targettype="post" data-targetid="${escapeHtml(
   9420                     p.id
   9421                   )}" data-readonly="1">Read-only</button>`
   9422             }
   9423             ${
   9424               p.protected
   9425                 ? `<button type="button" data-modaction="post_protection_set" data-targettype="post" data-targetid="${escapeHtml(
   9426                     p.id
   9427                   )}" data-unprotect="1">Unprotect</button>
   9428                    <button type="button" data-modaction="post_protection_set" data-targettype="post" data-targetid="${escapeHtml(
   9429                      p.id
   9430                    )}" data-protect="1">Change password...</button>`
   9431                 : `<button type="button" data-modaction="post_protection_set" data-targettype="post" data-targetid="${escapeHtml(
   9432                     p.id
   9433                   )}" data-protect="1">Protect...</button>`
   9434             }
   9435             <button type="button" data-modaction="message_purge_recent" data-targettype="post" data-targetid="${escapeHtml(
   9436               p.id
   9437             )}" data-count="25">Purge 25 msgs</button>
   9438             <button type="button" data-modaction="message_purge_recent" data-targettype="post" data-targetid="${escapeHtml(
   9439               p.id
   9440             )}" data-count="50">Purge 50 msgs</button>
   9441             ${
   9442               p.deleted
   9443                 ? `<button type="button" data-modaction="post_restore" data-targettype="post" data-targetid="${escapeHtml(
   9444                     p.id
   9445                   )}" ${p.restoreAvailable ? "" : "disabled"}>${p.restoreAvailable ? "Restore hive" : "No restore snapshot"}</button>`
   9446                 : `<button type="button" data-modaction="post_delete" data-targettype="post" data-targetid="${escapeHtml(
   9447                     p.id
   9448                   )}">Delete hive</button>`
   9449             }
   9450             <button type="button" class="danger" data-modaction="post_erase" data-targettype="post" data-targetid="${escapeHtml(
   9451               p.id
   9452             )}">Erase</button>
   9453           </div>
   9454         </div>`;
   9455       })
   9456       .join("");
   9457     return;
   9458   }
   9459 
   9460   if (modTab === "log") {
   9461     const isOwner = loggedInRole === "owner";
   9462     const viewTabs = `
   9463       <div class="row" style="gap:10px; flex-wrap:wrap; margin-bottom:10px;">
   9464         <button type="button" class="${modLogView === "dev" ? "primary" : "ghost"} smallBtn" data-modlogview="dev">Server dev log</button>
   9465         <button type="button" class="${modLogView === "moderation" ? "primary" : "ghost"} smallBtn" data-modlogview="moderation">Moderation log</button>
   9466       </div>
   9467     `;
   9468     const nukeCard = isOwner
   9469       ? `<div class="modCard">
   9470            <div class="modRowTop">
   9471              <div><b>NUKE</b></div>
   9472              <button type="button" class="danger" data-nuke="1" disabled>NUKE</button>
   9473            </div>
   9474            <div class="small muted" style="margin-bottom:10px">Clears all hives, reports, moderation log, and hive media uploads. Keeps users + profiles.</div>
   9475            <label class="row small" style="gap:10px;align-items:center;justify-content:flex-start">
   9476              <input type="checkbox" data-nukeconfirm="1" />
   9477              <span>ARE YOU SURE?</span>
   9478            </label>
   9479           </div>`
   9480       : "";
   9481 
   9482     if (modLogView === "dev") {
   9483       const lines = devLog
   9484         .slice(0, 300)
   9485         .reverse()
   9486         .map((e) => {
   9487           const ts = e?.createdAt ? new Date(e.createdAt).toLocaleString() : "";
   9488           const lvl = String(e?.level || "info").toUpperCase();
   9489           const scope = String(e?.scope || "server");
   9490           const msg = String(e?.message || "");
   9491           const data = String(e?.data || "");
   9492           const extra = data ? ` ${data}` : "";
   9493           return `[${ts}] ${lvl} ${scope}: ${msg}${extra}`;
   9494         })
   9495         .join("\n");
   9496 
   9497       modBodyEl.innerHTML = `
   9498         ${viewTabs}
   9499         <div class="modCard">
   9500           <div class="modRowTop">
   9501             <div><b>Dev log</b></div>
   9502             <div class="row" style="gap:10px; flex-wrap:wrap; justify-content:flex-end">
   9503               <button type="button" class="ghost smallBtn" data-devlogrefresh="1">Refresh</button>
   9504               <button type="button" class="ghost smallBtn" data-devlogcopy="1">Copy</button>
   9505               ${isOwner ? `<button type="button" class="danger smallBtn" data-devlogclear="1">Clear</button>` : ""}
   9506             </div>
   9507           </div>
   9508           <label class="row small muted" style="gap:10px; align-items:center; justify-content:flex-start; margin-bottom:10px;">
   9509             <input type="checkbox" data-devlogautoscroll="1" ${devLogAutoScroll ? "checked" : ""} />
   9510             <span>Auto-scroll</span>
   9511             <button type="button" class="ghost smallBtn" data-devlogtest="1" style="margin-left:auto;">Test log</button>
   9512           </label>
   9513           <pre class="devLogPre" id="devLogPre">${escapeHtml(lines || "(empty)")}</pre>
   9514         </div>
   9515       `;
   9516 
   9517       const pre = document.getElementById("devLogPre");
   9518       if (pre && devLogAutoScroll) pre.scrollTop = pre.scrollHeight;
   9519       return;
   9520     }
   9521 
   9522     if (!modLog.length) {
   9523       modBodyEl.innerHTML = `${viewTabs}${nukeCard}<div class="muted">No moderation log entries yet.</div>`;
   9524       return;
   9525     }
   9526     modBodyEl.innerHTML =
   9527       viewTabs +
   9528       nukeCard +
   9529       modLog
   9530         .map(
   9531           (entry) => `<div class="modCard">
   9532         <div class="modRowTop">
   9533           <div><b>${escapeHtml(entry.actionType || "action")}</b> ${statusBadge(entry.targetType || "")}</div>
   9534           <div class="muted">${new Date(entry.createdAt).toLocaleString()}</div>
   9535         </div>
   9536         <div class="small">by @${escapeHtml(entry.actor || "unknown")} on ${escapeHtml(entry.targetId || "(none)")}</div>
   9537         <div class="small muted">${escapeHtml(entry.reason || "")}</div>
   9538         ${
   9539           entry?.metadata?.beforePreview || entry?.metadata?.beforeText
   9540             ? `<div class="small muted">content: ${escapeHtml(entry.metadata.beforePreview || entry.metadata.beforeText || "")}</div>`
   9541             : ""
   9542         }
   9543         ${
   9544           entry?.metadata?.editCount
   9545             ? `<div class="small muted">edits: ${escapeHtml(String(entry.metadata.editCount))}</div>`
   9546             : ""
   9547         }
   9548         ${
   9549           entry?.targetType === "post" && (entry?.actionType === "post_delete" || entry?.actionType === "self_post_delete")
   9550             ? `<div class="modActions">
   9551                  <button type="button" data-modaction="post_restore" data-targettype="post" data-targetid="${escapeHtml(
   9552                    entry.targetId || ""
   9553                  )}">Restore hive</button>
   9554                </div>`
   9555             : ""
   9556         }
   9557         ${
   9558           entry?.targetType === "chat" &&
   9559           (entry?.actionType === "message_delete" || entry?.actionType === "self_message_delete")
   9560             ? `<div class="modActions">
   9561                  <button type="button" data-modaction="message_restore" data-targettype="chat" data-targetid="${escapeHtml(
   9562                    entry.targetId || ""
   9563                  )}">Restore message</button>
   9564                </div>`
   9565             : ""
   9566         }
   9567       </div>`
   9568         )
   9569         .join("");
   9570     return;
   9571   }
   9572 
   9573   if (!modReports.length) {
   9574     modBodyEl.innerHTML = `<div class="muted">No reports for this filter.</div>`;
   9575     return;
   9576   }
   9577   modBodyEl.innerHTML = modReports
   9578     .map((r) => {
   9579       const status = r.status || "open";
   9580       const canAct = status === "open";
   9581       return `<div class="modCard">
   9582         <div class="modRowTop">
   9583           <div><b>${escapeHtml(r.targetType || "target")}</b> ${statusBadge(status)}</div>
   9584           <div class="muted">${new Date(r.createdAt).toLocaleString()}</div>
   9585         </div>
   9586         <div class="small">target: ${escapeHtml(r.targetId || "")}</div>
   9587         <div class="small">reporter: @${escapeHtml(r.reporter || "")}</div>
   9588         <div class="small muted">${escapeHtml(r.reason || "")}</div>
   9589         ${
   9590           canAct
   9591             ? `<div class="modActions">
   9592                 <button type="button" data-modaction="report_resolve" data-targettype="report" data-targetid="${escapeHtml(r.id)}">Resolve</button>
   9593                 <button type="button" data-modaction="report_dismiss" data-targettype="report" data-targetid="${escapeHtml(r.id)}">Dismiss</button>
   9594               </div>`
   9595             : ""
   9596         }
   9597       </div>`;
   9598     })
   9599     .join("");
   9600 }
   9601 
   9602 function isMapChatActive() {
   9603   return Boolean(!activeDmThreadId && !activeChatPostId && activeMapsRoomId);
   9604 }
   9605 
   9606 function normalizeMapChatScope(scope) {
   9607   const s = String(scope || "").trim().toLowerCase();
   9608   return s === "global" ? "global" : "local";
   9609 }
   9610 
   9611 function mapChatListFor(mapId, scope) {
   9612   const mid = String(mapId || "").trim().toLowerCase();
   9613   if (!mid) return [];
   9614   const sc = normalizeMapChatScope(scope);
   9615   const store = sc === "global" ? mapsChatGlobalByMapId : mapsChatLocalByMapId;
   9616   const arr = store.get(mid);
   9617   return Array.isArray(arr) ? arr : [];
   9618 }
   9619 
   9620 function pushMapChatMessage(mapId, scope, message) {
   9621   const mid = String(mapId || "").trim().toLowerCase();
   9622   if (!mid) return;
   9623   const sc = normalizeMapChatScope(scope);
   9624   const store = sc === "global" ? mapsChatGlobalByMapId : mapsChatLocalByMapId;
   9625   const prev = store.get(mid);
   9626   const arr = Array.isArray(prev) ? prev.slice() : [];
   9627   arr.push(message);
   9628   if (arr.length > 240) arr.splice(0, arr.length - 240);
   9629   store.set(mid, arr);
   9630 }
   9631 
   9632 function renderChatPanel(forceScroll = false) {
   9633   updateChatModToggleVisibility();
   9634   renderChatContextSelect();
   9635   const mobileChatScreen = isMobileChatScreenActive();
   9636   const mediaState = captureMediaState(chatMessagesEl);
   9637   const activePost = activeChatPostId ? posts.get(activeChatPostId) : null;
   9638   if (
   9639     streamCurrentPostId &&
   9640     (activeDmThreadId || !activePost || !isStreamPost(activePost) || String(activePost.id || "") !== String(streamCurrentPostId))
   9641   ) {
   9642     leaveActiveStream(true);
   9643   }
   9644   if (activeDmThreadId) {
   9645     renderStreamStage(null);
   9646     const thread = dmThreadsById.get(activeDmThreadId) || null;
   9647     if (!thread) {
   9648       activeDmThreadId = null;
   9649     } else {
   9650       const atBottomBefore =
   9651         chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 24;
   9652       chatTitle.textContent = `@${thread.other}`;
   9653       if (chatBackToListBtn) chatBackToListBtn.classList.toggle("hidden", !mobileChatScreen);
   9654       const status = String(thread.status || "unknown");
   9655       const statusTxt =
   9656         status === "incoming"
   9657           ? "DM request (accept to chat)"
   9658           : status === "outgoing"
   9659             ? "DM request pending"
   9660             : status === "declined"
   9661               ? "DM request declined"
   9662               : "Private chat";
   9663       chatMeta.textContent = `with @${thread.other} | ${statusTxt} | purged daily`;
   9664 
   9665       const messages = dmMessagesByThreadId.get(activeDmThreadId) || [];
   9666       if (status !== "active" && messages.length === 0) {
   9667         const promptHtml =
   9668           status === "incoming"
   9669             ? `<div class="row" style="gap:8px;justify-content:flex-start">
   9670                 <button type="button" class="primary smallBtn" data-dmaccept="${escapeHtml(thread.id)}">Accept</button>
   9671                 <button type="button" class="ghost smallBtn" data-dmdecline="${escapeHtml(thread.id)}">Decline</button>
   9672               </div>`
   9673             : status === "declined"
   9674               ? `<button type="button" class="ghost smallBtn" data-dmrequest="${escapeHtml(thread.other)}">Request again</button>`
   9675               : `<div class="muted">Waiting for @${escapeHtml(thread.other)}...</div>`;
   9676         chatMessagesEl.innerHTML = `<div class="small muted">${promptHtml}</div>`;
   9677         restoreMediaState(chatMessagesEl, mediaState);
   9678         setReplyToMessage(null);
   9679         return;
   9680       }
   9681 
   9682       chatMessagesEl.innerHTML = messages
   9683         .map((m, index) => {
   9684           const from = m.fromUser || "";
   9685           const isYou = loggedInUser && from && from === loggedInUser;
   9686           const isModMsg = Boolean(m?.asMod) || String(from || "").toLowerCase() === "mod";
   9687           const rail = chatRailClass({
   9688             fromUser: from,
   9689             isModMessage: isModMsg
   9690           });
   9691           const prev = index > 0 ? messages[index - 1] : null;
   9692           const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from);
   9693           const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || "");
   9694           const youTag = isModMsg ? "" : isYou ? `<span class="muted">(you)</span>` : "";
   9695           const time = new Date(m.createdAt).toLocaleTimeString();
   9696           const tint = tintStylesFromHex(getProfile(from).color);
   9697           const html = typeof m.html === "string" && m.html.trim() ? m.html : "";
   9698           const content = html ? html : highlightMentionsInText(m.text || "");
   9699           return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${rail}" data-msgid="${escapeHtml(m.id)}" ${tint}>
   9700             <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div>
   9701             <div class="content">${content}</div>
   9702           </div>`;
   9703         })
   9704         .join("");
   9705       for (const contentEl of chatMessagesEl.querySelectorAll(".chatMsg .content")) {
   9706         decorateMentionNodesInElement(contentEl);
   9707         decorateYouTubeEmbedsInElement(contentEl);
   9708       }
   9709       restoreMediaState(chatMessagesEl, mediaState);
   9710       if (forceScroll || atBottomBefore) chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
   9711       return;
   9712     }
   9713   }
   9714 
   9715   const post = activePost;
   9716   if (!post) {
   9717     renderStreamStage(null);
   9718     if (isMapChatActive()) {
   9719       const mapId = String(activeMapsRoomId || "").trim().toLowerCase();
   9720       const scope = normalizeMapChatScope(activeMapsChatScope);
   9721       const atBottomBefore =
   9722         chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 24;
   9723 
   9724       const title = activeMapsRoomTitle ? `Map: ${activeMapsRoomTitle}` : `Map: ${mapId}`;
   9725       chatTitle.textContent = activeMapsRoomTitle ? `Map: ${activeMapsRoomTitle}` : "Map chat";
   9726       if (chatBackToListBtn) chatBackToListBtn.classList.toggle("hidden", !mobileChatScreen);
   9727       chatMeta.innerHTML = `
   9728         <span class="muted">${escapeHtml(title)}</span>
   9729         <span class="muted">|</span>
   9730         <span class="mapChatToggle">
   9731           <button type="button" class="${scope === "local" ? "primary" : "ghost"} smallBtn" data-mapchatscope="local" title="Local chat (nearby)">Local</button>
   9732           <button type="button" class="${scope === "global" ? "primary" : "ghost"} smallBtn" data-mapchatscope="global" title="Global chat (entire map)">Global</button>
   9733         </span>
   9734       `;
   9735 
   9736       if (chatPanelEl) chatPanelEl.classList.remove("walkie");
   9737       if (walkieBarEl) walkieBarEl.classList.add("hidden");
   9738       if (chatForm) chatForm.classList.remove("hidden");
   9739 
   9740       const messages = mapChatListFor(mapId, scope);
   9741       if (!messages.length) {
   9742         chatMessagesEl.innerHTML = `<div class="small muted">${
   9743           scope === "local" ? "Local chat is proximity-based. Say something nearby." : "No messages yet. Say hello!"
   9744         }</div>`;
   9745         restoreMediaState(chatMessagesEl, mediaState);
   9746         setReplyToMessage(null);
   9747         return;
   9748       }
   9749 
   9750       chatMessagesEl.innerHTML = messages
   9751         .map((m, index) => {
   9752           const from = String(m.fromUser || "");
   9753           const isYou = loggedInUser && from && from === loggedInUser;
   9754           const rail = chatRailClass({
   9755             fromUser: from,
   9756             isModMessage: Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod"
   9757           });
   9758           const prev = index > 0 ? messages[index - 1] : null;
   9759           const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from);
   9760           const who = renderUserPill(from || "");
   9761           const youTag = isYou ? `<span class="muted">(you)</span>` : "";
   9762           const time = new Date(Number(m.createdAt || 0) || Date.now()).toLocaleTimeString();
   9763           const tint = tintStylesFromHex(getProfile(from).color);
   9764           const content = highlightMentionsInText(String(m.text || ""));
   9765           return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${rail}" data-msgid="${escapeHtml(String(m.id || ""))}" ${tint}>
   9766             <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div>
   9767             <div class="content">${content}</div>
   9768           </div>`;
   9769         })
   9770         .join("");
   9771       for (const contentEl of chatMessagesEl.querySelectorAll(".chatMsg .content")) {
   9772         decorateMentionNodesInElement(contentEl);
   9773         decorateYouTubeEmbedsInElement(contentEl);
   9774       }
   9775       restoreMediaState(chatMessagesEl, mediaState);
   9776       if (forceScroll || atBottomBefore) chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
   9777       setReplyToMessage(null);
   9778       return;
   9779     }
   9780 
   9781     if (chatBackToListBtn) chatBackToListBtn.classList.add("hidden");
   9782     if (mobileChatScreen) {
   9783       chatTitle.textContent = "Chats";
   9784       chatMeta.textContent = "Select a hive chat.";
   9785       if (chatPanelEl) chatPanelEl.classList.remove("walkie");
   9786       if (walkieBarEl) walkieBarEl.classList.add("hidden");
   9787       if (chatForm) chatForm.classList.add("hidden");
   9788       chatMessagesEl.innerHTML = renderMobileChatListHtml();
   9789       restoreMediaState(chatMessagesEl, mediaState);
   9790       setReplyToMessage(null);
   9791       return;
   9792     }
   9793     chatTitle.textContent = "Chat";
   9794     chatMeta.textContent = "Select a post to chat.";
   9795     if (chatPanelEl) chatPanelEl.classList.remove("walkie");
   9796     if (walkieBarEl) walkieBarEl.classList.add("hidden");
   9797     if (chatForm) chatForm.classList.remove("hidden");
   9798     chatMessagesEl.innerHTML = `<div class="small muted">No chat selected.</div>
   9799       <div class="uiHint">Open a hive and press <b>Chat</b>, or use People -> DMs to open a private thread.</div>
   9800       <div class="row" style="gap:8px;justify-content:flex-start;margin-top:8px;">
   9801         <button type="button" class="ghost smallBtn" data-chatemptyopen="hives">Open Hives</button>
   9802         <button type="button" class="ghost smallBtn" data-chatemptyopen="people">Open People</button>
   9803       </div>`;
   9804     restoreMediaState(chatMessagesEl, mediaState);
   9805     setReplyToMessage(null);
   9806     return;
   9807   }
   9808 
   9809   updateChatModToggleVisibility();
   9810   renderStreamStage(post);
   9811   const mode = normalizePostMode(post.mode || post.chatMode || "");
   9812   const isWalkie = mode === "walkie";
   9813   const isStream = mode === "stream";
   9814   if (chatPanelEl) chatPanelEl.classList.toggle("walkie", isWalkie);
   9815   if (walkieBarEl) walkieBarEl.classList.toggle("hidden", !isWalkie);
   9816   if (chatForm) chatForm.classList.toggle("hidden", isWalkie);
   9817   if (walkieRecordBtn) walkieRecordBtn.disabled = !(isWalkie && loggedInUser);
   9818   if (isWalkie && walkieStatusEl && !loggedInUser) walkieStatusEl.textContent = "Sign in to talk.";
   9819   if (!isWalkie && walkieStatusEl) walkieStatusEl.textContent = "";
   9820 
   9821   const atBottomBefore =
   9822     chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 24;
   9823   chatTitle.textContent = postTitle(post);
   9824   if (chatBackToListBtn) chatBackToListBtn.classList.toggle("hidden", !mobileChatScreen);
   9825   const tags = (post.keywords || []).map((k) => `#${k}`).join(" ");
   9826   const author = post.author ? `by @${post.author}` : "";
   9827   const exp = formatCountdown(post.expiresAt);
   9828   const ro = post.readOnly ? " | read-only" : "";
   9829   const streamMeta = isStream ? ` | stream (${streamKindLabel(post.streamKind || "webcam")})` : "";
   9830   chatMeta.textContent = `${author}${isWalkie ? " | walkie talkie" : ""}${streamMeta}${ro} | ${
   9831     exp === "permanent" ? "permanent" : `expires in ${exp}`
   9832   } | ${tags}`.trim();
   9833   const canChatWrite = Boolean(isStaffRole(loggedInRole) || !post.readOnly);
   9834   if (chatEditor) chatEditor.contentEditable = String(Boolean(canChatWrite && !isWalkie));
   9835   const chatSendBtn = chatForm?.querySelector?.("button[type='submit']") || null;
   9836   if (chatSendBtn) chatSendBtn.disabled = !(loggedInUser && canChatWrite && !isWalkie);
   9837   if (post.deleted) {
   9838     chatMessagesEl.innerHTML = `<div class="small muted">Post was deleted.</div>`;
   9839     restoreMediaState(chatMessagesEl, mediaState);
   9840     setReplyToMessage(null);
   9841     return;
   9842   }
   9843 
   9844   const messages = chatByPost.get(post.id) || [];
   9845   const ignoreUserSet = new Set(
   9846     [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase())
   9847   );
   9848   const selfLower = String(loggedInUser || "").toLowerCase();
   9849   const visibleMessages = messages.filter((m) => {
   9850     const fromLower = String(m?.fromUser || "").toLowerCase();
   9851     if (!fromLower || fromLower === selfLower) return true;
   9852     return !ignoreUserSet.has(fromLower);
   9853   });
   9854 
   9855   chatMessagesEl.innerHTML = visibleMessages
   9856     .map((m, index) => {
   9857       const isModMsg = Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod";
   9858       const from = isModMsg ? "MOD" : m.fromUser || "";
   9859       const rail = chatRailClass({ fromUser: from, isModMessage: isModMsg });
   9860       const prev = index > 0 ? visibleMessages[index - 1] : null;
   9861       const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from);
   9862       const mentions = Array.isArray(m.mentions) ? m.mentions.map((u) => String(u || "").toLowerCase()) : [];
   9863       const mentionMe = Boolean(loggedInUser && mentions.includes(loggedInUser));
   9864       const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || "");
   9865       const youTag = !isModMsg && loggedInUser && from && from === loggedInUser ? `<span class="muted">(you)</span>` : "";
   9866       const time = new Date(m.createdAt).toLocaleTimeString();
   9867       const tint = isModMsg ? "" : tintStylesFromHex(getProfile(from).color);
   9868       const html = typeof m.html === "string" && m.html.trim() ? m.html : "";
   9869       const content = html ? html : highlightMentionsInText(m.text || "");
   9870       const replyMeta = m.replyTo && typeof m.replyTo === "object" ? m.replyTo : null;
   9871       const replyBlock = replyMeta
   9872         ? `<div class="chatReplyRef"><span class="small muted">@${escapeHtml(replyMeta.fromUser || "unknown")}</span><div class="small">${escapeHtml(
   9873             String(replyMeta.text || "[media]").slice(0, 120)
   9874           )}</div></div>`
   9875         : "";
   9876       const reacts = renderReactionButtons({ kind: "chat", id: m.id, reactions: m.reactions || {}, postId: post.id });
   9877       const deletedLine = m.deleted
   9878         ? `<div class="small muted">message deleted${
   9879             m.deletedBy ? ` by @${escapeHtml(m.deletedBy)}` : ""
   9880           } at ${escapeHtml(new Date(Number(m.deletedAt || m.createdAt || Date.now())).toLocaleString())}</div>`
   9881         : "";
   9882       const editedLine =
   9883         !m.deleted && Number(m.editCount || 0) > 0
   9884           ? `<div class="small muted">edited (${Number(m.editCount || 0)}) at ${escapeHtml(
   9885               new Date(Number(m.editedAt || m.createdAt || Date.now())).toLocaleTimeString()
   9886             )}</div>`
   9887           : "";
   9888       const reportAction = loggedInUser && !m.deleted
   9889         ? `<button type="button" class="ghost smallBtn" data-reportchat="${escapeHtml(m.id)}" data-postid="${escapeHtml(
   9890             post.id
   9891           )}">Report</button>`
   9892         : "";
   9893       const canManageOwnMessage = Boolean(loggedInUser && m.fromUser && m.fromUser === loggedInUser && !m.deleted);
   9894       const replyAction = loggedInUser && !m.deleted
   9895         ? `<button type="button" class="ghost smallBtn" data-replymsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(post.id)}">Reply</button>`
   9896         : "";
   9897       const ownEditAction = canManageOwnMessage
   9898         ? `<button type="button" class="ghost smallBtn" data-editmsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(post.id)}">Edit</button>`
   9899         : "";
   9900       const ownDeleteAction = canManageOwnMessage
   9901         ? `<button type="button" class="ghost smallBtn" data-deletemsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(post.id)}">Delete</button>`
   9902         : "";
   9903       return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${mentionMe ? "mentionMe" : ""} ${rail} ${isModMsg ? "isModMsg" : ""}" data-msgid="${escapeHtml(m.id)}" ${tint}>
   9904         <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div>
   9905         ${replyBlock}
   9906         ${deletedLine}
   9907         ${editedLine}
   9908         <div class="content">${content}</div>
   9909         <div class="chatActionsRow">
   9910           <div class="chatReactions">${m.deleted ? "" : reacts}</div>
   9911           <div class="chatTools">${replyAction}${ownEditAction}${ownDeleteAction}${reportAction}</div>
   9912         </div>
   9913       </div>`;
   9914     })
   9915     .join("");
   9916   for (const contentEl of chatMessagesEl.querySelectorAll(".chatMsg .content")) {
   9917     decorateMentionNodesInElement(contentEl);
   9918     decorateYouTubeEmbedsInElement(contentEl);
   9919   }
   9920   restoreMediaState(chatMessagesEl, mediaState);
   9921   if (forceScroll || atBottomBefore) chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
   9922 }
   9923 
   9924 function captureMediaState(containerEl) {
   9925   if (!containerEl) return [];
   9926   const list = [];
   9927   for (const el of containerEl.querySelectorAll("audio, video")) {
   9928     try {
   9929       const src = el.currentSrc || el.getAttribute("src") || "";
   9930       if (!src) continue;
   9931       list.push({
   9932         src,
   9933         currentTime: Number(el.currentTime || 0),
   9934         paused: Boolean(el.paused),
   9935         volume: Number.isFinite(el.volume) ? el.volume : 1,
   9936         playbackRate: Number.isFinite(el.playbackRate) ? el.playbackRate : 1
   9937       });
   9938     } catch {
   9939       // ignore
   9940     }
   9941   }
   9942   return list;
   9943 }
   9944 
   9945 function restoreMediaState(containerEl, mediaState) {
   9946   if (!containerEl || !Array.isArray(mediaState) || mediaState.length === 0) return;
   9947   const els = Array.from(containerEl.querySelectorAll("audio, video"));
   9948   for (const s of mediaState) {
   9949     const src = String(s?.src || "");
   9950     if (!src) continue;
   9951     const el = els.find((x) => (x.currentSrc || x.getAttribute("src") || "") === src);
   9952     if (!el) continue;
   9953     try {
   9954       if (Number.isFinite(s.volume)) el.volume = s.volume;
   9955       if (Number.isFinite(s.playbackRate)) el.playbackRate = s.playbackRate;
   9956       if (Number.isFinite(s.currentTime)) el.currentTime = s.currentTime;
   9957       if (!s.paused) el.play().catch(() => {});
   9958     } catch {
   9959       // ignore
   9960     }
   9961   }
   9962 }
   9963 
   9964 function appendChatHtmlAndDecorate(html, atBottomBefore) {
   9965   if (!chatMessagesEl) return null;
   9966   chatMessagesEl.insertAdjacentHTML("beforeend", html);
   9967   const last = chatMessagesEl.lastElementChild;
   9968   if (last && last.classList && last.classList.contains("chatMsg")) {
   9969     const contentEl = last.querySelector(".content");
   9970     if (contentEl) {
   9971       decorateMentionNodesInElement(contentEl);
   9972       decorateYouTubeEmbedsInElement(contentEl);
   9973     }
   9974   }
   9975   if (atBottomBefore) chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
   9976   return last;
   9977 }
   9978 
   9979 function appendPostChatMessageToDom(postId, message) {
   9980   if (!chatMessagesEl) return false;
   9981   const post = postId ? posts.get(postId) : null;
   9982   if (!post || post.deleted) return false;
   9983   if (!activeChatPostId || activeChatPostId !== postId) return false;
   9984   if (activeDmThreadId) return false;
   9985   if (!chatMessagesEl.querySelector(".chatMsg")) return false;
   9986 
   9987   const atBottomBefore =
   9988     chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 24;
   9989 
   9990   const ignoreUserSet = new Set(
   9991     [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase())
   9992   );
   9993   const selfLower = String(loggedInUser || "").toLowerCase();
   9994 
   9995   const messages = chatByPost.get(postId) || [];
   9996   let prevVisible = null;
   9997   for (let i = messages.length - 2; i >= 0; i -= 1) {
   9998     const pm = messages[i];
   9999     const fromLower = String(pm?.fromUser || "").toLowerCase();
  10000     if (!fromLower || fromLower === selfLower || !ignoreUserSet.has(fromLower)) {
  10001       prevVisible = pm;
  10002       break;
  10003     }
  10004   }
  10005 
  10006   const m = message;
  10007   const isModMsg = Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod";
  10008   const from = isModMsg ? "MOD" : m?.fromUser || "";
  10009   const isYou = loggedInUser && from && from === loggedInUser;
  10010   const rail = chatRailClass({ fromUser: from, isModMessage: isModMsg });
  10011   const sameAuthorAsPrev = Boolean(prevVisible && String(prevVisible.fromUser || "") === from);
  10012   const mentions = Array.isArray(m?.mentions) ? m.mentions.map((u) => String(u || "").toLowerCase()) : [];
  10013   const mentionMe = Boolean(loggedInUser && mentions.includes(loggedInUser));
  10014   const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || "");
  10015   const youTag = !isModMsg && isYou ? `<span class="muted">(you)</span>` : "";
  10016   const time = new Date(m.createdAt).toLocaleTimeString();
  10017   const tint = isModMsg ? "" : tintStylesFromHex(getProfile(from).color);
  10018   const html = typeof m.html === "string" && m.html.trim() ? m.html : "";
  10019   const content = html ? html : highlightMentionsInText(m.text || "");
  10020   const replyMeta = m.replyTo && typeof m.replyTo === "object" ? m.replyTo : null;
  10021   const replyBlock = replyMeta
  10022     ? `<div class="chatReplyRef"><span class="small muted">@${escapeHtml(replyMeta.fromUser || "unknown")}</span><div class="small">${escapeHtml(
  10023         String(replyMeta.text || "[media]").slice(0, 120)
  10024       )}</div></div>`
  10025     : "";
  10026   const reacts = renderReactionButtons({ kind: "chat", id: m.id, reactions: m.reactions || {}, postId });
  10027   const deletedLine = m.deleted
  10028     ? `<div class="small muted">message deleted${m.deletedBy ? ` by @${escapeHtml(m.deletedBy)}` : ""} at ${escapeHtml(
  10029         new Date(Number(m.deletedAt || m.createdAt || Date.now())).toLocaleString()
  10030       )}</div>`
  10031     : "";
  10032   const editedLine =
  10033     !m.deleted && Number(m.editCount || 0) > 0
  10034       ? `<div class="small muted">edited (${Number(m.editCount || 0)}) at ${escapeHtml(
  10035           new Date(Number(m.editedAt || m.createdAt || Date.now())).toLocaleTimeString()
  10036         )}</div>`
  10037       : "";
  10038   const reportAction =
  10039     loggedInUser && !m.deleted
  10040       ? `<button type="button" class="ghost smallBtn" data-reportchat="${escapeHtml(m.id)}" data-postid="${escapeHtml(postId)}">Report</button>`
  10041       : "";
  10042   const canManageOwnMessage = Boolean(loggedInUser && m.fromUser && m.fromUser === loggedInUser && !m.deleted);
  10043   const replyAction =
  10044     loggedInUser && !m.deleted
  10045       ? `<button type="button" class="ghost smallBtn" data-replymsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(postId)}">Reply</button>`
  10046       : "";
  10047   const ownEditAction = canManageOwnMessage
  10048     ? `<button type="button" class="ghost smallBtn" data-editmsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(postId)}">Edit</button>`
  10049     : "";
  10050   const ownDeleteAction = canManageOwnMessage
  10051     ? `<button type="button" class="ghost smallBtn" data-deletemsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(postId)}">Delete</button>`
  10052     : "";
  10053 
  10054   const msgHtml = `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${mentionMe ? "mentionMe" : ""} ${rail} ${isModMsg ? "isModMsg" : ""}" data-msgid="${escapeHtml(
  10055     m.id
  10056   )}" ${tint}>
  10057         <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div>
  10058         ${replyBlock}
  10059         ${deletedLine}
  10060         ${editedLine}
  10061         <div class="content">${content}</div>
  10062         <div class="chatActionsRow">
  10063           <div class="chatReactions">${m.deleted ? "" : reacts}</div>
  10064           <div class="chatTools">${replyAction}${ownEditAction}${ownDeleteAction}${reportAction}</div>
  10065         </div>
  10066       </div>`;
  10067 
  10068   appendChatHtmlAndDecorate(msgHtml, atBottomBefore);
  10069   return true;
  10070 }
  10071 
  10072 function appendDmMessageToDom(threadId, message) {
  10073   if (!chatMessagesEl) return false;
  10074   if (!activeDmThreadId || activeDmThreadId !== threadId) return false;
  10075   if (!chatMessagesEl.querySelector(".chatMsg")) return false;
  10076   const thread = dmThreadsById.get(threadId) || null;
  10077   if (!thread || String(thread.status || "unknown") !== "active") return false;
  10078 
  10079   const atBottomBefore =
  10080     chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 24;
  10081 
  10082   const messages = dmMessagesByThreadId.get(threadId) || [];
  10083   const prev = messages.length >= 2 ? messages[messages.length - 2] : null;
  10084 
  10085   const m = message;
  10086   const from = m.fromUser || "";
  10087   const isYou = loggedInUser && from && from === loggedInUser;
  10088   const rail = chatRailClass({ fromUser: from, isModMessage: false });
  10089   const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from);
  10090   const who = renderUserPill(from || "");
  10091   const youTag = isYou ? `<span class="muted">(you)</span>` : "";
  10092   const time = new Date(m.createdAt).toLocaleTimeString();
  10093   const tint = tintStylesFromHex(getProfile(from).color);
  10094   const html = typeof m.html === "string" && m.html.trim() ? m.html : "";
  10095   const content = html ? html : highlightMentionsInText(m.text || "");
  10096 
  10097   const msgHtml = `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${rail}" data-msgid="${escapeHtml(m.id)}" ${tint}>
  10098              <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div>
  10099              <div class="content">${content}</div>
  10100            </div>`;
  10101 
  10102   appendChatHtmlAndDecorate(msgHtml, atBottomBefore);
  10103   return true;
  10104 }
  10105 
  10106 function pulseChatMessage(messageId) {
  10107   if (!chatMessagesEl) return;
  10108   const id = String(messageId || "");
  10109   if (!id) return;
  10110   const el = chatMessagesEl.querySelector(`[data-msgid="${cssEscape(id)}"]`);
  10111   if (!el) return;
  10112   el.classList.add("isNewMsg");
  10113   window.setTimeout(() => el.classList.remove("isNewMsg"), 720);
  10114 }
  10115 
  10116 function updateActiveChatMeta() {
  10117   if (activeDmThreadId) return;
  10118   const post = activeChatPostId ? posts.get(activeChatPostId) : null;
  10119   if (!post) return;
  10120   const tags = (post.keywords || []).map((k) => `#${k}`).join(" ");
  10121   const author = post.author ? `by @${post.author}` : "";
  10122   const exp = formatCountdown(post.expiresAt);
  10123   const mode = normalizePostMode(post.mode || post.chatMode || "");
  10124   const modeMeta =
  10125     mode === "walkie" ? " | walkie talkie" : mode === "stream" ? ` | stream (${streamKindLabel(post.streamKind || "webcam")})` : "";
  10126   chatMeta.textContent = `${author}${modeMeta} | ${exp === "permanent" ? "permanent" : `expires in ${exp}`} | ${tags}`.trim();
  10127 }
  10128 
  10129 function openDmThread(threadId, opts = null) {
  10130   const id = String(threadId || "").trim();
  10131   if (!id) return;
  10132   const options = opts && typeof opts === "object" ? opts : {};
  10133   if (!options.preserveFocus) blurFocusedChatComposer();
  10134   const thread = dmThreadsById.get(id) || null;
  10135   if (!thread) {
  10136     pendingOpenDmThreadId = id;
  10137     if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "dmList" }));
  10138     toast("DMs", "Thread not found yet. Refreshing DM list.");
  10139     return;
  10140   }
  10141   if (String(thread.status || "") !== "active") {
  10142     pendingOpenDmThreadId = id;
  10143     if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "dmList" }));
  10144     toast("DMs", "DM is not active yet.");
  10145     return;
  10146   }
  10147   pendingOpenDmThreadId = "";
  10148   if (activeChatPostId) ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false }));
  10149   activeChatPostId = null;
  10150   activeDmThreadId = id;
  10151   touchRecentDmChat(id);
  10152   setReplyToMessage(null);
  10153   ws.send(JSON.stringify({ type: "dmHistory", threadId: id }));
  10154   renderChatPanel(true);
  10155   if (isMobileSwipeMode()) {
  10156     setMobileScreen("chat");
  10157     renderMobileNav();
  10158   }
  10159 }
  10160 
  10161 function sendModDmPrompt(rawUsername) {
  10162   const to = String(rawUsername || "")
  10163     .trim()
  10164     .replace(/^@+/, "")
  10165     .toLowerCase();
  10166   if (!to) return;
  10167   if (!loggedInUser) {
  10168     toast("Sign in required", "Sign in to send moderator DMs.");
  10169     return;
  10170   }
  10171   if (!canModerate) {
  10172     toast("Moderator only", "You need moderator permissions.");
  10173     return;
  10174   }
  10175   if (to === String(loggedInUser).toLowerCase()) {
  10176     toast("Unavailable", "Can't send a moderator DM to yourself.");
  10177     return;
  10178   }
  10179   const text = String(prompt(`Send moderator DM to @${to}:`) || "").trim();
  10180   if (!text) return;
  10181   ws.send(JSON.stringify({ type: "dmSendMod", to, text }));
  10182   toast("Moderator DM", `Sent to @${to}.`);
  10183 }
  10184 
  10185 function openChat(postId, opts = null) {
  10186   activeDmThreadId = null;
  10187   stopWalkieRecording();
  10188   const options = opts && typeof opts === "object" ? opts : {};
  10189   if (!options.preserveFocus) blurFocusedChatComposer();
  10190   const sourceEl = options.sourceEl instanceof HTMLElement ? options.sourceEl : null;
  10191   const post = posts.get(postId);
  10192   if (!post) return;
  10193   if (post.deleted) {
  10194     activeChatPostId = postId;
  10195     touchRecentHiveChat(postId);
  10196     renderChatPanel(true);
  10197     if (isMobileSwipeMode()) setMobilePanel("chat");
  10198     return;
  10199   }
  10200   if (post.locked) {
  10201     unlockPostFlow(postId, true);
  10202     return;
  10203   }
  10204 
  10205   // Rack mode: prefer an existing workspace chat panel; if none exists, create a split view
  10206   // (referrer on left, chat on right) so opening chat is deterministic.
  10207   if (rackLayoutEnabled && !isStreamPost(post)) {
  10208     const workspaceTarget = chooseWorkspaceChatTarget(sourceEl);
  10209     if (workspaceTarget?.kind === "instance" && workspaceTarget.panelId) {
  10210       touchRecentHiveChat(postId);
  10211       markRead(postId);
  10212       renderFeed();
  10213       ws.send(JSON.stringify({ type: "getChat", postId }));
  10214       setChatInstancePanelPost(workspaceTarget.panelId, postId, true);
  10215       renderChatContextSelect();
  10216       return;
  10217     }
  10218     if (workspaceTarget?.kind === "main") {
  10219       activeChatPostId = postId;
  10220       touchRecentHiveChat(postId);
  10221       markRead(postId);
  10222       renderFeed();
  10223       ws.send(JSON.stringify({ type: "getChat", postId }));
  10224       renderChatPanel(true);
  10225       renderTypingIndicator();
  10226       if (isMobileSwipeMode()) setMobilePanel("chat");
  10227       return;
  10228     }
  10229     if (ensureChatWorkspaceSplit(sourceEl)) {
  10230       activeChatPostId = postId;
  10231       touchRecentHiveChat(postId);
  10232       markRead(postId);
  10233       renderFeed();
  10234       ws.send(JSON.stringify({ type: "getChat", postId }));
  10235       renderChatPanel(true);
  10236       renderTypingIndicator();
  10237       if (isMobileSwipeMode()) setMobilePanel("chat");
  10238       return;
  10239     }
  10240   }
  10241   if (activeChatPostId && activeChatPostId !== postId) {
  10242     ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false }));
  10243     setReplyToMessage(null);
  10244   }
  10245   activeChatPostId = postId;
  10246   touchRecentHiveChat(postId);
  10247   markRead(postId);
  10248   renderFeed();
  10249   ws.send(JSON.stringify({ type: "getChat", postId }));
  10250   renderChatPanel(true);
  10251   renderTypingIndicator();
  10252   if (isMobileSwipeMode()) setMobilePanel("chat");
  10253 }
  10254 
  10255 let pendingOpenChatAfterUnlock = null;
  10256 function unlockPostFlow(postId, openChatAfter) {
  10257   const pw = prompt("Password for this post:");
  10258   if (!pw) return;
  10259   pendingOpenChatAfterUnlock = openChatAfter ? postId : null;
  10260   ws.send(JSON.stringify({ type: "unlockPost", postId, password: pw }));
  10261 }
  10262 
  10263 function runCmd(target, cmd) {
  10264   target.focus();
  10265   document.execCommand(cmd);
  10266 }
  10267 
  10268 function runLink(target) {
  10269   target.focus();
  10270   const url = prompt("Link URL (https://...)");
  10271   if (!url) return;
  10272   document.execCommand("createLink", false, url);
  10273 }
  10274 
  10275 function runEmoji(target) {
  10276   target.focus();
  10277   const raw = prompt("Emoji to insert (example: πŸ˜€πŸ”₯πŸ’–)");
  10278   const emoji = String(raw || "").trim();
  10279   if (!emoji) return;
  10280   document.execCommand("insertText", false, emoji);
  10281 }
  10282 
  10283 function readFileAsDataUrl(file) {
  10284   return new Promise((resolve, reject) => {
  10285     const reader = new FileReader();
  10286     reader.onload = () => resolve(String(reader.result || ""));
  10287     reader.onerror = () => reject(new Error("Failed to read file"));
  10288     reader.readAsDataURL(file);
  10289   });
  10290 }
  10291 
  10292 async function resizeImageToSquareDataUrl(file, sizePx) {
  10293   const dataUrl = await readFileAsDataUrl(file);
  10294   const img = new Image();
  10295   img.src = dataUrl;
  10296   await img.decode();
  10297   const canvas = document.createElement("canvas");
  10298   canvas.width = sizePx;
  10299   canvas.height = sizePx;
  10300   const ctx = canvas.getContext("2d");
  10301   if (!ctx) return "";
  10302   const side = Math.min(img.width, img.height);
  10303   const sx = Math.floor((img.width - side) / 2);
  10304   const sy = Math.floor((img.height - side) / 2);
  10305   ctx.drawImage(img, sx, sy, side, side, 0, 0, sizePx, sizePx);
  10306   // Preserve transparency for avatars (JPEG strips alpha).
  10307   const webp = canvas.toDataURL("image/webp", 0.9);
  10308   if (typeof webp === "string" && webp.startsWith("data:image/webp")) return webp;
  10309   return canvas.toDataURL("image/png");
  10310 }
  10311 
  10312 async function uploadMediaFile(file, kind) {
  10313   if (!file) return "";
  10314   const maxBytes = kind === "audio" ? CLIENT_AUDIO_UPLOAD_MAX_BYTES : CLIENT_IMAGE_UPLOAD_MAX_BYTES;
  10315   if (file.size > maxBytes) {
  10316     toast("File too large", `${kind === "audio" ? "Audio" : "Image"} is too large for this server.`);
  10317     return "";
  10318   }
  10319   const token = getSessionToken();
  10320   if (!token) {
  10321     toast("Sign in required", "Please sign in before uploading files.");
  10322     return "";
  10323   }
  10324   const loweredName = String(file.name || "").toLowerCase();
  10325   let contentType = (file.type || "").toLowerCase();
  10326   if (!contentType) {
  10327     if (kind === "image") {
  10328       if (loweredName.endsWith(".gif")) contentType = "image/gif";
  10329       else if (loweredName.endsWith(".png")) contentType = "image/png";
  10330       else if (loweredName.endsWith(".webp")) contentType = "image/webp";
  10331       else if (loweredName.endsWith(".jpg") || loweredName.endsWith(".jpeg")) contentType = "image/jpeg";
  10332     } else if (kind === "audio") {
  10333       if (loweredName.endsWith(".mp3")) contentType = "audio/mpeg";
  10334       else if (loweredName.endsWith(".wav")) contentType = "audio/wav";
  10335       else if (loweredName.endsWith(".ogg")) contentType = "audio/ogg";
  10336       else if (loweredName.endsWith(".webm")) contentType = "audio/webm";
  10337       else if (loweredName.endsWith(".aac")) contentType = "audio/aac";
  10338       else if (loweredName.endsWith(".m4a") || loweredName.endsWith(".mp4")) contentType = "audio/mp4";
  10339     }
  10340   }
  10341   const headers = {
  10342     Authorization: `Bearer ${token}`,
  10343     "Content-Type": contentType || "application/octet-stream"
  10344   };
  10345   try {
  10346     const res = await fetch(`/api/upload?kind=${encodeURIComponent(kind)}`, {
  10347       method: "POST",
  10348       headers,
  10349       body: file
  10350     });
  10351     const payload = await res.json().catch(() => ({}));
  10352     if (!res.ok) {
  10353       toast("Upload failed", payload?.error || "Upload failed.");
  10354       return "";
  10355     }
  10356     if (!payload?.url) {
  10357       toast("Upload failed", "Server did not return a media URL.");
  10358       return "";
  10359     }
  10360     return String(payload.url);
  10361   } catch {
  10362     toast("Upload failed", "Network error while uploading file.");
  10363     return "";
  10364   }
  10365 }
  10366 
  10367 async function ensureWalkieContext() {
  10368   if (walkieCtx) return walkieCtx;
  10369   const ctx = new (window.AudioContext || window.webkitAudioContext)();
  10370   walkieCtx = ctx;
  10371   return ctx;
  10372 }
  10373 
  10374 async function ensureWalkieDispatchBuffer() {
  10375   if (walkieDispatchBuffer) return walkieDispatchBuffer;
  10376   const ctx = await ensureWalkieContext();
  10377   try {
  10378     const res = await fetch("/assets/walkie/dispatch.mp3");
  10379     const arr = await res.arrayBuffer();
  10380     walkieDispatchBuffer = await ctx.decodeAudioData(arr);
  10381     return walkieDispatchBuffer;
  10382   } catch {
  10383     walkieDispatchBuffer = null;
  10384     return null;
  10385   }
  10386 }
  10387 
  10388 async function ensureWalkieGraph() {
  10389   const ctx = await ensureWalkieContext();
  10390   if (walkieMixNode && walkieDestNode) return { ctx, mix: walkieMixNode, dest: walkieDestNode };
  10391 
  10392   if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") {
  10393     throw new Error("Microphone is not supported in this browser.");
  10394   }
  10395   const host = String(location.hostname || "").toLowerCase();
  10396   const isLocal =
  10397     host === "localhost" ||
  10398     host === "127.0.0.1" ||
  10399     host === "::1" ||
  10400     host.startsWith("192.168.") ||
  10401     host.startsWith("10.") ||
  10402     host.startsWith("172.16.") ||
  10403     host.startsWith("172.17.") ||
  10404     host.startsWith("172.18.") ||
  10405     host.startsWith("172.19.") ||
  10406     host.startsWith("172.20.") ||
  10407     host.startsWith("172.21.") ||
  10408     host.startsWith("172.22.") ||
  10409     host.startsWith("172.23.") ||
  10410     host.startsWith("172.24.") ||
  10411     host.startsWith("172.25.") ||
  10412     host.startsWith("172.26.") ||
  10413     host.startsWith("172.27.") ||
  10414     host.startsWith("172.28.") ||
  10415     host.startsWith("172.29.") ||
  10416     host.startsWith("172.30.") ||
  10417     host.startsWith("172.31.");
  10418   if (!window.isSecureContext && !isLocal) {
  10419     throw new Error("Microphone requires HTTPS (or localhost). Use your Cloudflare tunnel URL.");
  10420   }
  10421 
  10422   if (!walkieMicStream) {
  10423     walkieMicStream = await navigator.mediaDevices.getUserMedia({
  10424       audio: {
  10425         echoCancellation: true,
  10426         noiseSuppression: true,
  10427         autoGainControl: true,
  10428       },
  10429     });
  10430   }
  10431 
  10432   const micSource = new MediaStreamAudioSourceNode(ctx, { mediaStream: walkieMicStream });
  10433   const mix = new GainNode(ctx, { gain: 1 });
  10434   micSource.connect(mix);
  10435 
  10436   let head = mix;
  10437   let tail = null;
  10438   let usedWorklet = false;
  10439 
  10440   if (ctx.audioWorklet) {
  10441     try {
  10442       await ctx.audioWorklet.addModule("/assets/walkie/transmission-processor.js");
  10443       const pre = new AudioWorkletNode(ctx, "transmission-sat", { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1] });
  10444       const hp1 = new BiquadFilterNode(ctx, { type: "highpass", Q: 0.9, frequency: 420 });
  10445       const hp2 = new BiquadFilterNode(ctx, { type: "highpass", Q: 0.9, frequency: 420 });
  10446       const lp1 = new BiquadFilterNode(ctx, { type: "lowpass", Q: 0.9, frequency: 4200 });
  10447       const lp2 = new BiquadFilterNode(ctx, { type: "lowpass", Q: 0.9, frequency: 4200 });
  10448       const dip = new BiquadFilterNode(ctx, { type: "peaking", frequency: 680, Q: 0.8, gain: -1.1 });
  10449       const mid = new BiquadFilterNode(ctx, { type: "peaking", frequency: 1550, Q: 1.25, gain: 2.0 });
  10450       const post = new AudioWorkletNode(ctx, "transmission-post", { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1] });
  10451 
  10452       head.connect(pre);
  10453       pre.connect(hp1);
  10454       hp1.connect(hp2);
  10455       hp2.connect(lp1);
  10456       lp1.connect(lp2);
  10457       lp2.connect(dip);
  10458       dip.connect(mid);
  10459       mid.connect(post);
  10460       tail = post;
  10461 
  10462       pre.parameters.get("drive")?.setValueAtTime(0.32, ctx.currentTime);
  10463       pre.parameters.get("asym")?.setValueAtTime(0.12, ctx.currentTime);
  10464       pre.parameters.get("mix")?.setValueAtTime(1, ctx.currentTime);
  10465 
  10466       post.parameters.get("drive")?.setValueAtTime(0.42, ctx.currentTime);
  10467       post.parameters.get("asym")?.setValueAtTime(0.12, ctx.currentTime);
  10468       post.parameters.get("comp")?.setValueAtTime(0.38, ctx.currentTime);
  10469       post.parameters.get("crush")?.setValueAtTime(0.04, ctx.currentTime);
  10470       post.parameters.get("badAmount")?.setValueAtTime(0.22, ctx.currentTime);
  10471       post.parameters.get("wowDepth")?.setValueAtTime(0.18, ctx.currentTime);
  10472       post.parameters.get("dropRate")?.setValueAtTime(0.18, ctx.currentTime);
  10473       post.parameters.get("dropDepth")?.setValueAtTime(0.25, ctx.currentTime);
  10474       post.parameters.get("crackle")?.setValueAtTime(0.22, ctx.currentTime);
  10475       post.parameters.get("lfoRate")?.setValueAtTime(0.75, ctx.currentTime);
  10476       post.parameters.get("noise")?.setValueAtTime(0.18, ctx.currentTime);
  10477       post.parameters.get("hiss")?.setValueAtTime(0.16, ctx.currentTime);
  10478       post.parameters.get("noiseColor")?.setValueAtTime(0.15, ctx.currentTime);
  10479       post.parameters.get("outGain")?.setValueAtTime(0.92, ctx.currentTime);
  10480 
  10481       usedWorklet = true;
  10482     } catch {
  10483       usedWorklet = false;
  10484     }
  10485   }
  10486 
  10487   if (!usedWorklet) {
  10488     const hp = new BiquadFilterNode(ctx, { type: "highpass", Q: 0.85, frequency: 420 });
  10489     const lp = new BiquadFilterNode(ctx, { type: "lowpass", Q: 0.85, frequency: 4200 });
  10490     const comp = new DynamicsCompressorNode(ctx, { threshold: -22, knee: 28, ratio: 5.2, attack: 0.004, release: 0.18 });
  10491     const shaper = new WaveShaperNode(ctx, {
  10492       curve: (() => {
  10493         const n = 512;
  10494         const c = new Float32Array(n);
  10495         for (let i = 0; i < n; i++) {
  10496           const x = (i / (n - 1)) * 2 - 1;
  10497           c[i] = Math.tanh(x * 2.4);
  10498         }
  10499         return c;
  10500       })(),
  10501       oversample: "2x",
  10502     });
  10503     head.connect(hp);
  10504     hp.connect(lp);
  10505     lp.connect(comp);
  10506     comp.connect(shaper);
  10507     tail = shaper;
  10508   }
  10509 
  10510   const dest = new MediaStreamAudioDestinationNode(ctx);
  10511   tail.connect(dest);
  10512 
  10513   walkieMixNode = mix;
  10514   walkieDestNode = dest;
  10515   return { ctx, mix, dest };
  10516 }
  10517 
  10518 function shouldHandleWalkieHotkey(evt) {
  10519   if (!evt) return false;
  10520   if (evt.repeat) return false;
  10521   if (evt.code !== "Backquote") return false;
  10522   const tag = String(document.activeElement?.tagName || "").toLowerCase();
  10523   if (tag === "input" || tag === "textarea") return false;
  10524   if (document.activeElement?.isContentEditable) {
  10525     const el = document.activeElement;
  10526     if (el && el === chatEditor && canWalkieTalkNow()) return true;
  10527     return false;
  10528   }
  10529   return true;
  10530 }
  10531 
  10532 function isTextEntryFocused() {
  10533   const el = document.activeElement;
  10534   if (!el) return false;
  10535   const tag = String(el.tagName || "").toLowerCase();
  10536   if (tag === "textarea") return true;
  10537   if (tag === "input") {
  10538     const type = String(el.getAttribute?.("type") || "text").toLowerCase();
  10539     return !["button", "checkbox", "color", "file", "hidden", "radio", "range", "reset", "submit"].includes(type);
  10540   }
  10541   return Boolean(el.isContentEditable);
  10542 }
  10543 
  10544 function isMapSurfaceActiveForHotkey() {
  10545   if (isMapChatActive()) return true;
  10546   const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;
  10547   if (active?.closest?.('[data-panel-id="maps"]')) return true;
  10548   if (isMobileScreenMode() && appRoot) {
  10549     const mobile = String(appRoot.getAttribute("data-mobile-screen") || "").trim();
  10550     if (mobile === "maps") return true;
  10551     if (mobile === "host" && mobileHostPanelId === "maps") return true;
  10552   }
  10553   return false;
  10554 }
  10555 
  10556 function shouldSubmitChatOnEnter(evt) {
  10557   if (!evt || evt.key !== "Enter") return false;
  10558   const mode = readChatEnterModePref();
  10559   if (mode === "enter") return !(evt.shiftKey || evt.altKey || evt.ctrlKey || evt.metaKey);
  10560   return Boolean(evt.ctrlKey || evt.metaKey);
  10561 }
  10562 
  10563 function cycleLayoutPresetBy(step) {
  10564   if (!layoutPresetEl || !rackLayoutEnabled || layoutPresetEl.disabled) return;
  10565   const options = Array.from(layoutPresetEl.options || [])
  10566     .map((opt) => String(opt.value || "").trim())
  10567     .filter((v) => v);
  10568   if (!options.length) return;
  10569   const current = resolvePresetKey(String(layoutPresetEl.value || rackLayoutState?.presetId || "onboardingDefault"));
  10570   let idx = options.indexOf(current);
  10571   if (idx < 0) idx = 0;
  10572   const len = options.length;
  10573   const next = options[(idx + step + len) % len];
  10574   if (!next) return;
  10575   layoutPresetEl.value = next;
  10576   applyPreset(next);
  10577 }
  10578 
  10579 let hotkeyPanelContext = "";
  10580 let hoveredWorkspacePanelId = "";
  10581 let hoveredHotbarPanelId = "";
  10582 
  10583 function isWorkspaceRackId(rackId) {
  10584   const id = String(rackId || "").trim();
  10585   return id === "mainWorkspaceRack" || id === "workspaceLeftSlot" || id === "workspaceRightSlot";
  10586 }
  10587 
  10588 function hoveredWorkspacePanelElement() {
  10589   const id = String(hoveredWorkspacePanelId || "").trim();
  10590   if (!id) return null;
  10591   const el = getPanelElement(id);
  10592   if (!(el instanceof HTMLElement)) return null;
  10593   if (el.classList.contains("hidden")) return null;
  10594   const parentId = String(el.parentElement?.id || "").trim();
  10595   if (!isWorkspaceRackId(parentId)) return null;
  10596   return el;
  10597 }
  10598 
  10599 function followWorkspacePanel(panelEl) {
  10600   const panel = panelEl instanceof HTMLElement ? panelEl : null;
  10601   if (!(panel instanceof HTMLElement)) return;
  10602   const workspace = panel.closest?.("#mainWorkspaceRack");
  10603   if (!(workspace instanceof HTMLElement)) return;
  10604   const left = panel.offsetLeft;
  10605   const width = panel.offsetWidth || 0;
  10606   const target = Math.max(0, Math.round(left - (workspace.clientWidth - width) / 2));
  10607   const max = Math.max(0, workspace.scrollWidth - workspace.clientWidth);
  10608   workspace.scrollTo({ left: Math.min(max, target), behavior: "smooth" });
  10609 }
  10610 
  10611 function glowWorkspacePanel(panelEl) {
  10612   const panel = panelEl instanceof HTMLElement ? panelEl : null;
  10613   if (!(panel instanceof HTMLElement)) return;
  10614   panel.classList.remove("workspaceArrivalGlow");
  10615   // Restart animation on repeated arrivals.
  10616   void panel.offsetWidth;
  10617   panel.classList.add("workspaceArrivalGlow");
  10618   const token = String(Date.now());
  10619   panel.dataset.workspaceArrivalToken = token;
  10620   setTimeout(() => {
  10621     if (panel.dataset.workspaceArrivalToken !== token) return;
  10622     panel.classList.remove("workspaceArrivalGlow");
  10623   }, 900);
  10624 }
  10625 
  10626 function focusWorkspaceArrival(panelEl) {
  10627   const panel = panelEl instanceof HTMLElement ? panelEl : null;
  10628   if (!(panel instanceof HTMLElement)) return;
  10629   const rackId = rackIdForPanelElement(panel);
  10630   if (!isWorkspaceRackId(rackId)) return;
  10631   followWorkspacePanel(panel);
  10632   glowWorkspacePanel(panel);
  10633 }
  10634 
  10635 function cycleHoveredWorkspacePanelSize() {
  10636   if (!rackLayoutEnabled) return false;
  10637   const panelEl = hoveredWorkspacePanelElement();
  10638   if (!(panelEl instanceof HTMLElement)) return false;
  10639   const panelId = String(panelEl.dataset.panelId || "").trim();
  10640   if (!panelId || isRightRackFixedPanel(panelId)) return false;
  10641   const order = ["skinny", "half", "full"];
  10642   const current = panelWorkspaceSize(panelId);
  10643   const idx = order.indexOf(current);
  10644   const next = order[(idx + 1 + order.length) % order.length];
  10645   setPanelWorkspaceSize(panelId, next);
  10646   applyAllWorkspacePanelSizes();
  10647   followWorkspacePanel(panelEl);
  10648   return true;
  10649 }
  10650 
  10651 function moveHoveredWorkspacePanelHorizontal(step) {
  10652   if (!rackLayoutEnabled) return false;
  10653   const panelEl = hoveredWorkspacePanelElement();
  10654   if (!(panelEl instanceof HTMLElement)) return false;
  10655   const parent = panelEl.parentElement;
  10656   if (!(parent instanceof HTMLElement)) return false;
  10657   const peers = Array.from(parent.querySelectorAll(":scope > .rackPanel:not(.hidden)"));
  10658   const idx = peers.indexOf(panelEl);
  10659   if (idx < 0) return false;
  10660   const nextIdx = idx + (step < 0 ? -1 : 1);
  10661   if (nextIdx < 0 || nextIdx >= peers.length) return false;
  10662   const other = peers[nextIdx];
  10663   if (!(other instanceof HTMLElement)) return false;
  10664   if (step < 0) parent.insertBefore(panelEl, other);
  10665   else parent.insertBefore(other, panelEl);
  10666   syncRackStateFromDom();
  10667   enforceWorkspaceRules();
  10668   followWorkspacePanel(panelEl);
  10669   return true;
  10670 }
  10671 
  10672 function dockHoveredWorkspacePanelToHotbar() {
  10673   if (!rackLayoutEnabled) return false;
  10674   const panelEl = hoveredWorkspacePanelElement();
  10675   if (!(panelEl instanceof HTMLElement)) return false;
  10676   const panelId = String(panelEl.dataset.panelId || "").trim();
  10677   if (!panelId || isRightRackFixedPanel(panelId)) return false;
  10678   dockPanel(panelId);
  10679   return true;
  10680 }
  10681 
  10682 function restoreHoveredHotbarPanelToWorkspace() {
  10683   if (!rackLayoutEnabled) return false;
  10684   const panelId = String(hoveredHotbarPanelId || "").trim();
  10685   if (!panelId) return false;
  10686   restorePanelFromHotbar(panelId, { userAdded: true });
  10687   return true;
  10688 }
  10689 function updateHotkeyPanelContextFromTarget(target) {
  10690   const el = target instanceof HTMLElement ? target : null;
  10691   if (!el) return;
  10692   if (el.closest("#hivesPanel")) {
  10693     hotkeyPanelContext = "hives";
  10694     return;
  10695   }
  10696   if (el.closest("aside.chat") || el.closest(".chatInstance") || el.closest("[data-panel-id^='chat:post:']")) {
  10697     hotkeyPanelContext = "chat";
  10698   }
  10699 }
  10700 
  10701 function activePanelContextForHotkeys() {
  10702   if (isMobileScreenMode() && appRoot) {
  10703     const mobile = String(appRoot.getAttribute("data-mobile-screen") || "").trim();
  10704     if (mobile === "hives") return "hives";
  10705     if (mobile === "chat" || (mobile === "host" && mobileHostPanelId === "chat")) return "chat";
  10706   }
  10707   const ae = document.activeElement instanceof HTMLElement ? document.activeElement : null;
  10708   if (ae) {
  10709     if (ae.closest("#hivesPanel")) return "hives";
  10710     if (ae.closest("aside.chat") || ae.closest(".chatInstance") || ae.closest("[data-panel-id^='chat:post:']")) return "chat";
  10711   }
  10712   return hotkeyPanelContext || "";
  10713 }
  10714 
  10715 function cycleHiveViewBy(step) {
  10716   if (!hiveTabsEl) return false;
  10717   const views = Array.from(hiveTabsEl.querySelectorAll("button[data-hiveview]:not([disabled])"))
  10718     .map((b) => String(b.getAttribute("data-hiveview") || "").trim())
  10719     .filter(Boolean);
  10720   if (!views.length) return false;
  10721   let idx = views.indexOf(String(activeHiveView || "all"));
  10722   if (idx < 0) idx = 0;
  10723   const len = views.length;
  10724   const next = views[(idx + step + len) % len];
  10725   if (!next || next === activeHiveView) return false;
  10726   activeHiveView = next;
  10727   renderFeed();
  10728   return true;
  10729 }
  10730 
  10731 function cycleChatContextBy(step) {
  10732   renderChatContextSelect();
  10733   if (!(chatContextSelectEl instanceof HTMLSelectElement)) return false;
  10734   const items = [
  10735     "__list__",
  10736     ...Array.from(chatContextSelectEl.options || [])
  10737       .map((o) => String(o.value || "").trim())
  10738       .filter((v) => v && (v.startsWith("dm:") || v.startsWith("post:"))),
  10739   ];
  10740   if (items.length <= 1) return false;
  10741   const current = activeDmThreadId ? `dm:${activeDmThreadId}` : activeChatPostId ? `post:${activeChatPostId}` : "__list__";
  10742   let idx = items.indexOf(current);
  10743   if (idx < 0) idx = 0;
  10744   const len = items.length;
  10745   const next = items[(idx + step + len) % len];
  10746   if (!next || next === current) return false;
  10747   if (next === "__list__") {
  10748     if (activeChatPostId && ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false }));
  10749     activeChatPostId = null;
  10750     activeDmThreadId = null;
  10751     activeMapsRoomId = "";
  10752     activeMapsRoomTitle = "";
  10753     setReplyToMessage(null);
  10754     renderChatPanel(true);
  10755     return true;
  10756   }
  10757   if (next.startsWith("dm:")) {
  10758     return openChatContextValue(next, { preserveFocus: false });
  10759   }
  10760   if (next.startsWith("post:")) {
  10761     return openChatContextValue(next, { preserveFocus: false });
  10762   }
  10763   return false;
  10764 }
  10765 
  10766 function streamIceConfig() {
  10767   if (!Array.isArray(streamIceServers) || !streamIceServers.length) return [];
  10768   return streamIceServers.map((row) => {
  10769     const urls = Array.isArray(row?.urls)
  10770       ? row.urls.map((x) => String(x || "").trim()).filter(Boolean)
  10771       : typeof row?.urls === "string" && row.urls.trim()
  10772         ? [row.urls.trim()]
  10773         : [];
  10774     const out = { urls };
  10775     if (typeof row?.username === "string" && row.username.trim()) out.username = row.username.trim();
  10776     if (typeof row?.credential === "string" && row.credential.trim()) out.credential = row.credential.trim();
  10777     return out;
  10778   });
  10779 }
  10780 
  10781 function stopStreamTracks(stream) {
  10782   const tracks = stream && typeof stream.getTracks === "function" ? stream.getTracks() : [];
  10783   for (const track of tracks) {
  10784     try {
  10785       track.onended = null;
  10786       track.stop();
  10787     } catch {
  10788       // ignore
  10789     }
  10790   }
  10791 }
  10792 
  10793 function closeStreamPeer(clientId) {
  10794   const id = String(clientId || "").trim();
  10795   if (!id) return;
  10796   const pc = streamPeerByClientId.get(id);
  10797   streamPeerByClientId.delete(id);
  10798   streamRemoteMediaByClientId.delete(id);
  10799   streamPeerUsernameByClientId.delete(id);
  10800   const remoteAudioEl = streamRemoteAudioByClientId.get(id);
  10801   if (remoteAudioEl) {
  10802     try {
  10803       remoteAudioEl.pause();
  10804     } catch {
  10805       // ignore
  10806     }
  10807     remoteAudioEl.srcObject = null;
  10808     remoteAudioEl.remove();
  10809   }
  10810   streamRemoteAudioByClientId.delete(id);
  10811   if (!pc) return;
  10812   try {
  10813     pc.onicecandidate = null;
  10814     pc.onconnectionstatechange = null;
  10815     pc.ontrack = null;
  10816     pc.close();
  10817   } catch {
  10818     // ignore
  10819   }
  10820 }
  10821 
  10822 function closeAllStreamPeers() {
  10823   for (const id of Array.from(streamPeerByClientId.keys())) closeStreamPeer(id);
  10824 }
  10825 
  10826 function clearStreamMediaPreview() {
  10827   if (streamStageVideoEl) {
  10828     try {
  10829       streamStageVideoEl.pause();
  10830     } catch {
  10831       // ignore
  10832     }
  10833     streamStageVideoEl.srcObject = null;
  10834     streamStageVideoEl.classList.add("hidden");
  10835     streamStageVideoEl.muted = false;
  10836   }
  10837   if (streamStageAudioEl) {
  10838     try {
  10839       streamStageAudioEl.pause();
  10840     } catch {
  10841       // ignore
  10842     }
  10843     streamStageAudioEl.srcObject = null;
  10844     streamStageAudioEl.classList.add("hidden");
  10845     streamStageAudioEl.muted = false;
  10846   }
  10847   streamRemoteMedia = null;
  10848 }
  10849 
  10850 function ensureRemoteAudioEl(clientId) {
  10851   const id = String(clientId || "").trim();
  10852   if (!id) return null;
  10853   const existing = streamRemoteAudioByClientId.get(id);
  10854   if (existing) return existing;
  10855   const el = document.createElement("audio");
  10856   el.autoplay = true;
  10857   el.controls = false;
  10858   el.preload = "none";
  10859   el.className = "streamStageAudio hidden";
  10860   streamRemoteAudioByClientId.set(id, el);
  10861   streamStageEl?.appendChild(el);
  10862   return el;
  10863 }
  10864 
  10865 function updateStreamOutputMuteState() {
  10866   const deafened = Boolean(streamVoiceDeafened);
  10867   const hostingPreview = streamCurrentRole === "host";
  10868   if (streamStageAudioEl) streamStageAudioEl.muted = deafened || hostingPreview;
  10869   if (streamStageVideoEl) streamStageVideoEl.muted = deafened || hostingPreview;
  10870   for (const el of streamRemoteAudioByClientId.values()) {
  10871     el.muted = deafened;
  10872   }
  10873 }
  10874 
  10875 function updateStreamLocalMuteState() {
  10876   const media = streamCurrentRole === "host" ? streamLocalMedia : streamVoiceMedia;
  10877   const tracks = media && typeof media.getAudioTracks === "function" ? media.getAudioTracks() : [];
  10878   for (const track of tracks) track.enabled = !streamVoiceMuted;
  10879 }
  10880 
  10881 function streamDisplayNameForClientId(peerClientId) {
  10882   const id = String(peerClientId || "").trim();
  10883   if (!id) return "Unknown";
  10884   if (id === String(clientId || "").trim()) return `${loggedInUser ? `@${loggedInUser}` : "You"} (you)`;
  10885   const username = String(streamPeerUsernameByClientId.get(id) || "").trim();
  10886   return username ? `@${username}` : `Peer ${id.slice(0, 6)}`;
  10887 }
  10888 
  10889 function renderStreamVoiceUsers() {
  10890   if (!streamVoiceUsersEl) return;
  10891   const rows = [];
  10892   for (const [peerId, audioEl] of streamRemoteAudioByClientId.entries()) {
  10893     if (!audioEl || peerId === streamCurrentHostClientId) continue;
  10894     const vol = Math.max(0, Math.min(1, Number(streamPeerVolumeByClientId.get(peerId) ?? audioEl.volume ?? 1)));
  10895     rows.push({ peerId, label: streamDisplayNameForClientId(peerId), vol });
  10896   }
  10897   if (!rows.length) {
  10898     streamVoiceUsersEl.innerHTML = `<div>No active voice peers.</div>`;
  10899     return;
  10900   }
  10901   streamVoiceUsersEl.innerHTML = rows
  10902     .map(
  10903       (row) => `<label class="streamVoiceUserRow">
  10904         <span>${escapeHtml(row.label)}</span>
  10905         <input type="range" min="0" max="100" step="1" value="${escapeHtml(String(Math.round(row.vol * 100)))}" data-streamvol="${escapeHtml(row.peerId)}" />
  10906       </label>`
  10907     )
  10908     .join("");
  10909 }
  10910 
  10911 function renderStreamVoiceControls(post, isHosting, isViewing) {
  10912   if (!streamVoiceControlsEl) return;
  10913   const streamPost = post && isStreamPost(post) ? post : null;
  10914   const enabled = Boolean(streamPost && (isHosting || isViewing));
  10915   streamVoiceControlsEl.classList.toggle("hidden", !enabled);
  10916   if (!enabled) return;
  10917   if (streamVoiceJoinToggleEl) {
  10918     streamVoiceJoinToggleEl.disabled = isHosting;
  10919     streamVoiceJoinToggleEl.checked = isHosting ? true : Boolean(streamVoiceJoined);
  10920   }
  10921   if (streamVoiceMuteBtn) {
  10922     streamVoiceMuteBtn.disabled = !(isHosting || streamVoiceJoined);
  10923     streamVoiceMuteBtn.textContent = streamVoiceMuted ? "Unmute mic" : "Mute mic";
  10924   }
  10925   if (streamVoiceDeafenBtn) {
  10926     streamVoiceDeafenBtn.disabled = !(isHosting || isViewing);
  10927     streamVoiceDeafenBtn.textContent = streamVoiceDeafened ? "Undeafen" : "Deafen";
  10928   }
  10929   renderStreamVoiceUsers();
  10930 }
  10931 
  10932 function addVoiceTracksToPeer(peerClientId) {
  10933   const id = String(peerClientId || "").trim();
  10934   if (!id) return;
  10935   const pc = streamPeerByClientId.get(id);
  10936   if (!pc) return;
  10937   const media = streamCurrentRole === "host" ? streamLocalMedia : streamVoiceMedia;
  10938   if (!media || typeof media.getAudioTracks !== "function") return;
  10939   const tracks = media.getAudioTracks();
  10940   if (!tracks.length) return;
  10941   for (const track of tracks) {
  10942     const hasSender = pc
  10943       .getSenders()
  10944       .some((sender) => sender?.track && sender.track.id === track.id && sender.track.kind === "audio");
  10945     if (hasSender) continue;
  10946     try {
  10947       pc.addTrack(track, media);
  10948     } catch {
  10949       // ignore addTrack failures
  10950     }
  10951   }
  10952 }
  10953 
  10954 async function renegotiateStreamPeer(peerClientId) {
  10955   const id = String(peerClientId || "").trim();
  10956   if (!id) return;
  10957   const pc = streamPeerByClientId.get(id);
  10958   if (!pc || !streamCurrentPostId || !ws || ws.readyState !== WebSocket.OPEN) return;
  10959   try {
  10960     const offer = await pc.createOffer();
  10961     await pc.setLocalDescription(offer);
  10962     ws.send(
  10963       JSON.stringify({
  10964         type: "streamSignal",
  10965         postId: streamCurrentPostId,
  10966         targetClientId: id,
  10967         signal: { type: "offer", sdp: String(offer.sdp || "") },
  10968       })
  10969     );
  10970   } catch (e) {
  10971     console.warn("stream renegotiation failed:", e?.message || e);
  10972   }
  10973 }
  10974 
  10975 function attachStreamPreview(stream, kind, local = false) {
  10976   const streamObj = stream && typeof stream.getTracks === "function" ? stream : null;
  10977   if (!streamObj) {
  10978     clearStreamMediaPreview();
  10979     return;
  10980   }
  10981   const k = normalizeStreamKind(kind);
  10982   const hasVideo = streamObj.getVideoTracks().length > 0;
  10983   if (k === "audio" || !hasVideo) {
  10984     if (streamStageVideoEl) {
  10985       streamStageVideoEl.srcObject = null;
  10986       streamStageVideoEl.classList.add("hidden");
  10987     }
  10988     if (streamStageAudioEl) {
  10989       streamStageAudioEl.srcObject = streamObj;
  10990       streamStageAudioEl.classList.remove("hidden");
  10991       streamStageAudioEl.muted = Boolean(local);
  10992       streamStageAudioEl.play?.().catch(() => {});
  10993     }
  10994     updateStreamOutputMuteState();
  10995     return;
  10996   }
  10997   if (streamStageAudioEl) {
  10998     streamStageAudioEl.srcObject = null;
  10999     streamStageAudioEl.classList.add("hidden");
  11000   }
  11001   if (streamStageVideoEl) {
  11002     streamStageVideoEl.srcObject = streamObj;
  11003     streamStageVideoEl.classList.remove("hidden");
  11004     streamStageVideoEl.muted = Boolean(local);
  11005     streamStageVideoEl.play?.().catch(() => {});
  11006   }
  11007   updateStreamOutputMuteState();
  11008 }
  11009 
  11010 function streamCanHostPost(post) {
  11011   if (!post || !loggedInUser) return false;
  11012   if (String(post.author || "") === String(loggedInUser || "")) return true;
  11013   return isStaffRole(loggedInRole);
  11014 }
  11015 
  11016 function streamResetState(keepPostId = false) {
  11017   closeAllStreamPeers();
  11018   stopStreamTracks(streamLocalMedia);
  11019   stopStreamTracks(streamVoiceMedia);
  11020   streamLocalMedia = null;
  11021   streamVoiceMedia = null;
  11022   streamVoiceJoined = false;
  11023   streamVoiceMuted = false;
  11024   streamVoiceDeafened = false;
  11025   streamRemoteMedia = null;
  11026   streamRemoteHostClientId = "";
  11027   streamCurrentHostClientId = "";
  11028   streamCurrentRole = "idle";
  11029   streamRemoteMediaByClientId.clear();
  11030   streamPeerUsernameByClientId.clear();
  11031   streamPeerVolumeByClientId.clear();
  11032   if (!keepPostId) streamCurrentPostId = "";
  11033   clearStreamMediaPreview();
  11034   renderStreamVoiceUsers();
  11035 }
  11036 
  11037 function leaveActiveStream(sendSignal = true) {
  11038   const postId = String(streamCurrentPostId || "").trim();
  11039   const wasRole = streamCurrentRole;
  11040   if (sendSignal && ws?.readyState === WebSocket.OPEN && postId) {
  11041     if (wasRole === "host") ws.send(JSON.stringify({ type: "streamHostStop", postId }));
  11042     else if (wasRole === "viewer") ws.send(JSON.stringify({ type: "streamLeave", postId }));
  11043   }
  11044   streamResetState(false);
  11045 }
  11046 
  11047 function streamStageCurrentPost() {
  11048   if (activeDmThreadId) return null;
  11049   const post = activeChatPostId ? posts.get(activeChatPostId) : null;
  11050   if (!post || post.deleted || !isStreamPost(post)) return null;
  11051   return post;
  11052 }
  11053 
  11054 function createStreamPeer(targetClientId) {
  11055   const target = String(targetClientId || "").trim();
  11056   if (!target) return null;
  11057   if (typeof RTCPeerConnection !== "function") {
  11058     toast("Stream", "WebRTC is not available in this browser.");
  11059     return null;
  11060   }
  11061   const existing = streamPeerByClientId.get(target);
  11062   if (existing) return existing;
  11063   const pc = new RTCPeerConnection({ iceServers: streamIceConfig() });
  11064   streamPeerByClientId.set(target, pc);
  11065   pc.onicecandidate = (evt) => {
  11066     if (!evt.candidate || !ws || ws.readyState !== WebSocket.OPEN || !streamCurrentPostId) return;
  11067     ws.send(
  11068       JSON.stringify({
  11069         type: "streamSignal",
  11070         postId: streamCurrentPostId,
  11071         targetClientId: target,
  11072         signal: { type: "candidate", candidate: evt.candidate },
  11073       })
  11074     );
  11075   };
  11076   pc.ontrack = (evt) => {
  11077     const remote = evt.streams && evt.streams[0] ? evt.streams[0] : null;
  11078     if (!remote) return;
  11079     streamRemoteMediaByClientId.set(target, remote);
  11080     if (target === streamCurrentHostClientId && streamCurrentRole !== "host") {
  11081       streamRemoteMedia = remote;
  11082       attachStreamPreview(remote, streamRemoteKind, false);
  11083       if (streamStagePlaceholderEl) streamStagePlaceholderEl.classList.add("hidden");
  11084     } else {
  11085       const remoteAudioEl = ensureRemoteAudioEl(target);
  11086       if (remoteAudioEl) {
  11087         remoteAudioEl.srcObject = remote;
  11088         const savedVolume = Number(streamPeerVolumeByClientId.get(target));
  11089         remoteAudioEl.volume = Number.isFinite(savedVolume) ? Math.max(0, Math.min(1, savedVolume)) : 1;
  11090         remoteAudioEl.play?.().catch(() => {});
  11091       }
  11092       updateStreamOutputMuteState();
  11093       renderStreamVoiceUsers();
  11094     }
  11095   };
  11096   pc.onconnectionstatechange = () => {
  11097     const state = String(pc.connectionState || "");
  11098     if (state === "failed" || state === "closed" || state === "disconnected") closeStreamPeer(target);
  11099   };
  11100   if (streamCurrentRole === "host" && streamLocalMedia) {
  11101     for (const track of streamLocalMedia.getTracks()) {
  11102       try {
  11103         pc.addTrack(track, streamLocalMedia);
  11104       } catch {
  11105         // ignore
  11106       }
  11107     }
  11108   } else if (streamVoiceJoined && streamVoiceMedia) {
  11109     addVoiceTracksToPeer(target);
  11110   }
  11111   return pc;
  11112 }
  11113 
  11114 async function handleStreamSignalMessage(msg) {
  11115   const postId = String(msg.postId || "").trim();
  11116   const fromClientId = String(msg.fromClientId || "").trim();
  11117   const signal = msg.signal && typeof msg.signal === "object" ? msg.signal : null;
  11118   if (!postId || !fromClientId || !signal) return;
  11119   if (!streamCurrentPostId || streamCurrentPostId !== postId) return;
  11120   const type = String(signal.type || "").trim().toLowerCase();
  11121   if (!type) return;
  11122   const pc = createStreamPeer(fromClientId);
  11123   if (!pc) return;
  11124   try {
  11125     if (type === "offer") {
  11126       await pc.setRemoteDescription(new RTCSessionDescription({ type: "offer", sdp: String(signal.sdp || "") }));
  11127       if (streamCurrentRole === "host" || streamVoiceJoined) addVoiceTracksToPeer(fromClientId);
  11128       const answer = await pc.createAnswer();
  11129       await pc.setLocalDescription(answer);
  11130       ws?.send(
  11131         JSON.stringify({
  11132           type: "streamSignal",
  11133           postId,
  11134           targetClientId: fromClientId,
  11135           signal: { type: "answer", sdp: String(answer.sdp || "") },
  11136         })
  11137       );
  11138       return;
  11139     }
  11140     if (type === "answer") {
  11141       await pc.setRemoteDescription(new RTCSessionDescription({ type: "answer", sdp: String(signal.sdp || "") }));
  11142       return;
  11143     }
  11144     if (type === "candidate" && signal.candidate) {
  11145       await pc.addIceCandidate(new RTCIceCandidate(signal.candidate));
  11146     }
  11147   } catch (e) {
  11148     console.warn("stream signal failed:", e?.message || e);
  11149   }
  11150 }
  11151 
  11152 async function handleStreamPeerJoinMessage(msg) {
  11153   const postId = String(msg.postId || "").trim();
  11154   const peerClientId = String(msg.peerClientId || msg.viewerClientId || "").trim();
  11155   const peerUsername = String(msg.peerUsername || msg.viewerUsername || "").trim();
  11156   const initiateOffer = msg.initiateOffer === true;
  11157   if (!postId || !peerClientId) return;
  11158   if (streamCurrentRole === "idle" || streamCurrentPostId !== postId) return;
  11159   if (peerUsername) streamPeerUsernameByClientId.set(peerClientId, peerUsername);
  11160   const pc = createStreamPeer(peerClientId);
  11161   if (!pc) return;
  11162   if (!initiateOffer) return;
  11163   try {
  11164     if (streamCurrentRole === "host" || streamVoiceJoined) addVoiceTracksToPeer(peerClientId);
  11165     const offer = await pc.createOffer();
  11166     await pc.setLocalDescription(offer);
  11167     ws?.send(
  11168       JSON.stringify({
  11169         type: "streamSignal",
  11170         postId,
  11171         targetClientId: peerClientId,
  11172         signal: { type: "offer", sdp: String(offer.sdp || "") },
  11173       })
  11174     );
  11175   } catch (e) {
  11176     console.warn("stream offer failed:", e?.message || e);
  11177     closeStreamPeer(peerClientId);
  11178   }
  11179 }
  11180 
  11181 async function tryGetMicrophoneTrack() {
  11182   if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") return null;
  11183   try {
  11184     const micStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
  11185     const track = micStream.getAudioTracks()[0] || null;
  11186     if (!track) {
  11187       try {
  11188         for (const t of micStream.getTracks()) t.stop();
  11189       } catch {}
  11190       return null;
  11191     }
  11192     track.addEventListener(
  11193       "ended",
  11194       () => {
  11195         try {
  11196           for (const t of micStream.getTracks()) {
  11197             if (t.id !== track.id) t.stop();
  11198           }
  11199         } catch {}
  11200       },
  11201       { once: true }
  11202     );
  11203     return track;
  11204   } catch {
  11205     return null;
  11206   }
  11207 }
  11208 
  11209 async function ensureStreamAudioForKind(media, kind, opts = null) {
  11210   if (!media) return media;
  11211   const options = opts && typeof opts === "object" ? opts : {};
  11212   const includeMicForScreen = options.includeMicForScreen !== false;
  11213   const audioTracks = media.getAudioTracks();
  11214   const hasAudio = audioTracks.length > 0;
  11215   if (kind === "audio" || kind === "webcam") {
  11216     if (hasAudio) return media;
  11217     const micTrack = await tryGetMicrophoneTrack();
  11218     if (micTrack) media.addTrack(micTrack);
  11219     return media;
  11220   }
  11221   if (kind === "screen") {
  11222     if (!includeMicForScreen) return media;
  11223     const micTrack = await tryGetMicrophoneTrack();
  11224     if (micTrack) media.addTrack(micTrack);
  11225   }
  11226   return media;
  11227 }
  11228 
  11229 async function enableStreamVoice() {
  11230   if (streamCurrentRole !== "viewer" || !streamCurrentPostId) return false;
  11231   if (streamVoiceJoined && streamVoiceMedia) {
  11232     updateStreamLocalMuteState();
  11233     return true;
  11234   }
  11235   if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") {
  11236     toast("Voice", "Microphone access is not available in this browser.");
  11237     return false;
  11238   }
  11239   try {
  11240     const media = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
  11241     if (!media.getAudioTracks().length) {
  11242       stopStreamTracks(media);
  11243       toast("Voice", "No microphone track available.");
  11244       return false;
  11245     }
  11246     streamVoiceMedia = media;
  11247     streamVoiceJoined = true;
  11248     updateStreamLocalMuteState();
  11249     for (const peerId of streamPeerByClientId.keys()) {
  11250       addVoiceTracksToPeer(peerId);
  11251       renegotiateStreamPeer(peerId);
  11252     }
  11253     renderChatPanel(false);
  11254     return true;
  11255   } catch (e) {
  11256     toast("Voice", String(e?.message || "Unable to access microphone."));
  11257     return false;
  11258   }
  11259 }
  11260 
  11261 function disableStreamVoice() {
  11262   if (!streamVoiceJoined && !streamVoiceMedia) return;
  11263   streamVoiceJoined = false;
  11264   stopStreamTracks(streamVoiceMedia);
  11265   streamVoiceMedia = null;
  11266   for (const peerId of streamPeerByClientId.keys()) {
  11267     renegotiateStreamPeer(peerId);
  11268   }
  11269   renderChatPanel(false);
  11270 }
  11271 
  11272 async function startStreamHost(post) {
  11273   if (!post || !isStreamPost(post)) return;
  11274   if (!streamEnabled) {
  11275     toast("Stream", "Streaming is disabled on this instance.");
  11276     return;
  11277   }
  11278   if (!loggedInUser) {
  11279     toast("Stream", "Sign in to start a stream.");
  11280     return;
  11281   }
  11282   if (!streamCanHostPost(post)) {
  11283     toast("Stream", "Only the hive owner or a moderator can host this stream.");
  11284     return;
  11285   }
  11286   const kind = normalizeStreamKind(post.streamKind || "webcam");
  11287   try {
  11288     if (!navigator.mediaDevices) throw new Error("Media devices are unavailable in this browser.");
  11289     let media = null;
  11290     let includeMicForScreen = true;
  11291     if (kind === "screen") {
  11292       media = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
  11293       try {
  11294         const remembered = readBoolPref("bzl_stream_screen_include_mic", true);
  11295         const ask = window.confirm(
  11296           remembered
  11297             ? "Include your microphone with screen share? (Recommended)"
  11298             : "Include your microphone with screen share?"
  11299         );
  11300         includeMicForScreen = Boolean(ask);
  11301         writeBoolPref("bzl_stream_screen_include_mic", includeMicForScreen);
  11302       } catch {
  11303         includeMicForScreen = true;
  11304       }
  11305     } else if (kind === "audio") {
  11306       media = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
  11307     } else {
  11308       media = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
  11309     }
  11310     if (!media) throw new Error("No stream media available.");
  11311     media = await ensureStreamAudioForKind(media, kind, { includeMicForScreen });
  11312     if (!media.getAudioTracks().length) {
  11313       throw new Error("No audio track available. Share system audio and/or allow microphone access to go live with sound.");
  11314     }
  11315 
  11316     leaveActiveStream(false);
  11317     streamCurrentPostId = String(post.id || "");
  11318     streamCurrentRole = "host";
  11319     streamRemoteKind = kind;
  11320     streamCurrentHostClientId = String(clientId || "");
  11321     streamLocalMedia = media;
  11322     streamVoiceJoined = true;
  11323     streamVoiceMuted = false;
  11324     streamVoiceDeafened = false;
  11325     updateStreamLocalMuteState();
  11326     updateStreamOutputMuteState();
  11327     for (const track of media.getTracks()) {
  11328       track.onended = () => {
  11329         if (streamCurrentRole === "host" && streamCurrentPostId === String(post.id || "")) leaveActiveStream(true);
  11330       };
  11331     }
  11332     attachStreamPreview(media, kind, true);
  11333     if (streamStagePlaceholderEl) streamStagePlaceholderEl.classList.add("hidden");
  11334     if (ws?.readyState === WebSocket.OPEN) {
  11335       ws.send(JSON.stringify({ type: "streamHostStart", postId: post.id, streamKind: kind }));
  11336     }
  11337     renderChatPanel(false);
  11338   } catch (e) {
  11339     toast("Stream", String(e?.message || "Unable to start stream."));
  11340   }
  11341 }
  11342 
  11343 function joinStream(post) {
  11344   if (!post || !isStreamPost(post)) return;
  11345   if (!streamEnabled) {
  11346     toast("Stream", "Streaming is disabled on this instance.");
  11347     return;
  11348   }
  11349   if (typeof RTCPeerConnection !== "function") {
  11350     toast("Stream", "WebRTC is not available in this browser.");
  11351     return;
  11352   }
  11353   leaveActiveStream(false);
  11354   streamCurrentPostId = String(post.id || "");
  11355   streamCurrentRole = "viewer";
  11356   streamRemoteKind = normalizeStreamKind(post.streamKind || "webcam");
  11357   streamCurrentHostClientId = "";
  11358   streamRemoteHostClientId = "";
  11359   streamVoiceJoined = false;
  11360   streamVoiceMuted = false;
  11361   streamVoiceDeafened = false;
  11362   if (streamStagePlaceholderEl) {
  11363     streamStagePlaceholderEl.classList.remove("hidden");
  11364     streamStagePlaceholderEl.textContent = "Connecting to stream...";
  11365   }
  11366   if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "streamJoin", postId: post.id }));
  11367   renderChatPanel(false);
  11368 }
  11369 
  11370 function renderStreamStage(post) {
  11371   const streamPost = post && isStreamPost(post) ? post : null;
  11372   if (!streamPost) {
  11373     if (streamStageEl) streamStageEl.classList.add("hidden");
  11374     if (streamVoiceControlsEl) streamVoiceControlsEl.classList.add("hidden");
  11375     return;
  11376   }
  11377   if (streamStageEl) streamStageEl.classList.remove("hidden");
  11378   const postId = String(streamPost.id || "");
  11379   const live = Boolean(streamLiveByPostId.get(postId) ?? streamPost.streamLive);
  11380   const kind = normalizeStreamKind(streamPost.streamKind || "webcam");
  11381   const canHost = streamCanHostPost(streamPost);
  11382   const isHosting = streamCurrentRole === "host" && streamCurrentPostId === postId;
  11383   const isViewing = streamCurrentRole === "viewer" && streamCurrentPostId === postId;
  11384   if (streamStageTitleEl) {
  11385     streamStageTitleEl.textContent = `${streamKindLabel(kind)} stream`;
  11386   }
  11387   if (streamStageStatusEl) {
  11388     if (isHosting) streamStageStatusEl.textContent = "You are live.";
  11389     else if (isViewing) streamStageStatusEl.textContent = streamRemoteMedia ? "Watching live stream. Voice room connected." : "Connecting...";
  11390     else if (live) streamStageStatusEl.textContent = "Live now. Join to watch.";
  11391     else if (canHost) streamStageStatusEl.textContent = "Offline. Start a stream for this hive.";
  11392     else streamStageStatusEl.textContent = "Stream is offline.";
  11393   }
  11394   if (streamStagePrimaryBtn) {
  11395     let label = "Join stream";
  11396     let disabled = false;
  11397     if (isHosting) label = "Stop stream";
  11398     else if (isViewing) label = "Leave stream";
  11399     else if (!live && canHost) label = `Go live (${streamKindLabel(kind)})`;
  11400     else if (!live && !canHost) {
  11401       label = "Offline";
  11402       disabled = true;
  11403     }
  11404     streamStagePrimaryBtn.textContent = label;
  11405     streamStagePrimaryBtn.disabled = disabled;
  11406   }
  11407   if (streamStagePlaceholderEl && !isHosting && !isViewing) {
  11408     streamStagePlaceholderEl.classList.remove("hidden");
  11409     streamStagePlaceholderEl.textContent = live
  11410       ? "Join this stream to watch live with chat."
  11411       : canHost
  11412         ? "Tap Go live to start screen share, webcam, or audio stream."
  11413         : "Waiting for the stream owner to go live.";
  11414   }
  11415   renderStreamVoiceControls(streamPost, isHosting, isViewing);
  11416 }
  11417 
  11418 function canWalkieTalkNow() {
  11419   if (!loggedInUser || !ws || ws.readyState !== WebSocket.OPEN) return false;
  11420   if (!activeChatPostId) return false;
  11421   const post = posts.get(activeChatPostId);
  11422   if (!post || post.deleted) return false;
  11423   return String(post.mode || post.chatMode || "").toLowerCase() === "walkie";
  11424 }
  11425 
  11426 async function startWalkieRecording() {
  11427   if (walkieRecording) return;
  11428   if (!canWalkieTalkNow()) return;
  11429   try {
  11430     if (walkieStatusEl) walkieStatusEl.textContent = "Requesting microphone...";
  11431     const { ctx, mix, dest } = await ensureWalkieGraph();
  11432     if (ctx.state === "suspended") await ctx.resume();
  11433 
  11434     walkieChunks = [];
  11435     const stream = dest.stream;
  11436     const preferred = [
  11437       "audio/webm;codecs=opus",
  11438       "audio/ogg;codecs=opus",
  11439       "audio/webm",
  11440       "audio/ogg",
  11441     ];
  11442     let mimeType = "";
  11443     for (const t of preferred) {
  11444       if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(t)) {
  11445         mimeType = t;
  11446         break;
  11447       }
  11448     }
  11449     const rec = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
  11450     walkieRecorder = rec;
  11451     walkieStartAt = Date.now();
  11452     walkieRecording = true;
  11453     if (walkieBarEl) walkieBarEl.classList.add("isRecording");
  11454     if (walkieStatusEl) walkieStatusEl.textContent = "Recording... release to send.";
  11455 
  11456     const dispatch = await ensureWalkieDispatchBuffer();
  11457     if (dispatch) {
  11458       const src = new AudioBufferSourceNode(ctx, { buffer: dispatch });
  11459       const g = new GainNode(ctx, { gain: 0.75 });
  11460       src.connect(g);
  11461       g.connect(mix);
  11462       src.start();
  11463       // Local feedback so user hears the click (quiet).
  11464       const mon = new GainNode(ctx, { gain: 0.10 });
  11465       g.connect(mon);
  11466       mon.connect(ctx.destination);
  11467     }
  11468 
  11469     rec.addEventListener("dataavailable", (e) => {
  11470       if (e.data && e.data.size > 0) walkieChunks.push(e.data);
  11471     });
  11472     rec.addEventListener("stop", async () => {
  11473       const tookMs = Date.now() - walkieStartAt;
  11474       walkieRecording = false;
  11475       if (walkieBarEl) walkieBarEl.classList.remove("isRecording");
  11476       if (walkieStatusEl) walkieStatusEl.textContent = "Processing...";
  11477 
  11478       // Give some browsers a tick to deliver the final dataavailable.
  11479       await new Promise((r) => window.setTimeout(r, 0));
  11480       const blob = new Blob(walkieChunks, { type: rec.mimeType || "audio/webm" });
  11481       walkieChunks = [];
  11482       if (!blob || blob.size < 800 || tookMs < 160) {
  11483         if (walkieStatusEl) walkieStatusEl.textContent = "";
  11484         toast("Walkie Talkie", "No audio captured. Check mic permissions/input and try again.");
  11485         return;
  11486       }
  11487 
  11488       const ext = (rec.mimeType || "").includes("ogg") ? "ogg" : "webm";
  11489       const file = new File([blob], `walkie-${Date.now()}.${ext}`, { type: rec.mimeType || blob.type || "audio/webm" });
  11490       if (walkieStatusEl) walkieStatusEl.textContent = "Uploading...";
  11491       const url = await uploadMediaFile(file, "audio");
  11492       if (!url) {
  11493         if (walkieStatusEl) walkieStatusEl.textContent = "";
  11494         return;
  11495       }
  11496       const post = posts.get(activeChatPostId);
  11497       if (!post || post.deleted) {
  11498         if (walkieStatusEl) walkieStatusEl.textContent = "";
  11499         return;
  11500       }
  11501       ws.send(JSON.stringify({ type: "chatMessage", postId: activeChatPostId, text: "", html: `<audio controls preload=\"none\" src=\"${escapeHtml(url)}\"></audio>` }));
  11502       if (walkieStatusEl) walkieStatusEl.textContent = "Sent.";
  11503       window.setTimeout(() => {
  11504         if (walkieStatusEl && walkieStatusEl.textContent === "Sent.") walkieStatusEl.textContent = "";
  11505       }, 900);
  11506       playSfx("ping", { volume: 0.22 });
  11507     });
  11508 
  11509     // Timeslice helps avoid empty blobs in some browsers.
  11510     rec.start(250);
  11511   } catch (e) {
  11512     walkieRecording = false;
  11513     if (walkieBarEl) walkieBarEl.classList.remove("isRecording");
  11514     const name = String(e?.name || "");
  11515     const msg = String(e?.message || "");
  11516     const pretty =
  11517       name === "NotAllowedError"
  11518         ? "Microphone permission denied. Allow mic access in your browser settings."
  11519         : name === "NotFoundError"
  11520           ? "No microphone device found."
  11521           : name === "NotReadableError"
  11522             ? "Microphone is in use by another app."
  11523             : msg || "Microphone recording failed.";
  11524     if (walkieStatusEl) walkieStatusEl.textContent = "";
  11525     toast("Walkie Talkie", pretty);
  11526   }
  11527 }
  11528 
  11529 async function stopWalkieRecording() {
  11530   if (!walkieRecorder || !walkieRecording) return;
  11531   try {
  11532     const { ctx, mix } = await ensureWalkieGraph();
  11533     const dispatch = await ensureWalkieDispatchBuffer();
  11534     if (dispatch) {
  11535       const src = new AudioBufferSourceNode(ctx, { buffer: dispatch });
  11536       const g = new GainNode(ctx, { gain: 0.55 });
  11537       src.connect(g);
  11538       g.connect(mix);
  11539       src.start();
  11540       const mon = new GainNode(ctx, { gain: 0.08 });
  11541       g.connect(mon);
  11542       mon.connect(ctx.destination);
  11543       window.setTimeout(() => {
  11544         try {
  11545           if (walkieRecorder && walkieRecorder.state !== "inactive") walkieRecorder.stop();
  11546         } catch {
  11547           // ignore
  11548         }
  11549         walkieRecorder = null;
  11550       }, 160);
  11551       return;
  11552     }
  11553   } catch {
  11554     // ignore
  11555   }
  11556   try {
  11557     if (walkieRecorder && walkieRecorder.state !== "inactive") walkieRecorder.stop();
  11558   } catch {
  11559     // ignore
  11560   }
  11561   walkieRecorder = null;
  11562 }
  11563 
  11564 function insertAudioTag(target, srcUrl) {
  11565   if (!srcUrl) return;
  11566   target.focus();
  11567   const safe = escapeHtml(srcUrl);
  11568   document.execCommand("insertHTML", false, `<audio controls preload="none" src="${safe}"></audio>`);
  11569 }
  11570 
  11571 function installDropUpload(targetEl, { allowImages = true, allowAudio = true } = {}) {
  11572   if (!targetEl) return;
  11573   const setActive = (on) => {
  11574     try {
  11575       targetEl.classList.toggle("isDropActive", Boolean(on));
  11576     } catch {
  11577       // ignore
  11578     }
  11579   };
  11580   targetEl.addEventListener("dragover", (e) => {
  11581     if (!e.dataTransfer) return;
  11582     if (!e.dataTransfer.types || !Array.from(e.dataTransfer.types).includes("Files")) return;
  11583     e.preventDefault();
  11584     setActive(true);
  11585   });
  11586   targetEl.addEventListener("dragleave", () => setActive(false));
  11587   targetEl.addEventListener("drop", async (e) => {
  11588     setActive(false);
  11589     const files = e.dataTransfer?.files ? Array.from(e.dataTransfer.files) : [];
  11590     if (!files.length) return;
  11591     e.preventDefault();
  11592     e.stopPropagation();
  11593 
  11594     for (const file of files.slice(0, 4)) {
  11595       const type = String(file.type || "").toLowerCase();
  11596       const name = String(file.name || "").toLowerCase();
  11597       const isImg = type.startsWith("image/") || /\.(gif|png|jpe?g|webp)$/.test(name);
  11598       const isAud = type.startsWith("audio/") || /\.(mp3|wav|ogg|m4a|aac|webm)$/.test(name);
  11599       if (isImg && allowImages) {
  11600         const url = await uploadMediaFile(file, "image");
  11601         if (!url) continue;
  11602         targetEl.focus();
  11603         document.execCommand("insertImage", false, url);
  11604       } else if (isAud && allowAudio) {
  11605         const url = await uploadMediaFile(file, "audio");
  11606         if (!url) continue;
  11607         insertAudioTag(targetEl, url);
  11608       }
  11609     }
  11610   });
  11611 }
  11612 
  11613 document.querySelector(".editorShell .toolbar")?.addEventListener("click", (e) => {
  11614   const btn = e.target.closest("button");
  11615   if (!btn) return;
  11616   const cmd = btn.getAttribute("data-cmd");
  11617   if (cmd) {
  11618     runCmd(editor, cmd);
  11619     return;
  11620   }
  11621   if (btn.getAttribute("data-link")) {
  11622     runLink(editor);
  11623     return;
  11624   }
  11625   if (btn.getAttribute("data-postimg")) {
  11626     postImageInput?.click();
  11627     return;
  11628   }
  11629   if (btn.getAttribute("data-postaudio")) {
  11630     postAudioInput?.click();
  11631     return;
  11632   }
  11633   if (btn.getAttribute("data-postemoji")) runEmoji(editor);
  11634 });
  11635 
  11636 document.addEventListener("click", (e) => {
  11637   const btn = e.target.closest?.("button");
  11638   if (!btn) return;
  11639   const toolbar = btn.closest?.(".chatComposer .toolbar");
  11640   if (!toolbar) return;
  11641   const composer = toolbar.closest?.(".chatComposer");
  11642   if (!composer) return;
  11643   const targetEditor = composer.querySelector?.(".chatEditor") || chatEditor;
  11644   if (!(targetEditor instanceof HTMLElement)) return;
  11645   chatUploadTargetEditor = targetEditor;
  11646 
  11647   const cmd = btn.getAttribute("data-chatcmd");
  11648   if (cmd) {
  11649     runCmd(targetEditor, cmd);
  11650     return;
  11651   }
  11652   if (btn.getAttribute("data-chatlink")) {
  11653     runLink(targetEditor);
  11654     return;
  11655   }
  11656   if (btn.getAttribute("data-chatimg")) {
  11657     chatImageInput?.click();
  11658     return;
  11659   }
  11660   if (btn.getAttribute("data-chataudio")) {
  11661     chatAudioInput?.click();
  11662     return;
  11663   }
  11664   if (btn.getAttribute("data-chatemoji")) runEmoji(targetEditor);
  11665 });
  11666 
  11667 profileBioToolbar?.addEventListener("click", (e) => {
  11668   const btn = e.target.closest("button");
  11669   if (!btn) return;
  11670   const cmd = btn.getAttribute("data-profilecmd");
  11671   if (cmd) {
  11672     runCmd(profileBioEditor, cmd);
  11673     return;
  11674   }
  11675   if (btn.getAttribute("data-profilelink")) {
  11676     runLink(profileBioEditor);
  11677     return;
  11678   }
  11679   if (btn.getAttribute("data-profileimg")) {
  11680     profileBioImageFileInput?.click();
  11681     return;
  11682   }
  11683   if (btn.getAttribute("data-profileaudio")) {
  11684     profileBioAudioFileInput?.click();
  11685     return;
  11686   }
  11687   if (btn.getAttribute("data-profileemoji")) runEmoji(profileBioEditor);
  11688 });
  11689 
  11690 editModalToolbar?.addEventListener("click", (e) => {
  11691   const btn = e.target.closest("button");
  11692   if (!btn) return;
  11693   const cmd = btn.getAttribute("data-editcmd");
  11694   if (cmd) {
  11695     runCmd(editModalEditor, cmd);
  11696     return;
  11697   }
  11698   if (btn.getAttribute("data-editlink")) {
  11699     runLink(editModalEditor);
  11700     return;
  11701   }
  11702   if (btn.getAttribute("data-editimg")) {
  11703     editModalImageInput?.click();
  11704     return;
  11705   }
  11706   if (btn.getAttribute("data-editaudio")) {
  11707     editModalAudioInput?.click();
  11708     return;
  11709   }
  11710   if (btn.getAttribute("data-editemoji")) runEmoji(editModalEditor);
  11711 });
  11712 
  11713 editModalImageInput?.addEventListener("change", async () => {
  11714   const file = editModalImageInput.files && editModalImageInput.files[0] ? editModalImageInput.files[0] : null;
  11715   editModalImageInput.value = "";
  11716   if (!file) return;
  11717   const url = await uploadMediaFile(file, "image");
  11718   if (!url) return;
  11719   editModalEditor?.focus();
  11720   document.execCommand("insertImage", false, url);
  11721 });
  11722 
  11723 editModalAudioInput?.addEventListener("change", async () => {
  11724   const file = editModalAudioInput.files && editModalAudioInput.files[0] ? editModalAudioInput.files[0] : null;
  11725   editModalAudioInput.value = "";
  11726   if (!file) return;
  11727   const url = await uploadMediaFile(file, "audio");
  11728   if (!url) return;
  11729   insertAudioTag(editModalEditor, url);
  11730 });
  11731 
  11732 editModal?.addEventListener("click", (e) => {
  11733   if (e.target?.getAttribute?.("data-modalclose")) setEditModalOpen(false);
  11734 });
  11735 
  11736 editModalCloseBtn?.addEventListener("click", () => setEditModalOpen(false));
  11737 editModalCancelBtn?.addEventListener("click", () => setEditModalOpen(false));
  11738 
  11739 editModalSaveBtn?.addEventListener("click", () => {
  11740   if (!editContext) return;
  11741   if (!editModalEditor) return;
  11742   const { html, text, hasImg, hasAudio } = collectEditorPayload(editModalEditor);
  11743   if (!text && !hasImg && !hasAudio) {
  11744     if (editModalStatus) editModalStatus.textContent = "Please add text, an image, or audio.";
  11745     editModalEditor.focus();
  11746     return;
  11747   }
  11748   if (editContext.kind === "post") {
  11749     const title = String(editModalPostTitleInput?.value || "")
  11750       .replace(/\s+/g, " ")
  11751       .trim()
  11752       .slice(0, 96);
  11753     if (!title) {
  11754       if (editModalStatus) editModalStatus.textContent = "Title is required.";
  11755       editModalPostTitleInput?.focus();
  11756       return;
  11757     }
  11758     const post = posts.get(editContext.postId);
  11759     const wasProtected = Boolean(post?.protected);
  11760     const wantsProtected = Boolean(editModalProtectedToggle?.checked);
  11761     const password = String(editModalPasswordInput?.value || "");
  11762     if (wantsProtected && !wasProtected && password.trim().length < 4) {
  11763       if (editModalStatus) editModalStatus.textContent = "Set a password (min 4 chars) to protect this post.";
  11764       editModalPasswordInput?.focus();
  11765       return;
  11766     }
  11767     const keywords = parseKeywordsInput(editModalKeywordsInput?.value || "");
  11768     const collectionId = String(editModalCollectionSelect?.value || post?.collectionId || "general");
  11769     const mode = normalizePostMode(editModalModeSelect?.value || post?.mode || "text");
  11770     const streamKind = normalizeStreamKind(editModalStreamKindSelect?.value || post?.streamKind || "webcam");
  11771     ws.send(
  11772       JSON.stringify({
  11773         type: "editPost",
  11774         postId: editContext.postId,
  11775         title,
  11776         content: text,
  11777         contentHtml: html,
  11778         keywords,
  11779         collectionId,
  11780         protected: wantsProtected,
  11781         password: password.trim(),
  11782         mode,
  11783         streamKind
  11784       })
  11785     );
  11786     setEditModalOpen(false);
  11787     return;
  11788   }
  11789   if (editContext.kind === "chat") {
  11790     ws.send(JSON.stringify({ type: "editChatMessage", postId: editContext.postId, messageId: editContext.messageId, text, html }));
  11791     setEditModalOpen(false);
  11792   }
  11793 });
  11794 
  11795 function sendLogin(username, password) {
  11796   ws.send(JSON.stringify({ type: "login", username: String(username || "").trim(), password: String(password || "") }));
  11797 }
  11798 
  11799 function sendRegister(username, password, code) {
  11800   ws.send(
  11801     JSON.stringify({
  11802       type: "register",
  11803       username: String(username || "").trim(),
  11804       password: String(password || ""),
  11805       code: String(code || "").trim(),
  11806     })
  11807   );
  11808 }
  11809 
  11810 authForm.addEventListener("submit", (e) => {
  11811   e.preventDefault();
  11812   sendLogin(authUser.value, authPass.value);
  11813 });
  11814 
  11815 registerBtn.addEventListener("click", () => sendRegister(authUser.value, authPass.value, authCode.value));
  11816 
  11817 authGateFormEl?.addEventListener("submit", (e) => {
  11818   e.preventDefault();
  11819   sendLogin(authGateUserEl?.value || "", authGatePassEl?.value || "");
  11820 });
  11821 
  11822 authGateRegisterEl?.addEventListener("click", () =>
  11823   sendRegister(authGateUserEl?.value || "", authGatePassEl?.value || "", authGateCodeEl?.value || "")
  11824 );
  11825 
  11826 authGateEl?.addEventListener("click", (e) => {
  11827   const tabBtn = e.target?.closest?.("button[data-authgate-tab]");
  11828   if (tabBtn) {
  11829     const tab = String(tabBtn.getAttribute("data-authgate-tab") || "about").trim();
  11830     if (["about", "rules", "roles"].includes(tab)) {
  11831       authGateOnboardingTab = tab;
  11832       const buttons = Array.from(authGateEl.querySelectorAll("button[data-authgate-tab]"));
  11833       for (const btn of buttons) {
  11834         const on = String(btn.getAttribute("data-authgate-tab") || "") === authGateOnboardingTab;
  11835         btn.classList.toggle("primary", on);
  11836         btn.classList.toggle("ghost", !on);
  11837       }
  11838       renderAuthGateOnboarding();
  11839     }
  11840   }
  11841 });
  11842 
  11843 authGateAcceptBtn?.addEventListener("click", () => {
  11844   if (!loggedInUser) {
  11845     toast("Sign in required", "Sign in to accept server rules.");
  11846     return;
  11847   }
  11848   ws.send(JSON.stringify({ type: "onboardingAcceptRules" }));
  11849 });
  11850 
  11851 authGateRefreshBtn?.addEventListener("click", () => {
  11852   if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
  11853 });
  11854 
  11855 bzlSplashStartBtn?.addEventListener("click", () => {
  11856   if (!splashNeedsGesture) return;
  11857   tryPlaySplashAudio({ fromGesture: true });
  11858 });
  11859 
  11860 tourBtn?.addEventListener("click", () => startGuidedTour({ auto: false }));
  11861 
  11862 logoutBtn.addEventListener("click", () => ws.send(JSON.stringify({ type: "logout" })));
  11863 
  11864 profileImageInput.addEventListener("change", async () => {
  11865   profileStatus.textContent = "";
  11866   const file = profileImageInput.files && profileImageInput.files[0] ? profileImageInput.files[0] : null;
  11867   if (!file) return;
  11868   try {
  11869     pendingProfileImage = await resizeImageToSquareDataUrl(file, 96);
  11870     if (pendingProfileImage) {
  11871       profilePreview.src = pendingProfileImage;
  11872       profilePreview.classList.add("hasImg");
  11873     }
  11874   } catch {
  11875     profileStatus.textContent = "Failed to load image.";
  11876   }
  11877 });
  11878 
  11879 removeProfileImageBtn.addEventListener("click", () => {
  11880   pendingProfileImage = "";
  11881   profilePreview.removeAttribute("src");
  11882   profilePreview.classList.remove("hasImg");
  11883 });
  11884 
  11885 saveProfileBtn.addEventListener("click", () => {
  11886   profileStatus.textContent = "";
  11887   const color = nameColorInput.value;
  11888   ws.send(JSON.stringify({ type: "updateProfile", image: pendingProfileImage, color }));
  11889 });
  11890 
  11891 profileBackBtn?.addEventListener("click", () => setCenterView("hives"));
  11892 
  11893 profileEditToggleBtn?.addEventListener("click", () => {
  11894   isEditingProfile = !isEditingProfile;
  11895   if (profileEditToggleBtn) profileEditToggleBtn.textContent = isEditingProfile ? "Close editor" : "Edit profile";
  11896   renderCenterPanels();
  11897 });
  11898 
  11899 profileCancelBtn?.addEventListener("click", () => {
  11900   isEditingProfile = false;
  11901   if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile";
  11902   renderCenterPanels();
  11903 });
  11904 
  11905 profileAddLinkBtn?.addEventListener("click", () => {
  11906   const links = profileLinksFromEditor();
  11907   links.push({ label: "Link", url: "https://" });
  11908   renderProfileLinksEditor(links);
  11909 });
  11910 
  11911 profileLinksEditor?.addEventListener("click", (e) => {
  11912   const btn = e.target.closest("[data-linkremove]");
  11913   if (!btn) return;
  11914   const idx = Number(btn.getAttribute("data-linkremove") || -1);
  11915   const links = profileLinksFromEditor();
  11916   if (idx < 0 || idx >= links.length) return;
  11917   links.splice(idx, 1);
  11918   renderProfileLinksEditor(links);
  11919 });
  11920 
  11921 profileThemeSongUploadBtn?.addEventListener("click", () => profileThemeSongFileInput?.click());
  11922 
  11923 profileThemeSongClearBtn?.addEventListener("click", () => syncProfileSongPreview(""));
  11924 
  11925 profileThemeSongFileInput?.addEventListener("change", async () => {
  11926   const file = profileThemeSongFileInput.files && profileThemeSongFileInput.files[0] ? profileThemeSongFileInput.files[0] : null;
  11927   profileThemeSongFileInput.value = "";
  11928   if (!file) return;
  11929   const url = await uploadMediaFile(file, "audio");
  11930   if (!url) return;
  11931   syncProfileSongPreview(url);
  11932 });
  11933 
  11934 profileBioImageFileInput?.addEventListener("change", async () => {
  11935   const file = profileBioImageFileInput.files && profileBioImageFileInput.files[0] ? profileBioImageFileInput.files[0] : null;
  11936   profileBioImageFileInput.value = "";
  11937   if (!file) return;
  11938   const url = await uploadMediaFile(file, "image");
  11939   if (!url) return;
  11940   profileBioEditor?.focus();
  11941   document.execCommand("insertImage", false, url);
  11942 });
  11943 
  11944 profileBioAudioFileInput?.addEventListener("change", async () => {
  11945   const file = profileBioAudioFileInput.files && profileBioAudioFileInput.files[0] ? profileBioAudioFileInput.files[0] : null;
  11946   profileBioAudioFileInput.value = "";
  11947   if (!file) return;
  11948   const url = await uploadMediaFile(file, "audio");
  11949   if (!url) return;
  11950   insertAudioTag(profileBioEditor, url);
  11951 });
  11952 
  11953 profileSaveBtn?.addEventListener("click", () => {
  11954   if (!loggedInUser || !activeProfile || activeProfile.username !== loggedInUser) return;
  11955   const pronouns = String(profilePronounsInput?.value || "")
  11956     .replace(/\s+/g, " ")
  11957     .trim()
  11958     .slice(0, 40);
  11959   const bioHtml = String(profileBioEditor?.innerHTML || "");
  11960   const themeSongUrl = String(profileThemeSongUrlInput?.value || "").trim();
  11961   const links = profileLinksFromEditor();
  11962   ws.send(JSON.stringify({ type: "updateProfile", pronouns, bioHtml, themeSongUrl, links }));
  11963 });
  11964 
  11965 newPostForm.addEventListener("submit", (e) => {
  11966   e.preventDefault();
  11967   if (onboardingNeedsAcceptanceNow()) {
  11968     toast("Onboarding", "Accept server rules first.");
  11969     return;
  11970   }
  11971   const title = String(postTitleInput?.value || "")
  11972     .replace(/\s+/g, " ")
  11973     .trim()
  11974     .slice(0, 96);
  11975   if (!title) {
  11976     toast("Post title", "Please add a short title.");
  11977     postTitleInput?.focus();
  11978     return;
  11979   }
  11980   const html = editor.innerHTML.trim();
  11981   const text = editor.innerText.trim();
  11982   const hasImg = Boolean(editor.querySelector("img"));
  11983   const hasAudio = Boolean(editor.querySelector("audio"));
  11984   if (!text && !hasImg && !hasAudio) {
  11985     toast("Post body", "Please add body text, image, or audio.");
  11986     editor.focus();
  11987     return;
  11988   }
  11989 
  11990   const keywords = parseKeywords(keywordsEl.value);
  11991   const collectionId = String(postCollectionEl?.value || "").trim();
  11992   if (!collectionId) {
  11993     toast("Collection", "Please choose a collection.");
  11994     return;
  11995   }
  11996   const ttlMinutes = Number(ttlMinutesEl.value || 60);
  11997   const canMakePermanent =
  11998     isStaffRole(loggedInRole) || Boolean(normalizeInstanceBranding(instanceBranding).allowMemberPermanentPosts);
  11999   const minMinutes = canMakePermanent ? 0 : 1;
  12000   const ttl = Math.max(minMinutes, Math.min(2880, Math.floor(ttlMinutes))) * 60_000;
  12001 
  12002   const isProtected = Boolean(isProtectedEl?.checked);
  12003   const password = typeof postPasswordEl?.value === "string" ? postPasswordEl.value : "";
  12004   if (isProtected && password.trim().length < 4) {
  12005     toast("Protected post", "Password must be at least 4 characters.");
  12006     return;
  12007   }
  12008   const mode = normalizePostMode(postModeEl?.value || "text");
  12009   const streamKind = normalizeStreamKind(streamKindEl?.value || "webcam");
  12010   ws.send(
  12011     JSON.stringify({
  12012       type: "newPost",
  12013       title,
  12014       collectionId,
  12015       contentHtml: html,
  12016       content: text,
  12017       keywords,
  12018       ttl,
  12019       protected: isProtected,
  12020       password,
  12021       mode,
  12022       streamKind,
  12023     })
  12024   );
  12025   if (postTitleInput) postTitleInput.value = "";
  12026   editor.innerHTML = "";
  12027   if (postPasswordEl) postPasswordEl.value = "";
  12028   if (isProtectedEl) isProtectedEl.checked = false;
  12029   if (postModeEl) postModeEl.value = "text";
  12030   if (streamKindEl) streamKindEl.value = "webcam";
  12031   syncComposerModeUi();
  12032   if (isMobileSwipeMode()) setComposerOpen(false);
  12033 });
  12034 
  12035 toggleComposerBtn?.addEventListener("click", () => {
  12036   if (isMobileScreenMode()) {
  12037     setComposerOpen(true);
  12038     const layout = loadMobileLayout();
  12039     layout.active = "composer";
  12040     saveMobileLayout(layout);
  12041     setMobileScreen("composer");
  12042     renderMobileNav();
  12043     if (composerOpen) (postTitleInput || editor)?.focus();
  12044     return;
  12045   }
  12046   setComposerOpen(!composerOpen);
  12047   if (composerOpen) (postTitleInput || editor)?.focus();
  12048 });
  12049 toggleComposerInlineBtn?.addEventListener("click", () => setComposerOpen(false));
  12050 
  12051 function submitChat() {
  12052   if (onboardingNeedsAcceptanceNow()) {
  12053     toast("Onboarding", "Accept server rules first.");
  12054     return;
  12055   }
  12056   const html = chatEditor.innerHTML.trim();
  12057   const text = chatEditor.innerText.trim();
  12058   const hasImg = Boolean(chatEditor.querySelector("img"));
  12059   const hasAudio = Boolean(chatEditor.querySelector("audio"));
  12060   if (activeDmThreadId) {
  12061     if (!text && !hasImg && !hasAudio) return;
  12062     if (!loggedInUser) {
  12063       toast("Sign in required", "Sign in to send DMs.");
  12064       return;
  12065     }
  12066     const thread = dmThreadsById.get(activeDmThreadId) || null;
  12067     if (!thread) {
  12068       toast("DMs", "This DM thread is unavailable.");
  12069       return;
  12070     }
  12071     if (String(thread.status || "") !== "active") {
  12072       toast("DMs", "You can only send messages after the DM is accepted.");
  12073       return;
  12074     }
  12075     ws.send(JSON.stringify({ type: "dmSend", threadId: activeDmThreadId, text, html }));
  12076     chatEditor.innerHTML = "";
  12077     return;
  12078   }
  12079 
  12080   if (isMapChatActive()) {
  12081     if (!text && !hasImg && !hasAudio) return;
  12082     if (hasImg || hasAudio) {
  12083       toast("Maps chat", "Maps chat is text-only for now.");
  12084       return;
  12085     }
  12086     if (!loggedInUser) {
  12087       toast("Sign in required", "Sign in to chat in maps.");
  12088       return;
  12089     }
  12090     try {
  12091       ws.send(JSON.stringify({ type: "plugin:maps:chatSend", mapId: activeMapsRoomId, scope: normalizeMapChatScope(activeMapsChatScope), text }));
  12092       // Optimistic add so it feels instant (server will also echo back).
  12093       pushMapChatMessage(activeMapsRoomId, activeMapsChatScope, {
  12094         id: `local_${Date.now()}_${Math.random().toString(16).slice(2)}`,
  12095         fromUser: loggedInUser,
  12096         text,
  12097         createdAt: Date.now(),
  12098       });
  12099     } catch {
  12100       // ignore
  12101     }
  12102     chatEditor.innerHTML = "";
  12103     setReplyToMessage(null);
  12104     renderChatPanel(true);
  12105     return;
  12106   }
  12107 
  12108   if (!activeChatPostId || (!text && !hasImg && !hasAudio)) return;
  12109   const post = posts.get(activeChatPostId);
  12110   if (post && String(post.mode || post.chatMode || "").toLowerCase() === "walkie") {
  12111     toast("Walkie Talkie", "This hive is walkie-only. Hold ~ to talk.");
  12112     return;
  12113   }
  12114   if (post?.readOnly && !isStaffRole(loggedInRole)) {
  12115     toast("Read-only", "This hive is read-only.");
  12116     return;
  12117   }
  12118   if (post?.deleted) {
  12119     toast("Unavailable", "This post was deleted.");
  12120     return;
  12121   }
  12122   const replyToId = replyToMessage?.id ? String(replyToMessage.id) : "";
  12123   const wantsMod = Boolean(canModerate && chatModToggleEl instanceof HTMLInputElement && chatModToggleEl.checked);
  12124   ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false }));
  12125   ws.send(JSON.stringify({ type: "chatMessage", postId: activeChatPostId, text, html, replyToId, asMod: wantsMod }));
  12126   chatEditor.innerHTML = "";
  12127   setReplyToMessage(null);
  12128 }
  12129 
  12130 filterKeywordsEl.addEventListener("input", () => renderFeed());
  12131 filterAuthorEl?.addEventListener("input", () => renderFeed());
  12132 sortByEl?.addEventListener("change", () => {
  12133   updateMobileSortCycleLabel();
  12134   renderFeed();
  12135 });
  12136 hiveTabsEl?.addEventListener("click", (e) => {
  12137   const btn = e.target.closest("[data-hiveview]");
  12138   if (!btn) return;
  12139   const next = btn.getAttribute("data-hiveview") || "all";
  12140   if (!loggedInUser && next !== "all") {
  12141     toast("Sign in required", "Sign in to use Starred and Hidden views.");
  12142     return;
  12143   }
  12144   activeHiveView = next;
  12145   renderFeed();
  12146 });
  12147 clearFilterBtn.addEventListener("click", () => {
  12148   filterKeywordsEl.value = "";
  12149   if (filterAuthorEl) filterAuthorEl.value = "";
  12150   if (sortByEl) sortByEl.value = "activity";
  12151   updateMobileSortCycleLabel();
  12152   activeHiveView = "all";
  12153   renderFeed();
  12154 });
  12155 
  12156 mobileHiveSearchBtn?.addEventListener("click", () => {
  12157   const initial = String(filterAuthorEl?.value || "").trim()
  12158     ? `@${String(filterAuthorEl?.value || "").trim()}`
  12159     : String(filterKeywordsEl?.value || "").trim();
  12160   const raw = prompt("Search hives by @author or keywords:", initial);
  12161   if (raw === null) return;
  12162   const q = String(raw || "").trim();
  12163   if (!q) {
  12164     if (filterAuthorEl) filterAuthorEl.value = "";
  12165     if (filterKeywordsEl) filterKeywordsEl.value = "";
  12166     renderFeed();
  12167     return;
  12168   }
  12169   const parts = q.split(/\s+/).filter(Boolean);
  12170   const authorPart = parts.find((part) => part.startsWith("@")) || (q.startsWith("@") ? q : "");
  12171   const author = authorPart.replace(/^@+/, "").trim();
  12172   const keywordParts = authorPart ? parts.filter((part) => part !== authorPart) : parts;
  12173   if (filterAuthorEl) filterAuthorEl.value = author || "";
  12174   if (filterKeywordsEl) filterKeywordsEl.value = keywordParts.join(", ");
  12175   renderFeed();
  12176 });
  12177 
  12178 mobileSortCycleBtn?.addEventListener("click", () => {
  12179   if (!sortByEl) return;
  12180   const order = ["activity", "popular", "expiring"];
  12181   const current = String(sortByEl.value || "activity");
  12182   const at = Math.max(0, order.indexOf(current));
  12183   const next = order[(at + 1) % order.length];
  12184   sortByEl.value = next;
  12185   updateMobileSortCycleLabel();
  12186   renderFeed();
  12187 });
  12188 
  12189 feedEl.addEventListener("click", (e) => {
  12190   const profileLink = e.target.closest("[data-viewprofile]");
  12191   if (profileLink) {
  12192     const username = profileLink.getAttribute("data-viewprofile") || "";
  12193     if (username) openUserProfile(username);
  12194     return;
  12195   }
  12196 
  12197   const menuBtn = e.target.closest("button[data-postmenu]");
  12198   if (menuBtn) {
  12199     const postId = menuBtn.getAttribute("data-postmenu") || "";
  12200     if (!postId) return;
  12201     const wasOpen = openPostMenuId === postId;
  12202 
  12203     for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden");
  12204     for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false");
  12205 
  12206     if (!wasOpen) {
  12207       const panel = feedEl.querySelector(`[data-postmenu-panel="${cssEscape(postId)}"]`);
  12208       if (panel) panel.classList.remove("hidden");
  12209       menuBtn.setAttribute("aria-expanded", "true");
  12210       openPostMenuId = postId;
  12211     } else {
  12212       openPostMenuId = "";
  12213     }
  12214     return;
  12215   }
  12216 
  12217   const chatBtn = e.target.closest("button[data-chat]");
  12218   if (chatBtn) {
  12219     if (openPostMenuId) {
  12220       for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden");
  12221       for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false");
  12222       openPostMenuId = "";
  12223     }
  12224     const postId = chatBtn.getAttribute("data-chat");
  12225     const post = postId ? posts.get(postId) : null;
  12226     if (post?.locked) unlockPostFlow(postId, true);
  12227     else openChat(postId, { sourceEl: chatBtn });
  12228     return;
  12229   }
  12230 
  12231   const boostBtn = e.target.closest("button[data-boostbtn]");
  12232   if (boostBtn) {
  12233     const postId = boostBtn.getAttribute("data-boostbtn");
  12234     const card = boostBtn.closest(".post");
  12235     const sel = card ? card.querySelector("select[data-boostsel]") : null;
  12236     const boostMs = sel ? Number(sel.value) : 3_600_000;
  12237     ws.send(JSON.stringify({ type: "boostPost", postId, boostMs }));
  12238     return;
  12239   }
  12240 
  12241   const reportPostBtn = e.target.closest("button[data-reportpost]");
  12242   if (reportPostBtn) {
  12243     if (openPostMenuId) {
  12244       for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden");
  12245       for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false");
  12246       openPostMenuId = "";
  12247     }
  12248     const postId = reportPostBtn.getAttribute("data-reportpost") || "";
  12249     if (!postId) return;
  12250     const post = posts.get(postId);
  12251     if (post?.deleted) {
  12252       toast("Unavailable", "This post was deleted.");
  12253       return;
  12254     }
  12255     const reason = promptReason("post report");
  12256     if (!reason) return;
  12257     ws.send(JSON.stringify({ type: "reportCreate", targetType: "post", targetId: postId, postId, reason }));
  12258     return;
  12259   }
  12260 
  12261   const hideBtn = e.target.closest("button[data-hidepost]");
  12262   if (hideBtn) {
  12263     if (openPostMenuId) {
  12264       for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden");
  12265       for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false");
  12266       openPostMenuId = "";
  12267     }
  12268     const postId = hideBtn.getAttribute("data-hidepost") || "";
  12269     if (!postId) return;
  12270     const hidden = prefSet("hiddenPostIds").has(postId);
  12271     ws.send(JSON.stringify({ type: hidden ? "unhidePost" : "hidePost", postId }));
  12272     return;
  12273   }
  12274 
  12275   const react = e.target.closest("[data-react]");
  12276   if (react && react.getAttribute("data-kind") === "post") {
  12277     if (openPostMenuId) {
  12278       for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden");
  12279       for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false");
  12280       openPostMenuId = "";
  12281     }
  12282     const postId = react.getAttribute("data-postid") || "";
  12283     const emoji = react.getAttribute("data-emoji") || "";
  12284     if (!postId || !emoji) return;
  12285     const post = posts.get(postId);
  12286     if (post?.deleted) {
  12287       toast("Unavailable", "This post was deleted.");
  12288       return;
  12289     }
  12290     markReactPulse("post", postId, emoji);
  12291     toggleMyReact("post", postId, emoji);
  12292     ws.send(JSON.stringify({ type: "react", targetType: "post", postId, emoji }));
  12293     renderFeed();
  12294     return;
  12295   }
  12296 
  12297   const editPostBtn = e.target.closest("button[data-editpost]");
  12298   if (editPostBtn) {
  12299     if (openPostMenuId) {
  12300       for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden");
  12301       for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false");
  12302       openPostMenuId = "";
  12303     }
  12304     const postId = editPostBtn.getAttribute("data-editpost") || "";
  12305   const post = postId ? posts.get(postId) : null;
  12306   if (!post || post.deleted || post.locked) return;
  12307   openEditModalForPost(post);
  12308   return;
  12309 }
  12310 
  12311   const deletePostBtn = e.target.closest("button[data-deletepost]");
  12312   if (deletePostBtn) {
  12313     if (openPostMenuId) {
  12314       for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden");
  12315       for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false");
  12316       openPostMenuId = "";
  12317     }
  12318     const postId = deletePostBtn.getAttribute("data-deletepost") || "";
  12319     if (!postId) return;
  12320     const ok = confirm("Delete this post? It will show as deleted.");
  12321     if (!ok) return;
  12322     ws.send(JSON.stringify({ type: "deletePostSelf", postId }));
  12323   }
  12324 });
  12325 
  12326 onboardingGateHintEl?.addEventListener("click", (e) => {
  12327   const btn = e.target?.closest?.("button[data-onboarding-open]");
  12328   if (!btn) return;
  12329   openOnboardingView();
  12330 });
  12331 
  12332 window.addEventListener("keydown", (e) => {
  12333   if (e.key !== "Escape") return;
  12334   if (!openPostMenuId) return;
  12335   for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden");
  12336   for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false");
  12337   openPostMenuId = "";
  12338 });
  12339 
  12340 window.addEventListener("keydown", (e) => {
  12341   if (e.defaultPrevented) return;
  12342   if (e.repeat) return;
  12343   if (e.key === "?" && !isTextEntryFocused()) {
  12344     e.preventDefault();
  12345     setShortcutHelpOpen(true);
  12346     return;
  12347   }
  12348   if (e.altKey || e.ctrlKey || e.metaKey) return;
  12349   if (isTextEntryFocused()) return;
  12350   if (e.key === "ArrowUp") {
  12351     if (restoreHoveredHotbarPanelToWorkspace() || cycleHoveredWorkspacePanelSize()) {
  12352       e.preventDefault();
  12353       return;
  12354     }
  12355   }
  12356   if (e.key === "ArrowLeft") {
  12357     if (moveHoveredWorkspacePanelHorizontal(-1)) {
  12358       e.preventDefault();
  12359       return;
  12360     }
  12361   }
  12362   if (e.key === "ArrowRight") {
  12363     if (moveHoveredWorkspacePanelHorizontal(1)) {
  12364       e.preventDefault();
  12365       return;
  12366     }
  12367   }
  12368   if (e.key === "ArrowDown") {
  12369     if (dockHoveredWorkspacePanelToHotbar()) {
  12370       e.preventDefault();
  12371       return;
  12372     }
  12373   }
  12374   const ctx = activePanelContextForHotkeys();
  12375   const plus = e.key === "=" || e.code === "NumpadAdd";
  12376   const minus = e.key === "-" || e.code === "NumpadSubtract";
  12377   if (ctx === "hives" && (plus || minus)) {
  12378     e.preventDefault();
  12379     cycleHiveViewBy(plus ? 1 : -1);
  12380     return;
  12381   }
  12382   if (ctx === "chat" && (plus || minus)) {
  12383     e.preventDefault();
  12384     cycleChatContextBy(plus ? 1 : -1);
  12385     return;
  12386   }
  12387   if (e.key === "[") {
  12388     e.preventDefault();
  12389     cycleLayoutPresetBy(-1);
  12390     return;
  12391   }
  12392   if (e.key === "]") {
  12393     e.preventDefault();
  12394     cycleLayoutPresetBy(1);
  12395     return;
  12396   }
  12397   if ((e.key === "r" || e.key === "R") && rackLayoutEnabled) {
  12398     if (isMapSurfaceActiveForHotkey()) return;
  12399     e.preventDefault();
  12400     const collapsed = Boolean(appRoot?.classList.contains("rightCollapsed"));
  12401     setRightCollapsed(!collapsed);
  12402   }
  12403 });
  12404 
  12405 window.addEventListener(
  12406   "pointerdown",
  12407   (e) => {
  12408     updateHotkeyPanelContextFromTarget(e.target);
  12409   },
  12410   true
  12411 );
  12412 
  12413 window.addEventListener(
  12414   "pointermove",
  12415   (e) => {
  12416     const target = e.target instanceof HTMLElement ? e.target : null;
  12417     const panel = target?.closest?.(".rackPanel[data-panel-id]");
  12418     if (panel instanceof HTMLElement) {
  12419       const panelId = String(panel.dataset.panelId || "").trim();
  12420       const rackId = String(panel.parentElement?.id || "").trim();
  12421       hoveredWorkspacePanelId = panelId && isWorkspaceRackId(rackId) ? panelId : "";
  12422     } else {
  12423       hoveredWorkspacePanelId = "";
  12424     }
  12425     const orb = target?.closest?.("[data-undock]");
  12426     hoveredHotbarPanelId = orb instanceof HTMLElement ? String(orb.getAttribute("data-undock") || "").trim() : "";
  12427   },
  12428   true
  12429 );
  12430 window.addEventListener("blur", () => {
  12431   hoveredWorkspacePanelId = "";
  12432   hoveredHotbarPanelId = "";
  12433 });
  12434 
  12435 window.addEventListener("click", (e) => {
  12436   if (!openPostMenuId) return;
  12437   const esc = cssEscape(openPostMenuId);
  12438   const inside = e.target?.closest?.(`[data-postmenu-panel="${esc}"], button[data-postmenu="${esc}"]`);
  12439   if (inside) return;
  12440   for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden");
  12441   for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false");
  12442   openPostMenuId = "";
  12443 });
  12444 
  12445 chatMessagesEl.addEventListener("click", (e) => {
  12446   const emptyActionBtn = e.target.closest("button[data-chatemptyopen]");
  12447   if (emptyActionBtn) {
  12448     const target = String(emptyActionBtn.getAttribute("data-chatemptyopen") || "").trim().toLowerCase();
  12449     if (target === "hives") {
  12450       if (isMobileSwipeMode()) {
  12451         setMobilePanel("hives");
  12452       } else {
  12453         const hivesHeader = hivesPanelEl?.querySelector?.(".panelHeader");
  12454         hivesHeader?.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
  12455       }
  12456       return;
  12457     }
  12458     if (target === "people") {
  12459       const peopleEl = getPanelElement("people") || peopleDrawerEl;
  12460       if (peopleEl && typeof undockPanel === "function" && isDocked("people")) undockPanel("people");
  12461       peopleEl?.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
  12462       return;
  12463     }
  12464   }
  12465 
  12466   const mobileChatOpenBtn = e.target.closest("button[data-mobilechatopen]");
  12467   if (mobileChatOpenBtn) {
  12468     const postId = mobileChatOpenBtn.getAttribute("data-mobilechatopen") || "";
  12469     if (postId) openChat(postId);
  12470     return;
  12471   }
  12472 
  12473   const dmAcceptBtn = e.target.closest("button[data-dmaccept]");
  12474   if (dmAcceptBtn) {
  12475     const threadId = dmAcceptBtn.getAttribute("data-dmaccept") || "";
  12476     if (threadId) {
  12477       pendingOpenDmThreadId = threadId;
  12478       ws.send(JSON.stringify({ type: "dmRequestRespond", threadId, accept: true }));
  12479     }
  12480     return;
  12481   }
  12482   const dmDeclineBtn = e.target.closest("button[data-dmdecline]");
  12483   if (dmDeclineBtn) {
  12484     const threadId = dmDeclineBtn.getAttribute("data-dmdecline") || "";
  12485     if (threadId) ws.send(JSON.stringify({ type: "dmRequestRespond", threadId, accept: false }));
  12486     return;
  12487   }
  12488   const dmOpenBtn = e.target.closest("button[data-dmopen]");
  12489   if (dmOpenBtn) {
  12490     const threadId = dmOpenBtn.getAttribute("data-dmopen") || "";
  12491     if (threadId) openDmThread(threadId);
  12492     return;
  12493   }
  12494   const dmRequestBtn = e.target.closest("button[data-dmrequest]");
  12495   if (dmRequestBtn && activeDmThreadId) {
  12496     const to = String(dmRequestBtn.getAttribute("data-dmrequest") || "")
  12497       .trim()
  12498       .replace(/^@+/, "")
  12499       .toLowerCase();
  12500     if (to) ws.send(JSON.stringify({ type: "dmRequestCreate", to }));
  12501     return;
  12502   }
  12503 
  12504   const profileLink = e.target.closest("[data-viewprofile]");
  12505   if (profileLink) {
  12506     const username = profileLink.getAttribute("data-viewprofile") || "";
  12507     if (username) openUserProfile(username);
  12508     return;
  12509   }
  12510 
  12511   const mention = e.target.closest(".mentionToken");
  12512   if (mention) {
  12513     const raw = String(mention.textContent || "").trim();
  12514     const username = raw.replace(/^@+/, "").toLowerCase();
  12515     if (username) openUserProfile(username);
  12516     return;
  12517   }
  12518 
  12519   const editBtn = e.target.closest("button[data-editmsg]");
  12520   if (editBtn) {
  12521     const messageId = editBtn.getAttribute("data-editmsg") || "";
  12522     const postId = editBtn.getAttribute("data-postid") || activeChatPostId || "";
  12523     if (!messageId || !postId) return;
  12524     const message = findChatMessage(postId, messageId);
  12525     if (!message || message.deleted) return;
  12526     openEditModalForChatMessage(message, postId);
  12527     return;
  12528   }
  12529 
  12530   const deleteBtn = e.target.closest("button[data-deletemsg]");
  12531   if (deleteBtn) {
  12532     const messageId = deleteBtn.getAttribute("data-deletemsg") || "";
  12533     if (!messageId) return;
  12534     const ok = confirm("Delete this message?");
  12535     if (!ok) return;
  12536     ws.send(JSON.stringify({ type: "deleteChatMessageSelf", messageId }));
  12537     return;
  12538   }
  12539 
  12540   const replyBtn = e.target.closest("button[data-replymsg]");
  12541   if (replyBtn) {
  12542     const messageId = replyBtn.getAttribute("data-replymsg") || "";
  12543     const postId = replyBtn.getAttribute("data-postid") || activeChatPostId || "";
  12544     if (!messageId || !postId) return;
  12545     const message = findChatMessage(postId, messageId);
  12546     if (!message) return;
  12547     setReplyToMessage(message);
  12548     chatEditor?.focus();
  12549     return;
  12550   }
  12551 
  12552   const reportChatBtn = e.target.closest("button[data-reportchat]");
  12553   if (reportChatBtn) {
  12554     const messageId = reportChatBtn.getAttribute("data-reportchat") || "";
  12555     const postId = reportChatBtn.getAttribute("data-postid") || activeChatPostId || "";
  12556     if (!messageId || !postId) return;
  12557     const message = findChatMessage(postId, messageId);
  12558     if (!message || message.deleted) {
  12559       toast("Unavailable", "That message was deleted.");
  12560       return;
  12561     }
  12562     const reason = promptReason("message report");
  12563     if (!reason) return;
  12564     ws.send(JSON.stringify({ type: "reportCreate", targetType: "chat", targetId: messageId, postId, reason }));
  12565     return;
  12566   }
  12567 
  12568   const react = e.target.closest("[data-react]");
  12569   if (!react || react.getAttribute("data-kind") !== "chat") return;
  12570   const postId = react.getAttribute("data-postid") || "";
  12571   const messageId = react.getAttribute("data-msgid") || "";
  12572   const emoji = react.getAttribute("data-emoji") || "";
  12573   if (!postId || !messageId || !emoji) return;
  12574   markReactPulse("chat", messageId, emoji);
  12575   toggleMyReact("chat", messageId, emoji);
  12576   ws.send(JSON.stringify({ type: "react", targetType: "chat", postId, messageId, emoji }));
  12577   renderChatPanel();
  12578 });
  12579 
  12580 chatReplyCancelBtn?.addEventListener("click", () => setReplyToMessage(null));
  12581 
  12582 chatBackToListBtn?.addEventListener("click", () => {
  12583   if (activeChatPostId && ws?.readyState === WebSocket.OPEN) {
  12584     ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false }));
  12585   }
  12586   activeChatPostId = null;
  12587   activeDmThreadId = null;
  12588   activeMapsRoomId = "";
  12589   activeMapsRoomTitle = "";
  12590   setReplyToMessage(null);
  12591   renderChatPanel(true);
  12592 });
  12593 
  12594 chatContextSelectEl?.addEventListener("change", () => {
  12595   if (syncingChatContextSelect) return;
  12596   const raw = String(chatContextSelectEl.value || "").trim();
  12597   if (!raw) return;
  12598   openChatContextValue(raw, { preserveFocus: false });
  12599 });
  12600 
  12601 modPanelEl?.addEventListener("click", (e) => {
  12602   const tabBtn = e.target.closest("[data-modtab]");
  12603   if (tabBtn) {
  12604     modTab = tabBtn.getAttribute("data-modtab") || "reports";
  12605     if (modTab === "server") requestServerInfo();
  12606     if (modTab === "onboarding") syncOnboardingAdminDraft(true);
  12607     renderModPanel();
  12608     return;
  12609   }
  12610 });
  12611 
  12612 modRefreshBtn?.addEventListener("click", () => {
  12613   if (!canModerate) return;
  12614   if (modTab === "server") requestServerInfo();
  12615   else if (modTab === "onboarding") {
  12616     if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
  12617     syncOnboardingAdminDraft(true);
  12618     renderModPanel();
  12619   }
  12620   else requestModData();
  12621 });
  12622 modReportStatusEl?.addEventListener("change", () => {
  12623   if (!canModerate) return;
  12624   ws.send(JSON.stringify({ type: "modListReports", status: modReportStatusEl.value || "open", limit: 200 }));
  12625 });
  12626 
  12627 modModal?.addEventListener("click", (e) => {
  12628   if (e.target?.getAttribute?.("data-modmodalclose")) setModModalOpen(false);
  12629 });
  12630 modModalClose?.addEventListener("click", () => setModModalOpen(false));
  12631 modModalCancel?.addEventListener("click", () => setModModalOpen(false));
  12632 
  12633 modModalBody?.addEventListener("change", (e) => {
  12634   if (!modModalContext) return;
  12635   if (modModalContext.kind === "collectionGate") {
  12636     if (e.target?.name === "gateVisibility") updateGateModalVisibility();
  12637     return;
  12638   }
  12639   if (modModalContext.kind !== "userRoles") return;
  12640   const checkbox = e.target?.closest?.("input[type='checkbox'][data-userrolekey]");
  12641   if (!checkbox) return;
  12642   const key = checkbox.getAttribute("data-userrolekey") || "";
  12643   const enabled = Boolean(checkbox.checked);
  12644   if (!key) return;
  12645   ws.send(JSON.stringify({ type: "userCustomRoleSet", targetId: modModalContext.username, key, enabled }));
  12646 });
  12647 
  12648 modModalPrimary?.addEventListener("click", () => {
  12649   if (!modModalContext) return;
  12650   if (modModalStatus) modModalStatus.textContent = "";
  12651   if (modModalContext.kind === "collectionCreate") {
  12652     const name = String(document.getElementById("modModalCollectionName")?.value || "").trim();
  12653     if (!name) {
  12654       if (modModalStatus) modModalStatus.textContent = "Name is required.";
  12655       return;
  12656     }
  12657     ws.send(JSON.stringify({ type: "collectionCreate", name }));
  12658     setModModalOpen(false);
  12659     return;
  12660   }
  12661   if (modModalContext.kind === "collectionGate") {
  12662     const collectionId = String(modModalContext.collectionId || "");
  12663     const visibility = String(modModalBody?.querySelector("input[name='gateVisibility']:checked")?.value || "public");
  12664     if (visibility !== "gated") {
  12665       ws.send(JSON.stringify({ type: "collectionSetGate", collectionId, visibility: "public", allowedRoles: [] }));
  12666       setModModalOpen(false);
  12667       return;
  12668     }
  12669     const allowedRoles = Array.from(modModalBody?.querySelectorAll("input[data-gatetoken]:checked") || []).map((el) =>
  12670       String(el.getAttribute("data-gatetoken") || "")
  12671     );
  12672     if (!allowedRoles.length) {
  12673       if (modModalStatus) modModalStatus.textContent = "Pick at least one allowed role for gated collections.";
  12674       return;
  12675     }
  12676     ws.send(JSON.stringify({ type: "collectionSetGate", collectionId, visibility: "gated", allowedRoles }));
  12677     setModModalOpen(false);
  12678   }
  12679 });
  12680 
  12681 modBodyEl?.addEventListener("click", (e) => {
  12682   const modLogViewBtn = e.target.closest("button[data-modlogview]");
  12683   if (modLogViewBtn) {
  12684     const next = String(modLogViewBtn.getAttribute("data-modlogview") || "dev");
  12685     modLogView = next === "moderation" ? "moderation" : "dev";
  12686     localStorage.setItem("bzl_modLogView", modLogView);
  12687     if (modLogView === "dev" && ws.readyState === WebSocket.OPEN) {
  12688       ws.send(JSON.stringify({ type: "devLogList", limit: 300 }));
  12689     }
  12690     renderModPanel();
  12691     return;
  12692   }
  12693 
  12694   const devLogRefreshBtn = e.target.closest("button[data-devlogrefresh]");
  12695   if (devLogRefreshBtn) {
  12696     if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "devLogList", limit: 300 }));
  12697     return;
  12698   }
  12699 
  12700   const devLogCopyBtn = e.target.closest("button[data-devlogcopy]");
  12701   if (devLogCopyBtn) {
  12702     const text = String(document.getElementById("devLogPre")?.textContent || "").trim();
  12703     if (!text) {
  12704       toast("Dev log", "Nothing to copy.");
  12705       return;
  12706     }
  12707     navigator.clipboard
  12708       .writeText(text)
  12709       .then(() => toast("Dev log", "Copied."))
  12710       .catch(() => toast("Dev log", "Copy failed."));
  12711     return;
  12712   }
  12713 
  12714   const devLogClearBtn = e.target.closest("button[data-devlogclear]");
  12715   if (devLogClearBtn) {
  12716     if (!(canModerate && (isOwnerRole(loggedInRole) || isAdminRole(loggedInRole)))) return;
  12717     const ok = confirm("Clear the server dev log?");
  12718     if (!ok) return;
  12719     ws.send(JSON.stringify({ type: "devLogClear" }));
  12720     return;
  12721   }
  12722 
  12723   const devLogTestBtn = e.target.closest("button[data-devlogtest]");
  12724   if (devLogTestBtn) {
  12725     sendDevLog("info", "ui", "Dev log test", { at: Date.now() });
  12726     return;
  12727   }
  12728 
  12729   const devLogAutoScrollToggle = e.target.closest("input[data-devlogautoscroll]");
  12730   if (devLogAutoScrollToggle) {
  12731     devLogAutoScroll = Boolean(devLogAutoScrollToggle.checked);
  12732     localStorage.setItem("bzl_devLogAutoScroll", devLogAutoScroll ? "1" : "0");
  12733     renderModPanel();
  12734     return;
  12735   }
  12736 
  12737   const serverRefreshBtn = e.target.closest("button[data-server-refresh]");
  12738   if (serverRefreshBtn) {
  12739     requestServerInfo();
  12740     if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
  12741     return;
  12742   }
  12743 
  12744   const onboardingRefreshBtn = e.target.closest("button[data-onboarding-refresh]");
  12745   if (onboardingRefreshBtn) {
  12746     if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
  12747     syncOnboardingAdminDraft(true);
  12748     renderModPanel();
  12749     return;
  12750   }
  12751 
  12752   const onbAdminTabBtn = e.target.closest("button[data-onb-admin-tab]");
  12753   if (onbAdminTabBtn) {
  12754     const tab = String(onbAdminTabBtn.getAttribute("data-onb-admin-tab") || "about").trim();
  12755     if (!["about", "rules", "roles"].includes(tab)) return;
  12756     onboardingAdminTab = tab;
  12757     renderModPanel();
  12758     return;
  12759   }
  12760 
  12761   const onbRuleAddBtn = e.target.closest("button[data-onb-ruleadd]");
  12762   if (onbRuleAddBtn) {
  12763     if (!(canModerate && isStaffRole(loggedInRole))) return;
  12764     normalizeOnboardingDraftRules();
  12765     const nextIndex = onboardingAdminDraft.rules.length + 1;
  12766     const id = `r${Date.now()}_${nextIndex}`;
  12767     onboardingAdminDraft.rules.push({
  12768       id,
  12769       order: nextIndex,
  12770       name: `Rule ${nextIndex}`,
  12771       shortDescription: "",
  12772       description: "",
  12773       severity: "info",
  12774     });
  12775     normalizeOnboardingDraftRules();
  12776     onboardingAdminExpandedRuleIds.add(id);
  12777     onboardingAdminTab = "rules";
  12778     renderModPanel();
  12779     return;
  12780   }
  12781 
  12782   const onbRuleToggleBtn = e.target.closest("button[data-onb-ruletoggle]");
  12783   if (onbRuleToggleBtn) {
  12784     const id = String(onbRuleToggleBtn.getAttribute("data-onb-ruletoggle") || "").trim();
  12785     if (!id) return;
  12786     if (onboardingAdminExpandedRuleIds.has(id)) onboardingAdminExpandedRuleIds.delete(id);
  12787     else onboardingAdminExpandedRuleIds.add(id);
  12788     renderModPanel();
  12789     return;
  12790   }
  12791 
  12792   const onbRuleDeleteBtn = e.target.closest("button[data-onb-ruledelete]");
  12793   if (onbRuleDeleteBtn) {
  12794     if (!(canModerate && isStaffRole(loggedInRole))) return;
  12795     const id = String(onbRuleDeleteBtn.getAttribute("data-onb-ruledelete") || "").trim();
  12796     onboardingAdminDraft.rules = onboardingAdminDraft.rules.filter((r) => r.id !== id);
  12797     onboardingAdminExpandedRuleIds.delete(id);
  12798     normalizeOnboardingDraftRules();
  12799     renderModPanel();
  12800     return;
  12801   }
  12802 
  12803   const onbRuleUpBtn = e.target.closest("button[data-onb-ruleup]");
  12804   if (onbRuleUpBtn) {
  12805     if (!(canModerate && isStaffRole(loggedInRole))) return;
  12806     const id = String(onbRuleUpBtn.getAttribute("data-onb-ruleup") || "").trim();
  12807     const idx = onboardingAdminDraft.rules.findIndex((r) => r.id === id);
  12808     if (idx <= 0) return;
  12809     const tmp = onboardingAdminDraft.rules[idx - 1];
  12810     onboardingAdminDraft.rules[idx - 1] = onboardingAdminDraft.rules[idx];
  12811     onboardingAdminDraft.rules[idx] = tmp;
  12812     normalizeOnboardingDraftRules();
  12813     renderModPanel();
  12814     return;
  12815   }
  12816 
  12817   const onbRuleDownBtn = e.target.closest("button[data-onb-ruledown]");
  12818   if (onbRuleDownBtn) {
  12819     if (!(canModerate && isStaffRole(loggedInRole))) return;
  12820     const id = String(onbRuleDownBtn.getAttribute("data-onb-ruledown") || "").trim();
  12821     const idx = onboardingAdminDraft.rules.findIndex((r) => r.id === id);
  12822     if (idx < 0 || idx >= onboardingAdminDraft.rules.length - 1) return;
  12823     const tmp = onboardingAdminDraft.rules[idx + 1];
  12824     onboardingAdminDraft.rules[idx + 1] = onboardingAdminDraft.rules[idx];
  12825     onboardingAdminDraft.rules[idx] = tmp;
  12826     normalizeOnboardingDraftRules();
  12827     renderModPanel();
  12828     return;
  12829   }
  12830 
  12831   const onboardingSaveBtn = e.target.closest("button[data-onboarding-save],button[data-onboarding-publish]");
  12832   if (onboardingSaveBtn) {
  12833     if (!(canModerate && isStaffRole(loggedInRole))) return;
  12834     const publish = onboardingSaveBtn.hasAttribute("data-onboarding-publish");
  12835     normalizeOnboardingDraftRules();
  12836     ws.send(
  12837       JSON.stringify({
  12838         type: "instanceSetOnboarding",
  12839         publish,
  12840         enabled: Boolean(onboardingAdminDraft.enabled),
  12841         about: { content: String(onboardingAdminDraft.aboutContent || "") },
  12842         rules: {
  12843           requireAcceptance: Boolean(onboardingAdminDraft.requireAcceptance),
  12844           blockReadUntilAccepted: Boolean(onboardingAdminDraft.blockReadUntilAccepted),
  12845           items: onboardingAdminDraft.rules,
  12846         },
  12847         roleSelect: {
  12848           enabled: Boolean(onboardingAdminDraft.roleSelectEnabled),
  12849           selfAssignableRoleIds: onboardingAdminDraft.selfAssignableRoleIds,
  12850         }
  12851       })
  12852     );
  12853     toast("Onboarding", publish ? "Publishing..." : "Saving...");
  12854     return;
  12855   }
  12856 
  12857   const instanceSaveBtn = e.target.closest("button[data-instance-save]");
  12858   if (instanceSaveBtn) {
  12859     if (!(canModerate && (isOwnerRole(loggedInRole) || isAdminRole(loggedInRole)))) return;
  12860     const title = String(modBodyEl.querySelector("input[data-instance-title]")?.value || "").replace(/\s+/g, " ").trim().slice(0, 32);
  12861     const subtitle = String(modBodyEl.querySelector("input[data-instance-subtitle]")?.value || "").replace(/\s+/g, " ").trim().slice(0, 80);
  12862     const allowMemberPermanentPosts = Boolean(modBodyEl.querySelector("input[data-instance-allowpermanent]")?.checked);
  12863     if (!title) {
  12864       toast("Instance", "Title is required.");
  12865       return;
  12866     }
  12867     const appearance = normalizeInstanceBranding(instanceBranding).appearance || {};
  12868     ws.send(
  12869       JSON.stringify({
  12870         type: "instanceSetBranding",
  12871         title,
  12872         subtitle,
  12873         allowMemberPermanentPosts,
  12874         appearance
  12875       })
  12876     );
  12877     toast("Instance", "Saving...");
  12878     return;
  12879   }
  12880 
  12881   const instanceSaveAppearanceBtn = e.target.closest("button[data-instance-saveappearance]");
  12882   if (instanceSaveAppearanceBtn) {
  12883     if (!(canModerate && isStaffRole(loggedInRole))) return;
  12884     const bg = String(modBodyEl.querySelector("input[data-instance-bg]")?.value || "").trim();
  12885     const panel = String(modBodyEl.querySelector("input[data-instance-panel]")?.value || "").trim();
  12886     const text = String(modBodyEl.querySelector("input[data-instance-text]")?.value || "").trim();
  12887     const good = String(modBodyEl.querySelector("input[data-instance-good]")?.value || "").trim();
  12888     const bad = String(modBodyEl.querySelector("input[data-instance-bad]")?.value || "").trim();
  12889     const accent = String(modBodyEl.querySelector("input[data-instance-accent]")?.value || "").trim();
  12890     const accent2 = String(modBodyEl.querySelector("input[data-instance-accent2]")?.value || "").trim();
  12891     const fontBody = String(modBodyEl.querySelector("select[data-instance-fontbody]")?.value || "").trim();
  12892     const fontMono = String(modBodyEl.querySelector("select[data-instance-fontmono]")?.value || "").trim();
  12893     const mutedPct = String(modBodyEl.querySelector("input[data-instance-mutedpct]")?.value || "").trim();
  12894     const linePct = String(modBodyEl.querySelector("input[data-instance-linepct]")?.value || "").trim();
  12895     const panel2Pct = String(modBodyEl.querySelector("input[data-instance-panel2pct]")?.value || "").trim();
  12896     ws.send(
  12897       JSON.stringify({
  12898         type: "instanceSetAppearance",
  12899         appearance: { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct }
  12900       })
  12901     );
  12902     toast("Theme", "Saving...");
  12903     return;
  12904   }
  12905 
  12906   const themeResetBtn = e.target.closest("button[data-theme-reset]");
  12907   if (themeResetBtn) {
  12908     if (!(canModerate && isStaffRole(loggedInRole))) return;
  12909     applyInstanceAppearance();
  12910     renderModPanel();
  12911     toast("Theme", "Reset to saved theme.");
  12912     return;
  12913   }
  12914 
  12915   const pluginReloadBtn = e.target.closest("button[data-pluginreload]");
  12916   if (pluginReloadBtn) {
  12917     if (!canManagePlugins()) return;
  12918     pluginAdminBusy = true;
  12919     pluginAdminStatus = "Reloading plugins...";
  12920     renderModPanel();
  12921     ws.send(JSON.stringify({ type: "pluginReload" }));
  12922     return;
  12923   }
  12924 
  12925   const pluginUninstallBtn = e.target.closest("button[data-pluginuninstall]");
  12926   if (pluginUninstallBtn) {
  12927     if (!canManagePlugins()) return;
  12928     const id = String(pluginUninstallBtn.getAttribute("data-pluginuninstall") || "").trim().toLowerCase();
  12929     if (!id) return;
  12930     const ok = confirm(`Uninstall "${id}"? This deletes the plugin files from this server.`);
  12931     if (!ok) return;
  12932     pluginAdminBusy = true;
  12933     pluginAdminStatus = `Uninstalling "${id}"...`;
  12934     renderModPanel();
  12935     ws.send(JSON.stringify({ type: "pluginUninstall", id }));
  12936     return;
  12937   }
  12938 
  12939   const pluginInstallBtn = e.target.closest("button[data-plugininstall]");
  12940   if (pluginInstallBtn) {
  12941     if (!canManagePlugins()) return;
  12942     const input = modBodyEl.querySelector("input[type='file'][data-pluginzip]") || null;
  12943     const file = input?.files && input.files[0] ? input.files[0] : null;
  12944     if (!file) {
  12945       pluginAdminStatus = "Choose a .zip file first.";
  12946       renderModPanel();
  12947       return;
  12948     }
  12949     const token = getSessionToken();
  12950     if (!token) {
  12951       pluginAdminStatus = "Session missing. Please sign out/in and try again.";
  12952       renderModPanel();
  12953       return;
  12954     }
  12955     const maxZipBytes = 50 * 1024 * 1024;
  12956     const fileSize = Number(file.size || 0);
  12957     if (fileSize > maxZipBytes) {
  12958       pluginAdminStatus = `Plugin zip is too large (${Math.ceil(fileSize / (1024 * 1024))}MB). Max is 50MB.`;
  12959       renderModPanel();
  12960       return;
  12961     }
  12962     pluginAdminBusy = true;
  12963     pluginAdminStatus = `Uploading plugin (${Math.ceil(fileSize / 1024)} KB)...`;
  12964     renderModPanel();
  12965     (async () => {
  12966       const controller = new AbortController();
  12967       const timeoutMs = 2 * 60 * 1000;
  12968       const timeout = setTimeout(() => {
  12969         try {
  12970           controller.abort(new Error("UPLOAD_TIMEOUT"));
  12971         } catch {
  12972           // ignore
  12973         }
  12974       }, timeoutMs);
  12975       try {
  12976         const res = await fetch("/api/plugin-install", {
  12977           method: "POST",
  12978           headers: { "Content-Type": "application/zip", Authorization: `Bearer ${token}` },
  12979           body: file,
  12980           credentials: "same-origin",
  12981           signal: controller.signal,
  12982         });
  12983         const json = await res.json().catch(() => null);
  12984         if (!res.ok || !json || !json.ok) {
  12985           pluginAdminBusy = false;
  12986           pluginAdminStatus = String(json?.error || `Install failed (${res.status}).`);
  12987           renderModPanel();
  12988           return;
  12989         }
  12990         if (input) input.value = "";
  12991         pluginAdminBusy = false;
  12992         pluginAdminStatus = `Installed "${json.plugin?.id || "plugin"}". Enable it below.`;
  12993         toast("Plugins", "Installed. Enable it to activate.");
  12994         renderModPanel();
  12995       } catch (err) {
  12996         pluginAdminBusy = false;
  12997         const message = String(err?.message || "");
  12998         if (message.includes("UPLOAD_TIMEOUT")) {
  12999           pluginAdminStatus = "Upload timed out after 2 minutes. Try a smaller zip or better network.";
  13000         } else if (String(err?.name || "") === "AbortError") {
  13001           pluginAdminStatus = "Upload was interrupted.";
  13002         } else {
  13003           pluginAdminStatus = `Install failed: ${message || "unknown error"}`;
  13004         }
  13005         renderModPanel();
  13006       } finally {
  13007         clearTimeout(timeout);
  13008       }
  13009     })();
  13010     return;
  13011   }
  13012 
  13013   const nukeBtn = e.target.closest("button[data-nuke]");
  13014   if (nukeBtn) {
  13015     if (!(canModerate && loggedInRole === "owner")) return;
  13016     const confirmEl = modBodyEl.querySelector("input[data-nukeconfirm]");
  13017     const okToggle = Boolean(confirmEl?.checked);
  13018     if (!okToggle) {
  13019       toast("NUKE", "Toggle ARE YOU SURE? first.");
  13020       return;
  13021     }
  13022     const ok = confirm("NUKE the board? This clears all hives, reports, moderation log, and hive media uploads.");
  13023     if (!ok) return;
  13024     ws.send(JSON.stringify({ type: "nukeBoard", confirm: true, confirmText: "ARE YOU SURE?" }));
  13025     toast("NUKE", "Working...");
  13026     return;
  13027   }
  13028 
  13029   const openChatBtn = e.target.closest("button[data-chat]");
  13030   if (openChatBtn) {
  13031     const postId = openChatBtn.getAttribute("data-chat") || "";
  13032     if (postId) openChat(postId);
  13033     return;
  13034   }
  13035 
  13036   const createCollectionBtn = e.target.closest("button[data-createcollection]");
  13037   if (createCollectionBtn) {
  13038     openCollectionCreateModal();
  13039     return;
  13040   }
  13041 
  13042   const archiveCollectionBtn = e.target.closest("button[data-archivecollection]");
  13043   if (archiveCollectionBtn) {
  13044     const collectionId = archiveCollectionBtn.getAttribute("data-archivecollection") || "";
  13045     if (!collectionId) return;
  13046     const ok = confirm("Archive this collection? Existing hives stay visible in All.");
  13047     if (!ok) return;
  13048     ws.send(JSON.stringify({ type: "collectionArchive", collectionId }));
  13049     return;
  13050   }
  13051 
  13052   const collectionGateBtn = e.target.closest("button[data-collectiongate]");
  13053   if (collectionGateBtn) {
  13054     const collectionId = collectionGateBtn.getAttribute("data-collectiongate") || "";
  13055     if (!collectionId) return;
  13056     openCollectionGateModal(collectionId);
  13057     return;
  13058   }
  13059 
  13060   const collectionPublicBtn = e.target.closest("button[data-collectionpublic]");
  13061   if (collectionPublicBtn) {
  13062     const collectionId = collectionPublicBtn.getAttribute("data-collectionpublic") || "";
  13063     if (!collectionId) return;
  13064     ws.send(JSON.stringify({ type: "collectionSetGate", collectionId, visibility: "public", allowedRoles: [] }));
  13065     return;
  13066   }
  13067 
  13068   const roleCreateBtn = e.target.closest("button[data-rolecreate]");
  13069   if (roleCreateBtn) {
  13070     const card = roleCreateBtn.closest(".modCard");
  13071     const label = String(card?.querySelector("input[data-rolelabel]")?.value || "").trim();
  13072     let key = String(card?.querySelector("input[data-rolekey]")?.value || "")
  13073       .trim()
  13074       .toLowerCase();
  13075     if (!key && label) {
  13076       key = label
  13077         .toLowerCase()
  13078         .replace(/[^a-z0-9]+/g, "_")
  13079         .replace(/^_+|_+$/g, "")
  13080         .slice(0, 18);
  13081       const keyEl = card?.querySelector("input[data-rolekey]");
  13082       if (keyEl && key) keyEl.value = key;
  13083     }
  13084     const color = String(card?.querySelector("input[data-rolecolor]")?.value || "#ff3ea5").trim();
  13085     if (!key || !label) {
  13086       toast("Roles", "Key and label are required.");
  13087       return;
  13088     }
  13089     ws.send(JSON.stringify({ type: "roleCreate", key, label, color }));
  13090     return;
  13091   }
  13092 
  13093   const roleArchiveBtn = e.target.closest("button[data-rolearchive]");
  13094   if (roleArchiveBtn) {
  13095     const key = roleArchiveBtn.getAttribute("data-rolearchive") || "";
  13096     if (!key) return;
  13097     const ok = confirm(`Archive role "${key}"?`);
  13098     if (!ok) return;
  13099     ws.send(JSON.stringify({ type: "roleArchive", key }));
  13100     return;
  13101   }
  13102 
  13103   const userManageRolesBtn = e.target.closest("button[data-usermanageroles]");
  13104   if (userManageRolesBtn) {
  13105     const targetId = userManageRolesBtn.getAttribute("data-usermanageroles") || "";
  13106     if (!targetId) return;
  13107     openUserRolesModal(targetId);
  13108     return;
  13109   }
  13110 
  13111   const actionBtn = e.target.closest("button[data-modaction]");
  13112   if (!actionBtn) return;
  13113   const actionType = actionBtn.getAttribute("data-modaction") || "";
  13114   const targetType = actionBtn.getAttribute("data-targettype") || "";
  13115   const targetId = actionBtn.getAttribute("data-targetid") || "";
  13116   if (!actionType || !targetType || !targetId) return;
  13117 
  13118   const metadata = {};
  13119 
  13120   if (actionType === "user_password_reset") {
  13121     const pw = prompt("Set a new password (min 4 chars):");
  13122     if (pw === null) return;
  13123     const next = String(pw || "");
  13124     if (next.length < 4) {
  13125       toast("Password reset", "Password must be at least 4 characters.");
  13126       return;
  13127     }
  13128     const ok = confirm("Reset this user's password to the value you entered?");
  13129     if (!ok) return;
  13130     metadata.newPassword = next;
  13131   }
  13132 
  13133   if (actionType === "post_erase") {
  13134     const ok = confirm("Erase this hive permanently? This cannot be restored.");
  13135     if (!ok) return;
  13136   }
  13137 
  13138   if (actionType === "post_readonly_set") {
  13139     metadata.readOnly = actionBtn.getAttribute("data-readonly") === "1";
  13140   }
  13141 
  13142   if (actionType === "post_protection_set") {
  13143     if (actionBtn.hasAttribute("data-unprotect")) {
  13144       metadata.enabled = false;
  13145     } else {
  13146       const pw = prompt("Set post password (min 4 chars):");
  13147       if (pw === null) return;
  13148       const next = String(pw || "");
  13149       if (next.length < 4) {
  13150         toast("Protected post", "Password must be at least 4 characters.");
  13151         return;
  13152       }
  13153       metadata.enabled = true;
  13154       metadata.password = next;
  13155     }
  13156   }
  13157 
  13158   const reason = promptReason(actionType);
  13159   if (!reason) return;
  13160   const minutesAttr = actionBtn.getAttribute("data-minutes");
  13161   const roleAttr = actionBtn.getAttribute("data-role");
  13162   const countAttr = actionBtn.getAttribute("data-count");
  13163   const ttlAttr = actionBtn.getAttribute("data-ttl");
  13164   const ttlPrompt = actionBtn.hasAttribute("data-ttlprompt");
  13165   if (minutesAttr) metadata.minutes = Number(minutesAttr);
  13166   if (roleAttr) metadata.role = roleAttr;
  13167   if (countAttr) metadata.count = Number(countAttr);
  13168   if (ttlAttr) metadata.ttlMinutes = Number(ttlAttr);
  13169   if (ttlPrompt && actionType === "post_ttl_set") {
  13170     const raw = prompt("Set TTL minutes (0 = permanent):", "60");
  13171     if (raw === null) return;
  13172     const n = Math.max(0, Math.min(2880, Math.floor(Number(raw))));
  13173     if (!Number.isFinite(n)) {
  13174       toast("TTL", "Enter a valid number.");
  13175       return;
  13176     }
  13177     metadata.ttlMinutes = n;
  13178   }
  13179   ws.send(JSON.stringify({ type: "modAction", actionType, targetType, targetId, reason, metadata }));
  13180 });
  13181 
  13182 modBodyEl?.addEventListener("change", (e) => {
  13183   const onbEnabled = e.target?.closest?.("input[data-onboarding-enabled]");
  13184   if (onbEnabled) {
  13185     onboardingAdminDraft.enabled = Boolean(onbEnabled.checked);
  13186     return;
  13187   }
  13188   const onbRequire = e.target?.closest?.("input[data-onboarding-require]");
  13189   if (onbRequire) {
  13190     onboardingAdminDraft.requireAcceptance = Boolean(onbRequire.checked);
  13191     return;
  13192   }
  13193   const onbBlockRead = e.target?.closest?.("input[data-onboarding-blockread]");
  13194   if (onbBlockRead) {
  13195     onboardingAdminDraft.blockReadUntilAccepted = Boolean(onbBlockRead.checked);
  13196     return;
  13197   }
  13198   const onbRoleEnabled = e.target?.closest?.("input[data-onboarding-roleenabled]");
  13199   if (onbRoleEnabled) {
  13200     onboardingAdminDraft.roleSelectEnabled = Boolean(onbRoleEnabled.checked);
  13201     return;
  13202   }
  13203   const onbRoleCheck = e.target?.closest?.("input[data-onboarding-rolecheck]");
  13204   if (onbRoleCheck) {
  13205     const key = String(onbRoleCheck.getAttribute("data-onboarding-rolecheck") || "").trim().toLowerCase();
  13206     if (!key) return;
  13207     const set = new Set(onboardingAdminDraft.selfAssignableRoleIds || []);
  13208     if (onbRoleCheck.checked) set.add(key);
  13209     else set.delete(key);
  13210     onboardingAdminDraft.selfAssignableRoleIds = Array.from(set);
  13211     return;
  13212   }
  13213   const onbRuleField = e.target?.closest?.("[data-onb-rulefield]");
  13214   if (onbRuleField) {
  13215     const id = String(onbRuleField.getAttribute("data-onb-ruleid") || "").trim();
  13216     const field = String(onbRuleField.getAttribute("data-onb-rulefield") || "").trim();
  13217     if (!id || !field) return;
  13218     const rule = onboardingAdminDraft.rules.find((r) => r.id === id);
  13219     if (!rule) return;
  13220     if (field === "severity") {
  13221       rule.severity = ["info", "warn", "critical"].includes(String(onbRuleField.value || "").toLowerCase())
  13222         ? String(onbRuleField.value || "").toLowerCase()
  13223         : "info";
  13224       return;
  13225     }
  13226     rule[field] = String(onbRuleField.value || "");
  13227     return;
  13228   }
  13229 
  13230   const presetSelect = e.target?.closest?.("select[data-theme-preset]");
  13231   if (presetSelect) {
  13232     if (!(canModerate && isStaffRole(loggedInRole))) return;
  13233     const id = String(presetSelect.value || "").trim();
  13234     if (!id) return;
  13235     const preset = THEME_PRESETS.find((p) => p.id === id) || null;
  13236     if (!preset) return;
  13237     const a = preset.appearance || {};
  13238     const setValue = (selector, value) => {
  13239       const el = modBodyEl.querySelector(selector);
  13240       if (!el) return;
  13241       el.value = String(value ?? "");
  13242     };
  13243     setValue("input[data-instance-bg]", a.bg);
  13244     setValue("input[data-instance-panel]", a.panel);
  13245     setValue("input[data-instance-text]", a.text);
  13246     setValue("input[data-instance-good]", a.good);
  13247     setValue("input[data-instance-bad]", a.bad);
  13248     setValue("input[data-instance-accent]", a.accent);
  13249     setValue("input[data-instance-accent2]", a.accent2);
  13250     setValue("input[data-instance-mutedpct]", a.mutedPct);
  13251     setValue("input[data-instance-linepct]", a.linePct);
  13252     setValue("input[data-instance-panel2pct]", a.panel2Pct);
  13253     setValue("select[data-instance-fontbody]", a.fontBody);
  13254     setValue("select[data-instance-fontmono]", a.fontMono);
  13255     applyInstanceAppearance(a);
  13256     toast("Theme", `Preset "${preset.name}" applied (preview). Click Save to persist.`);
  13257     return;
  13258   }
  13259 
  13260   const toggle = e.target?.closest?.("input[type='checkbox'][data-pluginenable]");
  13261   if (toggle) {
  13262     if (!canManagePlugins()) return;
  13263     const id = String(toggle.getAttribute("data-pluginenable") || "").trim().toLowerCase();
  13264     if (!id) return;
  13265     const enabled = Boolean(toggle.checked);
  13266     if (pluginEnableInFlight.has(id)) return;
  13267     const wsRef = window.__bzlWs;
  13268     if (!wsRef || wsRef.readyState !== WebSocket.OPEN) {
  13269       toast("Plugins", "Not connected.");
  13270       return;
  13271     }
  13272     pluginEnableInFlight.add(id);
  13273     // Optimistic UI update to avoid flicker/repeated toggles.
  13274     for (const p of plugins) {
  13275       if (p && String(p.id || "").toLowerCase() === id) p.enabled = enabled;
  13276     }
  13277     pluginAdminStatus = enabled ? "Enabling..." : "Disabling...";
  13278     renderModPanel();
  13279     wsRef.send(JSON.stringify({ type: "pluginSetEnabled", id, enabled }));
  13280     return;
  13281   }
  13282 });
  13283 
  13284 modBodyEl?.addEventListener("input", (e) => {
  13285   const aboutEl = e.target?.closest?.("textarea[data-onboarding-about]");
  13286   if (aboutEl) {
  13287     onboardingAdminDraft.aboutContent = String(aboutEl.value || "");
  13288     return;
  13289   }
  13290   const onbRuleField = e.target?.closest?.("input[data-onb-rulefield],textarea[data-onb-rulefield]");
  13291   if (!onbRuleField) return;
  13292   const id = String(onbRuleField.getAttribute("data-onb-ruleid") || "").trim();
  13293   const field = String(onbRuleField.getAttribute("data-onb-rulefield") || "").trim();
  13294   if (!id || !field) return;
  13295   const rule = onboardingAdminDraft.rules.find((r) => r.id === id);
  13296   if (!rule) return;
  13297   rule[field] = String(onbRuleField.value || "");
  13298 });
  13299 
  13300 modBodyEl?.addEventListener("change", (e) => {
  13301   const toggle = e.target?.closest?.("input[data-nukeconfirm]");
  13302   if (!toggle) return;
  13303   const btn = modBodyEl.querySelector("button[data-nuke]");
  13304   if (!btn) return;
  13305   btn.disabled = !Boolean(toggle.checked);
  13306 });
  13307 
  13308 chatForm.addEventListener("submit", (e) => {
  13309   e.preventDefault();
  13310   submitChat();
  13311 });
  13312 
  13313 chatMeta?.addEventListener("click", (e) => {
  13314   const btn = e.target?.closest?.("button[data-mapchatscope]");
  13315   if (!btn) return;
  13316   const scope = normalizeMapChatScope(btn.getAttribute("data-mapchatscope") || "local");
  13317   activeMapsChatScope = scope;
  13318   // Fetch global history on-demand when switching to global.
  13319   if (scope === "global" && activeMapsRoomId) {
  13320     try {
  13321       const wsRef = window.__bzlWs;
  13322       if (wsRef && wsRef.readyState === WebSocket.OPEN) {
  13323         wsRef.send(JSON.stringify({ type: "plugin:maps:chatHistoryReq", mapId: activeMapsRoomId }));
  13324       }
  13325     } catch {
  13326       // ignore
  13327     }
  13328   }
  13329   renderChatPanel(true);
  13330 });
  13331 
  13332 chatEditor.addEventListener("keydown", (e) => {
  13333   if (mentionState.open) {
  13334     if (e.key === "ArrowDown") {
  13335       e.preventDefault();
  13336       mentionState.selected = Math.min(mentionState.items.length - 1, mentionState.selected + 1);
  13337       renderMentionMenu();
  13338       return;
  13339     }
  13340     if (e.key === "ArrowUp") {
  13341       e.preventDefault();
  13342       mentionState.selected = Math.max(0, mentionState.selected - 1);
  13343       renderMentionMenu();
  13344       return;
  13345     }
  13346     if (e.key === "Enter" || e.key === "Tab") {
  13347       e.preventDefault();
  13348       const picked = mentionState.items[mentionState.selected];
  13349       if (picked) replaceCurrentMentionToken(picked);
  13350       closeMentionMenu();
  13351       return;
  13352     }
  13353     if (e.key === "Escape") {
  13354       e.preventDefault();
  13355       closeMentionMenu();
  13356       return;
  13357     }
  13358   }
  13359   if (e.key !== "Enter") return;
  13360   if (!shouldSubmitChatOnEnter(e)) return;
  13361   e.preventDefault();
  13362   submitChat();
  13363 });
  13364 
  13365 chatEditor.addEventListener("input", () => {
  13366   if (!activeChatPostId || !loggedInUser) return;
  13367   const textTail = String(chatEditor.innerText || "").slice(-80);
  13368   const m = /@([a-z0-9_.-]{0,31})$/i.exec(textTail);
  13369   if (m) {
  13370     const query = String(m[1] || "");
  13371     mentionState.open = true;
  13372     mentionState.query = query;
  13373     mentionState.items = listMentionCandidates(query);
  13374     mentionState.selected = 0;
  13375     mentionState.anchorRect = getCaretRect();
  13376     renderMentionMenu();
  13377   } else {
  13378     closeMentionMenu();
  13379   }
  13380 
  13381   const t = Date.now();
  13382   if (t - lastTypingSentAt > 900) {
  13383     ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: true }));
  13384     lastTypingSentAt = t;
  13385   }
  13386   if (typingStopTimer) clearTimeout(typingStopTimer);
  13387   typingStopTimer = setTimeout(() => {
  13388     if (!activeChatPostId) return;
  13389     ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false }));
  13390   }, 1800);
  13391 });
  13392 
  13393 chatEditor.addEventListener("focus", () => {
  13394   chatUploadTargetEditor = chatEditor;
  13395 });
  13396 
  13397 chatEditor.addEventListener("blur", () => {
  13398   if (!activeChatPostId || !loggedInUser) return;
  13399   ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false }));
  13400   setTimeout(() => closeMentionMenu(), 0);
  13401 });
  13402 
  13403 editor.addEventListener("keydown", (e) => {
  13404   if (e.key !== "Enter") return;
  13405   if (!(e.ctrlKey || e.metaKey)) return;
  13406   e.preventDefault();
  13407   newPostForm.requestSubmit();
  13408 });
  13409 
  13410 chatImageInput.addEventListener("change", async () => {
  13411   const file = chatImageInput.files && chatImageInput.files[0] ? chatImageInput.files[0] : null;
  13412   chatImageInput.value = "";
  13413   if (!file) return;
  13414   try {
  13415     const url = await uploadMediaFile(file, "image");
  13416     if (!url) return;
  13417     const target = chatUploadTargetEditor instanceof HTMLElement ? chatUploadTargetEditor : chatEditor;
  13418     target.focus();
  13419     document.execCommand("insertImage", false, url);
  13420   } catch {
  13421     // ignore
  13422   }
  13423 });
  13424 
  13425 postImageInput?.addEventListener("change", async () => {
  13426   const file = postImageInput.files && postImageInput.files[0] ? postImageInput.files[0] : null;
  13427   postImageInput.value = "";
  13428   if (!file) return;
  13429   try {
  13430     const url = await uploadMediaFile(file, "image");
  13431     if (!url) return;
  13432     editor.focus();
  13433     document.execCommand("insertImage", false, url);
  13434   } catch {
  13435     // ignore
  13436   }
  13437 });
  13438 
  13439 chatAudioInput?.addEventListener("change", async () => {
  13440   const file = chatAudioInput.files && chatAudioInput.files[0] ? chatAudioInput.files[0] : null;
  13441   chatAudioInput.value = "";
  13442   if (!file) return;
  13443   try {
  13444     const url = await uploadMediaFile(file, "audio");
  13445     if (!url) return;
  13446     const target = chatUploadTargetEditor instanceof HTMLElement ? chatUploadTargetEditor : chatEditor;
  13447     insertAudioTag(target, url);
  13448   } catch {
  13449     // ignore
  13450   }
  13451 });
  13452 
  13453 postAudioInput?.addEventListener("change", async () => {
  13454   const file = postAudioInput.files && postAudioInput.files[0] ? postAudioInput.files[0] : null;
  13455   postAudioInput.value = "";
  13456   if (!file) return;
  13457   try {
  13458     const url = await uploadMediaFile(file, "audio");
  13459     if (!url) return;
  13460     insertAudioTag(editor, url);
  13461   } catch {
  13462     // ignore
  13463   }
  13464 });
  13465 
  13466 setInterval(() => {
  13467   for (const el of document.querySelectorAll("[data-countdown]")) {
  13468     const id = el.getAttribute("data-countdown");
  13469     const post = posts.get(id);
  13470     if (!post) continue;
  13471     el.textContent = formatCountdown(post.expiresAt);
  13472   }
  13473   for (const el of document.querySelectorAll("[data-boost]")) {
  13474     const id = el.getAttribute("data-boost");
  13475     const post = posts.get(id);
  13476     if (!post) continue;
  13477     const txt = formatBoostRemaining(Number(post.boostUntil || 0));
  13478     if (!txt) {
  13479       el.remove();
  13480       continue;
  13481     }
  13482     el.textContent = `boost ${txt}`;
  13483   }
  13484   if (activeChatPostId) updateActiveChatMeta();
  13485 }, 1000);
  13486 
  13487 function unlockSfxOnce() {
  13488   if (!pendingOpenSfx) return;
  13489   playSfx("open", { volume: 0.34 }).then((ok) => {
  13490     if (ok) pendingOpenSfx = false;
  13491   });
  13492 }
  13493 
  13494 window.addEventListener("pointerdown", unlockSfxOnce, { once: true, capture: true });
  13495 window.addEventListener("keydown", unlockSfxOnce, { once: true, capture: true });
  13496 
  13497 playSfx("open", { volume: 0.34 }).then((ok) => {
  13498   if (ok) pendingOpenSfx = false;
  13499 });
  13500 
  13501 let ws = null;
  13502 let wsKeepaliveTimer = null;
  13503 let wsReconnectTimer = null;
  13504 let wsReconnectAttempt = 0;
  13505 let lastForegroundResyncAt = 0;
  13506 let wsStaleWatchdogTimer = null;
  13507 let wsLastInboundAt = 0;
  13508 let wsLastStaleReconnectAt = 0;
  13509 const WS_STALE_CHECK_MS = 15_000;
  13510 const WS_STALE_INBOUND_MS = 180_000;
  13511 const WS_STALE_RECONNECT_COOLDOWN_MS = 90_000;
  13512 
  13513 function clearWsKeepalive() {
  13514   if (!wsKeepaliveTimer) return;
  13515   try {
  13516     clearInterval(wsKeepaliveTimer);
  13517   } catch {
  13518     // ignore
  13519   }
  13520   wsKeepaliveTimer = null;
  13521 }
  13522 
  13523 function clearWsReconnect() {
  13524   if (!wsReconnectTimer) return;
  13525   try {
  13526     clearTimeout(wsReconnectTimer);
  13527   } catch {
  13528     // ignore
  13529   }
  13530   wsReconnectTimer = null;
  13531 }
  13532 
  13533 function clearWsStaleWatchdog() {
  13534   if (!wsStaleWatchdogTimer) return;
  13535   try {
  13536     clearInterval(wsStaleWatchdogTimer);
  13537   } catch {
  13538     // ignore
  13539   }
  13540   wsStaleWatchdogTimer = null;
  13541 }
  13542 
  13543 function noteWsInbound() {
  13544   wsLastInboundAt = Date.now();
  13545 }
  13546 
  13547 function startWsStaleWatchdog(sock) {
  13548   clearWsStaleWatchdog();
  13549   if (!readStayConnectedPref()) return;
  13550   if (!sock || sock.readyState !== WebSocket.OPEN) return;
  13551   noteWsInbound();
  13552   wsStaleWatchdogTimer = setInterval(() => {
  13553     if (!sock || sock !== ws) return;
  13554     if (sock.readyState !== WebSocket.OPEN) return;
  13555     const now = Date.now();
  13556     const idleMs = now - wsLastInboundAt;
  13557     if (idleMs < WS_STALE_INBOUND_MS) return;
  13558     if (now - wsLastStaleReconnectAt < WS_STALE_RECONNECT_COOLDOWN_MS) return;
  13559     wsLastStaleReconnectAt = now;
  13560     setConn("connecting");
  13561     try {
  13562       sock.close(4001, "stale-inbound-timeout");
  13563     } catch {
  13564       // ignore
  13565     }
  13566     setTimeout(() => {
  13567       if (!readStayConnectedPref()) return;
  13568       if (ws && ws !== sock && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
  13569       connectWs();
  13570     }, 900);
  13571   }, WS_STALE_CHECK_MS);
  13572 }
  13573 
  13574 function startWsKeepalive(sock) {
  13575   clearWsKeepalive();
  13576   if (!readStayConnectedPref()) return;
  13577   wsKeepaliveTimer = setInterval(() => {
  13578     if (!sock || sock !== ws) return;
  13579     if (sock.readyState !== WebSocket.OPEN) return;
  13580     try {
  13581       sock.send(JSON.stringify({ type: "ping" }));
  13582     } catch {
  13583       // ignore
  13584     }
  13585   }, 25_000);
  13586 }
  13587 
  13588 function scheduleWsReconnect() {
  13589   clearWsReconnect();
  13590   if (!readStayConnectedPref()) return;
  13591   const attempt = Math.min(6, Math.max(0, wsReconnectAttempt));
  13592   const base = 1000 * Math.pow(2, attempt);
  13593   const jitter = Math.floor(Math.random() * 250);
  13594   const delay = Math.min(15_000, base) + jitter;
  13595   wsReconnectAttempt += 1;
  13596   setConn("connecting");
  13597   wsReconnectTimer = setTimeout(() => {
  13598     wsReconnectTimer = null;
  13599     connectWs();
  13600   }, delay);
  13601 }
  13602 
  13603 function connectWs() {
  13604   if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
  13605   clearWsKeepalive();
  13606   clearWsStaleWatchdog();
  13607   setConn("connecting");
  13608   const sock = new WebSocket(wsUrl());
  13609   ws = sock;
  13610   window.__bzlWs = sock;
  13611 
  13612   sock.addEventListener("open", () => {
  13613     if (sock !== ws) return;
  13614     noteWsInbound();
  13615     setConn("open");
  13616     wsReconnectAttempt = 0;
  13617     clearWsReconnect();
  13618     startWsKeepalive(sock);
  13619     startWsStaleWatchdog(sock);
  13620     const token = getSessionToken();
  13621     if (token) {
  13622       try {
  13623         sock.send(JSON.stringify({ type: "resumeSession", token }));
  13624       } catch {
  13625         // ignore
  13626       }
  13627     }
  13628     setTimeout(() => {
  13629       if (sock !== ws) return;
  13630       requestForegroundResync("ws-open");
  13631     }, 120);
  13632   });
  13633 
  13634   sock.addEventListener("close", () => {
  13635     if (sock !== ws) return;
  13636     leaveActiveStream(false);
  13637     setConn("closed");
  13638     clearWsKeepalive();
  13639     clearWsStaleWatchdog();
  13640     scheduleWsReconnect();
  13641   });
  13642 
  13643   sock.addEventListener("error", () => {
  13644     if (sock !== ws) return;
  13645     setConn("closed");
  13646     clearWsStaleWatchdog();
  13647   });
  13648 
  13649   sock.addEventListener("message", onWsMessage);
  13650 }
  13651 
  13652 function requestForegroundResync(reason = "") {
  13653   const now = Date.now();
  13654   if (now - lastForegroundResyncAt < 1400) return;
  13655   lastForegroundResyncAt = now;
  13656   if (!ws || ws.readyState !== WebSocket.OPEN) return;
  13657   try {
  13658     ws.send(JSON.stringify({ type: "peopleList" }));
  13659     ws.send(JSON.stringify({ type: "dmList" }));
  13660     if (activeDmThreadId) {
  13661       ws.send(JSON.stringify({ type: "dmHistory", threadId: activeDmThreadId }));
  13662     } else if (activeChatPostId) {
  13663       ws.send(JSON.stringify({ type: "getChat", postId: activeChatPostId }));
  13664     }
  13665     if (activeMapsRoomId) {
  13666       ws.send(JSON.stringify({ type: "plugin:maps:chatHistoryReq", mapId: activeMapsRoomId }));
  13667     }
  13668     if (reason === "visibility") ws.send(JSON.stringify({ type: "onboardingGet" }));
  13669   } catch {
  13670     // ignore
  13671   }
  13672 }
  13673 
  13674 function onWsMessage(evt) {
  13675   noteWsInbound();
  13676   let msg;
  13677   try {
  13678     msg = JSON.parse(evt.data);
  13679   } catch {
  13680     return;
  13681   }
  13682   if (!msg || typeof msg !== "object") return;
  13683 
  13684   if (msg.type === "init") {
  13685     leaveActiveStream(false);
  13686     clientId = msg.clientId || null;
  13687     canRegisterFirstUser = Boolean(msg.auth?.canRegisterFirstUser);
  13688     registrationEnabled = Boolean(msg.auth?.registrationEnabled);
  13689     loggedInRole = "member";
  13690     canModerate = false;
  13691     dmThreads = [];
  13692     dmThreadsById = new Map();
  13693     dmMessagesByThreadId.clear();
  13694     activeDmThreadId = null;
  13695     pendingOpenDmThreadId = "";
  13696     lanUrls = [];
  13697     modReports = [];
  13698     modUsers = [];
  13699     modLog = [];
  13700     devLog = [];
  13701     profiles = msg.profiles && typeof msg.profiles === "object" ? msg.profiles : {};
  13702     instanceBranding = normalizeInstanceBranding(msg.instance || {});
  13703     onboardingState = normalizeOnboardingState(msg.auth?.onboarding || {});
  13704     renderInstanceBranding();
  13705     collections = normalizeCollections(msg.collections);
  13706     customRoles = normalizeRoleDefs(msg.roles?.custom);
  13707     setPlugins(msg.plugins);
  13708     streamEnabled = Boolean(msg.stream?.enabled);
  13709     streamIceServers = Array.isArray(msg.stream?.iceServers) && msg.stream.iceServers.length ? msg.stream.iceServers : streamIceServers;
  13710     renderCollectionSelect();
  13711     peopleMembers = Array.isArray(msg.people?.members) ? msg.people.members : [];
  13712     if (!peopleMembers.length && ws.readyState === WebSocket.OPEN) {
  13713       ws.send(JSON.stringify({ type: "peopleList" }));
  13714     }
  13715     if (msg.reactions?.allowed && Array.isArray(msg.reactions.allowed)) allowedReactions = msg.reactions.allowed;
  13716     if (msg.reactions?.allowedPost && Array.isArray(msg.reactions.allowedPost)) allowedPostReactions = msg.reactions.allowedPost;
  13717     if (msg.reactions?.allowedChat && Array.isArray(msg.reactions.allowedChat)) allowedChatReactions = msg.reactions.allowedChat;
  13718     setUserPrefs({ starredPostIds: [], hiddenPostIds: [] });
  13719     unreadByPostId.clear();
  13720     streamLiveByPostId.clear();
  13721     posts.clear();
  13722     for (const p of msg.posts || []) {
  13723       posts.set(p.id, p);
  13724       if (p && typeof p.id === "string") streamLiveByPostId.set(p.id, Boolean(p.streamLive));
  13725     }
  13726     setAuthUi();
  13727     renderFeed();
  13728     renderChatPanel();
  13729     renderLanHint();
  13730     renderPeoplePanel();
  13731     renderCenterPanels();
  13732     maybeAutoStartGuidedTour();
  13733     if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
  13734     return;
  13735   }
  13736 
  13737   // Generic plugin event dispatch: `plugin:<pluginId>:<eventName>`
  13738   // (Maps has some core-handled messages below; for other plugins, dispatch + stop.)
  13739   if (typeof msg.type === "string") {
  13740     const m = msg.type.match(/^plugin:([a-z0-9][a-z0-9_.-]{0,31}):([a-zA-Z0-9][a-zA-Z0-9_.-]{0,63})$/);
  13741     if (m) {
  13742       const pluginId = String(m[1] || "").toLowerCase();
  13743       const ev = String(m[2] || "");
  13744       const byEvent = pluginClientHandlers.get(pluginId);
  13745       const set = byEvent ? byEvent.get(ev) : null;
  13746       if (set && set.size) {
  13747         for (const fn of Array.from(set)) {
  13748           try {
  13749             fn(msg);
  13750           } catch (e) {
  13751             console.warn(`Plugin handler failed (${pluginId}:${ev}):`, e?.message || e);
  13752           }
  13753         }
  13754       }
  13755       if (pluginId !== "maps") return;
  13756     }
  13757   }
  13758 
  13759   if (msg.type === "plugin:maps:joinOk") {
  13760     const map = msg.map && typeof msg.map === "object" ? msg.map : null;
  13761     const mapId = map && typeof map.id === "string" ? map.id.trim().toLowerCase() : "";
  13762     if (mapId) {
  13763       activeMapsRoomId = mapId;
  13764       activeMapsRoomTitle = map && typeof map.title === "string" ? map.title.trim().slice(0, 64) : mapId;
  13765       activeMapsChatScope = "local";
  13766       try {
  13767         if (ws.readyState === WebSocket.OPEN) {
  13768           ws.send(JSON.stringify({ type: "plugin:maps:chatHistoryReq", mapId }));
  13769         }
  13770       } catch {
  13771         // ignore
  13772       }
  13773       if (isMapChatActive()) renderChatPanel(true);
  13774     }
  13775     return;
  13776   }
  13777 
  13778   if (msg.type === "plugin:maps:left") {
  13779     const wasActive = Boolean(activeMapsRoomId);
  13780     activeMapsRoomId = "";
  13781     activeMapsRoomTitle = "";
  13782     activeMapsChatScope = "local";
  13783     if (wasActive && !activeDmThreadId && !activeChatPostId) renderChatPanel(true);
  13784     return;
  13785   }
  13786 
  13787   if (msg.type === "plugin:maps:chatHistory") {
  13788     const mapId = typeof msg.mapId === "string" ? msg.mapId.trim().toLowerCase() : "";
  13789     const scope = normalizeMapChatScope(msg.scope || "global");
  13790     const messages = Array.isArray(msg.messages) ? msg.messages : [];
  13791     if (mapId && scope === "global") {
  13792       mapsChatGlobalByMapId.set(
  13793         mapId,
  13794         messages
  13795           .map((m) => ({
  13796             id: String(m?.id || ""),
  13797             fromUser: String(m?.fromUser || m?.username || ""),
  13798             text: String(m?.text || ""),
  13799             createdAt: Number(m?.createdAt || 0) || Date.now(),
  13800           }))
  13801           .filter((m) => m.id && m.fromUser && m.text)
  13802           .slice(-240)
  13803       );
  13804       if (isMapChatActive()) renderChatPanel(false);
  13805     }
  13806     return;
  13807   }
  13808 
  13809   if (msg.type === "plugin:maps:chatMessage") {
  13810     const mapId = typeof msg.mapId === "string" ? msg.mapId.trim().toLowerCase() : "";
  13811     const scope = normalizeMapChatScope(msg.scope || "local");
  13812     const m = msg.message && typeof msg.message === "object" ? msg.message : null;
  13813     if (mapId && m) {
  13814       pushMapChatMessage(mapId, scope, {
  13815         id: String(m.id || ""),
  13816         fromUser: String(m.fromUser || m.username || ""),
  13817         text: String(m.text || ""),
  13818         createdAt: Number(m.createdAt || 0) || Date.now(),
  13819       });
  13820       if (isMapChatActive()) renderChatPanel(false);
  13821     }
  13822     return;
  13823   }
  13824 
  13825   if (msg.type === "streamState") {
  13826     const postId = String(msg.postId || "").trim();
  13827     if (!postId) return;
  13828     const live = Boolean(msg.live);
  13829     streamLiveByPostId.set(postId, live);
  13830     const post = posts.get(postId);
  13831     if (post) {
  13832       post.streamLive = live;
  13833       post.streamKind = normalizeStreamKind(msg.kind || post.streamKind || "webcam");
  13834       post.streamHost = String(msg.host || "");
  13835       post.streamHostClientId = String(msg.hostClientId || "");
  13836       post.streamViewerCount = Math.max(0, Number(msg.viewerCount || 0) || 0);
  13837     }
  13838     if (live && streamCurrentRole === "viewer" && streamCurrentPostId === postId && !streamCurrentHostClientId) {
  13839       streamCurrentHostClientId = String(msg.hostClientId || "");
  13840       streamRemoteHostClientId = streamCurrentHostClientId;
  13841     }
  13842     if (!live && streamCurrentPostId === postId && streamCurrentRole !== "idle") {
  13843       leaveActiveStream(false);
  13844     }
  13845     renderFeed();
  13846     if (activeChatPostId === postId || streamCurrentPostId === postId) renderChatPanel(false);
  13847     return;
  13848   }
  13849 
  13850   if (msg.type === "streamEnded") {
  13851     const postId = String(msg.postId || "").trim();
  13852     if (!postId) return;
  13853     streamLiveByPostId.set(postId, false);
  13854     const post = posts.get(postId);
  13855     if (post) post.streamLive = false;
  13856     if (streamCurrentPostId === postId && streamCurrentRole !== "idle") {
  13857       leaveActiveStream(false);
  13858       if (activeChatPostId === postId) toast("Stream", "Live stream ended.");
  13859     }
  13860     renderFeed();
  13861     if (activeChatPostId === postId) renderChatPanel(false);
  13862     return;
  13863   }
  13864 
  13865   if (msg.type === "streamJoinAck") {
  13866     const postId = String(msg.postId || "").trim();
  13867     if (!postId || streamCurrentRole !== "viewer" || streamCurrentPostId !== postId) return;
  13868     if (!Boolean(msg.live)) {
  13869       leaveActiveStream(false);
  13870       toast("Stream", "This stream is offline.");
  13871       renderChatPanel(false);
  13872       return;
  13873     }
  13874     streamCurrentHostClientId = String(msg.hostClientId || "");
  13875     streamRemoteHostClientId = streamCurrentHostClientId;
  13876     streamRemoteKind = normalizeStreamKind(msg.kind || "webcam");
  13877     const peers = Array.isArray(msg.peerClientIds) ? msg.peerClientIds : [];
  13878     const peerUsers = msg.peerUsernames && typeof msg.peerUsernames === "object" ? msg.peerUsernames : {};
  13879     for (const peerIdRaw of peers) {
  13880       const peerId = String(peerIdRaw || "").trim();
  13881       if (!peerId || peerId === String(clientId || "")) continue;
  13882       const peerName = String(peerUsers[peerId] || "").trim();
  13883       if (peerName) streamPeerUsernameByClientId.set(peerId, peerName);
  13884       handleStreamPeerJoinMessage({ postId, peerClientId: peerId, peerUsername: peerName, initiateOffer: true });
  13885     }
  13886     renderChatPanel(false);
  13887     return;
  13888   }
  13889 
  13890   if (msg.type === "streamPeerJoin" || msg.type === "streamViewerJoin") {
  13891     handleStreamPeerJoinMessage(msg);
  13892     return;
  13893   }
  13894 
  13895   if (msg.type === "streamPeerLeave" || msg.type === "streamViewerLeave") {
  13896     const postId = String(msg.postId || "").trim();
  13897     const peerClientId = String(msg.peerClientId || msg.viewerClientId || "").trim();
  13898     if (!postId || !peerClientId) return;
  13899     if (streamCurrentPostId === postId) {
  13900       closeStreamPeer(peerClientId);
  13901       renderStreamVoiceUsers();
  13902     }
  13903     return;
  13904   }
  13905 
  13906   if (msg.type === "streamSignal") {
  13907     handleStreamSignalMessage(msg);
  13908     return;
  13909   }
  13910 
  13911   if (msg.type === "collectionsUpdated") {
  13912     const prevView = activeHiveView;
  13913     collections = normalizeCollections(msg.collections);
  13914     renderCollectionSelect();
  13915     ensureActiveCollectionView();
  13916     if (activeHiveView !== prevView) renderFeed();
  13917     renderModPanel();
  13918     return;
  13919   }
  13920 
  13921   if (msg.type === "instanceUpdated" && msg.instance && typeof msg.instance === "object") {
  13922     instanceBranding = normalizeInstanceBranding(msg.instance);
  13923     onboardingState = normalizeOnboardingState(onboardingState);
  13924     if (modTab === "onboarding") syncOnboardingAdminDraft(true);
  13925     renderInstanceBranding();
  13926     applyInstanceAppearance();
  13927     setAuthUi();
  13928     return;
  13929   }
  13930 
  13931   if (msg.type === "instanceOk" && msg.instance && typeof msg.instance === "object") {
  13932     instanceBranding = normalizeInstanceBranding(msg.instance);
  13933     onboardingState = normalizeOnboardingState(onboardingState);
  13934     if (modTab === "onboarding") syncOnboardingAdminDraft(true);
  13935     renderInstanceBranding();
  13936     applyInstanceAppearance();
  13937     setAuthUi();
  13938     toast("Instance", "Saved.");
  13939     return;
  13940   }
  13941 
  13942   if (msg.type === "postsSnapshot") {
  13943     streamLiveByPostId.clear();
  13944     posts.clear();
  13945     for (const post of Array.isArray(msg.posts) ? msg.posts : []) {
  13946       posts.set(post.id, post);
  13947       if (post && typeof post.id === "string") streamLiveByPostId.set(post.id, Boolean(post.streamLive));
  13948     }
  13949     if (activeChatPostId && !posts.has(activeChatPostId)) {
  13950       activeChatPostId = null;
  13951     }
  13952     renderFeed();
  13953     renderChatPanel();
  13954     return;
  13955   }
  13956 
  13957   if (msg.type === "boardReset") {
  13958     posts.clear();
  13959     streamLiveByPostId.clear();
  13960     chatByPost.clear();
  13961     unreadByPostId.clear();
  13962     typingUsersByPostId.clear();
  13963     newPostAnimIds.clear();
  13964     if (buzzTimers.size) {
  13965       for (const t of buzzTimers.values()) clearTimeout(t);
  13966       buzzTimers.clear();
  13967     }
  13968     activeChatPostId = null;
  13969     renderFeed();
  13970     renderChatPanel(true);
  13971     renderTypingIndicator();
  13972     renderModPanel();
  13973     if (canModerate) requestModData();
  13974     toast("Board reset", "All hives, reports, and logs were cleared.");
  13975     return;
  13976   }
  13977 
  13978   if (msg.type === "rolesUpdated") {
  13979     customRoles = normalizeRoleDefs(msg.roles);
  13980     renderPeoplePanel();
  13981     renderModPanel();
  13982     return;
  13983   }
  13984 
  13985   if (msg.type === "pluginsUpdated") {
  13986     setPlugins(msg.plugins);
  13987     return;
  13988   }
  13989 
  13990   if (msg.type === "profilesUpdated" && msg.profiles && typeof msg.profiles === "object") {
  13991     const nextProfiles = msg.profiles;
  13992     const nextKeys = Object.keys(nextProfiles);
  13993     const currentKeys = Object.keys(profiles || {});
  13994     if (nextKeys.length === 0 && currentKeys.length > 0) {
  13995       return;
  13996     }
  13997     profiles = nextProfiles;
  13998     setAuthUi();
  13999     renderFeed();
  14000     renderChatPanel();
  14001     renderPeoplePanel();
  14002     if (centerView === "profile") renderCenterPanels();
  14003     return;
  14004   }
  14005 
  14006   if (msg.type === "userProfile" && msg.profile) {
  14007     const profile = normalizeProfileData(msg.profile);
  14008     if (!profile.username) return;
  14009     if (activeProfileUsername && profile.username !== activeProfileUsername) return;
  14010     activeProfile = profile;
  14011     setCenterView("profile", profile.username);
  14012     return;
  14013   }
  14014 
  14015   if (msg.type === "userProfileUpdated" && msg.profile) {
  14016     const profile = normalizeProfileData(msg.profile);
  14017     if (!profile.username) return;
  14018     if (centerView === "profile" && activeProfileUsername === profile.username) {
  14019       activeProfile = profile;
  14020       renderCenterPanels();
  14021     }
  14022     return;
  14023   }
  14024 
  14025   if (msg.type === "newPost" && msg.post) {
  14026     const isNewId = !posts.has(msg.post.id);
  14027     posts.set(msg.post.id, msg.post);
  14028     streamLiveByPostId.set(msg.post.id, Boolean(msg.post.streamLive));
  14029     renderFeed();
  14030     if (isNewId) {
  14031       newPostAnimIds.add(msg.post.id);
  14032       setTimeout(() => {
  14033         newPostAnimIds.delete(msg.post.id);
  14034         renderFeed();
  14035       }, 950);
  14036     }
  14037     const author = msg.post.author || "";
  14038     const title = postTitle(msg.post);
  14039     const authorLower = String(author || "").toLowerCase();
  14040     const selfLower = String(loggedInUser || "").toLowerCase();
  14041     const ignoreUserSet = new Set(
  14042       [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase())
  14043     );
  14044     if (author && loggedInUser && author === loggedInUser) {
  14045       playSfx("post", { volume: 0.36 });
  14046     }
  14047     if (author && author !== loggedInUser && readNotifNewHivePref() && !(authorLower && authorLower !== selfLower && ignoreUserSet.has(authorLower))) {
  14048       if (readNotifSoundPref()) playSfx("notif", { volume: 0.5 });
  14049       if (!windowFocused || document.hidden) {
  14050         maybeNotify(`Bzl: ${title}`, `New post by @${author}`, { postId: msg.post.id });
  14051       } else {
  14052         toast("New post", `${author ? `@${author}: ` : ""}${title}`);
  14053       }
  14054     }
  14055     return;
  14056   }
  14057 
  14058   if (msg.type === "postUpdated" && msg.post) {
  14059     posts.set(msg.post.id, msg.post);
  14060     streamLiveByPostId.set(msg.post.id, Boolean(msg.post.streamLive));
  14061     renderFeed();
  14062     renderChatPanel();
  14063     return;
  14064   }
  14065 
  14066   if (msg.type === "deletePost") {
  14067     if (userPrefs?.starredPostIds) userPrefs.starredPostIds = userPrefs.starredPostIds.filter((id) => id !== msg.id);
  14068     if (userPrefs?.hiddenPostIds) userPrefs.hiddenPostIds = userPrefs.hiddenPostIds.filter((id) => id !== msg.id);
  14069     if (streamCurrentPostId && String(streamCurrentPostId) === String(msg.id || "")) leaveActiveStream(false);
  14070     posts.delete(msg.id);
  14071     streamLiveByPostId.delete(msg.id);
  14072     chatByPost.delete(msg.id);
  14073     unreadByPostId.delete(msg.id);
  14074     typingUsersByPostId.delete(msg.id);
  14075     if (buzzTimers.has(msg.id)) {
  14076       clearTimeout(buzzTimers.get(msg.id));
  14077       buzzTimers.delete(msg.id);
  14078     }
  14079     if (activeChatPostId === msg.id) activeChatPostId = null;
  14080     renderFeed();
  14081     renderChatPanel();
  14082     renderTypingIndicator();
  14083     return;
  14084   }
  14085 
  14086   if (msg.type === "loginOk") {
  14087     loggedInUser = msg.username || null;
  14088     loggedInRole = typeof msg.role === "string" ? msg.role : "member";
  14089     canModerate = Boolean(msg.canModerate);
  14090     onboardingState = normalizeOnboardingState(msg.onboarding || onboardingState);
  14091     if (typeof msg.sessionToken === "string" && msg.sessionToken) setSessionToken(msg.sessionToken);
  14092     const profile = msg.profile || {};
  14093     pendingProfileImage = typeof profile.image === "string" ? profile.image : "";
  14094     if (pendingProfileImage) {
  14095       profilePreview.src = pendingProfileImage;
  14096       profilePreview.classList.add("hasImg");
  14097     } else {
  14098       profilePreview.removeAttribute("src");
  14099       profilePreview.classList.remove("hasImg");
  14100     }
  14101     if (profile.color) nameColorInput.value = profile.color;
  14102     setUserPrefs(msg.prefs || {});
  14103     authPass.value = "";
  14104     profileStatus.textContent = "";
  14105     setAuthUi();
  14106     renderFeed();
  14107     renderLanHint();
  14108     if (centerView === "profile" && activeProfileUsername === loggedInUser) {
  14109       ws.send(JSON.stringify({ type: "getUserProfile", username: loggedInUser }));
  14110     } else {
  14111       renderCenterPanels();
  14112     }
  14113     if (canModerate) requestModData();
  14114     if (rackLayoutEnabled) applyDockState();
  14115     updateLayoutPresetOptions();
  14116     renderOnboardingCard();
  14117     maybeAutoStartGuidedTour();
  14118     return;
  14119   }
  14120 
  14121   if (msg.type === "logoutOk") {
  14122     const priorUser = loggedInUser;
  14123     setSessionToken("");
  14124     leaveActiveStream(false);
  14125     loggedInUser = null;
  14126     loggedInRole = "member";
  14127     canModerate = false;
  14128     onboardingState = normalizeOnboardingState({ acceptedRulesVersion: 0, acceptedAt: 0, needsAcceptance: false });
  14129     dmThreads = [];
  14130     dmThreadsById = new Map();
  14131     dmMessagesByThreadId.clear();
  14132     activeDmThreadId = null;
  14133     pendingOpenDmThreadId = "";
  14134     stopWalkieRecording();
  14135     streamLiveByPostId.clear();
  14136     lanUrls = [];
  14137     modReports = [];
  14138     modUsers = [];
  14139     modLog = [];
  14140     setUserPrefs({ starredPostIds: [], hiddenPostIds: [] });
  14141     activeHiveView = "all";
  14142     guidedTourAutoStartedForUser = "";
  14143     stopGuidedTour({ completed: false, seenUser: priorUser });
  14144     setAuthUi();
  14145     maybeAutoStartGuidedTour();
  14146     renderFeed();
  14147     renderLanHint();
  14148     renderPeoplePanel();
  14149     renderCenterPanels();
  14150     if (rackLayoutEnabled) applyDockState();
  14151     updateLayoutPresetOptions();
  14152     renderOnboardingCard();
  14153     return;
  14154   }
  14155 
  14156   if (msg.type === "authState") {
  14157     if (!loggedInUser || msg.username !== loggedInUser) return;
  14158     loggedInRole = typeof msg.role === "string" ? msg.role : loggedInRole;
  14159     canModerate = Boolean(msg.canModerate);
  14160     onboardingState = normalizeOnboardingState(msg.onboarding || onboardingState);
  14161     if (!canModerate) lanUrls = [];
  14162     if (msg.prefs && typeof msg.prefs === "object") setUserPrefs(msg.prefs);
  14163     setAuthUi();
  14164     renderLanHint();
  14165     if (rackLayoutEnabled) applyDockState();
  14166     renderPeoplePanel();
  14167     if (canModerate) requestModData();
  14168     updateLayoutPresetOptions();
  14169     renderOnboardingCard();
  14170     return;
  14171   }
  14172 
  14173   if (msg.type === "onboardingState" && msg.onboarding && typeof msg.onboarding === "object") {
  14174     onboardingState = normalizeOnboardingState(msg.onboarding);
  14175     setAuthUi();
  14176     renderOnboardingCard();
  14177     return;
  14178   }
  14179 
  14180   if (msg.type === "sessionInvalid") {
  14181     setSessionToken("");
  14182     setUserPrefs({ starredPostIds: [], hiddenPostIds: [] });
  14183     dmThreads = [];
  14184     dmThreadsById = new Map();
  14185     dmMessagesByThreadId.clear();
  14186     activeDmThreadId = null;
  14187     pendingOpenDmThreadId = "";
  14188     return;
  14189   }
  14190 
  14191   if (msg.type === "userPrefs") {
  14192     setUserPrefs(msg.prefs || {});
  14193     renderFeed();
  14194     return;
  14195   }
  14196 
  14197   if (msg.type === "peopleSnapshot") {
  14198     peopleMembers = Array.isArray(msg.members) ? msg.members : [];
  14199     renderPeoplePanel();
  14200     return;
  14201   }
  14202 
  14203   if (msg.type === "dmSnapshot") {
  14204     setDmThreads(Array.isArray(msg.threads) ? msg.threads : []);
  14205     return;
  14206   }
  14207 
  14208   if (msg.type === "dmThreadOk" && msg.thread) {
  14209     const t = normalizeDmThread(msg.thread);
  14210     if (!t) return;
  14211     upsertDmThread(t);
  14212     if (pendingOpenDmThreadId && pendingOpenDmThreadId === t.id && String(t.status || "") === "active") {
  14213       openDmThread(t.id);
  14214     }
  14215     return;
  14216   }
  14217 
  14218   if (msg.type === "dmThreadUpdated" && msg.thread) {
  14219     const me = String(loggedInUser || "").trim().toLowerCase();
  14220     const a = msg.thread?.a ? normalizeDmThread(msg.thread.a) : null;
  14221     const b = msg.thread?.b ? normalizeDmThread(msg.thread.b) : null;
  14222     const mine = me ? [a, b].find((t) => t && String(t.other || "").toLowerCase() !== me) : a || b;
  14223     if (mine) {
  14224       upsertDmThread(mine);
  14225       if (pendingOpenDmThreadId && pendingOpenDmThreadId === mine.id && String(mine.status || "") === "active") {
  14226         openDmThread(mine.id);
  14227       }
  14228       if (activeDmThreadId && mine.id === activeDmThreadId) {
  14229         const current = dmMessagesByThreadId.get(activeDmThreadId) || null;
  14230         if (!current || current.length === 0) ws.send(JSON.stringify({ type: "dmHistory", threadId: activeDmThreadId }));
  14231       }
  14232     }
  14233     return;
  14234   }
  14235 
  14236   if (msg.type === "dmHistory") {
  14237     const threadId = String(msg.threadId || "").trim();
  14238     if (!threadId) return;
  14239     const messages = Array.isArray(msg.messages) ? msg.messages.map(normalizeDmMessage).filter(Boolean) : [];
  14240     dmMessagesByThreadId.set(threadId, messages);
  14241     if (activeDmThreadId === threadId) renderChatPanel(true);
  14242     return;
  14243   }
  14244 
  14245   if (msg.type === "dmMessage" && msg.threadId && msg.message) {
  14246     const threadId = String(msg.threadId || "").trim();
  14247     const message = normalizeDmMessage(msg.message);
  14248     if (!threadId || !message) return;
  14249     const existing = dmMessagesByThreadId.get(threadId) || [];
  14250     if (!existing.some((m) => m.id === message.id)) {
  14251       existing.push(message);
  14252       dmMessagesByThreadId.set(threadId, existing);
  14253     }
  14254     const sender = String(message.fromUser || "");
  14255     const isFromYou = Boolean(sender && loggedInUser && sender === loggedInUser);
  14256     if (activeDmThreadId === threadId && windowFocused && !document.hidden) {
  14257       if (!appendDmMessageToDom(threadId, message)) renderChatPanel();
  14258       pulseChatMessage(message.id);
  14259     } else {
  14260       if (!isFromYou) {
  14261         const title = `DM from @${sender || "unknown"}`;
  14262         const body = String(message.text || "").slice(0, 160) || "New message";
  14263         if (!windowFocused || document.hidden) maybeNotify(`Bzl: ${title}`, body, { threadId });
  14264         else toast("DM", `${sender ? `@${sender}: ` : ""}${body}`);
  14265         playSfx("ping", { volume: 0.38 });
  14266       }
  14267       renderPeoplePanel();
  14268     }
  14269     return;
  14270   }
  14271 
  14272   if (msg.type === "dmModMessageReceived") {
  14273     const threadId = String(msg.threadId || "").trim();
  14274     if (!threadId) return;
  14275     if (!dmThreadsById.has(threadId) && ws?.readyState === WebSocket.OPEN) {
  14276       pendingOpenDmThreadId = threadId;
  14277       ws.send(JSON.stringify({ type: "dmList" }));
  14278     }
  14279     if (isMobileScreenMode()) {
  14280       const layout = loadMobileLayout();
  14281       layout.active = "chat";
  14282       saveMobileLayout(layout);
  14283       setMobileScreen("chat");
  14284       renderMobileNav();
  14285     }
  14286     if (dmThreadsById.has(threadId)) openDmThread(threadId);
  14287     toast("Moderator message", "Opened priority moderator DM.");
  14288     return;
  14289   }
  14290 
  14291   if (msg.type === "lanInfo") {
  14292     lanUrls = Array.isArray(msg.lanUrls) ? msg.lanUrls : [];
  14293     renderLanHint();
  14294     return;
  14295   }
  14296 
  14297   if (msg.type === "loginError") {
  14298     authHint.textContent = msg.message || "Login failed.";
  14299     return;
  14300   }
  14301 
  14302   if (msg.type === "profileOk") {
  14303     const profile = msg.profile || {};
  14304     pendingProfileImage = typeof profile.image === "string" ? profile.image : pendingProfileImage;
  14305     if (pendingProfileImage) {
  14306       profilePreview.src = pendingProfileImage;
  14307       profilePreview.classList.add("hasImg");
  14308     } else {
  14309       profilePreview.removeAttribute("src");
  14310       profilePreview.classList.remove("hasImg");
  14311     }
  14312     if (profile.color) nameColorInput.value = profile.color;
  14313     profileStatus.textContent = "Saved.";
  14314     const normalized = normalizeProfileData(profile, loggedInUser || "");
  14315     if (loggedInUser && normalized.username === loggedInUser) {
  14316       activeProfile = normalized;
  14317       activeProfileUsername = loggedInUser;
  14318       if (centerView === "profile") {
  14319         isEditingProfile = false;
  14320         if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile";
  14321         renderCenterPanels();
  14322       }
  14323     }
  14324     return;
  14325   }
  14326 
  14327   if (msg.type === "error") {
  14328     const m = msg.message || "Error";
  14329     authHint.textContent = m;
  14330     profileStatus.textContent = m;
  14331     toast("Error", m);
  14332     return;
  14333   }
  14334 
  14335   if (msg.type === "rateLimited") {
  14336     const m = msg.message || "Too many requests. Please wait and try again.";
  14337     toast("Rate limit", m);
  14338     return;
  14339   }
  14340 
  14341   if (msg.type === "permissionDenied") {
  14342     const m = msg.message || "Permission denied.";
  14343     if (/(owner|moderator) access required/i.test(m)) {
  14344       pluginAdminStatus = m;
  14345       pluginAdminBusy = false;
  14346       pluginEnableInFlight.clear();
  14347       renderModPanel();
  14348     }
  14349     toast("Moderation", m);
  14350     return;
  14351   }
  14352 
  14353   if (msg.type === "collectionOk") {
  14354     toast("Collections", "Collection created.");
  14355     return;
  14356   }
  14357 
  14358   if (msg.type === "roleOk") {
  14359     toast("Roles", "Role created.");
  14360     return;
  14361   }
  14362 
  14363   if (msg.type === "pluginOk") {
  14364     if (msg.uninstalled) pluginAdminStatus = "Plugin uninstalled.";
  14365     else if (typeof msg.enabled === "boolean") pluginAdminStatus = msg.enabled ? "Plugin enabled." : "Plugin disabled.";
  14366     else if (msg.reloaded) pluginAdminStatus = "Plugins reloaded.";
  14367     else pluginAdminStatus = "Plugin updated.";
  14368     pluginAdminBusy = false;
  14369     if (msg.id) pluginEnableInFlight.delete(String(msg.id || "").trim().toLowerCase());
  14370     if (modTab === "server") renderModPanel();
  14371     return;
  14372   }
  14373 
  14374   if (msg.type === "postUnlocked") {
  14375     const postId = msg.postId || "";
  14376     if (!postId || !msg.post) return;
  14377     posts.set(postId, msg.post);
  14378     streamLiveByPostId.set(postId, Boolean(msg.post.streamLive));
  14379     if (Array.isArray(msg.messages)) {
  14380       chatByPost.set(postId, msg.messages);
  14381       hydrateOwnRecentChatFromHistory(postId, msg.messages);
  14382     }
  14383     renderFeed();
  14384     renderChatPanel();
  14385     renderTypingIndicator();
  14386     if (pendingOpenChatAfterUnlock === postId) {
  14387       pendingOpenChatAfterUnlock = null;
  14388       openChat(postId);
  14389     } else {
  14390       toast("Unlocked", "You can view and chat in this post.");
  14391     }
  14392     return;
  14393   }
  14394 
  14395   if (msg.type === "chatHistory") {
  14396     const history = Array.isArray(msg.messages) ? msg.messages : [];
  14397     chatByPost.set(msg.postId, history);
  14398     hydrateOwnRecentChatFromHistory(msg.postId, history);
  14399     markRead(msg.postId);
  14400     renderChatPanel(true);
  14401     renderTypingIndicator();
  14402     renderChatInstancesForPost(msg.postId);
  14403     return;
  14404   }
  14405 
  14406   if (msg.type === "modSnapshot") {
  14407     if (Array.isArray(msg.reports)) modReports = msg.reports;
  14408     if (Array.isArray(msg.users)) modUsers = msg.users;
  14409     if (Array.isArray(msg.log)) modLog = msg.log;
  14410     renderModPanel();
  14411     return;
  14412   }
  14413 
  14414   if (msg.type === "devLogSnapshot") {
  14415     if (Array.isArray(msg.log)) devLog = msg.log;
  14416     if (canModerate && modTab === "log" && modLogView === "dev") renderModPanel();
  14417     return;
  14418   }
  14419 
  14420   if (msg.type === "devLogAppended" && msg.entry) {
  14421     devLog.unshift(msg.entry);
  14422     if (devLog.length > 300) devLog.splice(300);
  14423     if (canModerate && modTab === "log" && modLogView === "dev") renderModPanel();
  14424     return;
  14425   }
  14426 
  14427   if (msg.type === "modLogAppended" && msg.entry) {
  14428     modLog.unshift(msg.entry);
  14429     if (modLog.length > 200) modLog.splice(200);
  14430     renderModPanel();
  14431     return;
  14432   }
  14433 
  14434   if (msg.type === "modActionApplied") {
  14435     requestModData();
  14436     renderFeed();
  14437     renderChatPanel();
  14438     renderModPanel();
  14439     return;
  14440   }
  14441 
  14442   if (msg.type === "nukeOk") {
  14443     toast(
  14444       "NUKE complete",
  14445       `Cleared ${Number(msg.deletedPosts || 0)} hives and deleted ${Number(msg.deletedUploads || 0)} uploads (kept ${Number(msg.keptUploads || 0)} profile files).`
  14446     );
  14447     return;
  14448   }
  14449 
  14450   if (msg.type === "reportCreated" && msg.report) {
  14451     if (canModerate) {
  14452       const idx = modReports.findIndex((r) => r.id === msg.report.id);
  14453       if (idx >= 0) modReports[idx] = msg.report;
  14454       else modReports.unshift(msg.report);
  14455       if (modReports.length > 200) modReports.splice(200);
  14456       renderModPanel();
  14457     } else if (msg.report.reporter === loggedInUser) {
  14458       toast("Report submitted", "Thanks. A moderator will review it.");
  14459     }
  14460     return;
  14461   }
  14462 
  14463   if (msg.type === "reportUpdated" && msg.report) {
  14464     const idx = modReports.findIndex((r) => r.id === msg.report.id);
  14465     if (idx >= 0) modReports[idx] = msg.report;
  14466     else modReports.unshift(msg.report);
  14467     if (modReports.length > 200) modReports.splice(200);
  14468     renderModPanel();
  14469     return;
  14470   }
  14471 
  14472   if (msg.type === "reactionUpdated" && msg.targetType === "chat") {
  14473     const postId = msg.postId || "";
  14474     const messageId = msg.messageId || "";
  14475     const reactions = msg.reactions && typeof msg.reactions === "object" ? msg.reactions : {};
  14476     const arr = chatByPost.get(postId) || [];
  14477     const m = arr.find((x) => x && x.id === messageId);
  14478     if (m) m.reactions = reactions;
  14479     if (activeChatPostId === postId) renderChatPanel();
  14480     renderChatInstancesForPost(postId);
  14481     return;
  14482   }
  14483 
  14484   if (msg.type === "typing") {
  14485     const postId = msg.postId || "";
  14486     const username = msg.username || "";
  14487     if (!postId || !username) return;
  14488     if (loggedInUser && username === loggedInUser) return;
  14489     const ignoreUserSet = new Set(
  14490       [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase())
  14491     );
  14492     const usernameLower = String(username || "").toLowerCase();
  14493     const selfLower = String(loggedInUser || "").toLowerCase();
  14494     if (usernameLower && usernameLower !== selfLower && ignoreUserSet.has(usernameLower)) return;
  14495     const isTyping = Boolean(msg.isTyping);
  14496     const set = typingUsersByPostId.get(postId) || new Set();
  14497     if (isTyping) set.add(username);
  14498     else set.delete(username);
  14499     if (set.size === 0) typingUsersByPostId.delete(postId);
  14500     else typingUsersByPostId.set(postId, set);
  14501     if (activeChatPostId === postId) renderTypingIndicator();
  14502     renderChatInstancesForPost(postId);
  14503     return;
  14504   }
  14505 
  14506   if (msg.type === "chatMessage") {
  14507     const arr = chatByPost.get(msg.postId) || [];
  14508     arr.push(msg.message);
  14509     if (arr.length > 200) arr.splice(0, arr.length - 200);
  14510     chatByPost.set(msg.postId, arr);
  14511     const sender = msg.message?.fromUser || "";
  14512     if (sender) {
  14513       const set = typingUsersByPostId.get(msg.postId);
  14514       if (set && set.has(sender)) {
  14515         set.delete(sender);
  14516         if (set.size === 0) typingUsersByPostId.delete(msg.postId);
  14517       }
  14518     }
  14519     const isFromYou = Boolean(sender && loggedInUser && sender === loggedInUser);
  14520     if (isFromYou) noteOwnRecentChat(msg.postId, msg.message?.createdAt);
  14521     const senderLower = String(sender || "").toLowerCase();
  14522     const selfLower = String(loggedInUser || "").toLowerCase();
  14523     const ignoreUserSet = new Set(
  14524       [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase())
  14525     );
  14526     if (!isFromYou && senderLower && senderLower !== selfLower && ignoreUserSet.has(senderLower)) {
  14527       if (activeChatPostId === msg.postId) renderChatPanel();
  14528       renderChatInstancesForPost(msg.postId);
  14529       return;
  14530     }
  14531     const p = posts.get(msg.postId);
  14532     const postOwnerLower = String(p?.author || "").toLowerCase();
  14533     const isOnYourHive = Boolean(selfLower && postOwnerLower && selfLower === postOwnerLower);
  14534     const mentions = Array.isArray(msg.message?.mentions) ? msg.message.mentions.map((u) => String(u || "").toLowerCase()) : [];
  14535     const mentionsYou = Boolean(selfLower && mentions.includes(selfLower) && !isFromYou);
  14536     const replyToUserLower = String(msg.message?.replyTo?.fromUser || "").toLowerCase();
  14537     const repliesYou = Boolean(selfLower && replyToUserLower && replyToUserLower === selfLower && !isFromYou);
  14538     const isRecentHiveChat = hasOwnRecentChat(msg.postId);
  14539     const shouldAlertReplies = readNotifReplyPingPref() && (mentionsYou || repliesYou);
  14540     const shouldAlertMyHive = readNotifMyHiveChatPref() && isOnYourHive;
  14541     const shouldAlertRecent = readNotifRecentHiveChatPref() && isRecentHiveChat;
  14542     const shouldAlert = Boolean(!isFromYou && (shouldAlertReplies || shouldAlertMyHive || shouldAlertRecent));
  14543     if (shouldAlert && readNotifSoundPref()) playSfx("notif", { volume: 0.5 });
  14544     const title = p ? postTitle(p) : "Chat";
  14545     const body = sender ? `@${sender}: ${msg.message?.text || ""}` : msg.message?.text || "";
  14546     const notifyTitle = mentionsYou || repliesYou ? `Bzl: Reply in ${title}` : `Bzl: ${title}`;
  14547     const toastTitle = mentionsYou || repliesYou ? "Reply ping" : title;
  14548     const toastBody = mentionsYou || repliesYou ? `@${sender} replied/mentioned you` : body.slice(0, 120);
  14549     if (activeChatPostId === msg.postId && windowFocused && !document.hidden) {
  14550       markRead(msg.postId);
  14551       if (!appendPostChatMessageToDom(msg.postId, msg.message)) renderChatPanel();
  14552       pulseChatMessage(msg.message?.id);
  14553       renderTypingIndicator();
  14554       if (shouldAlert) toast(toastTitle, toastBody);
  14555     } else {
  14556       if (!buzzTimers.has(msg.postId)) {
  14557         const t = window.setTimeout(() => {
  14558           buzzTimers.delete(msg.postId);
  14559           renderFeed();
  14560         }, 750);
  14561         buzzTimers.set(msg.postId, t);
  14562       } else {
  14563         clearTimeout(buzzTimers.get(msg.postId));
  14564         const t = window.setTimeout(() => {
  14565           buzzTimers.delete(msg.postId);
  14566           renderFeed();
  14567         }, 750);
  14568         buzzTimers.set(msg.postId, t);
  14569       }
  14570       bumpUnread(msg.postId);
  14571       renderFeed();
  14572       if (shouldAlert) {
  14573         if (!windowFocused || document.hidden) {
  14574           maybeNotify(notifyTitle, body.slice(0, 160), { postId: msg.postId });
  14575         } else {
  14576           toast(toastTitle, toastBody);
  14577         }
  14578       }
  14579     }
  14580     renderChatInstancesForPost(msg.postId);
  14581   }
  14582 }
  14583 
  14584 setConn("connecting");
  14585 connectWs();
  14586 initSplashSequence();
  14587 
  14588 renderLanHint();
  14589 writeHintsEnabledPref(readHintsEnabledPref());
  14590 initDisplayPrefsUi();
  14591 initAppearanceControls();
  14592 if (stayConnectedEl) {
  14593   stayConnectedEl.checked = readStayConnectedPref();
  14594   stayConnectedEl.addEventListener("change", () => {
  14595     const on = Boolean(stayConnectedEl.checked);
  14596     writeStayConnectedPref(on);
  14597     if (on) {
  14598       if (!ws || ws.readyState === WebSocket.CLOSED) connectWs();
  14599       startWsKeepalive(ws);
  14600       if (ws && ws.readyState === WebSocket.OPEN) startWsStaleWatchdog(ws);
  14601     } else {
  14602       clearWsReconnect();
  14603       clearWsKeepalive();
  14604       clearWsStaleWatchdog();
  14605     }
  14606   });
  14607 }
  14608 if (enableHintsEl) {
  14609   enableHintsEl.checked = readHintsEnabledPref();
  14610   enableHintsEl.addEventListener("change", () => {
  14611     writeHintsEnabledPref(Boolean(enableHintsEl.checked));
  14612   });
  14613 }
  14614 if (notifSoundToggleEl) {
  14615   notifSoundToggleEl.checked = readNotifSoundPref();
  14616   notifSoundToggleEl.addEventListener("change", () => {
  14617     writeNotifSoundPref(Boolean(notifSoundToggleEl.checked));
  14618     updateNotifUi();
  14619   });
  14620 }
  14621 if (notifNewHiveToggleEl) {
  14622   notifNewHiveToggleEl.checked = readNotifNewHivePref();
  14623   notifNewHiveToggleEl.addEventListener("change", () => {
  14624     writeNotifNewHivePref(Boolean(notifNewHiveToggleEl.checked));
  14625     updateNotifUi();
  14626   });
  14627 }
  14628 if (notifReplyPingToggleEl) {
  14629   notifReplyPingToggleEl.checked = readNotifReplyPingPref();
  14630   notifReplyPingToggleEl.addEventListener("change", () => {
  14631     writeNotifReplyPingPref(Boolean(notifReplyPingToggleEl.checked));
  14632     updateNotifUi();
  14633   });
  14634 }
  14635 if (notifMyHiveChatsToggleEl) {
  14636   notifMyHiveChatsToggleEl.checked = readNotifMyHiveChatPref();
  14637   notifMyHiveChatsToggleEl.addEventListener("change", () => {
  14638     writeNotifMyHiveChatPref(Boolean(notifMyHiveChatsToggleEl.checked));
  14639     updateNotifUi();
  14640   });
  14641 }
  14642 if (notifRecentHiveChatsToggleEl) {
  14643   notifRecentHiveChatsToggleEl.checked = readNotifRecentHiveChatPref();
  14644   notifRecentHiveChatsToggleEl.addEventListener("change", () => {
  14645     writeNotifRecentHiveChatPref(Boolean(notifRecentHiveChatsToggleEl.checked));
  14646     updateNotifUi();
  14647   });
  14648 }
  14649 if (chatEnterModeEl) {
  14650   chatEnterModeEl.value = readChatEnterModePref();
  14651   chatEnterModeEl.addEventListener("change", () => {
  14652     writeChatEnterModePref(chatEnterModeEl.value);
  14653   });
  14654 }
  14655 if (resetCurrentLayoutBtn) {
  14656   resetCurrentLayoutBtn.addEventListener("click", () => {
  14657     if (!rackLayoutEnabled) return;
  14658     const currentPreset = String(rackLayoutState?.presetId || layoutPresetEl?.value || "defaultSocial");
  14659     applyPreset(currentPreset);
  14660     toast("Layout", "Current preset layout reset.");
  14661   });
  14662 }
  14663 renderPeoplePanel();
  14664 setPeopleOpen(getPeopleOpen());
  14665 composerOpen = getComposerOpen();
  14666 setComposerOpen(composerOpen);
  14667 applySidebarWidth(readStoredSidebarWidth(), false);
  14668 applyChatWidth(readStoredChatWidth(), false);
  14669 applyModWidth(readStoredModWidth(), false);
  14670 applyPeopleWidth(readStoredPeopleWidth(), false);
  14671 applyChatDock();
  14672 
  14673 if (toggleReactionsEl) {
  14674   toggleReactionsEl.checked = showReactions;
  14675   toggleReactionsEl.addEventListener("change", () => {
  14676     showReactions = Boolean(toggleReactionsEl.checked);
  14677     localStorage.setItem("bzl_showReactions", showReactions ? "1" : "0");
  14678     renderFeed();
  14679     renderChatPanel();
  14680   });
  14681 }
  14682 
  14683 if (hivesViewModeEl) {
  14684   const pref = readStringPref(HIVES_VIEW_MODE_KEY, "auto");
  14685   hivesViewModeEl.value = pref === "cards" || pref === "list" ? pref : "auto";
  14686   hivesViewModeEl.addEventListener("change", () => {
  14687     const next = String(hivesViewModeEl.value || "auto").toLowerCase();
  14688     writeStringPref(HIVES_VIEW_MODE_KEY, next === "cards" || next === "list" ? next : "auto");
  14689     applyHivesViewMode();
  14690   });
  14691 }
  14692 installHivesAutoViewMode();
  14693 applyHivesViewMode();
  14694 updateMobileSortCycleLabel();
  14695 
  14696 if (chatHeaderEl && appRoot) {
  14697   chatHeaderEl.setAttribute("draggable", "true");
  14698   chatHeaderEl.title = "Drag left/right to dock chat";
  14699   chatHeaderEl.addEventListener("dragstart", (e) => {
  14700     try {
  14701       e.dataTransfer.effectAllowed = "move";
  14702       e.dataTransfer.setData("text/plain", "bzl:dock:chat");
  14703     } catch {
  14704       // ignore
  14705     }
  14706     appRoot.classList.add("isDocking");
  14707   });
  14708   chatHeaderEl.addEventListener("dragend", () => {
  14709     appRoot.classList.remove("isDocking");
  14710   });
  14711   appRoot.addEventListener("dragover", (e) => {
  14712     if (!appRoot.classList.contains("isDocking")) return;
  14713     e.preventDefault();
  14714     try {
  14715       e.dataTransfer.dropEffect = "move";
  14716     } catch {
  14717       // ignore
  14718     }
  14719   });
  14720   appRoot.addEventListener("drop", (e) => {
  14721     if (!appRoot.classList.contains("isDocking")) return;
  14722     e.preventDefault();
  14723     appRoot.classList.remove("isDocking");
  14724     const next = e.clientX > window.innerWidth * 0.58 ? "right" : "left";
  14725     if (next === chatDock) return;
  14726     chatDock = next;
  14727     localStorage.setItem("bzl_chatDock", chatDock);
  14728     applyChatDock();
  14729   });
  14730 }
  14731 
  14732 installDropUpload(editor, { allowImages: true, allowAudio: true });
  14733 installDropUpload(chatEditor, { allowImages: true, allowAudio: true });
  14734 installDropUpload(profileBioEditor, { allowImages: true, allowAudio: true });
  14735 installDropUpload(editModalEditor, { allowImages: true, allowAudio: true });
  14736 
  14737 mediaModal?.addEventListener("click", (e) => {
  14738   if (e.target?.getAttribute?.("data-mediamodalclose")) setMediaModalOpen(false);
  14739 });
  14740 mediaModalClose?.addEventListener("click", () => setMediaModalOpen(false));
  14741 mediaModalCopyLink?.addEventListener("click", async () => {
  14742   const url = String(mediaModalOpenLink?.href || "").trim();
  14743   if (!url || url === "#") return;
  14744   try {
  14745     await navigator.clipboard.writeText(url);
  14746     if (mediaModalStatus) mediaModalStatus.textContent = "Copied.";
  14747   } catch {
  14748     if (mediaModalStatus) mediaModalStatus.textContent = "Copy failed (clipboard blocked).";
  14749   }
  14750 });
  14751 shortcutHelpModal?.addEventListener("click", (e) => {
  14752   if (e.target?.getAttribute?.("data-shortcutclose")) setShortcutHelpOpen(false);
  14753 });
  14754 shortcutHelpCloseBtn?.addEventListener("click", () => setShortcutHelpOpen(false));
  14755 openShortcutHelpBtn?.addEventListener("click", () => setShortcutHelpOpen(true));
  14756 document.addEventListener("keydown", (e) => {
  14757   if (e.key !== "Escape") return;
  14758   if (mediaModal && !mediaModal.classList.contains("hidden")) {
  14759     setMediaModalOpen(false);
  14760     return;
  14761   }
  14762   if (shortcutHelpModal && !shortcutHelpModal.classList.contains("hidden")) setShortcutHelpOpen(false);
  14763 });
  14764 document.body.addEventListener("click", (e) => {
  14765   const img = e.target?.closest?.("img");
  14766   if (!img) return;
  14767   if (img.id === "profilePreview") return;
  14768   if (img.closest("#mediaModal")) return;
  14769   const inAllowed =
  14770     img.closest(".chatMsg .content") ||
  14771     img.closest(".profileBio") ||
  14772     img.closest(".profileCard") ||
  14773     img.closest(".editor") ||
  14774     img.closest("#editModalEditor");
  14775   if (!inAllowed) return;
  14776   const src = img.getAttribute("src") || "";
  14777   if (!src) return;
  14778   openMediaModal(src);
  14779 });
  14780 
  14781 setSidebarHidden(getSidebarHidden());
  14782 toggleSidebarBtn?.addEventListener("click", () => setSidebarHidden(true));
  14783 showSidebarBtn?.addEventListener("click", () => setSidebarHidden(false));
  14784 togglePeopleBtn?.addEventListener("click", () => setPeopleOpen(!peopleOpen));
  14785 closePeopleBtn?.addEventListener("click", () => setPeopleOpen(false));
  14786 peopleMembersTabBtn?.addEventListener("click", () => {
  14787   peopleTab = "members";
  14788   renderPeoplePanel();
  14789 });
  14790 peopleDmsTabBtn?.addEventListener("click", () => {
  14791   peopleTab = "dms";
  14792   renderPeoplePanel();
  14793 });
  14794 peopleSearchEl?.addEventListener("input", () => renderPeoplePanel());
  14795 peopleListEl?.addEventListener("click", (e) => {
  14796   const modDmBtn = e.target.closest("button[data-moddm]");
  14797   if (modDmBtn) {
  14798     sendModDmPrompt(modDmBtn.getAttribute("data-moddm") || "");
  14799     return;
  14800   }
  14801   const dmBtn = e.target.closest("button[data-dmrequest]");
  14802   if (dmBtn) {
  14803     const to = String(dmBtn.getAttribute("data-dmrequest") || "")
  14804       .trim()
  14805       .replace(/^@+/, "")
  14806       .toLowerCase();
  14807     if (!to) return;
  14808     if (!loggedInUser) {
  14809       toast("Sign in required", "Sign in to start a DM.");
  14810       return;
  14811     }
  14812     if (to === String(loggedInUser).toLowerCase()) return;
  14813     ws.send(JSON.stringify({ type: "dmRequestCreate", to }));
  14814     peopleTab = "dms";
  14815     renderPeoplePanel();
  14816     return;
  14817   }
  14818   const btn = e.target.closest("[data-viewprofile]");
  14819   if (!btn) return;
  14820   const username = btn.getAttribute("data-viewprofile") || "";
  14821   openUserProfile(username);
  14822 });
  14823 
  14824 peopleDmsViewEl?.addEventListener("click", (e) => {
  14825   const modDmBtn = e.target.closest("button[data-moddm]");
  14826   if (modDmBtn) {
  14827     sendModDmPrompt(modDmBtn.getAttribute("data-moddm") || "");
  14828     return;
  14829   }
  14830   const profileLink = e.target.closest("[data-viewprofile]");
  14831   if (profileLink) {
  14832     const username = profileLink.getAttribute("data-viewprofile") || "";
  14833     if (username) openUserProfile(username);
  14834     return;
  14835   }
  14836 
  14837   const openBtn = e.target.closest("button[data-dmopen]");
  14838   if (openBtn) {
  14839     const threadId = openBtn.getAttribute("data-dmopen") || "";
  14840     if (!threadId) return;
  14841     openDmThread(threadId);
  14842     return;
  14843   }
  14844 
  14845   const acceptBtn = e.target.closest("button[data-dmaccept]");
  14846   if (acceptBtn) {
  14847     const threadId = acceptBtn.getAttribute("data-dmaccept") || "";
  14848     if (!threadId) return;
  14849     pendingOpenDmThreadId = threadId;
  14850     ws.send(JSON.stringify({ type: "dmRequestRespond", threadId, accept: true }));
  14851     return;
  14852   }
  14853 
  14854   const declineBtn = e.target.closest("button[data-dmdecline]");
  14855   if (declineBtn) {
  14856     const threadId = declineBtn.getAttribute("data-dmdecline") || "";
  14857     if (!threadId) return;
  14858     ws.send(JSON.stringify({ type: "dmRequestRespond", threadId, accept: false }));
  14859     return;
  14860   }
  14861 
  14862   const requestAgainBtn = e.target.closest("button[data-dmrequest]");
  14863   if (requestAgainBtn) {
  14864     const to = String(requestAgainBtn.getAttribute("data-dmrequest") || "")
  14865       .trim()
  14866       .replace(/^@+/, "")
  14867       .toLowerCase();
  14868     if (!to || !loggedInUser) return;
  14869     if (to === String(loggedInUser).toLowerCase()) return;
  14870     ws.send(JSON.stringify({ type: "dmRequestCreate", to }));
  14871     return;
  14872   }
  14873 
  14874   const requestFromSelectBtn = e.target.closest("button[data-dmrequestfromselect]");
  14875   if (requestFromSelectBtn) {
  14876     const sel = peopleDmsViewEl.querySelector("select[data-dmto]");
  14877     const to = String(sel?.value || "")
  14878       .trim()
  14879       .replace(/^@+/, "")
  14880       .toLowerCase();
  14881     if (!to) return;
  14882     if (!loggedInUser) {
  14883       toast("Sign in required", "Sign in to start a DM.");
  14884       return;
  14885     }
  14886     if (to === String(loggedInUser).toLowerCase()) return;
  14887     ws.send(JSON.stringify({ type: "dmRequestCreate", to }));
  14888     if (sel) sel.value = "";
  14889     return;
  14890   }
  14891 });
  14892 
  14893 onboardingAcceptBtn?.addEventListener("click", () => {
  14894   if (!loggedInUser) {
  14895     toast("Sign in required", "Sign in to accept server rules.");
  14896     return;
  14897   }
  14898   ws.send(JSON.stringify({ type: "onboardingAcceptRules" }));
  14899 });
  14900 
  14901 onboardingRefreshBtn?.addEventListener("click", () => {
  14902   if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
  14903 });
  14904 
  14905 onboardingPanelAcceptBtn?.addEventListener("click", () => {
  14906   if (!loggedInUser) {
  14907     toast("Sign in required", "Sign in to accept server rules.");
  14908     return;
  14909   }
  14910   ws.send(JSON.stringify({ type: "onboardingAcceptRules" }));
  14911 });
  14912 
  14913 onboardingPanelRefreshBtn?.addEventListener("click", () => {
  14914   if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
  14915 });
  14916 
  14917 onboardingPanelBodyEl?.addEventListener("click", (e) => {
  14918   const tabBtn = e.target.closest?.("button[data-onbtab]");
  14919   if (!tabBtn) return;
  14920   const tab = String(tabBtn.getAttribute("data-onbtab") || "about").trim();
  14921   if (!["about", "rules", "roles"].includes(tab)) return;
  14922   onboardingViewerTab = tab;
  14923   renderOnboardingPanel();
  14924 });
  14925 
  14926 profileCard?.addEventListener("click", (e) => {
  14927   const modDmBtn = e.target.closest("button[data-moddm]");
  14928   if (modDmBtn) {
  14929     sendModDmPrompt(modDmBtn.getAttribute("data-moddm") || "");
  14930     return;
  14931   }
  14932   const dmBtn = e.target.closest("button[data-dmrequest]");
  14933   if (!dmBtn) return;
  14934   const to = String(dmBtn.getAttribute("data-dmrequest") || "")
  14935     .trim()
  14936     .replace(/^@+/, "")
  14937     .toLowerCase();
  14938   if (!to) return;
  14939   if (!loggedInUser) {
  14940     toast("Sign in required", "Sign in to start a DM.");
  14941     return;
  14942   }
  14943   if (to === String(loggedInUser).toLowerCase()) return;
  14944   ws.send(JSON.stringify({ type: "dmRequestCreate", to }));
  14945   peopleTab = "dms";
  14946   setPeopleOpen(true);
  14947   renderPeoplePanel();
  14948 });
  14949 profileCard?.addEventListener("click", (e) => {
  14950   const ignoreBtn = e.target.closest("button[data-ignoreuser],button[data-unignoreuser],button[data-blockuser],button[data-unblockuser]");
  14951   if (!ignoreBtn) return;
  14952   const raw =
  14953     ignoreBtn.getAttribute("data-ignoreuser") ||
  14954     ignoreBtn.getAttribute("data-unignoreuser") ||
  14955     ignoreBtn.getAttribute("data-blockuser") ||
  14956     ignoreBtn.getAttribute("data-unblockuser") ||
  14957     "";
  14958   const username = String(raw).trim().replace(/^@+/, "").toLowerCase();
  14959   if (!username || !loggedInUser) return;
  14960   if (username === String(loggedInUser).toLowerCase()) return;
  14961   if (ignoreBtn.hasAttribute("data-ignoreuser")) ws.send(JSON.stringify({ type: "ignoreUser", username }));
  14962   else if (ignoreBtn.hasAttribute("data-unignoreuser")) ws.send(JSON.stringify({ type: "unignoreUser", username }));
  14963   else if (ignoreBtn.hasAttribute("data-blockuser")) ws.send(JSON.stringify({ type: "blockUser", username }));
  14964   else if (ignoreBtn.hasAttribute("data-unblockuser")) ws.send(JSON.stringify({ type: "unblockUser", username }));
  14965 });
  14966 chatResizeHandle?.addEventListener("mousedown", (e) => {
  14967   e.preventDefault();
  14968   startChatResize(e.clientX);
  14969 });
  14970 chatResizeHandle?.addEventListener("dblclick", () => applyChatWidth(CHAT_WIDTH_DEFAULT));
  14971 sidebarResizeHandle?.addEventListener("mousedown", (e) => {
  14972   e.preventDefault();
  14973   startSidebarResize(e.clientX);
  14974 });
  14975 sidebarResizeHandle?.addEventListener("dblclick", () => applySidebarWidth(SIDEBAR_WIDTH_DEFAULT));
  14976 mainResizeHandle?.addEventListener("mousedown", (e) => {
  14977   e.preventDefault();
  14978   startModResize(e.clientX);
  14979 });
  14980 mainResizeHandle?.addEventListener("dblclick", () => applyModWidth(MOD_WIDTH_DEFAULT));
  14981 peopleResizeHandle?.addEventListener("mousedown", (e) => {
  14982   e.preventDefault();
  14983   startPeopleResize(e.clientX);
  14984 });
  14985 peopleResizeHandle?.addEventListener("dblclick", () => applyPeopleWidth(PEOPLE_WIDTH_DEFAULT));
  14986 sidebarPanelEl?.addEventListener("mousedown", (e) => {
  14987   if (e.button !== 0 || isMobileSwipeMode()) return;
  14988   const rect = sidebarPanelEl.getBoundingClientRect();
  14989   if (Math.abs(e.clientX - rect.right) > 12) return;
  14990   e.preventDefault();
  14991   startSidebarResize(e.clientX);
  14992 });
  14993 chatPanelEl?.addEventListener("mousedown", (e) => {
  14994   if (e.button !== 0 || isMobileSwipeMode()) return;
  14995   const rect = chatPanelEl.getBoundingClientRect();
  14996   if (Math.abs(e.clientX - rect.right) > 12) return;
  14997   e.preventDefault();
  14998   startChatResize(e.clientX);
  14999 });
  15000 modPanelEl?.addEventListener("mousedown", (e) => {
  15001   if (e.button !== 0 || isMobileSwipeMode() || modPanelEl.classList.contains("hidden")) return;
  15002   const rect = modPanelEl.getBoundingClientRect();
  15003   if (Math.abs(e.clientX - rect.left) > 12) return;
  15004   e.preventDefault();
  15005   startModResize(e.clientX);
  15006 });
  15007 peopleDrawerEl?.addEventListener("mousedown", (e) => {
  15008   if (e.button !== 0 || isMobileSwipeMode() || peopleDrawerEl.classList.contains("hidden")) return;
  15009   const rect = peopleDrawerEl.getBoundingClientRect();
  15010   if (Math.abs(e.clientX - rect.left) > 12) return;
  15011   e.preventDefault();
  15012   startPeopleResize(e.clientX);
  15013 });
  15014 mobileNavEl?.addEventListener("click", (e) => {
  15015   const btn = e.target.closest("[data-mobilescreen]");
  15016   if (!btn) return;
  15017   const id = String(btn.getAttribute("data-mobilescreen") || "").trim();
  15018   if (!id) return;
  15019   if (id === "more") {
  15020     renderMobileMoreList();
  15021     setMobileMoreOpen(true);
  15022     return;
  15023   }
  15024   const layout = loadMobileLayout();
  15025   layout.active = id;
  15026   saveMobileLayout(layout);
  15027   setMobileScreen(id);
  15028   renderMobileNav();
  15029 });
  15030 
  15031 function renderMobileMoreList() {
  15032   if (!(mobileMoreListEl instanceof HTMLElement)) return;
  15033   const q = String(mobileMoreSearchEl?.value || "").trim().toLowerCase();
  15034   const { core, plugins } = availableMobileScreens();
  15035 
  15036   const filter = (item) => {
  15037     if (!q) return true;
  15038     return String(item.title || "").toLowerCase().includes(q) || String(item.id || "").toLowerCase().includes(q);
  15039   };
  15040 
  15041   const section = (title, items) => {
  15042     const wrap = document.createElement("div");
  15043     const head = document.createElement("div");
  15044     head.className = "muted small";
  15045     head.textContent = title;
  15046     head.style.margin = "6px 0 6px 2px";
  15047     wrap.appendChild(head);
  15048     const list = document.createElement("div");
  15049     list.style.display = "flex";
  15050     list.style.flexDirection = "column";
  15051     list.style.gap = "10px";
  15052     for (const it of items.filter(filter)) {
  15053       const row = document.createElement("button");
  15054       row.type = "button";
  15055       row.className = "mobileMoreItem";
  15056       row.innerHTML = `<span>${escapeHtml(it.title || it.id)}</span><span class="muted small">${escapeHtml(it.core ? "core" : "plugin")}</span>`;
  15057       row.onclick = () => {
  15058         const layout = loadMobileLayout();
  15059         layout.active = it.id;
  15060         saveMobileLayout(layout);
  15061         setMobileScreen(it.id);
  15062         renderMobileNav();
  15063         setMobileMoreOpen(false);
  15064       };
  15065       list.appendChild(row);
  15066     }
  15067     wrap.appendChild(list);
  15068     return wrap;
  15069   };
  15070 
  15071   mobileMoreListEl.innerHTML = "";
  15072   mobileMoreListEl.appendChild(section("Core", core));
  15073   if (plugins.length) mobileMoreListEl.appendChild(section("Plugins", plugins));
  15074 }
  15075 
  15076 mobileMoreSearchEl?.addEventListener("input", () => {
  15077   if (!mobileMoreOpen) return;
  15078   renderMobileMoreList();
  15079 });
  15080 
  15081 mobileMoreCloseBtn?.addEventListener("click", () => setMobileMoreOpen(false));
  15082 mobileMoreSheetEl?.addEventListener("click", (e) => {
  15083   const target = e.target;
  15084   if (!target) return;
  15085   if (target.closest?.("[data-mobilemoreclose]")) setMobileMoreOpen(false);
  15086 });
  15087 
  15088 streamStagePrimaryBtn?.addEventListener("click", () => {
  15089   const post = streamStageCurrentPost();
  15090   if (!post) return;
  15091   const postId = String(post.id || "");
  15092   if (!postId) return;
  15093   if (streamCurrentRole === "host" && streamCurrentPostId === postId) {
  15094     leaveActiveStream(true);
  15095     renderChatPanel(false);
  15096     return;
  15097   }
  15098   if (streamCurrentRole === "viewer" && streamCurrentPostId === postId) {
  15099     leaveActiveStream(true);
  15100     renderChatPanel(false);
  15101     return;
  15102   }
  15103   const live = Boolean(streamLiveByPostId.get(postId) ?? post.streamLive);
  15104   if (live) joinStream(post);
  15105   else startStreamHost(post);
  15106 });
  15107 
  15108 streamVoiceJoinToggleEl?.addEventListener("change", async () => {
  15109   const enabled = Boolean(streamVoiceJoinToggleEl.checked);
  15110   if (streamCurrentRole !== "viewer") {
  15111     streamVoiceJoinToggleEl.checked = streamCurrentRole === "host";
  15112     return;
  15113   }
  15114   if (enabled) {
  15115     const ok = await enableStreamVoice();
  15116     if (!ok) streamVoiceJoinToggleEl.checked = false;
  15117   } else {
  15118     disableStreamVoice();
  15119     streamVoiceJoinToggleEl.checked = false;
  15120   }
  15121   renderChatPanel(false);
  15122 });
  15123 
  15124 streamVoiceMuteBtn?.addEventListener("click", () => {
  15125   if (!(streamCurrentRole === "host" || streamVoiceJoined)) return;
  15126   streamVoiceMuted = !streamVoiceMuted;
  15127   updateStreamLocalMuteState();
  15128   renderChatPanel(false);
  15129 });
  15130 
  15131 streamVoiceDeafenBtn?.addEventListener("click", () => {
  15132   if (!(streamCurrentRole === "host" || streamCurrentRole === "viewer")) return;
  15133   streamVoiceDeafened = !streamVoiceDeafened;
  15134   updateStreamOutputMuteState();
  15135   renderChatPanel(false);
  15136 });
  15137 
  15138 streamVoiceUsersEl?.addEventListener("input", (e) => {
  15139   const input = e.target;
  15140   if (!(input instanceof HTMLInputElement)) return;
  15141   const peerId = String(input.getAttribute("data-streamvol") || "").trim();
  15142   if (!peerId) return;
  15143   const volPct = Math.max(0, Math.min(100, Number(input.value) || 0));
  15144   const vol = volPct / 100;
  15145   streamPeerVolumeByClientId.set(peerId, vol);
  15146   const audioEl = streamRemoteAudioByClientId.get(peerId);
  15147   if (audioEl) audioEl.volume = vol;
  15148 });
  15149 
  15150 walkieRecordBtn?.addEventListener("pointerdown", (e) => {
  15151   e.preventDefault();
  15152   startWalkieRecording();
  15153 });
  15154 walkieRecordBtn?.addEventListener("pointerup", (e) => {
  15155   e.preventDefault();
  15156   stopWalkieRecording();
  15157 });
  15158 walkieRecordBtn?.addEventListener("pointerleave", () => stopWalkieRecording());
  15159 walkieRecordBtn?.addEventListener("mousedown", (e) => {
  15160   e.preventDefault();
  15161   startWalkieRecording();
  15162 });
  15163 walkieRecordBtn?.addEventListener("mouseup", (e) => {
  15164   e.preventDefault();
  15165   stopWalkieRecording();
  15166 });
  15167 walkieRecordBtn?.addEventListener(
  15168   "touchstart",
  15169   (e) => {
  15170     e.preventDefault();
  15171     startWalkieRecording();
  15172   },
  15173   { passive: false }
  15174 );
  15175 walkieRecordBtn?.addEventListener(
  15176   "touchend",
  15177   (e) => {
  15178     e.preventDefault();
  15179     stopWalkieRecording();
  15180   },
  15181   { passive: false }
  15182 );
  15183 
  15184 window.addEventListener("keydown", (e) => {
  15185   if (!shouldHandleWalkieHotkey(e)) return;
  15186   if (!canWalkieTalkNow()) return;
  15187   e.preventDefault();
  15188   startWalkieRecording();
  15189 });
  15190 window.addEventListener("keyup", (e) => {
  15191   if (!shouldHandleWalkieHotkey(e)) return;
  15192   if (!canWalkieTalkNow()) return;
  15193   e.preventDefault();
  15194   stopWalkieRecording();
  15195 });
  15196 window.addEventListener("pointerup", () => stopWalkieRecording());
  15197 window.addEventListener("mouseup", () => stopWalkieRecording());
  15198 window.addEventListener("mousemove", (e) => {
  15199   if (chatResizeDragging) {
  15200     const next = chatResizeStartWidth + (e.clientX - chatResizeStartX);
  15201     applyChatWidth(next, false);
  15202     return;
  15203   }
  15204   if (sidebarResizeDragging) {
  15205     const next = sidebarResizeStartWidth + (e.clientX - sidebarResizeStartX);
  15206     applySidebarWidth(next, false);
  15207     return;
  15208   }
  15209   if (modResizeDragging) {
  15210     const next = modResizeStartWidth - (e.clientX - modResizeStartX);
  15211     applyModWidth(next, false);
  15212     return;
  15213   }
  15214   if (peopleResizeDragging) {
  15215     const next = peopleResizeStartWidth - (e.clientX - peopleResizeStartX);
  15216     applyPeopleWidth(next, false);
  15217   }
  15218 });
  15219 window.addEventListener("mouseup", () => {
  15220   if (chatResizeDragging && chatPanelEl) {
  15221     applyChatWidth(chatPanelEl.getBoundingClientRect().width || readStoredChatWidth());
  15222   }
  15223   if (sidebarResizeDragging && sidebarPanelEl) {
  15224     applySidebarWidth(sidebarPanelEl.getBoundingClientRect().width || readStoredSidebarWidth());
  15225   }
  15226   if (modResizeDragging && modPanelEl) {
  15227     applyModWidth(modPanelEl.getBoundingClientRect().width || readStoredModWidth());
  15228   }
  15229   if (peopleResizeDragging && peopleDrawerEl) {
  15230     applyPeopleWidth(peopleDrawerEl.getBoundingClientRect().width || readStoredPeopleWidth());
  15231   }
  15232   stopAnyPanelResize();
  15233 });
  15234 
  15235 appRoot?.addEventListener(
  15236   "touchstart",
  15237   (e) => {
  15238     if (!isMobileSwipeMode()) return;
  15239     if (!e.touches || e.touches.length !== 1) return;
  15240     const t = e.touches[0];
  15241     touchStartX = t.clientX;
  15242     touchStartY = t.clientY;
  15243     touchTracking = true;
  15244   },
  15245   { passive: true }
  15246 );
  15247 
  15248 appRoot?.addEventListener(
  15249   "touchend",
  15250   (e) => {
  15251     if (!isMobileSwipeMode() || !touchTracking) return;
  15252     touchTracking = false;
  15253     if (!e.changedTouches || e.changedTouches.length !== 1) return;
  15254     const t = e.changedTouches[0];
  15255     const dx = t.clientX - touchStartX;
  15256     const dy = t.clientY - touchStartY;
  15257     if (Math.abs(dx) < 60) return;
  15258     if (Math.abs(dx) < Math.abs(dy) * 1.2) return;
  15259     if (dx < 0) shiftMobilePanel(1);
  15260     else shiftMobilePanel(-1);
  15261   },
  15262   { passive: true }
  15263 );
  15264 
  15265 window.addEventListener("resize", applyMobileMode);
  15266 applyMobileMode();
  15267 
  15268 // Initialize experimental rack layout (safe no-op when disabled).
  15269 initRackLayout();
  15270 
  15271 window.addEventListener("focus", () => {
  15272   windowFocused = true;
  15273   updateNotifUi();
  15274   if (readStayConnectedPref() && (!ws || ws.readyState === WebSocket.CLOSED)) {
  15275     connectWs();
  15276     return;
  15277   }
  15278   requestForegroundResync("focus");
  15279 });
  15280 window.addEventListener("blur", () => {
  15281   windowFocused = false;
  15282   stopAnyPanelResize();
  15283 });
  15284 document.addEventListener("visibilitychange", () => {
  15285   updateNotifUi();
  15286   if (document.hidden) return;
  15287   if (readStayConnectedPref() && (!ws || ws.readyState === WebSocket.CLOSED)) {
  15288     connectWs();
  15289     return;
  15290   }
  15291   requestForegroundResync("visibility");
  15292 });
  15293 
  15294 enableNotifsBtn?.addEventListener("click", async () => {
  15295   if (!notifSupported()) return;
  15296   try {
  15297     const res = await Notification.requestPermission();
  15298     if (res === "granted") toast("Notifications", "Enabled.");
  15299   } catch {
  15300     // ignore
  15301   }
  15302   updateNotifUi();
  15303 });
  15304 
  15305 updateNotifUi();