bzl

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

app.js (472681B)


      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 toggleReactionsEl = document.getElementById("toggleReactions");
     28 const hivesViewModeEl = document.getElementById("hivesViewMode");
     29 const toggleRackLayoutEl = document.getElementById("toggleRackLayout");
     30 const toggleSideRackEl = document.getElementById("toggleSideRack");
     31 const toggleRightRackEl = document.getElementById("toggleRightRack");
     32 const layoutPresetEl = document.getElementById("layoutPreset");
     33 const uiScaleEl = document.getElementById("uiScale");
     34 const deviceLayoutEl = document.getElementById("deviceLayout");
     35 const stayConnectedEl = document.getElementById("stayConnected");
     36 const enableHintsEl = document.getElementById("enableHints");
     37 const chatEnterModeEl = document.getElementById("chatEnterMode");
     38 const openShortcutHelpBtn = document.getElementById("openShortcutHelp");
     39 const resetCurrentLayoutBtn = document.getElementById("resetCurrentLayout");
     40 const dockHotbarEl = document.getElementById("dockHotbar");
     41 const showSideRackBtn = document.getElementById("showSideRack");
     42 const showRightRackBtn = document.getElementById("showRightRack");
     43 const chatModToggleWrapEl = document.getElementById("chatModToggleWrap");
     44 const chatModToggleEl = document.getElementById("chatModToggle");
     45 
     46 const authHint = document.getElementById("authHint");
     47 const onboardingCard = document.getElementById("onboardingCard");
     48 const onboardingBody = document.getElementById("onboardingBody");
     49 const onboardingAcceptBtn = document.getElementById("onboardingAccept");
     50 const onboardingRefreshBtn = document.getElementById("onboardingRefresh");
     51 const userLabel = document.getElementById("userLabel");
     52 const authForm = document.getElementById("authForm");
     53 const authUser = document.getElementById("authUser");
     54 const authPass = document.getElementById("authPass");
     55 const codeRow = document.getElementById("codeRow");
     56 const authCode = document.getElementById("authCode");
     57 const registerBtn = document.getElementById("registerBtn");
     58 const logoutBtn = document.getElementById("logoutBtn");
     59 
     60 const profileImageInput = document.getElementById("profileImage");
     61 const profilePreview = document.getElementById("profilePreview");
     62 const removeProfileImageBtn = document.getElementById("removeProfileImage");
     63 const nameColorInput = document.getElementById("nameColor");
     64 const saveProfileBtn = document.getElementById("saveProfile");
     65 const profileStatus = document.getElementById("profileStatus");
     66 // Instance + plugin admin UI lives in Moderation -> Server tab (rendered dynamically).
     67 const modPanelEl = document.getElementById("modPanel");
     68 const modBodyEl = document.getElementById("modBody");
     69 const modRefreshBtn = document.getElementById("modRefresh");
     70 const modReportStatusEl = document.getElementById("modReportStatus");
     71 const modModal = document.getElementById("modModal");
     72 const modModalTitle = document.getElementById("modModalTitle");
     73 const modModalBody = document.getElementById("modModalBody");
     74 const modModalPrimary = document.getElementById("modModalPrimary");
     75 const modModalCancel = document.getElementById("modModalCancel");
     76 const modModalClose = document.getElementById("modModalClose");
     77 const modModalStatus = document.getElementById("modModalStatus");
     78 const mediaModal = document.getElementById("mediaModal");
     79 const mediaModalTitle = document.getElementById("mediaModalTitle");
     80 const mediaModalImg = document.getElementById("mediaModalImg");
     81 const mediaModalOpenLink = document.getElementById("mediaModalOpenLink");
     82 const mediaModalCopyLink = document.getElementById("mediaModalCopyLink");
     83 const mediaModalClose = document.getElementById("mediaModalClose");
     84 const mediaModalStatus = document.getElementById("mediaModalStatus");
     85 const shortcutHelpModal = document.getElementById("shortcutHelpModal");
     86 const shortcutHelpCloseBtn = document.getElementById("shortcutHelpClose");
     87 
     88 const newPostForm = document.getElementById("newPostForm");
     89 const pollinatePanel = document.getElementById("pollinatePanel");
     90 const toggleComposerBtn = document.getElementById("toggleComposer");
     91 const toggleComposerInlineBtn = document.getElementById("toggleComposerInline");
     92 const mainRackEl = document.getElementById("mainRack");
     93 const mainWorkspaceRackEl = document.getElementById("mainWorkspaceRack");
     94 const mainSideRackEl = document.getElementById("mainSideRack");
     95 const hivesPanelEl = document.getElementById("hivesPanel");
     96 const postTitleInput = document.getElementById("postTitle");
     97 const postImageInput = document.getElementById("postImage");
     98 const postAudioInput = document.getElementById("postAudio");
     99 const editor = document.getElementById("editor");
    100 const postCollectionEl = document.getElementById("postCollection");
    101 const keywordsEl = document.getElementById("keywords");
    102 const ttlMinutesEl = document.getElementById("ttlMinutes");
    103 const isProtectedEl = document.getElementById("isProtected");
    104 const isWalkieEl = document.getElementById("isWalkie");
    105 const postPasswordEl = document.getElementById("postPassword");
    106 
    107 const filterKeywordsEl = document.getElementById("filterKeywords");
    108 const filterAuthorEl = document.getElementById("filterAuthor");
    109 const sortByEl = document.getElementById("sortBy");
    110 const mobileHiveSearchBtn = document.getElementById("mobileHiveSearch");
    111 const mobileSortCycleBtn = document.getElementById("mobileSortCycle");
    112 const clearFilterBtn = document.getElementById("clearFilter");
    113 const feedEl = document.getElementById("feed");
    114 const hiveTabsEl = document.getElementById("hiveTabs");
    115 const onboardingPanelEl = document.getElementById("onboardingPanel");
    116 const onboardingPanelBodyEl = document.getElementById("onboardingPanelBody");
    117 const onboardingPanelAcceptBtn = document.getElementById("onboardingPanelAccept");
    118 const onboardingPanelRefreshBtn = document.getElementById("onboardingPanelRefresh");
    119 const profileViewPanel = document.getElementById("profileViewPanel");
    120 const profileViewTitle = document.getElementById("profileViewTitle");
    121 const profileViewMeta = document.getElementById("profileViewMeta");
    122 const profileCard = document.getElementById("profileCard");
    123 const profileBackBtn = document.getElementById("profileBackBtn");
    124 const profileEditToggleBtn = document.getElementById("profileEditToggleBtn");
    125 const profileEditPanel = document.getElementById("profileEditPanel");
    126 const profilePronounsInput = document.getElementById("profilePronouns");
    127 const profileThemeSongUrlInput = document.getElementById("profileThemeSongUrl");
    128 const profileThemeSongUploadBtn = document.getElementById("profileThemeSongUploadBtn");
    129 const profileThemeSongClearBtn = document.getElementById("profileThemeSongClearBtn");
    130 const profileThemeSongFileInput = document.getElementById("profileThemeSongFile");
    131 const profileThemeSongPreview = document.getElementById("profileThemeSongPreview");
    132 const profileBioToolbar = document.getElementById("profileBioToolbar");
    133 const profileBioEditor = document.getElementById("profileBioEditor");
    134 const profileBioImageFileInput = document.getElementById("profileBioImageFile");
    135 const profileBioAudioFileInput = document.getElementById("profileBioAudioFile");
    136 const profileAddLinkBtn = document.getElementById("profileAddLinkBtn");
    137 const profileLinksEditor = document.getElementById("profileLinksEditor");
    138 const profileSaveBtn = document.getElementById("profileSaveBtn");
    139 const profileCancelBtn = document.getElementById("profileCancelBtn");
    140 
    141 const chatTitle = document.getElementById("chatTitle");
    142 const chatMeta = document.getElementById("chatMeta");
    143 const chatContextSelectEl = document.getElementById("chatContextSelect");
    144 const chatBackToListBtn = document.getElementById("chatBackToList");
    145 const chatMessagesEl = document.getElementById("chatMessages");
    146 const typingIndicator = document.getElementById("typingIndicator");
    147 const chatForm = document.getElementById("chatForm");
    148 const chatReplyBanner = document.getElementById("chatReplyBanner");
    149 const chatReplyWho = document.getElementById("chatReplyWho");
    150 const chatReplyText = document.getElementById("chatReplyText");
    151 const chatReplyCancelBtn = document.getElementById("chatReplyCancel");
    152 const chatEditor = document.getElementById("chatEditor");
    153 const mentionMenuEl = document.getElementById("mentionMenu");
    154 const chatImageInput = document.getElementById("chatImage");
    155 const chatAudioInput = document.getElementById("chatAudio");
    156 
    157 // When selecting images/audio for chat, route the insertion to the most-recently focused rich editor
    158 // (main chat panel or a chat instance panel).
    159 let chatUploadTargetEditor = chatEditor;
    160 const walkieBarEl = document.getElementById("walkieBar");
    161 const walkieRecordBtn = document.getElementById("walkieRecordBtn");
    162 const walkieStatusEl = document.getElementById("walkieStatus");
    163 const sidebarPanelEl = document.querySelector(".sidebar");
    164 const chatResizeHandle = document.getElementById("chatResizeHandle");
    165 const sidebarResizeHandle = document.getElementById("sidebarResizeHandle");
    166 const mainResizeHandle = document.getElementById("mainResizeHandle");
    167 const chatPanelEl = document.querySelector(".chat");
    168 const peopleResizeHandle = document.getElementById("peopleResizeHandle");
    169 const chatHeaderEl = chatPanelEl ? chatPanelEl.querySelector(".panelHeader") : null;
    170 const editModal = document.getElementById("editModal");
    171 const editModalTitle = document.getElementById("editModalTitle");
    172 const editModalCloseBtn = document.getElementById("editModalClose");
    173 const editModalCancelBtn = document.getElementById("editModalCancel");
    174 const editModalSaveBtn = document.getElementById("editModalSave");
    175 const editModalStatus = document.getElementById("editModalStatus");
    176 const editModalPostTitleRow = document.getElementById("editModalPostTitleRow");
    177 const editModalPostTitleInput = document.getElementById("editModalPostTitle");
    178 const editModalPostMeta = document.getElementById("editModalPostMeta");
    179 const editModalKeywordsInput = document.getElementById("editModalKeywords");
    180 const editModalCollectionSelect = document.getElementById("editModalCollection");
    181 const editModalProtectedToggle = document.getElementById("editModalProtected");
    182 const editModalWalkieToggle = document.getElementById("editModalWalkie");
    183 const editModalPasswordRow = document.getElementById("editModalPasswordRow");
    184 const editModalPasswordInput = document.getElementById("editModalPassword");
    185 const editModalToolbar = document.getElementById("editModalToolbar");
    186 const editModalEditor = document.getElementById("editModalEditor");
    187 const editModalImageInput = document.getElementById("editModalImage");
    188 const editModalAudioInput = document.getElementById("editModalAudio");
    189 
    190 // Temporarily force rack mode on (hide toggle) while the feature stabilizes.
    191 const FORCE_RACK_MODE = true;
    192 
    193 // Display prefs (device layout + text scale)
    194 const UI_SCALE_KEY = "bzl_uiScale"; // "auto" | "xs" | "sm" | "md" | "lg"
    195 const DEVICE_LAYOUT_KEY = "bzl_deviceLayout"; // "auto" | "widescreen" | "fourThree" | "threeTwo" | "ultrawide" | "portrait"
    196 
    197 /** @type {Map<string, any>} */
    198 const posts = new Map();
    199 /** @type {Record<string, {image?: string, color?: string}>} */
    200 let profiles = {};
    201 
    202 /** @type {Map<string, any[]>} */
    203 const chatByPost = new Map();
    204 /** @type {Map<string, number>} */
    205 const unreadByPostId = new Map();
    206 /** @type {Map<string, Set<string>>} */
    207 const typingUsersByPostId = new Map();
    208 /** @type {Set<string>} */
    209 const myReacts = new Set();
    210 /** @type {Map<string, number>} */
    211 const reactPulseByKey = new Map();
    212 let allowedReactions = ["πŸ‘", "❀️", "😑", "😭", "πŸ₯Ί", "πŸ˜‚"];
    213 
    214 let clientId = null;
    215 let loggedInUser = null;
    216 let loggedInRole = "member";
    217 let canModerate = false;
    218 let canRegisterFirstUser = false;
    219 let registrationEnabled = false;
    220 let activeChatPostId = null;
    221 let activeMapsRoomId = "";
    222 let activeMapsRoomTitle = "";
    223 let activeMapsChatScope = "local"; // "local" | "global"
    224 /** @type {Map<string, any[]>} */
    225 const mapsChatGlobalByMapId = new Map();
    226 /** @type {Map<string, any[]>} */
    227 const mapsChatLocalByMapId = new Map();
    228 let pendingProfileImage = "";
    229 let windowFocused = true;
    230 let typingStopTimer = null;
    231 let lastTypingSentAt = 0;
    232 let modTab = "reports";
    233 let onboardingViewerTab = "about";
    234 let onboardingAdminTab = "about";
    235 let onboardingAdminDraft = {
    236   enabled: true,
    237   aboutContent: "",
    238   requireAcceptance: false,
    239   blockReadUntilAccepted: false,
    240   roleSelectEnabled: true,
    241   selfAssignableRoleIds: [],
    242   rules: [],
    243 };
    244 let onboardingAdminDraftStamp = "";
    245 const onboardingAdminExpandedRuleIds = new Set();
    246 let modReports = [];
    247 let modUsers = [];
    248 let modLog = [];
    249 let devLog = [];
    250 let modLogView = localStorage.getItem("bzl_modLogView") || "dev"; // "dev" | "moderation"
    251 let devLogAutoScroll = localStorage.getItem("bzl_devLogAutoScroll") !== "0";
    252 let modModalContext = null;
    253 let lanUrls = [];
    254 const MOBILE_LAYOUT_KEY = "bzl_mobile_layout_v1";
    255 let mobilePanel = "hives"; // Back-compat: used by older call sites (maps to mobile "screen" now).
    256 let mobileMoreOpen = false;
    257 let mobileHostPanelId = "";
    258 const mobileHostRestoreParentByPanelId = new Map();
    259 const mobileHostedPanelIds = new Set();
    260 const mobileHostEphemeralPanelIds = new Set();
    261 let composerOpen = false;
    262 let touchStartX = 0;
    263 let touchStartY = 0;
    264 let touchTracking = false;
    265 let peopleOpen = false;
    266 let peopleTab = "members";
    267 let peopleMembers = [];
    268 let openPostMenuId = "";
    269 
    270 // Multi-instance chat panels (MVP: per-hive/post chat panels).
    271 /** @type {Map<string, {postId:string}>} */
    272 const chatPanelInstances = new Map();
    273 
    274 function isChatInstancePanelId(panelId) {
    275   const id = String(panelId || "");
    276   return id.startsWith("chat:post:");
    277 }
    278 
    279 function chatInstancePanelIdForPost(postId) {
    280   const pid = String(postId || "").trim();
    281   if (!pid) return "";
    282   return `chat:post:${pid}`;
    283 }
    284 let dmThreads = [];
    285 /** @type {Map<string, any>} */
    286 let dmThreadsById = new Map();
    287 /** @type {Map<string, any[]>} */
    288 const dmMessagesByThreadId = new Map();
    289 let activeDmThreadId = null;
    290 let pendingOpenDmThreadId = "";
    291 const CHAT_RECENTS_LIMIT = 24;
    292 let recentHiveChatIds = [];
    293 let recentDmChatThreadIds = [];
    294 let syncingChatContextSelect = false;
    295 let walkieRecording = false;
    296 let walkieStartAt = 0;
    297 let walkieRecorder = null;
    298 let walkieChunks = [];
    299 let walkieCtx = null;
    300 let walkieMicStream = null;
    301 let walkieMixNode = null;
    302 let walkieDestNode = null;
    303 let walkieDispatchBuffer = null;
    304 const SESSION_TOKEN_KEY = "bzl_session_token";
    305 const CLIENT_IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
    306 const CLIENT_AUDIO_UPLOAD_MAX_BYTES = 150 * 1024 * 1024;
    307 let allowedPostReactions = ["πŸ‘", "❀️", "😑", "😭", "πŸ₯Ί", "πŸ˜‚", "⭐"];
    308 let allowedChatReactions = ["πŸ‘", "❀️", "😑", "😭", "πŸ₯Ί", "πŸ˜‚"];
    309 let userPrefs = { starredPostIds: [], hiddenPostIds: [], ignoredUsers: [], blockedUsers: [] };
    310 let showReactions = localStorage.getItem("bzl_showReactions") !== "0";
    311 let chatDock = localStorage.getItem("bzl_chatDock") === "right" ? "right" : "left";
    312 let activeHiveView = "all";
    313 let collections = [];
    314 let customRoles = [];
    315 let plugins = [];
    316 const loadedPluginClientVersionById = new Map(); // pluginId -> version string
    317 let centerView = "hives";
    318 const HIVES_VIEW_MODE_KEY = "bzl_hivesViewMode";
    319 const HIVES_LIST_AUTO_THRESHOLD_PX = 520;
    320 let lastHivesWidthPx = 0;
    321 let hivesResizeObserver = null;
    322 
    323 // --- Rack layout (experimental) ------------------------------------------------
    324 
    325 const RACK_LAYOUT_ENABLED_KEY = "bzl_rackLayout_enabled";
    326 const RACK_LAYOUT_STATE_KEY = "bzl_rackLayout_state_v2";
    327 const RACK_SIDE_COLLAPSED_KEY = "bzl_rackLayout_sideCollapsed";
    328 const RACK_RIGHT_COLLAPSED_KEY = "bzl_rackLayout_rightCollapsed";
    329 const WORKSPACE_EXPANDED_PRIMARY_KEY = "bzl_workspace_expandedPrimary";
    330 const WORKSPACE_EXPANDED_DISPLACED_KEY = "bzl_workspace_expandedDisplaced";
    331 
    332 /**
    333  * @typedef {{
    334  *   version: 2,
    335  *   presetId: string,
    336  *   docked: { bottom: string[] },
    337  *   racks?: { workspaceLeft?: string[], workspaceRight?: string[], side?: string[], right?: string[] },
    338  * }} RackLayoutState
    339  */
    340 
    341 /** @type {RackLayoutState} */
    342 let rackLayoutState = {
    343   version: 2,
    344   presetId: "onboardingDefault",
    345   docked: { bottom: [] },
    346   racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
    347 };
    348 let rackLayoutEnabled = false;
    349 let rightRackEl = null;
    350 let mainRack = null;
    351 let mainSideRack = null;
    352 const WORKSPACE_ACTIVE_PRIMARY_KEY = "bzl_workspace_activePrimary";
    353 
    354 function readBoolPref(key, fallback = false) {
    355   try {
    356     const raw = localStorage.getItem(key);
    357     if (raw == null) return fallback;
    358     return raw === "1" || raw === "true";
    359   } catch {
    360     return fallback;
    361   }
    362 }
    363 
    364 function writeBoolPref(key, value) {
    365   try {
    366     localStorage.setItem(key, value ? "1" : "0");
    367   } catch {
    368     // ignore
    369   }
    370 }
    371 
    372 function readWorkspaceExpandedPrimary() {
    373   return readStringPref(WORKSPACE_EXPANDED_PRIMARY_KEY, "").trim();
    374 }
    375 
    376 function writeWorkspaceExpandedPrimary(panelId) {
    377   writeStringPref(WORKSPACE_EXPANDED_PRIMARY_KEY, String(panelId || "").trim());
    378 }
    379 
    380 function readWorkspaceExpandedDisplaced() {
    381   return readStringPref(WORKSPACE_EXPANDED_DISPLACED_KEY, "").trim();
    382 }
    383 
    384 function writeWorkspaceExpandedDisplaced(panelId) {
    385   writeStringPref(WORKSPACE_EXPANDED_DISPLACED_KEY, String(panelId || "").trim());
    386 }
    387 
    388 function clearWorkspaceExpandedState() {
    389   writeWorkspaceExpandedPrimary("");
    390   writeWorkspaceExpandedDisplaced("");
    391 }
    392 
    393 function togglePrimaryExpand(panelId) {
    394   if (!rackLayoutEnabled) return;
    395   const id = String(panelId || "").trim();
    396   if (!id) return;
    397   if (!panelCanExpand(id)) return;
    398 
    399   const current = readWorkspaceExpandedPrimary();
    400   const left = ensureWorkspaceLeftRack();
    401   const right = ensureWorkspaceRightRack();
    402   if (!left || !right) return;
    403 
    404   // If the panel isn't in a workspace slot, pull it into the workspace first.
    405   const panelEl = getPanelElement(id);
    406   if (panelEl) {
    407     const inWorkspace = panelEl.parentElement === left || panelEl.parentElement === right;
    408     if (!inWorkspace) {
    409       const leftExisting = left.querySelector?.(":scope > .rackPanel:not(.hidden)");
    410       const rightExisting = right.querySelector?.(":scope > .rackPanel:not(.hidden)");
    411       const leftEmpty = !leftExisting;
    412       const rightEmpty = !rightExisting;
    413       // Prefer the right slot for "aux" expandables like Moderation/Composer.
    414       const target = rightEmpty ? right : leftEmpty ? left : right;
    415       const existing = target === left ? leftExisting : rightExisting;
    416       if (existing instanceof HTMLElement && existing !== panelEl) {
    417         const existingId = String(existing.dataset?.panelId || "").trim();
    418         if (existingId) dockPanel(existingId);
    419       }
    420       target.appendChild(panelEl);
    421       syncRackStateFromDom();
    422       enforceWorkspaceRules();
    423     }
    424   }
    425 
    426   const leftPanel = left.querySelector?.(":scope > .rackPanel");
    427   const rightPanel = right.querySelector?.(":scope > .rackPanel");
    428   const leftId = String(leftPanel?.dataset?.panelId || "").trim();
    429   const rightId = String(rightPanel?.dataset?.panelId || "").trim();
    430 
    431   if (current && current === id) {
    432     // Collapse: try to restore the displaced panel (if any) back into the now-visible other slot.
    433     const displaced = readWorkspaceExpandedDisplaced();
    434     clearWorkspaceExpandedState();
    435     if (displaced && isDocked(displaced)) {
    436       undockPanel(displaced);
    437       const el = getPanelElement(displaced);
    438       if (el) {
    439         if (leftId === id && !rightId) right.appendChild(el);
    440         else if (rightId === id && !leftId) left.appendChild(el);
    441       }
    442     }
    443     enforceWorkspaceRules();
    444     return;
    445   }
    446 
    447   // Expand: if the other slot is occupied, dock it so it stays accessible via hotbar.
    448   writeWorkspaceExpandedPrimary(id);
    449   let displaced = "";
    450   if (leftId === id && rightId) displaced = rightId;
    451   if (rightId === id && leftId) displaced = leftId;
    452   if (displaced && displaced !== id) {
    453     writeWorkspaceExpandedDisplaced(displaced);
    454     dockPanel(displaced);
    455   } else {
    456     writeWorkspaceExpandedDisplaced("");
    457   }
    458   enforceWorkspaceRules();
    459 }
    460 
    461 function readStringPref(key, fallback = "") {
    462   try {
    463     const raw = localStorage.getItem(key);
    464     if (raw == null) return fallback;
    465     return String(raw);
    466   } catch {
    467     return fallback;
    468   }
    469 }
    470 
    471 function normalizeUiScale(raw) {
    472   const v = String(raw || "").trim().toLowerCase();
    473   if (v === "auto") return "auto";
    474   if (v === "xs" || v === "compact") return "xs";
    475   if (v === "sm" || v === "small") return "sm";
    476   if (v === "lg" || v === "large") return "lg";
    477   return "md";
    478 }
    479 
    480 function normalizeDeviceLayout(raw) {
    481   const v = String(raw || "").trim().toLowerCase();
    482   if (v === "widescreen") return "widescreen";
    483   if (v === "fourthree" || v === "fourThree".toLowerCase() || v === "4:3" || v === "4x3") return "fourThree";
    484   if (v === "threetwo" || v === "threeTwo".toLowerCase() || v === "3:2" || v === "3x2") return "threeTwo";
    485   if (v === "ultrawide") return "ultrawide";
    486   if (v === "portrait") return "portrait";
    487   return "auto";
    488 }
    489 
    490 function detectViewportSize() {
    491   const w = Math.max(1, Number(window.innerWidth) || 1);
    492   const h = Math.max(1, Number(window.innerHeight) || 1);
    493   // Keep this intentionally simple: we mostly care about "can we fit columns sanely?"
    494   // Consider both width and height so low-res (ex: 1280x720) can auto-compact.
    495   if (w <= 1100 || h <= 720) return "xs";
    496   if (w <= 1400 || h <= 820) return "sm";
    497   if (w <= 1800) return "md";
    498   return "lg";
    499 }
    500 
    501 function detectAspectLayout() {
    502   const w = Math.max(1, Number(window.innerWidth) || 1);
    503   const h = Math.max(1, Number(window.innerHeight) || 1);
    504   const ratio = w / h;
    505   // Heuristics:
    506   // - Portrait: <= ~1.25
    507   // - 4:3-ish: 1.25..1.38
    508   // - 3:2-ish: 1.38..1.62 (covers 3:2 and nearby)
    509   // - Widescreen: 1.62..1.95 (16:10..~2:1)
    510   // - Ultrawide: >= 1.95
    511   if (ratio <= 1.25) return "portrait";
    512   if (ratio < 1.38) return "fourThree";
    513   if (ratio >= 1.38 && ratio < 1.62) return "threeTwo";
    514   if (ratio >= 1.95) return "ultrawide";
    515   return "widescreen";
    516 }
    517 
    518 function applyDisplayPrefs() {
    519   const root = document.documentElement;
    520   if (!root) return;
    521   const scalePref = normalizeUiScale(readStringPref(UI_SCALE_KEY, "auto"));
    522   const layoutPref = normalizeDeviceLayout(readStringPref(DEVICE_LAYOUT_KEY, "auto"));
    523   const layout = layoutPref === "auto" ? detectAspectLayout() : layoutPref;
    524   const viewport = detectViewportSize();
    525   const scale =
    526     scalePref === "auto" ? (viewport === "xs" ? "xs" : viewport === "sm" ? "sm" : "md") : scalePref;
    527 
    528   root.dataset.uiScale = scale;
    529   root.dataset.uiScalePref = scalePref;
    530   root.dataset.deviceLayout = layoutPref;
    531   root.dataset.aspect = layout;
    532   root.dataset.viewport = viewport;
    533 
    534   if (uiScaleEl) uiScaleEl.value = scalePref;
    535   if (deviceLayoutEl) deviceLayoutEl.value = layoutPref;
    536 }
    537 
    538 function initDisplayPrefsUi() {
    539   applyDisplayPrefs();
    540   if (uiScaleEl) {
    541     uiScaleEl.value = normalizeUiScale(readStringPref(UI_SCALE_KEY, "auto"));
    542     uiScaleEl.addEventListener("change", () => {
    543       const next = normalizeUiScale(uiScaleEl.value);
    544       try {
    545         localStorage.setItem(UI_SCALE_KEY, next);
    546       } catch {
    547         // ignore
    548       }
    549       applyDisplayPrefs();
    550     });
    551   }
    552   if (deviceLayoutEl) {
    553     deviceLayoutEl.value = normalizeDeviceLayout(readStringPref(DEVICE_LAYOUT_KEY, "auto"));
    554     deviceLayoutEl.addEventListener("change", () => {
    555       const next = normalizeDeviceLayout(deviceLayoutEl.value);
    556       try {
    557         localStorage.setItem(DEVICE_LAYOUT_KEY, next);
    558       } catch {
    559         // ignore
    560       }
    561       applyDisplayPrefs();
    562     });
    563   }
    564 
    565   let resizeTimer = null;
    566   window.addEventListener("resize", () => {
    567     if (resizeTimer) window.clearTimeout(resizeTimer);
    568     resizeTimer = window.setTimeout(() => {
    569       resizeTimer = null;
    570       // Always re-apply (viewport changes matter even when layout is manually pinned).
    571       applyDisplayPrefs();
    572     }, 90);
    573   });
    574 }
    575 
    576 function writeStringPref(key, value) {
    577   try {
    578     localStorage.setItem(key, String(value));
    579   } catch {
    580     // ignore
    581   }
    582 }
    583 
    584 function resolveHivesViewMode() {
    585   const pref = readStringPref(HIVES_VIEW_MODE_KEY, "list");
    586   const normalized = String(pref || "auto").toLowerCase();
    587   if (normalized === "list") return "list";
    588   if (normalized === "cards") return "cards";
    589   // auto (currently treated as list by default; we can reintroduce responsive modes later)
    590   return "list";
    591 }
    592 
    593 function applyHivesViewMode() {
    594   const mode = resolveHivesViewMode();
    595   const list = mode === "list";
    596   feedEl?.classList.toggle("hivesListView", list);
    597   hivesPanelEl?.classList.toggle("hivesListView", list);
    598 }
    599 
    600 function installHivesAutoViewMode() {
    601   if (!hivesPanelEl) return;
    602   if (typeof ResizeObserver === "undefined") {
    603     window.addEventListener("resize", () => applyHivesViewMode());
    604     return;
    605   }
    606   if (hivesResizeObserver) return;
    607   hivesResizeObserver = new ResizeObserver((entries) => {
    608     const entry = entries && entries[0];
    609     const w = Number(entry?.contentRect?.width || 0);
    610     if (!w) return;
    611     const rounded = Math.round(w);
    612     if (rounded === lastHivesWidthPx) return;
    613     lastHivesWidthPx = rounded;
    614     applyHivesViewMode();
    615   });
    616   try {
    617     hivesResizeObserver.observe(hivesPanelEl);
    618   } catch {
    619     // ignore
    620   }
    621 }
    622 
    623 function setSideCollapsed(collapsed, opts) {
    624   const options = opts && typeof opts === "object" ? opts : {};
    625   const persist = options.persist !== false;
    626   const updateControls = options.updateControls !== false;
    627   if (!appRoot) return;
    628   appRoot.classList.toggle("sideCollapsed", Boolean(collapsed));
    629   if (persist) writeBoolPref(RACK_SIDE_COLLAPSED_KEY, Boolean(collapsed));
    630   if (updateControls && toggleSideRackEl) toggleSideRackEl.checked = !Boolean(collapsed);
    631   updateSideRackEmptyState();
    632 }
    633 
    634 function setRightCollapsed(collapsed, opts) {
    635   const options = opts && typeof opts === "object" ? opts : {};
    636   const persist = options.persist !== false;
    637   const updateControls = options.updateControls !== false;
    638   if (!appRoot) return;
    639   appRoot.classList.toggle("rightCollapsed", Boolean(collapsed));
    640   if (persist) writeBoolPref(RACK_RIGHT_COLLAPSED_KEY, Boolean(collapsed));
    641   if (updateControls && toggleRightRackEl) toggleRightRackEl.checked = !Boolean(collapsed);
    642 }
    643 
    644 function updateSideRackEmptyState() {
    645   if (!appRoot) return;
    646   const side = mainSideRackEl || mainSideRack || document.getElementById("mainSideRack");
    647   if (!(side instanceof HTMLElement)) return;
    648   const hasVisible = Boolean(side.querySelector?.(".rackPanel:not(.hidden)"));
    649   appRoot.classList.toggle("sideRackEmpty", !hasVisible);
    650 }
    651 
    652 // Panel registry (skeleton): this will become the primary way core + plugins register UI panels.
    653 // For now, it powers rack mode (docking + ordering + workspace rules) and plugin panel shells.
    654 /** @type {Map<string, {id:string,title:string,icon?:string,source:string,role:string,defaultRack:string,element?:HTMLElement|null}>} */
    655 const panelRegistry = new Map();
    656 
    657 function registerCorePanel(def) {
    658   const id = String(def?.id || "").trim();
    659   if (!id) return;
    660   const title = String(def?.title || id).trim();
    661   const icon = typeof def?.icon === "string" ? def.icon : "";
    662   const role = typeof def?.role === "string" ? def.role : "aux";
    663   const defaultRack = typeof def?.defaultRack === "string" ? def.defaultRack : "right";
    664   const element = def?.element instanceof HTMLElement ? def.element : null;
    665   panelRegistry.set(id, { id, title, icon, source: "core", role, defaultRack, element });
    666 }
    667 
    668 function togglePanelSkinny(panelId) {
    669   if (!rackLayoutEnabled) return;
    670   const id = String(panelId || "").trim();
    671   if (!id) return;
    672   if (!panelIsSkinnyCapable(id)) return;
    673   const panelEl = getPanelElement(id);
    674   if (!panelEl) return;
    675 
    676   const left = ensureWorkspaceLeftRack();
    677   const right = ensureWorkspaceRightRack();
    678   const side = ensureMainSideRack();
    679   if (!left || !right || !side) return;
    680 
    681   const parentId = rackIdForPanelElement(panelEl);
    682   const inSkinny = parentId === "mainSideRack" || parentId === "rightRack";
    683 
    684   if (inSkinny) {
    685     // Move to workspace (prefer an empty slot; otherwise prefer right).
    686     const leftExisting = left.querySelector?.(":scope > .rackPanel:not(.hidden)");
    687     const rightExisting = right.querySelector?.(":scope > .rackPanel:not(.hidden)");
    688     const target = !rightExisting ? right : !leftExisting ? left : right;
    689     const existing = target === left ? leftExisting : rightExisting;
    690     if (existing instanceof HTMLElement && existing !== panelEl) {
    691       const existingId = String(existing.dataset?.panelId || "").trim();
    692       if (existingId) dockPanel(existingId);
    693     }
    694     target.appendChild(panelEl);
    695     rememberPanelLastRack(id, target.id);
    696     saveRackLayoutState();
    697     syncRackStateFromDom();
    698     enforceWorkspaceRules();
    699     return;
    700   }
    701 
    702   // Move to side rack (skinny).
    703   setSideCollapsed(false);
    704   side.prepend(panelEl);
    705   rememberPanelLastRack(id, side.id);
    706   saveRackLayoutState();
    707   syncRackStateFromDom();
    708   enforceWorkspaceRules();
    709 }
    710 
    711 registerCorePanel({ id: "chat", title: "Chat", icon: "πŸ’¬", role: "primary", defaultRack: "main", element: chatPanelEl });
    712 registerCorePanel({ id: "hives", title: "Hives", icon: "🐝", role: "primary", defaultRack: "main", element: hivesPanelEl });
    713 registerCorePanel({ id: "onboarding", title: "Onboarding", icon: "🧭", role: "primary", defaultRack: "main", element: onboardingPanelEl });
    714 registerCorePanel({ id: "people", title: "People", icon: "πŸ‘₯", role: "aux", defaultRack: "right", element: peopleDrawerEl });
    715 registerCorePanel({ id: "moderation", title: "Moderation", icon: "πŸ›‘οΈ", role: "aux", defaultRack: "right", element: modPanelEl });
    716 registerCorePanel({ id: "profile", title: "Profile", icon: "πŸ‘€", role: "transient", defaultRack: "main", element: profileViewPanel });
    717 registerCorePanel({ id: "composer", title: "New Hive", icon: "✍️", role: "aux", defaultRack: "main", element: pollinatePanel });
    718 
    719 let pluginRackPanelEl = null;
    720 let pluginRackWidgetsRackEl = null;
    721 let pluginRackAddMenuEl = null;
    722 
    723 function closePluginRackAddMenu() {
    724   if (!pluginRackAddMenuEl) return;
    725   try {
    726     pluginRackAddMenuEl.remove();
    727   } catch {
    728     // ignore
    729   }
    730   pluginRackAddMenuEl = null;
    731 }
    732 
    733 function panelIsPluginOwned(panelId) {
    734   const id = String(panelId || "").trim();
    735   if (!id) return false;
    736   if (id.startsWith("chat:")) return false;
    737   const entry = panelRegistry.get(id);
    738   const src = typeof entry?.source === "string" ? entry.source : "";
    739   return src.startsWith("plugin:");
    740 }
    741 
    742 function panelIsHostableInPluginRack(panelId) {
    743   const id = String(panelId || "").trim();
    744   if (!id) return false;
    745   if (id === "pluginRack") return false;
    746   if (!panelIsPluginOwned(id)) return false;
    747   // Widgets should be small, stackable tools (not full workspace surfaces like Maps).
    748   if (panelRole(id) === "primary") return false;
    749   return true;
    750 }
    751 
    752 function ensurePluginRackPanel() {
    753   if (pluginRackPanelEl instanceof HTMLElement && pluginRackPanelEl.isConnected) return pluginRackPanelEl;
    754 
    755   if (!(pluginRackPanelEl instanceof HTMLElement)) {
    756     const shell = document.createElement("section");
    757     shell.className = "panel panelFill pluginRackPanel rackPanel";
    758     shell.dataset.panelId = "pluginRack";
    759     shell.innerHTML = `
    760       <div class="panelHeader">
    761         <div class="panelTitle">${escapeHtml("Plugin Rack")}</div>
    762         <div class="row"></div>
    763       </div>
    764       <div class="panelBody pluginRackBody">
    765         <div class="pluginRackToolbar">
    766           <button type="button" class="ghost smallBtn" data-pluginrackadd="1">+ Add widget</button>
    767           <div class="small muted pluginRackHint">Drop plugin panels here to stack them.</div>
    768         </div>
    769         <div id="pluginRackWidgetsRack" class="pluginRackWidgets" aria-label="Plugin widgets"></div>
    770       </div>
    771     `;
    772     pluginRackPanelEl = shell;
    773     pluginRackWidgetsRackEl = shell.querySelector("#pluginRackWidgetsRack");
    774 
    775     shell.querySelector("[data-pluginrackadd]")?.addEventListener("click", (e) => {
    776       const anchor = e.currentTarget;
    777       if (pluginRackAddMenuEl) closePluginRackAddMenu();
    778       else openPluginRackAddMenu(anchor);
    779     });
    780   }
    781 
    782   // Ensure it's registered as a core panel for docking + layout state.
    783   registerCorePanel({ id: "pluginRack", title: "Plugin Rack", icon: "🧩", role: "aux", defaultRack: "main", element: pluginRackPanelEl });
    784 
    785   // Append into the DOM so it can be docked/restored. (It will typically live in the hotbar.)
    786   const side = ensureMainSideRack();
    787   if (side && pluginRackPanelEl.parentElement !== side) side.appendChild(pluginRackPanelEl);
    788 
    789   return pluginRackPanelEl;
    790 }
    791 
    792 function ensurePluginRackWidgetsRack() {
    793   ensurePluginRackPanel();
    794   return pluginRackWidgetsRackEl instanceof HTMLElement ? pluginRackWidgetsRackEl : null;
    795 }
    796 
    797 function readPluginRackWidgetsOrder() {
    798   const rack = ensurePluginRackWidgetsRack();
    799   return rack ? readRackOrder(rack) : [];
    800 }
    801 
    802 function removePanelFromPluginRack(panelId) {
    803   const id = String(panelId || "").trim();
    804   if (!id) return;
    805   rackLayoutState.pluginRackWidgets = Array.isArray(rackLayoutState.pluginRackWidgets)
    806     ? rackLayoutState.pluginRackWidgets.filter((x) => x !== id)
    807     : [];
    808   const el = getPanelElement(id);
    809   if (el) el.classList.remove("pluginRackWidget");
    810   const rack = ensurePluginRackWidgetsRack();
    811   if (rack && el && el.parentElement === rack) rack.removeChild(el);
    812   const side = ensureMainSideRack();
    813   if (side && el && !el.parentElement) side.appendChild(el);
    814 }
    815 
    816 function hostPanelInPluginRack(panelId) {
    817   const id = String(panelId || "").trim();
    818   if (!id) return;
    819   if (!rackLayoutEnabled) return;
    820   if (!panelIsHostableInPluginRack(id)) {
    821     toast("Can't add widget", `${panelTitle(id)} can't be hosted in Plugin Rack.`);
    822     return;
    823   }
    824 
    825   const rack = ensurePluginRackWidgetsRack();
    826   const el = getPanelElement(id);
    827   if (!rack || !el) return;
    828 
    829   // Hosting implies it should be visible in the rack, not docked.
    830   if (isDocked(id)) undockPanel(id);
    831 
    832   const lastRack = rackIdForPanelElement(el);
    833   if (lastRack) rememberPanelLastRack(id, lastRack);
    834 
    835   el.classList.add("pluginRackWidget");
    836   if (el.parentElement !== rack) rack.appendChild(el);
    837 
    838   const next = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []);
    839   next.add(id);
    840   rackLayoutState.pluginRackWidgets = Array.from(next);
    841   saveRackLayoutState();
    842   syncRackStateFromDom();
    843   enforceWorkspaceRules();
    844 }
    845 
    846 function openPluginRackAddMenu(anchorEl) {
    847   closePluginRackAddMenu();
    848   if (!(anchorEl instanceof HTMLElement)) return;
    849   if (!rackLayoutEnabled) return;
    850 
    851   const hosted = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []);
    852   const candidates = Array.from(panelRegistry.keys())
    853     .filter((id) => panelIsHostableInPluginRack(id) && !hosted.has(id))
    854     .sort((a, b) => panelTitle(a).localeCompare(panelTitle(b)));
    855 
    856   const items = candidates
    857     .map((id) => `<button type="button" class="ghost smallBtn" data-pluginrackhost="${escapeHtml(id)}">${escapeHtml(panelTitle(id))}</button>`)
    858     .join("");
    859 
    860   const menu = document.createElement("div");
    861   menu.className = "hotbarAddMenu pluginRackAddMenu";
    862   menu.innerHTML = `
    863     <div class="small muted" style="padding:6px 8px 4px;">Add widget</div>
    864     <div class="hotbarAddMenuList">${items || `<div class="small muted" style="padding:6px 8px;">No plugin widgets available.</div>`}</div>
    865   `;
    866 
    867   const rect = anchorEl.getBoundingClientRect();
    868   const left = Math.max(12, Math.min(window.innerWidth - 260, rect.left));
    869   const top = Math.max(12, Math.min(window.innerHeight - 320, rect.bottom + 8));
    870   menu.style.left = `${left}px`;
    871   menu.style.top = `${top}px`;
    872 
    873   menu.addEventListener("click", (e) => {
    874     const btn = e.target.closest?.("[data-pluginrackhost]");
    875     if (!btn) return;
    876     const id = String(btn.getAttribute("data-pluginrackhost") || "").trim();
    877     if (!id) return;
    878     hostPanelInPluginRack(id);
    879     closePluginRackAddMenu();
    880   });
    881 
    882   document.body.appendChild(menu);
    883   pluginRackAddMenuEl = menu;
    884 }
    885 
    886 // Rack mode: Profile should behave like a normal dockable panel (not a flow that replaces Hives).
    887 // Override the role after the initial core registration (Map#set will replace the previous entry).
    888 panelRegistry.set("profile", { ...(panelRegistry.get("profile") || { id: "profile", source: "core" }), role: "aux" });
    889 
    890 // Expose for quick inspection in the browser console while iterating.
    891 window.__bzlPanels = { panelRegistry };
    892 
    893 const PRESET_DEFS = {
    894   // Presets are hard-applied (exact placement). Anything not explicitly placed starts in the hotbar.
    895   // Workspace uses two full-height primary slots (left + right). No vertical splits.
    896   onboardingDefault: {
    897     presetId: "onboardingDefault",
    898     label: "Onboarding (Default)",
    899     group: "user",
    900     workspaceLeftOrder: ["onboarding"],
    901     workspaceRightOrder: ["hives"],
    902     sideOrder: ["chat", "profile", "composer"],
    903     sideCollapsed: false,
    904     rightOrder: ["people"],
    905     dockBottom: ["pluginRack", "maps", "library-browser", "library-shelf", "library-reader"],
    906   },
    907   social: {
    908     presetId: "social",
    909     label: "Default (Social)",
    910     group: "user",
    911     workspaceLeftOrder: ["hives"],
    912     workspaceRightOrder: ["chat"],
    913     sideOrder: ["profile", "composer"],
    914     sideCollapsed: true,
    915     rightOrder: ["people"],
    916     dockBottom: ["pluginRack", "maps", "library-browser", "library-shelf", "library-reader"],
    917   },
    918   chatFocus: {
    919     presetId: "chatFocus",
    920     label: "Chat Focus",
    921     group: "user",
    922     workspaceLeftOrder: ["chat"],
    923     workspaceRightOrder: [],
    924     expandedPrimary: "chat",
    925     sideOrder: ["profile"],
    926     sideCollapsed: true,
    927     rightOrder: ["people"],
    928     dockBottom: ["pluginRack", "hives", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
    929   },
    930   browse: {
    931     presetId: "browse",
    932     label: "Browse",
    933     group: "user",
    934     workspaceLeftOrder: ["hives"],
    935     workspaceRightOrder: [],
    936     expandedPrimary: "hives",
    937     sideOrder: ["chat"],
    938     sideCollapsed: true,
    939     rightOrder: ["profile"],
    940     dockBottom: ["pluginRack", "people", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
    941   },
    942   creator: {
    943     presetId: "creator",
    944     label: "Creator",
    945     group: "user",
    946     workspaceLeftOrder: ["hives"],
    947     workspaceRightOrder: ["composer"],
    948     composerOpen: true,
    949     sideOrder: ["people"],
    950     sideCollapsed: true,
    951     rightOrder: ["profile"],
    952     dockBottom: ["pluginRack", "chat", "maps", "library-browser", "library-shelf", "library-reader"],
    953   },
    954   mapsSession: {
    955     presetId: "mapsSession",
    956     label: "Maps Session",
    957     group: "user",
    958     workspaceLeftOrder: ["maps"], // if installed
    959     workspaceRightOrder: ["chat"],
    960     sideOrder: ["hives"],
    961     sideCollapsed: true,
    962     rightOrder: ["people"],
    963     dockBottom: ["pluginRack", "profile", "composer", "library-browser", "library-shelf", "library-reader"],
    964   },
    965   quiet: {
    966     presetId: "quiet",
    967     label: "Quiet (No People)",
    968     group: "user",
    969     workspaceLeftOrder: ["hives"],
    970     workspaceRightOrder: ["profile"],
    971     sideOrder: ["composer"],
    972     sideCollapsed: true,
    973     rightOrder: [],
    974     rightCollapsed: true,
    975     dockBottom: ["pluginRack", "chat", "people", "maps", "library-browser", "library-shelf", "library-reader"],
    976   },
    977   readingNook: {
    978     presetId: "readingNook",
    979     label: "Reading Nook",
    980     group: "user",
    981     workspaceLeftOrder: ["library-reader"],
    982     workspaceRightOrder: ["library-shelf"],
    983     sideOrder: ["profile"],
    984     sideCollapsed: true,
    985     rightOrder: ["people"],
    986     dockBottom: ["pluginRack", "hives", "chat", "composer", "maps", "library-browser"],
    987   },
    988   libraryCurator: {
    989     presetId: "libraryCurator",
    990     label: "Library Curator",
    991     group: "user",
    992     workspaceLeftOrder: ["library-browser"],
    993     workspaceRightOrder: ["library-shelf"],
    994     sideOrder: ["profile"],
    995     sideCollapsed: true,
    996     rightOrder: ["people"],
    997     dockBottom: ["pluginRack", "hives", "chat", "composer", "maps", "library-reader"],
    998   },
    999   ops: {
   1000     presetId: "ops",
   1001     label: "Ops",
   1002     group: "mod",
   1003     modOnly: true,
   1004     workspaceLeftOrder: ["moderation"],
   1005     workspaceRightOrder: ["chat"],
   1006     sideOrder: ["hives"],
   1007     sideCollapsed: true,
   1008     rightOrder: ["people"],
   1009     dockBottom: ["pluginRack", "profile", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
   1010   },
   1011   reportsFocus: {
   1012     presetId: "reportsFocus",
   1013     label: "Reports Focus",
   1014     group: "mod",
   1015     modOnly: true,
   1016     workspaceLeftOrder: ["moderation"],
   1017     workspaceRightOrder: [],
   1018     expandedPrimary: "moderation",
   1019     sideOrder: ["people"],
   1020     sideCollapsed: true,
   1021     rightOrder: ["chat"],
   1022     dockBottom: ["pluginRack", "hives", "profile", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
   1023   },
   1024   communityWatch: {
   1025     presetId: "communityWatch",
   1026     label: "Community Watch",
   1027     group: "mod",
   1028     modOnly: true,
   1029     workspaceLeftOrder: ["hives"],
   1030     workspaceRightOrder: ["moderation"],
   1031     sideOrder: ["chat"],
   1032     sideCollapsed: true,
   1033     rightOrder: ["people"],
   1034     dockBottom: ["pluginRack", "profile", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
   1035   },
   1036   serverAdmin: {
   1037     presetId: "serverAdmin",
   1038     label: "Server Admin",
   1039     group: "mod",
   1040     modOnly: true,
   1041     workspaceLeftOrder: ["moderation"],
   1042     workspaceRightOrder: ["hives"],
   1043     sideOrder: ["chat"],
   1044     sideCollapsed: true,
   1045     rightOrder: ["people"],
   1046     dockBottom: ["pluginRack", "profile", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
   1047   },
   1048 };
   1049 
   1050 const PRESET_ALIASES = {
   1051   // Back-compat for older preset ids.
   1052   discordLike: "social",
   1053   onboarding: "onboardingDefault",
   1054   chat: "chatFocus",
   1055   browsing: "browse",
   1056   maps: "mapsSession",
   1057   focus: "quiet",
   1058   clean: "social",
   1059   moderation: "ops",
   1060   reading: "readingNook",
   1061   library: "libraryCurator",
   1062 };
   1063 
   1064 function resolvePresetKey(presetId) {
   1065   const raw = String(presetId || "").trim();
   1066   const mapped = Object.prototype.hasOwnProperty.call(PRESET_ALIASES, raw) ? PRESET_ALIASES[raw] : raw;
   1067   return Object.prototype.hasOwnProperty.call(PRESET_DEFS, mapped) ? mapped : "onboardingDefault";
   1068 }
   1069 
   1070 function updateLayoutPresetOptions() {
   1071   if (!layoutPresetEl) return;
   1072   const current = resolvePresetKey(rackLayoutState?.presetId || layoutPresetEl.value || "onboardingDefault");
   1073 
   1074   const defs = Object.values(PRESET_DEFS).filter((d) => d && typeof d === "object");
   1075   const userDefs = defs.filter((d) => d.group === "user");
   1076   const modDefs = defs.filter((d) => d.group === "mod");
   1077 
   1078   const makeOpt = (def) => {
   1079     const opt = document.createElement("option");
   1080     opt.value = String(def.presetId || "");
   1081     opt.textContent = String(def.label || def.presetId || "Preset");
   1082     return opt;
   1083   };
   1084 
   1085   layoutPresetEl.innerHTML = "";
   1086 
   1087   const userGroup = document.createElement("optgroup");
   1088   userGroup.label = "Presets";
   1089   for (const def of userDefs) userGroup.appendChild(makeOpt(def));
   1090   layoutPresetEl.appendChild(userGroup);
   1091 
   1092   if (canModerate) {
   1093     const modGroup = document.createElement("optgroup");
   1094     modGroup.label = "Moderation (mods)";
   1095     for (const def of modDefs) modGroup.appendChild(makeOpt(def));
   1096     layoutPresetEl.appendChild(modGroup);
   1097   }
   1098 
   1099   const nextValue = canModerate ? current : (PRESET_DEFS[current]?.modOnly ? "onboardingDefault" : current);
   1100   layoutPresetEl.value = Object.prototype.hasOwnProperty.call(PRESET_DEFS, nextValue) ? nextValue : "onboardingDefault";
   1101 }
   1102 
   1103 function readRackLayoutEnabled() {
   1104   if (FORCE_RACK_MODE) return true;
   1105   try {
   1106     return localStorage.getItem(RACK_LAYOUT_ENABLED_KEY) === "1";
   1107   } catch {
   1108     return false;
   1109   }
   1110 }
   1111 
   1112 function writeRackLayoutEnabled(enabled) {
   1113   if (FORCE_RACK_MODE) {
   1114     rackLayoutEnabled = true;
   1115     try {
   1116       localStorage.setItem(RACK_LAYOUT_ENABLED_KEY, "1");
   1117     } catch {
   1118       // ignore
   1119     }
   1120     return;
   1121   }
   1122   rackLayoutEnabled = Boolean(enabled);
   1123   try {
   1124     localStorage.setItem(RACK_LAYOUT_ENABLED_KEY, rackLayoutEnabled ? "1" : "0");
   1125   } catch {
   1126     // ignore
   1127   }
   1128 }
   1129 
   1130 /** @returns {RackLayoutState} */
   1131 function loadRackLayoutState() {
   1132   try {
   1133     const raw = localStorage.getItem(RACK_LAYOUT_STATE_KEY);
   1134     if (!raw)
   1135       return {
   1136         version: 2,
   1137         presetId: "onboardingDefault",
   1138         docked: { bottom: [] },
   1139         racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
   1140         pluginRackWidgets: [],
   1141         lastRackByPanelId: {},
   1142       };
   1143     const parsed = JSON.parse(raw);
   1144     if (!parsed || parsed.version !== 2)
   1145       return {
   1146         version: 2,
   1147         presetId: "onboardingDefault",
   1148         docked: { bottom: [] },
   1149         racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
   1150         pluginRackWidgets: [],
   1151         lastRackByPanelId: {},
   1152       };
   1153     const bottom = Array.isArray(parsed?.docked?.bottom) ? parsed.docked.bottom.map((x) => String(x || "")).filter(Boolean) : [];
   1154     const pluginRackWidgets = Array.isArray(parsed?.pluginRackWidgets)
   1155       ? parsed.pluginRackWidgets.map((x) => String(x || "")).filter(Boolean)
   1156       : [];
   1157     const presetId = typeof parsed?.presetId === "string" ? parsed.presetId : "onboardingDefault";
   1158     const workspaceLeft = Array.isArray(parsed?.racks?.workspaceLeft) ? parsed.racks.workspaceLeft.map((x) => String(x || "")).filter(Boolean) : [];
   1159     const workspaceRight = Array.isArray(parsed?.racks?.workspaceRight) ? parsed.racks.workspaceRight.map((x) => String(x || "")).filter(Boolean) : [];
   1160     const side = Array.isArray(parsed?.racks?.side) ? parsed.racks.side.map((x) => String(x || "")).filter(Boolean) : [];
   1161     const right = Array.isArray(parsed?.racks?.right) ? parsed.racks.right.map((x) => String(x || "")).filter(Boolean) : [];
   1162     const lastRackByPanelIdRaw = parsed?.lastRackByPanelId && typeof parsed.lastRackByPanelId === "object" ? parsed.lastRackByPanelId : {};
   1163     const lastRackByPanelId = {};
   1164     for (const [k, v] of Object.entries(lastRackByPanelIdRaw)) {
   1165       const id = String(k || "").trim();
   1166       const rackId = typeof v === "string" ? v.trim() : "";
   1167       if (!id || !rackId) continue;
   1168       lastRackByPanelId[id] = rackId;
   1169     }
   1170     return { version: 2, presetId, docked: { bottom }, racks: { workspaceLeft, workspaceRight, side, right }, pluginRackWidgets, lastRackByPanelId };
   1171   } catch {
   1172     return {
   1173       version: 2,
   1174       presetId: "onboardingDefault",
   1175       docked: { bottom: [] },
   1176       racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
   1177       pluginRackWidgets: [],
   1178       lastRackByPanelId: {},
   1179     };
   1180   }
   1181 }
   1182 
   1183 function saveRackLayoutState() {
   1184   try {
   1185     localStorage.setItem(RACK_LAYOUT_STATE_KEY, JSON.stringify(rackLayoutState));
   1186   } catch {
   1187     // ignore
   1188   }
   1189 }
   1190 
   1191 function ensureWorkspaceSlots() {
   1192   const workspace = mainWorkspaceRackEl || document.getElementById("mainWorkspaceRack");
   1193   if (!workspace) return { left: null, right: null };
   1194 
   1195   let left = workspace.querySelector?.("#workspaceLeftSlot");
   1196   let right = workspace.querySelector?.("#workspaceRightSlot");
   1197 
   1198   if (!left) {
   1199     left = document.createElement("div");
   1200     left.id = "workspaceLeftSlot";
   1201     left.className = "workspaceSlot workspaceSlotLeft";
   1202     left.setAttribute("aria-label", "Workspace left");
   1203     workspace.prepend(left);
   1204   }
   1205   if (!right) {
   1206     right = document.createElement("div");
   1207     right.id = "workspaceRightSlot";
   1208     right.className = "workspaceSlot workspaceSlotRight";
   1209     right.setAttribute("aria-label", "Workspace right");
   1210     const afterLeft = workspace.querySelector?.("#workspaceLeftSlot");
   1211     if (afterLeft && afterLeft.nextSibling) workspace.insertBefore(right, afterLeft.nextSibling);
   1212     else workspace.appendChild(right);
   1213   }
   1214   return { left, right };
   1215 }
   1216 
   1217 function panelTitle(panelId) {
   1218   const entry = panelRegistry.get(panelId);
   1219   if (entry?.title) return entry.title;
   1220   if (panelId === "maps") return "Maps";
   1221   if (panelId === "library") return "Library";
   1222   return String(panelId || "");
   1223 }
   1224 
   1225 function chatRailClass({ fromUser, isModMessage }) {
   1226   const from = String(fromUser || "").trim();
   1227   const isSystem = !from || from.toLowerCase() === "system";
   1228   const isModMsg = Boolean(isModMessage);
   1229   const isYou = Boolean(loggedInUser && from && from === loggedInUser);
   1230   if (isSystem || isModMsg) return "railLeft";
   1231   if (isYou) return "railRight";
   1232   return "railCenter";
   1233 }
   1234 
   1235 function updateChatModToggleVisibility() {
   1236   if (!chatModToggleWrapEl) return;
   1237   const canUse = Boolean(canModerate && activeChatPostId && !activeDmThreadId && !isMapChatActive());
   1238   chatModToggleWrapEl.classList.toggle("hidden", !canUse);
   1239   if (!canUse && chatModToggleEl) chatModToggleEl.checked = false;
   1240 }
   1241 
   1242 function panelIcon(panelId) {
   1243   const entry = panelRegistry.get(panelId);
   1244   if (entry?.icon) return entry.icon;
   1245   if (panelId === "maps") return "πŸ—ΊοΈ";
   1246   if (panelId === "library") return "πŸ“š";
   1247   return "β€’";
   1248 }
   1249 
   1250 function panelRole(panelId) {
   1251   const entry = panelRegistry.get(panelId);
   1252   return typeof entry?.role === "string" ? entry.role : "aux";
   1253 }
   1254 
   1255 function panelCanExpand(panelId) {
   1256   const id = String(panelId || "").trim();
   1257   if (!id) return false;
   1258   if (id.startsWith("chat:")) return true;
   1259   if (panelRole(id) === "primary") return true;
   1260   // Allow a few core panels to take over the workspace even though they aren't "primary" by default.
   1261   return id === "moderation" || id === "composer" || id === "pluginRack";
   1262 }
   1263 
   1264 // Panels that are allowed to live in "skinny" columns (side rack / right rack).
   1265 // These panels should be able to render in a narrow width without breaking layout.
   1266 const SKINNY_CAPABLE_PANELS = new Set(["people", "profile", "composer", "chat", "pluginRack", "dice"]);
   1267 
   1268 function panelIsSkinnyCapable(panelId) {
   1269   const id = String(panelId || "").trim();
   1270   if (!id) return false;
   1271   if (id.startsWith("chat:")) return true;
   1272   return SKINNY_CAPABLE_PANELS.has(id);
   1273 }
   1274 
   1275 function isDocked(panelId) {
   1276   return rackLayoutState.docked.bottom.includes(panelId);
   1277 }
   1278 
   1279 function getPanelElement(panelId) {
   1280   const id = String(panelId || "").trim();
   1281   if (!id) return null;
   1282   const entry = panelRegistry.get(id);
   1283   const el = entry?.element;
   1284   return el instanceof HTMLElement ? el : null;
   1285 }
   1286 
   1287 function rackIdForPanelElement(panelEl) {
   1288   const el = panelEl instanceof HTMLElement ? panelEl : null;
   1289   if (!el) return "";
   1290   const parent = el.parentElement;
   1291   const id = parent && typeof parent.id === "string" ? parent.id : "";
   1292   if (id === "workspaceLeftSlot" || id === "workspaceRightSlot" || id === "mainSideRack" || id === "rightRack") return id;
   1293   return "";
   1294 }
   1295 
   1296 function updateSkinnyChatPanels() {
   1297   const applySkinnyState = (panelEl) => {
   1298     if (!(panelEl instanceof HTMLElement)) return;
   1299     const rackId = rackIdForPanelElement(panelEl);
   1300     const inSkinnyRack = rackId === "mainSideRack" || rackId === "rightRack";
   1301     panelEl.classList.toggle("isSkinnyChat", Boolean(rackLayoutEnabled && inSkinnyRack));
   1302   };
   1303 
   1304   applySkinnyState(chatPanelEl);
   1305   for (const panelId of chatPanelInstances.keys()) {
   1306     applySkinnyState(getPanelElement(panelId));
   1307   }
   1308 }
   1309 
   1310 function rememberPanelLastRack(panelId, rackId) {
   1311   const id = String(panelId || "").trim();
   1312   const rack = String(rackId || "").trim();
   1313   if (!id || !rack) return;
   1314   if (!rackLayoutState.lastRackByPanelId || typeof rackLayoutState.lastRackByPanelId !== "object") rackLayoutState.lastRackByPanelId = {};
   1315   rackLayoutState.lastRackByPanelId[id] = rack;
   1316 }
   1317 
   1318 function dockPanel(panelId) {
   1319   const id = String(panelId || "").trim();
   1320   if (!id) return;
   1321   // Docking a hosted widget should implicitly un-host it.
   1322   removePanelFromPluginRack(id);
   1323   const el = getPanelElement(id);
   1324   const lastRack = rackIdForPanelElement(el);
   1325   if (lastRack) rememberPanelLastRack(id, lastRack);
   1326   if (!isDocked(id)) rackLayoutState.docked.bottom.push(id);
   1327   saveRackLayoutState();
   1328   applyDockState();
   1329 }
   1330 
   1331 function undockPanel(panelId) {
   1332   const id = String(panelId || "").trim();
   1333   if (!id) return;
   1334   rackLayoutState.docked.bottom = rackLayoutState.docked.bottom.filter((x) => x !== id);
   1335   saveRackLayoutState();
   1336   applyDockState();
   1337 }
   1338 
   1339 function restorePanelFromHotbar(panelId) {
   1340   const id = String(panelId || "").trim();
   1341   if (!id) return;
   1342   if (!rackLayoutEnabled) return;
   1343 
   1344   const panelEl = getPanelElement(id);
   1345   if (!panelEl) return;
   1346 
   1347   // Decide where to restore the panel.
   1348   const lastRackId =
   1349     rackLayoutState?.lastRackByPanelId && typeof rackLayoutState.lastRackByPanelId === "object"
   1350       ? String(rackLayoutState.lastRackByPanelId[id] || "")
   1351       : "";
   1352   const lastRack = lastRackId ? document.getElementById(lastRackId) : null;
   1353 
   1354   const leftSlot = ensureWorkspaceLeftRack();
   1355   const rightSlot = ensureWorkspaceRightRack();
   1356   const sideRack = ensureMainSideRack();
   1357   const rightRack = ensureRightRack();
   1358 
   1359   const pickWorkspaceSlot = () => {
   1360     const leftEmpty = leftSlot ? leftSlot.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false;
   1361     const rightEmpty = rightSlot ? rightSlot.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false;
   1362     return leftEmpty ? leftSlot : rightEmpty ? rightSlot : leftSlot;
   1363   };
   1364 
   1365   let targetRack = null;
   1366   if (lastRack instanceof HTMLElement) {
   1367     targetRack = lastRack;
   1368   } else if (panelIsSkinnyCapable(id)) {
   1369     // Heuristic: aux-like panels default to side rack; "right" defaults to the right rack.
   1370     const defRack = String(panelRegistry.get(id)?.defaultRack || "");
   1371     targetRack = defRack === "right" ? rightRack : sideRack;
   1372   } else {
   1373     targetRack = pickWorkspaceSlot();
   1374   }
   1375 
   1376   // If restoring into a collapsed rack, uncollapse it (hotbar acts like a summonable launcher).
   1377   if (targetRack && targetRack.id === "mainSideRack") setSideCollapsed(false);
   1378   if (targetRack && targetRack.id === "rightRack") setRightCollapsed(false);
   1379 
   1380   // If the panel already lives in a rack, keep its place and just reveal it.
   1381   const currentRackId = rackIdForPanelElement(panelEl);
   1382   const currentRack = currentRackId ? document.getElementById(currentRackId) : null;
   1383 
   1384   undockPanel(id);
   1385 
   1386   if (!(currentRack instanceof HTMLElement)) {
   1387     const rack = targetRack instanceof HTMLElement ? targetRack : null;
   1388     if (rack) {
   1389       // Right rack + workspace slots are single-slot: docking the existing occupant is the least surprising behavior.
   1390       const isWorkspaceSlot = rack.id === "workspaceLeftSlot" || rack.id === "workspaceRightSlot";
   1391       const isRightRackSlot = rack.id === "rightRack";
   1392       if (isWorkspaceSlot || isRightRackSlot) {
   1393         const existing = rack.querySelector?.(":scope > .rackPanel:not(.hidden)");
   1394         if (existing instanceof HTMLElement && existing !== panelEl) {
   1395           const existingId = String(existing.dataset.panelId || "").trim();
   1396           if (existingId) dockPanel(existingId);
   1397         }
   1398       }
   1399       rack.appendChild(panelEl);
   1400       rememberPanelLastRack(id, rack.id);
   1401       saveRackLayoutState();
   1402     }
   1403   } else {
   1404     // Ensure the rack is visible if we restored into it.
   1405     if (currentRack.id === "mainSideRack") setSideCollapsed(false);
   1406     if (currentRack.id === "rightRack") setRightCollapsed(false);
   1407   }
   1408 
   1409   syncRackStateFromDom();
   1410   enforceWorkspaceRules();
   1411 }
   1412 
   1413 function showHotbar(show) {
   1414   if (!dockHotbarEl) return;
   1415   if (!show && dockHotbarEl.dataset.lockVisible === "1") return;
   1416   dockHotbarEl.classList.toggle("hidden", !show);
   1417   dockHotbarEl.classList.toggle("show", Boolean(show));
   1418   if (appRoot) appRoot.classList.toggle("hotbarVisible", Boolean(show));
   1419 }
   1420 
   1421 function renderHotbar() {
   1422   if (!dockHotbarEl) return;
   1423   const items = rackLayoutState.docked.bottom.slice().filter((id) => getPanelElement(id));
   1424   const includePlus = Boolean(rackLayoutEnabled);
   1425   if (!items.length && !includePlus) {
   1426     dockHotbarEl.classList.add("hidden");
   1427     dockHotbarEl.classList.remove("show");
   1428     dockHotbarEl.innerHTML = "";
   1429     if (appRoot) appRoot.classList.remove("hotbarVisible");
   1430     return;
   1431   }
   1432 
   1433   const orbsHtml = items
   1434     .map(
   1435       (id) => `
   1436     <button type="button" class="dockOrb" data-undock="${escapeHtml(id)}" title="Restore ${escapeHtml(panelTitle(id))}">
   1437       <span class="dockOrbIcon" aria-hidden="true">${escapeHtml(panelIcon(id))}</span>
   1438       <span>${escapeHtml(panelTitle(id))}</span>
   1439     </button>
   1440   `
   1441     )
   1442     .join("");
   1443 
   1444   const plusHtml = includePlus
   1445     ? `
   1446     <button type="button" class="dockOrb dockOrbPlus" data-hotbarplus="1" title="Add panel">
   1447       <span class="dockOrbIcon" aria-hidden="true">+</span>
   1448       <span>Add</span>
   1449     </button>
   1450   `
   1451     : "";
   1452 
   1453   dockHotbarEl.innerHTML = `${orbsHtml}${plusHtml}`;
   1454   dockHotbarEl.classList.remove("hidden");
   1455   requestAnimationFrame(() => showHotbar(true));
   1456 }
   1457 
   1458 let hotbarPlusMenuEl = null;
   1459 let workspaceAddMenuEl = null;
   1460 
   1461 function closeHotbarPlusMenu() {
   1462   if (!hotbarPlusMenuEl) return;
   1463   try {
   1464     hotbarPlusMenuEl.remove();
   1465   } catch {
   1466     // ignore
   1467   }
   1468   hotbarPlusMenuEl = null;
   1469 }
   1470 
   1471 function closeWorkspaceAddMenu() {
   1472   if (!workspaceAddMenuEl) return;
   1473   try {
   1474     workspaceAddMenuEl.remove();
   1475   } catch {
   1476     // ignore
   1477   }
   1478   workspaceAddMenuEl = null;
   1479 }
   1480 
   1481 function workspaceAddCandidates() {
   1482   return Array.from(panelRegistry.keys())
   1483     .filter((id) => Boolean(getPanelElement(id)))
   1484     .filter((id) => !id.startsWith("chat:post:"))
   1485     .filter((id) => id !== "profile")
   1486     .filter((id) => !(id === "moderation" && !canModerate))
   1487     .map((id) => ({
   1488       id,
   1489       title: panelTitle(id),
   1490       icon: panelIcon(id),
   1491       docked: isDocked(id),
   1492     }))
   1493     .sort((a, b) => a.title.localeCompare(b.title));
   1494 }
   1495 
   1496 function restorePanelToWorkspaceSlot(panelId, slotId) {
   1497   const id = String(panelId || "").trim();
   1498   const slot = String(slotId || "").trim();
   1499   if (!id || !slot) return;
   1500   const target = slot === "workspaceRightSlot" ? ensureWorkspaceRightRack() : ensureWorkspaceLeftRack();
   1501   if (!(target instanceof HTMLElement)) return;
   1502   const panelEl = getPanelElement(id);
   1503   if (!(panelEl instanceof HTMLElement)) return;
   1504   if (isDocked(id)) undockPanel(id);
   1505   const existing = target.querySelector?.(":scope > .rackPanel:not(.hidden)");
   1506   if (existing instanceof HTMLElement && existing !== panelEl) {
   1507     const existingId = String(existing.dataset.panelId || "").trim();
   1508     if (existingId) dockPanel(existingId);
   1509   }
   1510   target.appendChild(panelEl);
   1511   rememberPanelLastRack(id, target.id);
   1512   saveRackLayoutState();
   1513   applyDockState();
   1514   syncRackStateFromDom();
   1515   enforceWorkspaceRules();
   1516 }
   1517 
   1518 function openWorkspaceAddMenu(anchorEl, slotId) {
   1519   closeWorkspaceAddMenu();
   1520   if (!(anchorEl instanceof HTMLElement)) return;
   1521   const slot = String(slotId || "").trim();
   1522   if (!slot) return;
   1523   const items = workspaceAddCandidates()
   1524     .map(
   1525       (p) => `<button type="button" class="ghost smallBtn" data-workspaceaddpanel="${escapeHtml(p.id)}" data-workspaceaddslot="${escapeHtml(slot)}">
   1526         ${escapeHtml(p.icon)} ${escapeHtml(p.title)}${p.docked ? " (docked)" : ""}
   1527       </button>`
   1528     )
   1529     .join("");
   1530   const menu = document.createElement("div");
   1531   menu.className = "hotbarAddMenu";
   1532   menu.innerHTML = `<div class="hotbarAddMenuList">${items || `<div class="small muted" style="padding:6px 8px;">No panels available.</div>`}</div>`;
   1533   const rect = anchorEl.getBoundingClientRect();
   1534   menu.style.left = `${Math.max(12, Math.min(window.innerWidth - 272, rect.left - 10))}px`;
   1535   menu.style.top = `${Math.max(12, rect.bottom + 8)}px`;
   1536   menu.addEventListener("click", (e) => {
   1537     const btn = e.target.closest?.("[data-workspaceaddpanel][data-workspaceaddslot]");
   1538     if (!btn) return;
   1539     const id = String(btn.getAttribute("data-workspaceaddpanel") || "").trim();
   1540     const slotIdNext = String(btn.getAttribute("data-workspaceaddslot") || "").trim();
   1541     if (!id || !slotIdNext) return;
   1542     restorePanelToWorkspaceSlot(id, slotIdNext);
   1543     closeWorkspaceAddMenu();
   1544   });
   1545   document.body.appendChild(menu);
   1546   workspaceAddMenuEl = menu;
   1547 }
   1548 
   1549 function openHotbarPlusMenu(anchorEl) {
   1550   closeHotbarPlusMenu();
   1551   if (!dockHotbarEl) return;
   1552   if (!(anchorEl instanceof HTMLElement)) return;
   1553 
   1554   const list = sortPosts(Array.from(posts.values())).slice(0, 8);
   1555   const items = list
   1556     .map((p) => {
   1557       const id = String(p?.id || "").trim();
   1558       if (!id) return "";
   1559       const title = postTitle(p);
   1560       return `<button type="button" class="ghost smallBtn" data-addchatpost="${escapeHtml(id)}">${escapeHtml(title)}</button>`;
   1561     })
   1562     .filter(Boolean)
   1563     .join("");
   1564 
   1565   const menu = document.createElement("div");
   1566   menu.className = "hotbarAddMenu";
   1567   menu.innerHTML = `
   1568     <div class="small muted" style="padding:6px 8px 4px;">New chat panel for...</div>
   1569     <div class="hotbarAddMenuList">${items || `<div class="small muted" style="padding:6px 8px;">No hives yet.</div>`}</div>
   1570   `;
   1571 
   1572   const rect = anchorEl.getBoundingClientRect();
   1573   const left = Math.max(12, Math.min(window.innerWidth - 260, rect.left - 200));
   1574   const top = Math.max(12, rect.top - 260);
   1575   menu.style.left = `${left}px`;
   1576   menu.style.top = `${top}px`;
   1577 
   1578   menu.addEventListener("click", (e) => {
   1579     const btn = e.target.closest?.("[data-addchatpost]");
   1580     if (!btn) return;
   1581     const postId = String(btn.getAttribute("data-addchatpost") || "").trim();
   1582     if (!postId) return;
   1583     ensureChatPostPanelInstance(postId, { docked: true });
   1584     try {
   1585       ws.send(JSON.stringify({ type: "getChat", postId }));
   1586     } catch {
   1587       // ignore
   1588     }
   1589     closeHotbarPlusMenu();
   1590     renderHotbar();
   1591   });
   1592 
   1593   document.body.appendChild(menu);
   1594   hotbarPlusMenuEl = menu;
   1595 }
   1596 
   1597 function applyDockState() {
   1598   // For the first implementation phase, we support docking any registered panel that has a DOM element.
   1599   for (const [id, p] of panelRegistry.entries()) {
   1600     const el = p?.element;
   1601     if (!(el instanceof HTMLElement)) continue;
   1602     if (id === "moderation" && !canModerate) {
   1603       el.classList.add("hidden");
   1604       continue;
   1605     }
   1606     el.classList.toggle("hidden", isDocked(id));
   1607   }
   1608 
   1609   renderHotbar();
   1610   updateSideRackEmptyState();
   1611   updateSkinnyChatPanels();
   1612   renderWorkspaceSlotAffordances();
   1613 }
   1614 
   1615 function renderWorkspaceSlotAffordances() {
   1616   if (!rackLayoutEnabled) return;
   1617   const left = ensureWorkspaceLeftRack();
   1618   const right = ensureWorkspaceRightRack();
   1619   for (const slot of [left, right]) {
   1620     if (!(slot instanceof HTMLElement)) continue;
   1621     const hasVisible = Boolean(slot.querySelector?.(":scope > .rackPanel:not(.hidden)"));
   1622     slot.classList.toggle("workspaceSlotEmpty", !hasVisible);
   1623     const existing = slot.querySelector?.(":scope > .workspaceEmptyAdd");
   1624     if (hasVisible) {
   1625       if (existing) existing.remove();
   1626       continue;
   1627     }
   1628     if (existing) continue;
   1629     const btn = document.createElement("button");
   1630     btn.type = "button";
   1631     btn.className = "workspaceEmptyAdd ghost";
   1632     btn.setAttribute("data-workspaceadd", slot.id || "");
   1633     btn.innerHTML = `<span class="workspaceEmptyAddPlus">+</span><span>Add panel</span>`;
   1634     slot.appendChild(btn);
   1635   }
   1636 }
   1637 
   1638 function readRackOrder(rackEl) {
   1639   if (!(rackEl instanceof HTMLElement)) return [];
   1640   return Array.from(rackEl.querySelectorAll(".rackPanel"))
   1641     .filter((el) => el instanceof HTMLElement && !el.classList.contains("hidden"))
   1642     .map((el) => String(el?.dataset?.panelId || "").trim())
   1643     .filter(Boolean);
   1644 }
   1645 
   1646 function applyRackStateToDom() {
   1647   if (!rackLayoutEnabled) return;
   1648   // Ensure core "virtual" panels exist before we try to place them.
   1649   ensurePluginRackPanel();
   1650   const left = ensureWorkspaceLeftRack();
   1651   const rightWorkspace = ensureWorkspaceRightRack();
   1652   const side = ensureMainSideRack();
   1653   const right = ensureRightRack();
   1654   if (!left || !rightWorkspace || !side || !right) return;
   1655   const leftOrder = Array.isArray(rackLayoutState?.racks?.workspaceLeft) ? rackLayoutState.racks.workspaceLeft : [];
   1656   const rightOrderW = Array.isArray(rackLayoutState?.racks?.workspaceRight) ? rackLayoutState.racks.workspaceRight : [];
   1657   const sideOrder = Array.isArray(rackLayoutState?.racks?.side) ? rackLayoutState.racks.side : [];
   1658   const rightOrder = Array.isArray(rackLayoutState?.racks?.right) ? rackLayoutState.racks.right : [];
   1659 
   1660   for (const panelId of leftOrder) {
   1661     const el = getPanelElement(panelId);
   1662     if (el) left.appendChild(el);
   1663   }
   1664   for (const panelId of rightOrderW) {
   1665     const el = getPanelElement(panelId);
   1666     if (el) rightWorkspace.appendChild(el);
   1667   }
   1668   for (const panelId of sideOrder) {
   1669     const el = getPanelElement(panelId);
   1670     if (el) side.appendChild(el);
   1671   }
   1672   for (const panelId of rightOrder) {
   1673     const el = getPanelElement(panelId);
   1674     if (el) right.appendChild(el);
   1675   }
   1676 
   1677   // Hosted plugin widgets live inside Plugin Rack, not a top-level rack.
   1678   const widgetsOrder = Array.isArray(rackLayoutState?.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : [];
   1679   const widgetsRack = ensurePluginRackWidgetsRack();
   1680   if (widgetsRack) {
   1681     for (const panelId of widgetsOrder) {
   1682       const el = getPanelElement(panelId);
   1683       if (!el) continue;
   1684       el.classList.add("pluginRackWidget");
   1685       widgetsRack.appendChild(el);
   1686     }
   1687   }
   1688 }
   1689 
   1690 function readWorkspaceActivePrimary() {
   1691   try {
   1692     const raw = localStorage.getItem(WORKSPACE_ACTIVE_PRIMARY_KEY);
   1693     return raw ? String(raw) : "";
   1694   } catch {
   1695     return "";
   1696   }
   1697 }
   1698 
   1699 function writeWorkspaceActivePrimary(panelId) {
   1700   const id = String(panelId || "").trim();
   1701   if (!id) return;
   1702   try {
   1703     localStorage.setItem(WORKSPACE_ACTIVE_PRIMARY_KEY, id);
   1704   } catch {
   1705     // ignore
   1706   }
   1707 }
   1708 
   1709 function enforceWorkspaceRules() {
   1710   if (!rackLayoutEnabled) return;
   1711   const left = ensureWorkspaceLeftRack();
   1712   const rightWorkspace = ensureWorkspaceRightRack();
   1713   const side = ensureMainSideRack();
   1714   const rightRack = ensureRightRack();
   1715   if (!left || !rightWorkspace || !side || !rightRack) return;
   1716 
   1717   // Primary panels: allow up to 2 visible (one per workspace slot). Enforce max 1 per slot.
   1718   const cleanupSlot = (slotEl) => {
   1719     const kids = Array.from(slotEl.querySelectorAll(":scope > .rackPanel:not(.hidden)"));
   1720     if (kids.length <= 1) return;
   1721     for (const extra of kids.slice(1)) side.appendChild(extra);
   1722   };
   1723   cleanupSlot(left);
   1724   cleanupSlot(rightWorkspace);
   1725 
   1726   // Side rack and right rack are "skinny columns": only allow skinny-capable panels.
   1727   const enforceSkinny = (rackEl) => {
   1728     const kids = Array.from(rackEl.querySelectorAll(":scope > .rackPanel:not(.hidden)"));
   1729     for (const kid of kids) {
   1730       const id = String(kid?.dataset?.panelId || "").trim();
   1731       if (!id) continue;
   1732       if (!panelIsSkinnyCapable(id)) dockPanel(id);
   1733     }
   1734   };
   1735   enforceSkinny(side);
   1736   enforceSkinny(rightRack);
   1737 
   1738   // Side rack can stack, but keep it compact: at most 2 visible panels.
   1739   const sideKids = Array.from(side.querySelectorAll(":scope > .rackPanel:not(.hidden)"));
   1740   if (sideKids.length > 2) {
   1741     for (const extra of sideKids.slice(2)) {
   1742       const id = String(extra?.dataset?.panelId || "").trim();
   1743       if (id) dockPanel(id);
   1744     }
   1745   }
   1746 
   1747   // Right rack is single-slot: keep at most one visible panel.
   1748   const rightKids = Array.from(rightRack.querySelectorAll(":scope > .rackPanel:not(.hidden)"));
   1749   if (rightKids.length > 1) {
   1750     for (const extra of rightKids.slice(1)) {
   1751       const id = String(extra?.dataset?.panelId || "").trim();
   1752       if (id) dockPanel(id);
   1753     }
   1754   }
   1755 
   1756   // Panels that live in the workspace slots should be "full" by default (especially primaries).
   1757   for (const slot of [left, rightWorkspace]) {
   1758     const panel = slot.querySelector?.(":scope > .rackPanel:not(.hidden)");
   1759     if (!(panel instanceof HTMLElement)) continue;
   1760     const id = String(panel.dataset.panelId || "").trim();
   1761     if (!id) continue;
   1762     panel.classList.remove("panelCollapsed");
   1763     panel.dataset.panelDisplay = "full";
   1764   }
   1765 
   1766   // If only one workspace slot is occupied, allow it to expand to full width to avoid blank space.
   1767   // (We temporarily disable this during drag so the empty slot remains a visible drop target.)
   1768   const leftPanel = left.querySelector?.(":scope > .rackPanel:not(.hidden)");
   1769   const rightPanel = rightWorkspace.querySelector?.(":scope > .rackPanel:not(.hidden)");
   1770   const leftId = String(leftPanel?.dataset?.panelId || "").trim();
   1771   const rightId = String(rightPanel?.dataset?.panelId || "").trim();
   1772 
   1773   // Workspace expansion (explicit maximize for primaries).
   1774   const expandedId = readWorkspaceExpandedPrimary();
   1775   const expandedInLeft = Boolean(expandedId && expandedId === leftId);
   1776   const expandedInRight = Boolean(expandedId && expandedId === rightId);
   1777   const expandedValid = expandedInLeft || expandedInRight;
   1778   if (appRoot) {
   1779     appRoot.classList.toggle("workspaceExpandedLeft", expandedInLeft);
   1780     appRoot.classList.toggle("workspaceExpandedRight", expandedInRight);
   1781     if (!expandedValid) appRoot.classList.remove("workspaceExpandedLeft", "workspaceExpandedRight");
   1782   }
   1783   if (expandedId && !expandedValid) clearWorkspaceExpandedState();
   1784 
   1785   // If expanded and the other slot is occupied, keep it accessible via hotbar.
   1786   if (expandedInLeft && rightId && rightId !== expandedId) {
   1787     if (!readWorkspaceExpandedDisplaced()) writeWorkspaceExpandedDisplaced(rightId);
   1788     dockPanel(rightId);
   1789   }
   1790   if (expandedInRight && leftId && leftId !== expandedId) {
   1791     if (!readWorkspaceExpandedDisplaced()) writeWorkspaceExpandedDisplaced(leftId);
   1792     dockPanel(leftId);
   1793   }
   1794 
   1795   // Auto-expand single-primary only when not explicitly expanded.
   1796   if (appRoot && !appRoot.classList.contains("rackIsDragging") && !expandedValid) {
   1797     const leftOnly = Boolean(leftPanel && !rightPanel);
   1798     const rightOnly = Boolean(!leftPanel && rightPanel);
   1799     appRoot.classList.toggle("workspaceSingleLeft", leftOnly);
   1800     appRoot.classList.toggle("workspaceSingleRight", rightOnly);
   1801   } else if (appRoot) {
   1802     appRoot.classList.remove("workspaceSingleLeft", "workspaceSingleRight");
   1803   }
   1804 
   1805   // Transient panels should live in the side column and be collapsed by default.
   1806   for (const el of Array.from(appRoot.querySelectorAll("#mainWorkspaceRack .rackPanel, #mainSideRack .rackPanel"))) {
   1807     const id = String(el?.dataset?.panelId || "").trim();
   1808     if (!id) continue;
   1809     if (panelRole(id) !== "transient") continue;
   1810     if (el.parentElement !== side) side.appendChild(el);
   1811     el.classList.add("panelCollapsed");
   1812     el.dataset.panelDisplay = "collapsed";
   1813   }
   1814 
   1815   updateSkinnyChatPanels();
   1816   renderWorkspaceSlotAffordances();
   1817   syncRackStateFromDom();
   1818 }
   1819 
   1820 function installWorkspaceInteractions() {
   1821   if (!rackLayoutEnabled) return;
   1822   if (!appRoot) return;
   1823   if (appRoot.dataset.workspaceClicks === "1") return;
   1824   appRoot.dataset.workspaceClicks = "1";
   1825 
   1826   appRoot.addEventListener("click", (e) => {
   1827     if (!rackLayoutEnabled) return;
   1828     const target = e.target;
   1829     const addBtn = target?.closest?.("[data-workspaceadd]");
   1830     if (addBtn instanceof HTMLElement) {
   1831       const slotId = String(addBtn.getAttribute("data-workspaceadd") || "").trim();
   1832       if (!slotId) return;
   1833       if (workspaceAddMenuEl) closeWorkspaceAddMenu();
   1834       else openWorkspaceAddMenu(addBtn, slotId);
   1835       return;
   1836     }
   1837     const interactive = target?.closest?.("button,a,input,select,textarea,label");
   1838     if (interactive) return;
   1839     const panel = target?.closest?.(".rackPanel");
   1840     if (!panel) return;
   1841     if (!(panel instanceof HTMLElement)) return;
   1842     if (!panel.closest?.("#mainRack")) return;
   1843     const panelId = String(panel.dataset.panelId || "").trim();
   1844     if (!panelId) return;
   1845     if (panelRole(panelId) !== "primary") return;
   1846     writeWorkspaceActivePrimary(panelId);
   1847     enforceWorkspaceRules();
   1848   });
   1849 }
   1850 
   1851 function syncRackStateFromDom() {
   1852   if (!rackLayoutEnabled) return;
   1853   const left = ensureWorkspaceLeftRack();
   1854   const rightWorkspace = ensureWorkspaceRightRack();
   1855   const side = ensureMainSideRack();
   1856   const right = ensureRightRack();
   1857   if (!left || !rightWorkspace || !side || !right) return;
   1858   rackLayoutState.racks = {
   1859     workspaceLeft: readRackOrder(left),
   1860     workspaceRight: readRackOrder(rightWorkspace),
   1861     side: readRackOrder(side),
   1862     right: readRackOrder(right),
   1863   };
   1864   rackLayoutState.pluginRackWidgets = readPluginRackWidgetsOrder();
   1865   const hosted = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []);
   1866   for (const [id, entry] of panelRegistry.entries()) {
   1867     const el = entry?.element;
   1868     if (!(el instanceof HTMLElement)) continue;
   1869     if (!el.classList.contains("pluginRackWidget") && hosted.has(id)) el.classList.add("pluginRackWidget");
   1870     if (el.classList.contains("pluginRackWidget") && !hosted.has(id)) el.classList.remove("pluginRackWidget");
   1871   }
   1872   saveRackLayoutState();
   1873 }
   1874 
   1875 function ensureRightRack() {
   1876   if (!appRoot) return null;
   1877   if (rightRackEl && rightRackEl.isConnected) return rightRackEl;
   1878   const el = document.createElement("aside");
   1879   el.id = "rightRack";
   1880   el.className = "rightRack";
   1881   appRoot.appendChild(el);
   1882   rightRackEl = el;
   1883   return el;
   1884 }
   1885 
   1886 function ensureMainRack() {
   1887   // In rack mode, "main rack" is the workspace column inside #mainRack.
   1888   if (mainRack && mainRack.isConnected) return mainRack;
   1889   if (mainWorkspaceRackEl) {
   1890     mainRack = mainWorkspaceRackEl;
   1891     return mainRack;
   1892   }
   1893 
   1894   const wrapper = mainRackEl || document.querySelector("#mainRack") || document.querySelector("main.main");
   1895   if (!wrapper) return null;
   1896 
   1897   let workspace = wrapper.querySelector?.("#mainWorkspaceRack");
   1898   let side = wrapper.querySelector?.("#mainSideRack");
   1899   if (!workspace) {
   1900     const w = document.createElement("div");
   1901     w.id = "mainWorkspaceRack";
   1902     w.className = "workspaceRack";
   1903     w.setAttribute("aria-label", "Workspace");
   1904     wrapper.appendChild(w);
   1905     workspace = w;
   1906   }
   1907   if (!side) {
   1908     const s = document.createElement("div");
   1909     s.id = "mainSideRack";
   1910     s.className = "sideRack";
   1911     s.setAttribute("aria-label", "Side panels");
   1912     wrapper.appendChild(s);
   1913     side = s;
   1914   }
   1915   mainSideRack = side;
   1916   mainRack = workspace;
   1917   return mainRack;
   1918 }
   1919 
   1920 function ensureMainSideRack() {
   1921   if (mainSideRack && mainSideRack.isConnected) return mainSideRack;
   1922   if (mainSideRackEl) {
   1923     mainSideRack = mainSideRackEl;
   1924     return mainSideRack;
   1925   }
   1926   // Ensure the workspace rack exists too (creates both columns if missing).
   1927   ensureMainRack();
   1928   return mainSideRack instanceof HTMLElement ? mainSideRack : null;
   1929 }
   1930 
   1931 function ensureWorkspaceLeftRack() {
   1932   const { left } = ensureWorkspaceSlots();
   1933   return left instanceof HTMLElement ? left : null;
   1934 }
   1935 
   1936 function ensureWorkspaceRightRack() {
   1937   const { right } = ensureWorkspaceSlots();
   1938   return right instanceof HTMLElement ? right : null;
   1939 }
   1940 
   1941 function enableRackLayoutDom() {
   1942   if (!appRoot) return;
   1943   appRoot.classList.add("rackMode");
   1944   const rack = ensureRightRack();
   1945   if (!rack) return;
   1946   const main = ensureMainRack();
   1947   const left = ensureWorkspaceLeftRack();
   1948   const rightWorkspace = ensureWorkspaceRightRack();
   1949   const side = ensureMainSideRack();
   1950 
   1951   const mark = (el, panelId) => {
   1952     if (!el) return;
   1953     el.classList.add("rackPanel");
   1954     el.dataset.panelId = panelId;
   1955   };
   1956 
   1957   // Move right-side panels into the rack so they become stackable.
   1958   // (This is a stepping stone toward full dockable panels.)
   1959   if (chatPanelEl) {
   1960     mark(chatPanelEl, "chat");
   1961     // Chat is a workspace primary in rack mode by default; enforceWorkspaceRules will manage if moved.
   1962     if (rightWorkspace && chatPanelEl.parentElement !== rightWorkspace) rightWorkspace.appendChild(chatPanelEl);
   1963   }
   1964   if (peopleDrawerEl) {
   1965     mark(peopleDrawerEl, "people");
   1966     if (peopleDrawerEl.parentElement !== rack) rack.appendChild(peopleDrawerEl);
   1967   }
   1968   if (modPanelEl) {
   1969     mark(modPanelEl, "moderation");
   1970     if (modPanelEl.parentElement !== rack) rack.appendChild(modPanelEl);
   1971   }
   1972 
   1973   // Mark center panels as rack panels too (they already live in mainRack in normal DOM).
   1974   if (main) {
   1975     if (onboardingPanelEl) {
   1976       mark(onboardingPanelEl, "onboarding");
   1977       if (left && onboardingPanelEl.parentElement !== left) left.appendChild(onboardingPanelEl);
   1978       onboardingPanelEl.classList.remove("hidden");
   1979     }
   1980     if (hivesPanelEl) {
   1981       mark(hivesPanelEl, "hives");
   1982       if (left && hivesPanelEl.parentElement !== left) left.appendChild(hivesPanelEl);
   1983     }
   1984     if (profileViewPanel) {
   1985       mark(profileViewPanel, "profile");
   1986       if (side && profileViewPanel.parentElement !== side) side.appendChild(profileViewPanel);
   1987       // In rack mode, profile is its own panel; don't keep it hidden behind the legacy center-view toggle.
   1988       profileViewPanel.classList.remove("hidden");
   1989     }
   1990     if (pollinatePanel) {
   1991       mark(pollinatePanel, "composer");
   1992       if (side && pollinatePanel.parentElement !== side) side.appendChild(pollinatePanel);
   1993     }
   1994   }
   1995 
   1996   // Hide old resizers in rack mode (we'll replace with rack-aware resizing later).
   1997   chatResizeHandle?.classList.add("hidden");
   1998   peopleResizeHandle?.classList.add("hidden");
   1999 
   2000   // People drawer chrome: hide the close button (panel is now a rack item).
   2001   closePeopleBtn?.classList.add("hidden");
   2002   // People drawer toggle button is obsolete in rack mode.
   2003   togglePeopleBtn?.classList.add("hidden");
   2004   // Ensure people panel isn't hidden by legacy state.
   2005   peopleDrawerEl?.classList.remove("hidden");
   2006   peopleOpen = true;
   2007 
   2008   // Profile panel no longer "replaces" the feed in rack mode, so the back button is confusing.
   2009   profileBackBtn?.classList.add("hidden");
   2010 }
   2011 
   2012 function disableRackLayoutDom() {
   2013   if (!appRoot) return;
   2014   appRoot.classList.remove("rackMode");
   2015   // No attempt to move elements back (yet). Disable is meant for page reload use.
   2016 }
   2017 
   2018 function applyPreset(presetId) {
   2019   const key = resolvePresetKey(presetId);
   2020   const def = PRESET_DEFS[key];
   2021   if (!def) return;
   2022   if (def.modOnly && !canModerate) {
   2023     applyPreset("onboardingDefault");
   2024     return;
   2025   }
   2026 
   2027   // Presets are hard-applied: clear any hosted widgets so placement remains deterministic.
   2028   closePluginRackAddMenu();
   2029   for (const id of readPluginRackWidgetsOrder()) removePanelFromPluginRack(id);
   2030   rackLayoutState.pluginRackWidgets = [];
   2031 
   2032   rackLayoutState.presetId = def.presetId || key;
   2033 
   2034   const workspaceLeftOrder = Array.isArray(def.workspaceLeftOrder) ? def.workspaceLeftOrder.map((x) => String(x || "")).filter(Boolean) : [];
   2035   const workspaceRightOrder = Array.isArray(def.workspaceRightOrder) ? def.workspaceRightOrder.map((x) => String(x || "")).filter(Boolean) : [];
   2036   const sideOrder = Array.isArray(def.sideOrder) ? def.sideOrder.map((x) => String(x || "")).filter(Boolean) : [];
   2037   const rightOrderRaw = Array.isArray(def.rightOrder) ? def.rightOrder.map((x) => String(x || "")).filter(Boolean) : [];
   2038   // Right rack is a single skinny-capable panel.
   2039   const rightOrder = rightOrderRaw.length ? [rightOrderRaw[0]] : [];
   2040 
   2041   // Applying a preset should be deterministic even after the user has rearranged panels.
   2042   clearWorkspaceExpandedState();
   2043   const expandedPrimary = typeof def.expandedPrimary === "string" ? def.expandedPrimary.trim() : "";
   2044   if (expandedPrimary) writeWorkspaceExpandedPrimary(expandedPrimary);
   2045 
   2046   if (typeof def.composerOpen === "boolean") setComposerOpen(def.composerOpen);
   2047   setSideCollapsed(Boolean(def.sideCollapsed), { persist: true });
   2048   setRightCollapsed(Boolean(def.rightCollapsed), { persist: true });
   2049 
   2050   const leftRack = ensureWorkspaceLeftRack();
   2051   const rightWorkspaceRack = ensureWorkspaceRightRack();
   2052   const sideRack = ensureMainSideRack();
   2053   const rightRack = ensureRightRack();
   2054   if (!leftRack || !rightWorkspaceRack || !sideRack || !rightRack) return;
   2055 
   2056   const placed = new Set([...workspaceLeftOrder, ...workspaceRightOrder, ...sideOrder, ...rightOrder]);
   2057   const docked = new Set(Array.isArray(def.dockBottom) ? def.dockBottom.map((x) => String(x || "")).filter(Boolean) : []);
   2058   for (const id of placed) docked.delete(id);
   2059 
   2060   // Default: anything not explicitly placed by the preset goes to the hotbar.
   2061   for (const id of Array.from(panelRegistry.keys())) {
   2062     if (!placed.has(id)) docked.add(id);
   2063   }
   2064 
   2065   // Moderation panel should not be forced visible for non-mods.
   2066   if (!canModerate) {
   2067     docked.add("moderation");
   2068     // Also ensure moderation isn't placed anywhere.
   2069     workspaceLeftOrder.splice(0, workspaceLeftOrder.length, ...workspaceLeftOrder.filter((x) => x !== "moderation"));
   2070     workspaceRightOrder.splice(0, workspaceRightOrder.length, ...workspaceRightOrder.filter((x) => x !== "moderation"));
   2071     sideOrder.splice(0, sideOrder.length, ...sideOrder.filter((x) => x !== "moderation"));
   2072   }
   2073 
   2074   rackLayoutState.docked.bottom = Array.from(docked);
   2075 
   2076   saveRackLayoutState();
   2077   applyDockState();
   2078 
   2079   // Detach all known panels before re-placing, so we don't end up with "stale" panels sticking in old racks.
   2080   const elsById = new Map();
   2081   for (const id of Array.from(panelRegistry.keys())) {
   2082     const el = getPanelElement(id);
   2083     if (el) elsById.set(id, el);
   2084   }
   2085   for (const el of elsById.values()) {
   2086     if (el.parentElement) el.parentElement.removeChild(el);
   2087   }
   2088 
   2089   if (leftRack) {
   2090     for (const panelId of workspaceLeftOrder) {
   2091       if (docked.has(panelId)) continue;
   2092       const el = elsById.get(panelId) || getPanelElement(panelId);
   2093       if (el) leftRack.appendChild(el);
   2094     }
   2095   }
   2096   if (rightWorkspaceRack) {
   2097     for (const panelId of workspaceRightOrder) {
   2098       if (docked.has(panelId)) continue;
   2099       const el = elsById.get(panelId) || getPanelElement(panelId);
   2100       if (el) rightWorkspaceRack.appendChild(el);
   2101     }
   2102   }
   2103   if (sideRack) {
   2104     for (const panelId of sideOrder) {
   2105       if (docked.has(panelId)) continue;
   2106       const el = elsById.get(panelId) || getPanelElement(panelId);
   2107       if (el) sideRack.appendChild(el);
   2108     }
   2109   }
   2110   if (rightRack) {
   2111     for (const panelId of rightOrder) {
   2112       if (docked.has(panelId)) continue;
   2113       const el = elsById.get(panelId) || getPanelElement(panelId);
   2114       if (el) rightRack.appendChild(el);
   2115     }
   2116   }
   2117 
   2118   syncRackStateFromDom();
   2119   enforceWorkspaceRules();
   2120   updateLayoutPresetOptions();
   2121 }
   2122 
   2123 function installPanelMinimizeButtons() {
   2124   const addMinBtn = (headerEl, panelId) => {
   2125     if (!headerEl) return;
   2126     const row = headerEl.querySelector(".row") || headerEl.querySelector(".filters") || headerEl;
   2127 
   2128     if (!headerEl.querySelector(`[data-rackdrag="${panelId}"]`)) {
   2129       const drag = document.createElement("button");
   2130       drag.type = "button";
   2131       drag.className = "ghost smallBtn rackDragHandle";
   2132       drag.textContent = "≑";
   2133       drag.title = "Drag to reorder";
   2134       drag.setAttribute("data-rackdrag", panelId);
   2135       row.appendChild(drag);
   2136     }
   2137 
   2138     if (panelIsSkinnyCapable(panelId) && !headerEl.querySelector(`[data-skinny="${panelId}"]`)) {
   2139       const skinny = document.createElement("button");
   2140       skinny.type = "button";
   2141       skinny.className = "ghost smallBtn";
   2142       skinny.textContent = "↔";
   2143       skinny.title = "Toggle skinny/full";
   2144       skinny.setAttribute("data-skinny", panelId);
   2145       skinny.onclick = () => togglePanelSkinny(panelId);
   2146       row.appendChild(skinny);
   2147     }
   2148     if (!panelIsSkinnyCapable(panelId)) {
   2149       headerEl.querySelector(`[data-skinny="${cssEscape(panelId)}"]`)?.remove();
   2150     }
   2151 
   2152     if (panelCanExpand(panelId) && !headerEl.querySelector(`[data-expand="${panelId}"]`)) {
   2153       const expand = document.createElement("button");
   2154       expand.type = "button";
   2155       expand.className = "ghost smallBtn";
   2156       expand.textContent = "β–‘";
   2157       expand.title = "Expand workspace";
   2158       expand.setAttribute("data-expand", panelId);
   2159       expand.onclick = () => togglePrimaryExpand(panelId);
   2160       row.appendChild(expand);
   2161     }
   2162 
   2163     if (!headerEl.querySelector(`[data-minimize="${panelId}"]`)) {
   2164       const btn = document.createElement("button");
   2165       btn.type = "button";
   2166       btn.className = "ghost smallBtn";
   2167       btn.textContent = "-";
   2168       btn.title = "Minimize to hotbar";
   2169       btn.setAttribute("data-minimize", panelId);
   2170       btn.onclick = () => dockPanel(panelId);
   2171       row.appendChild(btn);
   2172     }
   2173   };
   2174 
   2175   addMinBtn(chatHeaderEl, "chat");
   2176   addMinBtn(modPanelEl?.querySelector(".panelHeader"), "moderation");
   2177   addMinBtn(peopleDrawerEl?.querySelector(".panelHeader"), "people");
   2178   addMinBtn(hivesPanelEl?.querySelector(".panelHeader"), "hives");
   2179   addMinBtn(profileViewPanel?.querySelector(".panelHeader"), "profile");
   2180   addMinBtn(pollinatePanel?.querySelector(".panelHeader"), "composer");
   2181   ensurePluginRackPanel();
   2182   addMinBtn(pluginRackPanelEl?.querySelector(".panelHeader"), "pluginRack");
   2183 }
   2184 
   2185 function ensurePluginPanelShell(panelId, title, icon, defaultRack, role) {
   2186   const wantsMain = String(defaultRack || "").toLowerCase() === "main";
   2187   const isPrimary = String(role || "").toLowerCase() === "primary";
   2188   let preferred = null;
   2189   if (wantsMain && isPrimary) {
   2190     // Primary panels should live inside a workspace slot, not as loose items in the workspace grid.
   2191     const left = ensureWorkspaceLeftRack();
   2192     const right = ensureWorkspaceRightRack();
   2193     const side = ensureMainSideRack();
   2194     const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel").length === 0 : false;
   2195     const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel").length === 0 : false;
   2196     preferred = leftEmpty ? left : rightEmpty ? right : side;
   2197   } else if (wantsMain) {
   2198     preferred = ensureMainSideRack();
   2199   } else {
   2200     preferred = ensureRightRack();
   2201   }
   2202   const rack = preferred || ensureRightRack() || ensureMainSideRack() || ensureWorkspaceLeftRack() || ensureWorkspaceRightRack() || ensureMainRack();
   2203   if (!rack) return null;
   2204 
   2205   const existing = document.querySelector?.(`.panel.pluginPanel[data-panel-id="${CSS.escape(panelId)}"]`);
   2206   if (existing instanceof HTMLElement) {
   2207     if (existing.parentElement !== rack) rack.appendChild(existing);
   2208     return existing;
   2209   }
   2210 
   2211   const shell = document.createElement("section");
   2212   shell.className = "panel panelFill pluginPanel rackPanel";
   2213   shell.dataset.panelId = panelId;
   2214   shell.innerHTML = `
   2215     <div class="panelHeader">
   2216       <div class="panelTitle">${escapeHtml(title || panelId)}</div>
   2217       <div class="row">
   2218         <button type="button" class="ghost smallBtn rackDragHandle" data-rackdrag="${escapeHtml(panelId)}" title="Drag to reorder">≑</button>
   2219         <button type="button" class="ghost smallBtn" data-minimize="${escapeHtml(panelId)}" title="Minimize to hotbar">-</button>
   2220       </div>
   2221     </div>
   2222     <div class="panelBody" data-pluginmount="1"></div>
   2223   `;
   2224 
   2225   const minBtn = shell.querySelector(`[data-minimize="${panelId}"]`);
   2226   if (isPrimary || panelCanExpand(panelId)) {
   2227     const headerRow = shell.querySelector(".panelHeader .row");
   2228     if (headerRow && !headerRow.querySelector(`[data-expand="${panelId}"]`)) {
   2229       const expand = document.createElement("button");
   2230       expand.type = "button";
   2231       expand.className = "ghost smallBtn";
   2232       expand.textContent = "β–‘";
   2233       expand.title = "Expand workspace";
   2234       expand.setAttribute("data-expand", panelId);
   2235       expand.addEventListener("click", () => togglePrimaryExpand(panelId));
   2236       if (minBtn && minBtn.parentElement === headerRow) headerRow.insertBefore(expand, minBtn);
   2237       else headerRow.appendChild(expand);
   2238     }
   2239   }
   2240   if (minBtn) minBtn.addEventListener("click", () => dockPanel(panelId));
   2241 
   2242   rack.appendChild(shell);
   2243   return shell;
   2244 }
   2245 
   2246 function ensureChatPostPanelInstance(postId, opts) {
   2247   if (!rackLayoutEnabled) return "";
   2248   const pid = String(postId || "").trim();
   2249   if (!pid) return "";
   2250   const post = posts.get(pid) || null;
   2251   const panelId = chatInstancePanelIdForPost(pid);
   2252   if (!panelId) return "";
   2253 
   2254   if (panelRegistry.has(panelId)) return panelId;
   2255 
   2256   const title = post?.title ? `Chat: ${String(post.title).slice(0, 32)}` : "Chat";
   2257   const shell = document.createElement("section");
   2258   shell.className = "panel panelFill rackPanel chat chatInstance";
   2259   shell.dataset.panelId = panelId;
   2260   shell.innerHTML = `
   2261     <div class="panelHeader">
   2262       <div>
   2263         <div class="panelTitle">${escapeHtml(title)}</div>
   2264         <div class="small muted chatMeta"></div>
   2265       </div>
   2266       <div class="row">
   2267         <button type="button" class="ghost smallBtn rackDragHandle" data-rackdrag="${escapeHtml(panelId)}" title="Drag to reorder">≑</button>
   2268         <button type="button" class="ghost smallBtn" data-skinny="${escapeHtml(panelId)}" title="Toggle skinny/full">↔</button>
   2269         <button type="button" class="ghost smallBtn" data-expand="${escapeHtml(panelId)}" title="Expand workspace">β–‘</button>
   2270         <button type="button" class="ghost smallBtn" data-minimize="${escapeHtml(panelId)}" title="Minimize to hotbar">-</button>
   2271       </div>
   2272     </div>
   2273     <div class="chatMessages"></div>
   2274     <div class="typingIndicator small muted"></div>
   2275     <form class="chatForm">
   2276       <div class="chatComposer">
   2277         <div class="toolbar" role="toolbar" aria-label="Chat formatting">
   2278           <button type="button" data-chatcmd="bold"><b>B</b></button>
   2279           <button type="button" data-chatcmd="italic"><i>I</i></button>
   2280           <button type="button" data-chatcmd="underline"><u>U</u></button>
   2281           <button type="button" data-chatcmd="strikeThrough"><s>S</s></button>
   2282           <span class="sep"></span>
   2283           <button type="button" data-chatcmd="insertUnorderedList">List</button>
   2284           <button type="button" data-chatcmd="insertOrderedList">1. List</button>
   2285           <button type="button" data-chatlink="1">Link</button>
   2286           <button type="button" data-chatimg="1">GIF/Image</button>
   2287           <button type="button" data-chataudio="1">Audio</button>
   2288           <button type="button" data-chatemoji="1">Emoji</button>
   2289           <button type="button" data-chatcmd="removeFormat">Clear</button>
   2290         </div>
   2291         <div class="chatInstanceTools">
   2292           <label class="checkRow chatModToggle chatInstModToggle hidden" title="Send as moderator/system message (left rail)">
   2293             <span>Mod</span>
   2294             <input class="chatInstModToggleInput" type="checkbox" />
   2295           </label>
   2296         </div>
   2297         <div class="editor chatEditor" contenteditable="true" aria-label="Chat editor"></div>
   2298       </div>
   2299       <button class="primary" type="submit">Send</button>
   2300     </form>
   2301   `;
   2302 
   2303   const metaEl = shell.querySelector(".chatMeta");
   2304   const messagesEl = shell.querySelector(".chatMessages");
   2305   const typingEl = shell.querySelector(".typingIndicator");
   2306   const formEl = shell.querySelector("form.chatForm");
   2307   const editorEl = shell.querySelector(".chatEditor");
   2308   const modToggleWrapEl = shell.querySelector(".chatInstModToggle");
   2309   const modToggleEl = shell.querySelector(".chatInstModToggleInput");
   2310 
   2311   shell.querySelector(`[data-minimize="${cssEscape(panelId)}"]`)?.addEventListener("click", () => dockPanel(panelId));
   2312   shell.querySelector(`[data-expand="${cssEscape(panelId)}"]`)?.addEventListener("click", () => togglePrimaryExpand(panelId));
   2313   shell.querySelector(`[data-skinny="${cssEscape(panelId)}"]`)?.addEventListener("click", () => togglePanelSkinny(panelId));
   2314 
   2315   if (formEl && editorEl) {
   2316     formEl.addEventListener("submit", (e) => {
   2317       e.preventDefault();
   2318       const html = String(editorEl.innerHTML || "").trim();
   2319       const text = String(editorEl.innerText || "").trim();
   2320       const hasImg = Boolean(editorEl.querySelector("img"));
   2321       const hasAudio = Boolean(editorEl.querySelector("audio"));
   2322       if (!text && !hasImg && !hasAudio) return;
   2323       if (!loggedInUser) {
   2324         toast("Sign in required", "Sign in to chat.");
   2325         return;
   2326       }
   2327       const currentPost = posts.get(pid) || null;
   2328       if (currentPost && String(currentPost.mode || currentPost.chatMode || "").toLowerCase() === "walkie") {
   2329         toast("Walkie Talkie", "This hive is walkie-only. Hold ~ to talk.");
   2330         return;
   2331       }
   2332       if (currentPost?.readOnly && !(loggedInRole === "owner" || loggedInRole === "moderator")) {
   2333         toast("Read-only", "This hive is read-only.");
   2334         return;
   2335       }
   2336       if (currentPost?.deleted) {
   2337         toast("Unavailable", "This post was deleted.");
   2338         return;
   2339       }
   2340       const wantsMod = Boolean(canModerate && modToggleEl instanceof HTMLInputElement && modToggleEl.checked);
   2341       ws.send(JSON.stringify({ type: "typing", postId: pid, isTyping: false }));
   2342       ws.send(JSON.stringify({ type: "chatMessage", postId: pid, text, html, replyToId: "", asMod: wantsMod }));
   2343       editorEl.innerHTML = "";
   2344       // Leave global reply-to state alone; this instance panel is independent (MVP).
   2345     });
   2346 
   2347     editorEl.addEventListener("focus", () => {
   2348       chatUploadTargetEditor = editorEl;
   2349     });
   2350 
   2351     editorEl.addEventListener("keydown", (e) => {
   2352       if (!shouldSubmitChatOnEnter(e)) return;
   2353       e.preventDefault();
   2354       formEl.requestSubmit();
   2355     });
   2356 
   2357     // Allow drag/drop uploads in instance chats too.
   2358     try {
   2359       installDropUpload(editorEl, { allowImages: true, allowAudio: true });
   2360     } catch {
   2361       // ignore
   2362     }
   2363   }
   2364 
   2365   if (modToggleWrapEl) modToggleWrapEl.classList.toggle("hidden", !canModerate);
   2366 
   2367   // Register + insert.
   2368   panelRegistry.set(panelId, {
   2369     id: panelId,
   2370     title,
   2371     icon: "πŸ’¬",
   2372     source: "core",
   2373     role: "aux",
   2374     defaultRack: "main",
   2375     element: shell,
   2376   });
   2377   chatPanelInstances.set(panelId, { postId: pid });
   2378 
   2379   const options = opts && typeof opts === "object" ? opts : {};
   2380   const docked = Boolean(options.docked);
   2381   const sideRack = ensureMainSideRack();
   2382   if (docked) {
   2383     // Keep it out of layout; show as orb.
   2384     if (sideRack) sideRack.appendChild(shell);
   2385     dockPanel(panelId);
   2386   } else {
   2387     setSideCollapsed(false);
   2388     if (sideRack) sideRack.prepend(shell);
   2389     rememberPanelLastRack(panelId, "mainSideRack");
   2390     saveRackLayoutState();
   2391     applyDockState();
   2392     syncRackStateFromDom();
   2393     enforceWorkspaceRules();
   2394   }
   2395 
   2396   renderChatPostPanelInstance(panelId, true);
   2397   return panelId;
   2398 }
   2399 
   2400 function renderTypingIndicatorForPost(postId, targetEl) {
   2401   if (!(targetEl instanceof HTMLElement)) return;
   2402   const id = String(postId || "").trim();
   2403   if (!id) {
   2404     targetEl.textContent = "";
   2405     return;
   2406   }
   2407   const set = typingUsersByPostId.get(id);
   2408   if (!set || set.size === 0) {
   2409     targetEl.textContent = "";
   2410     return;
   2411   }
   2412   const names = Array.from(set.values()).slice(0, 3);
   2413   const more = set.size > names.length ? ` +${set.size - names.length}` : "";
   2414   targetEl.textContent = `${names.map((u) => `@${u}`).join(", ")}${more} typing...`;
   2415 }
   2416 
   2417 function renderChatPostPanelInstance(panelId, forceScroll) {
   2418   const id = String(panelId || "").trim();
   2419   if (!id) return;
   2420   const inst = chatPanelInstances.get(id);
   2421   if (!inst) return;
   2422   const postId = String(inst.postId || "").trim();
   2423   const post = postId ? posts.get(postId) : null;
   2424   const root = getPanelElement(id);
   2425   if (!(root instanceof HTMLElement)) return;
   2426   const metaEl = root.querySelector(".chatMeta");
   2427   const messagesEl = root.querySelector(".chatMessages");
   2428   const typingEl = root.querySelector(".typingIndicator");
   2429   const editorEl = root.querySelector(".chatEditor");
   2430   const sendBtn = root.querySelector("form.chatForm button[type='submit']");
   2431 
   2432   if (metaEl) {
   2433     if (!post) metaEl.textContent = "Hive not found.";
   2434     else {
   2435       const tags = (post.keywords || []).map((k) => `#${k}`).join(" ");
   2436       const author = post.author ? `by @${post.author}` : "";
   2437       const exp = formatCountdown(post.expiresAt);
   2438       const ro = post.readOnly ? " | read-only" : "";
   2439       metaEl.textContent = `${author}${ro} | ${exp === "permanent" ? "permanent" : `expires in ${exp}`} | ${tags}`.trim();
   2440     }
   2441   }
   2442 
   2443   if (!(messagesEl instanceof HTMLElement)) return;
   2444   const atBottomBefore = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 24;
   2445 
   2446   if (!post) {
   2447     messagesEl.innerHTML = `<div class="small muted">Hive not found.</div>`;
   2448     if (typingEl) typingEl.textContent = "";
   2449     return;
   2450   }
   2451   if (post.deleted) {
   2452     messagesEl.innerHTML = `<div class="small muted">Post was deleted.</div>`;
   2453     if (typingEl) typingEl.textContent = "";
   2454     return;
   2455   }
   2456 
   2457   const isWalkie = String(post.mode || post.chatMode || "").toLowerCase() === "walkie";
   2458   const canChatWrite = Boolean(loggedInRole === "owner" || loggedInRole === "moderator" || !post.readOnly);
   2459   if (editorEl) editorEl.contentEditable = String(Boolean(canChatWrite && !isWalkie));
   2460   if (sendBtn instanceof HTMLButtonElement) sendBtn.disabled = !(loggedInUser && canChatWrite && !isWalkie);
   2461 
   2462   const modToggleWrapEl = root.querySelector(".chatInstModToggle");
   2463   const modToggleEl = root.querySelector(".chatInstModToggleInput");
   2464   if (modToggleWrapEl) modToggleWrapEl.classList.toggle("hidden", !canModerate);
   2465   if (!canModerate && modToggleEl instanceof HTMLInputElement) modToggleEl.checked = false;
   2466 
   2467   const messages = chatByPost.get(post.id) || [];
   2468   const ignoreUserSet = new Set(
   2469     [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase())
   2470   );
   2471   const selfLower = String(loggedInUser || "").toLowerCase();
   2472   const visibleMessages = messages.filter((m) => {
   2473     const fromLower = String(m?.fromUser || "").toLowerCase();
   2474     if (!fromLower || fromLower === selfLower) return true;
   2475     return !ignoreUserSet.has(fromLower);
   2476   });
   2477 
   2478   messagesEl.innerHTML = visibleMessages
   2479     .map((m, index) => {
   2480       const isModMsg = Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod";
   2481       const from = isModMsg ? "MOD" : m.fromUser || "";
   2482       const isYou = loggedInUser && from && from === loggedInUser;
   2483       const rail = chatRailClass({ fromUser: from, isModMessage: isModMsg });
   2484       const prev = index > 0 ? visibleMessages[index - 1] : null;
   2485       const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from);
   2486       const mentions = Array.isArray(m.mentions) ? m.mentions.map((u) => String(u || "").toLowerCase()) : [];
   2487       const mentionMe = Boolean(loggedInUser && mentions.includes(loggedInUser));
   2488       const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || "");
   2489       const youTag = !isModMsg && isYou ? `<span class="muted">(you)</span>` : "";
   2490       const time = new Date(m.createdAt).toLocaleTimeString();
   2491       const tint = isModMsg ? "" : tintStylesFromHex(getProfile(from).color);
   2492       const html = typeof m.html === "string" && m.html.trim() ? m.html : "";
   2493       const content = html ? html : highlightMentionsInText(m.text || "");
   2494       const replyMeta = m.replyTo && typeof m.replyTo === "object" ? m.replyTo : null;
   2495       const replyBlock = replyMeta
   2496         ? `<div class="chatReplyRef"><span class="small muted">@${escapeHtml(replyMeta.fromUser || "unknown")}</span><div class="small">${escapeHtml(
   2497             String(replyMeta.text || "[media]").slice(0, 120)
   2498           )}</div></div>`
   2499         : "";
   2500       const reacts = renderReactionButtons({ kind: "chat", id: m.id, reactions: m.reactions || {}, postId: post.id });
   2501       const deletedLine = m.deleted
   2502         ? `<div class="small muted">message deleted${
   2503             m.deletedBy ? ` by @${escapeHtml(m.deletedBy)}` : ""
   2504           } at ${escapeHtml(new Date(Number(m.deletedAt || m.createdAt || Date.now())).toLocaleString())}</div>`
   2505         : "";
   2506       const editedLine =
   2507         !m.deleted && Number(m.editCount || 0) > 0
   2508           ? `<div class="small muted">edited (${Number(m.editCount || 0)}) at ${escapeHtml(
   2509               new Date(Number(m.editedAt || m.createdAt || Date.now())).toLocaleTimeString()
   2510             )}</div>`
   2511           : "";
   2512       const reportAction =
   2513         loggedInUser && !m.deleted
   2514           ? `<button type="button" class="ghost smallBtn" data-reportchat="${escapeHtml(m.id)}" data-postid="${escapeHtml(
   2515               post.id
   2516             )}">Report</button>`
   2517           : "";
   2518       const deleteAction =
   2519         loggedInUser && !m.deleted && (loggedInRole === "owner" || loggedInRole === "moderator" || from === loggedInUser)
   2520           ? `<button type="button" class="ghost smallBtn" data-delchat="${escapeHtml(m.id)}" data-postid="${escapeHtml(
   2521               post.id
   2522             )}">Delete</button>`
   2523           : "";
   2524       const actions =
   2525         reportAction || deleteAction
   2526           ? `<div class="chatTools">${reportAction}${deleteAction}</div>`
   2527           : "";
   2528       return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${mentionMe ? "mentionMe" : ""} ${rail} ${isModMsg ? "isModMsg" : ""}" data-msgid="${escapeHtml(
   2529         m.id
   2530       )}" ${tint}>
   2531         <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div>
   2532         ${replyBlock}
   2533         <div class="content">${content}</div>
   2534         ${deletedLine}${editedLine}
   2535         <div class="chatActionsRow">${reacts}${actions}</div>
   2536       </div>`;
   2537     })
   2538     .join("");
   2539 
   2540   for (const contentEl of messagesEl.querySelectorAll(".chatMsg .content")) {
   2541     decorateMentionNodesInElement(contentEl);
   2542     decorateYouTubeEmbedsInElement(contentEl);
   2543   }
   2544 
   2545   renderTypingIndicatorForPost(post.id, typingEl);
   2546 
   2547   if (forceScroll || atBottomBefore) messagesEl.scrollTop = messagesEl.scrollHeight;
   2548 }
   2549 
   2550 function renderChatInstancesForPost(postId) {
   2551   const pid = String(postId || "").trim();
   2552   if (!pid) return;
   2553   for (const [panelId, inst] of chatPanelInstances.entries()) {
   2554     if (String(inst?.postId || "") !== pid) continue;
   2555     renderChatPostPanelInstance(panelId);
   2556   }
   2557 }
   2558 
   2559 function setChatInstancePanelPost(panelId, postId, forceScroll = true) {
   2560   const pid = String(postId || "").trim();
   2561   const id = String(panelId || "").trim();
   2562   if (!pid || !id) return false;
   2563   const inst = chatPanelInstances.get(id);
   2564   if (!inst) return false;
   2565   const post = posts.get(pid);
   2566   if (!post) return false;
   2567   inst.postId = pid;
   2568   chatPanelInstances.set(id, inst);
   2569   const root = getPanelElement(id);
   2570   const titleEl = root?.querySelector?.(".panelTitle");
   2571   if (titleEl) titleEl.textContent = post?.title ? `Chat: ${String(post.title).slice(0, 32)}` : "Chat";
   2572   renderChatPostPanelInstance(id, forceScroll);
   2573   return true;
   2574 }
   2575 
   2576 function nearestVisibleChatInstancePanelId(sourceEl) {
   2577   const anchor = sourceEl instanceof HTMLElement ? sourceEl : null;
   2578   if (!anchor) return "";
   2579   const anchorRect = anchor.getBoundingClientRect();
   2580   const ax = anchorRect.left + anchorRect.width / 2;
   2581   const ay = anchorRect.top + anchorRect.height / 2;
   2582   let bestId = "";
   2583   let bestDist = Number.POSITIVE_INFINITY;
   2584   for (const [panelId] of chatPanelInstances.entries()) {
   2585     const root = getPanelElement(panelId);
   2586     if (!(root instanceof HTMLElement)) continue;
   2587     if (root.classList.contains("hidden")) continue;
   2588     const rect = root.getBoundingClientRect();
   2589     if (rect.width <= 1 || rect.height <= 1) continue;
   2590     const cx = rect.left + rect.width / 2;
   2591     const cy = rect.top + rect.height / 2;
   2592     const dist = Math.hypot(cx - ax, cy - ay);
   2593     if (dist < bestDist) {
   2594       bestDist = dist;
   2595       bestId = panelId;
   2596     }
   2597   }
   2598   return bestId;
   2599 }
   2600 
   2601 function applyPluginPresetHint(panelDef) {
   2602   if (!rackLayoutEnabled) return;
   2603   const id = String(panelDef?.id || "").trim();
   2604   if (!id) return;
   2605   if (isDocked(id)) return;
   2606   const presetId = rackLayoutState?.presetId || "";
   2607   const hint = panelDef?.presetHints && typeof panelDef.presetHints === "object" ? panelDef.presetHints[presetId] : null;
   2608   const place = hint && typeof hint === "object" ? String(hint.place || "") : "";
   2609   if (place === "docked.bottom") {
   2610     dockPanel(id);
   2611     return;
   2612   }
   2613   if (place === "main" || place === "right") {
   2614     const rack = place === "main" ? ensureMainSideRack() : ensureRightRack();
   2615     const el = getPanelElement(id);
   2616     if (rack && el) rack.appendChild(el);
   2617   }
   2618 }
   2619 
   2620 function enableRackDnD() {
   2621   if (!rackLayoutEnabled) return;
   2622   const right = ensureRightRack();
   2623   const left = ensureWorkspaceLeftRack();
   2624   const rightWorkspace = ensureWorkspaceRightRack();
   2625   const side = ensureMainSideRack();
   2626   if (!right || !left || !rightWorkspace || !side) return;
   2627   const pluginWidgets = ensurePluginRackWidgetsRack();
   2628   const racks = [left, rightWorkspace, side, right, pluginWidgets].filter((x) => x instanceof HTMLElement);
   2629 
   2630   // Guard against double-install if initRackLayout is called more than once.
   2631   if (appRoot?.dataset?.rackDnd === "1") return;
   2632   if (appRoot) appRoot.dataset.rackDnd = "1";
   2633 
   2634   let draggingEl = null;
   2635   let placeholderEl = null;
   2636   let pointerId = null;
   2637   let dragOffset = { x: 0, y: 0 };
   2638   let draggingPanelId = "";
   2639   let activeRack = null;
   2640   let originRack = null;
   2641   let originBefore = null;
   2642 
   2643   const cancelDrag = () => {
   2644     if (!draggingEl) return;
   2645     cleanup();
   2646     enforceWorkspaceRules();
   2647   };
   2648 
   2649   const cleanup = () => {
   2650     if (appRoot) appRoot.classList.remove("rackIsDragging");
   2651     if (draggingEl) {
   2652       draggingEl.classList.remove("rackDragging");
   2653       draggingEl.style.position = "";
   2654       draggingEl.style.left = "";
   2655       draggingEl.style.top = "";
   2656       draggingEl.style.width = "";
   2657       draggingEl.style.zIndex = "";
   2658       draggingEl.style.pointerEvents = "";
   2659     }
   2660     if (dockHotbarEl) dockHotbarEl.classList.remove("dockTarget");
   2661     if (placeholderEl && placeholderEl.parentElement) placeholderEl.parentElement.removeChild(placeholderEl);
   2662     draggingEl = null;
   2663     placeholderEl = null;
   2664     pointerId = null;
   2665     draggingPanelId = "";
   2666     activeRack = null;
   2667     originRack = null;
   2668     originBefore = null;
   2669   };
   2670 
   2671   const siblings = (rack) => Array.from(rack.querySelectorAll(".rackPanel")).filter((el) => el !== draggingEl && el !== placeholderEl);
   2672 
   2673   const insertPlaceholderAt = (rack, y) => {
   2674     const items = siblings(rack);
   2675     for (const el of items) {
   2676       const r = el.getBoundingClientRect();
   2677       const mid = r.top + r.height / 2;
   2678       if (y < mid) {
   2679         rack.insertBefore(placeholderEl, el);
   2680         return;
   2681       }
   2682     }
   2683     rack.appendChild(placeholderEl);
   2684   };
   2685 
   2686   const rackAtPoint = (x, y) => {
   2687     for (const r of racks) {
   2688       const rect = r.getBoundingClientRect();
   2689       if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return r;
   2690     }
   2691     return null;
   2692   };
   2693 
   2694   const onMove = (e) => {
   2695     if (!draggingEl || e.pointerId !== pointerId) return;
   2696     e.preventDefault();
   2697     const x = e.clientX - dragOffset.x;
   2698     const y = e.clientY - dragOffset.y;
   2699     draggingEl.style.left = `${x}px`;
   2700     draggingEl.style.top = `${y}px`;
   2701 
   2702     const targetRack = rackAtPoint(e.clientX, e.clientY) || activeRack;
   2703     if (targetRack && placeholderEl && placeholderEl.parentElement !== targetRack) {
   2704       targetRack.appendChild(placeholderEl);
   2705     }
   2706     if (targetRack) {
   2707       activeRack = targetRack;
   2708       insertPlaceholderAt(targetRack, e.clientY);
   2709     }
   2710 
   2711     if (dockHotbarEl) {
   2712       const nearBottom = e.clientY > window.innerHeight - 90;
   2713       dockHotbarEl.classList.toggle("dockTarget", Boolean(nearBottom));
   2714       if (nearBottom) showHotbar(true);
   2715     }
   2716   };
   2717 
   2718     const onUp = (e) => {
   2719       if (!draggingEl || e.pointerId !== pointerId) return;
   2720       e.preventDefault();
   2721       const targetRack = placeholderEl?.parentElement || activeRack;
   2722       if (targetRack && placeholderEl && placeholderEl.parentElement === targetRack) {
   2723         const isWorkspaceSlot = targetRack.id === "workspaceLeftSlot" || targetRack.id === "workspaceRightSlot";
   2724         const isRightRackSlot = targetRack.id === "rightRack";
   2725         const isSideRackSlot = targetRack.id === "mainSideRack";
   2726         const isPluginRackWidgets = targetRack.id === "pluginRackWidgetsRack";
   2727         const isSkinnyRackSlot = isRightRackSlot || isSideRackSlot;
   2728         const skinnyOk = panelIsSkinnyCapable(draggingPanelId);
   2729 
   2730         if (isPluginRackWidgets && !panelIsHostableInPluginRack(draggingPanelId)) {
   2731           toast("Can't place there", `${panelTitle(draggingPanelId)} can't be hosted in Plugin Rack.`);
   2732           if (originRack) {
   2733             if (originBefore && originBefore.parentElement === originRack) originRack.insertBefore(draggingEl, originBefore);
   2734             else originRack.appendChild(draggingEl);
   2735           }
   2736           cleanup();
   2737           syncRackStateFromDom();
   2738           enforceWorkspaceRules();
   2739           return;
   2740         }
   2741 
   2742         // Only skinny-capable panels may live in skinny columns (side / right racks).
   2743         if (isSkinnyRackSlot && !skinnyOk) {
   2744           toast("Can't place there", `${panelTitle(draggingPanelId)} can't be placed in a skinny rack.`);
   2745           if (originRack) {
   2746             if (originBefore && originBefore.parentElement === originRack) originRack.insertBefore(draggingEl, originBefore);
   2747             else originRack.appendChild(draggingEl);
   2748           }
   2749           cleanup();
   2750           syncRackStateFromDom();
   2751           enforceWorkspaceRules();
   2752           return;
   2753         }
   2754 
   2755         if (isWorkspaceSlot || isRightRackSlot) {
   2756           const existing = Array.from(targetRack.querySelectorAll(":scope > .rackPanel")).find((x) => x !== draggingEl);
   2757           targetRack.insertBefore(draggingEl, placeholderEl);
   2758           // Swap if occupied: send the previous occupant back to the origin rack position.
   2759           if (existing && originRack) {
   2760             if (originBefore && originBefore.parentElement === originRack) originRack.insertBefore(existing, originBefore);
   2761             else originRack.appendChild(existing);
   2762           }
   2763         } else {
   2764           targetRack.insertBefore(draggingEl, placeholderEl);
   2765         }
   2766         if (isPluginRackWidgets) draggingEl.classList.add("pluginRackWidget");
   2767       }
   2768     const shouldDock = Boolean(dockHotbarEl && e.clientY > window.innerHeight - 90);
   2769     const dockId = draggingPanelId;
   2770     cleanup();
   2771     if (shouldDock && dockId) dockPanel(dockId);
   2772     syncRackStateFromDom();
   2773     enforceWorkspaceRules();
   2774   };
   2775 
   2776   // Use window-level listeners so cross-rack dragging stays responsive even when the cursor passes over gaps/resizers.
   2777   window.addEventListener("pointermove", onMove);
   2778   window.addEventListener("pointerup", onUp);
   2779   window.addEventListener("pointercancel", onUp);
   2780   // Extra safety: pointer events can fail to deliver pointerup if the mouse is released outside the window.
   2781   window.addEventListener("blur", cancelDrag);
   2782   window.addEventListener("mouseup", cancelDrag);
   2783   window.addEventListener("touchend", cancelDrag, { passive: true });
   2784   document.addEventListener("visibilitychange", () => {
   2785     if (document.visibilityState !== "visible") cancelDrag();
   2786   });
   2787   window.addEventListener("keydown", (e) => {
   2788     if (e.key === "Escape") cancelDrag();
   2789   });
   2790 
   2791   const onDown = (e) => {
   2792     const btn = e.target.closest?.("[data-rackdrag]");
   2793     if (!btn) return;
   2794     const el = btn.closest?.(".rackPanel");
   2795     if (!(el instanceof HTMLElement)) return;
   2796     if (el.classList.contains("hidden")) return;
   2797 
   2798     e.preventDefault();
   2799     // If a drag somehow got stuck, start clean.
   2800     cleanup();
   2801     if (appRoot) appRoot.classList.add("rackIsDragging");
   2802     draggingEl = el;
   2803     draggingPanelId = String(el.dataset.panelId || "");
   2804     pointerId = e.pointerId;
   2805     draggingEl.setPointerCapture?.(pointerId);
   2806 
   2807     activeRack = el.parentElement;
   2808     originRack = activeRack;
   2809     originBefore = draggingEl.nextSibling;
   2810     const rect = draggingEl.getBoundingClientRect();
   2811     dragOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top };
   2812 
   2813     placeholderEl = document.createElement("div");
   2814     placeholderEl.className = "rackPlaceholder";
   2815     placeholderEl.style.height = `${Math.max(40, Math.round(rect.height))}px`;
   2816 
   2817     (activeRack || main).insertBefore(placeholderEl, draggingEl.nextSibling);
   2818 
   2819     draggingEl.classList.add("rackDragging");
   2820     draggingEl.style.position = "fixed";
   2821     draggingEl.style.left = `${rect.left}px`;
   2822     draggingEl.style.top = `${rect.top}px`;
   2823     draggingEl.style.width = `${rect.width}px`;
   2824     draggingEl.style.zIndex = "80";
   2825     draggingEl.style.pointerEvents = "none";
   2826   };
   2827 
   2828   // Delegate to the app root so panels can be dragged regardless of which rack they're currently in.
   2829   (appRoot || document).addEventListener("pointerdown", onDown);
   2830 }
   2831 
   2832 function initRackLayout() {
   2833   rackLayoutEnabled = readRackLayoutEnabled();
   2834   let hadState = false;
   2835   try {
   2836     hadState = Boolean(localStorage.getItem(RACK_LAYOUT_STATE_KEY));
   2837   } catch {
   2838     hadState = false;
   2839   }
   2840   rackLayoutState = loadRackLayoutState();
   2841   // Normalize older preset ids in persisted state.
   2842   rackLayoutState.presetId = resolvePresetKey(rackLayoutState.presetId);
   2843 
   2844   if (toggleRackLayoutEl) {
   2845     toggleRackLayoutEl.checked = rackLayoutEnabled;
   2846     // Hide/disable the toggle while rack mode is forced on.
   2847     if (FORCE_RACK_MODE) {
   2848       toggleRackLayoutEl.checked = true;
   2849       toggleRackLayoutEl.disabled = true;
   2850       const row = toggleRackLayoutEl.closest?.("label");
   2851       if (row) row.classList.add("hidden");
   2852       const toggleBtn = document.getElementById("toggleRackLayoutBtn");
   2853       if (toggleBtn) toggleBtn.classList.add("hidden");
   2854     } else {
   2855       toggleRackLayoutEl.onchange = () => {
   2856         writeRackLayoutEnabled(Boolean(toggleRackLayoutEl.checked));
   2857         // Reload is the simplest safe path while the feature is in flux.
   2858         location.reload();
   2859       };
   2860     }
   2861   }
   2862 
   2863   if (layoutPresetEl) {
   2864     updateLayoutPresetOptions();
   2865     layoutPresetEl.value = resolvePresetKey(rackLayoutState.presetId || "onboardingDefault");
   2866     layoutPresetEl.disabled = !rackLayoutEnabled;
   2867     layoutPresetEl.onchange = () => {
   2868       if (!rackLayoutEnabled) return;
   2869       const next = String(layoutPresetEl.value || "onboardingDefault");
   2870       applyPreset(next);
   2871     };
   2872   }
   2873 
   2874   if (!rackLayoutEnabled) {
   2875     disableRackLayoutDom();
   2876     setSideCollapsed(false, { persist: false, updateControls: false });
   2877     setRightCollapsed(false, { persist: false, updateControls: false });
   2878     toggleSideRackEl && (toggleSideRackEl.disabled = true);
   2879     toggleRightRackEl && (toggleRightRackEl.disabled = true);
   2880     showSideRackBtn?.classList.add("hidden");
   2881     showRightRackBtn?.classList.add("hidden");
   2882     showHotbar(false);
   2883     return;
   2884   }
   2885 
   2886   enableRackLayoutDom();
   2887 
   2888   // Ensure Plugin Rack exists and is accessible (defaults to hotbar unless explicitly placed).
   2889   ensurePluginRackPanel();
   2890   const pluginRackPlaced =
   2891     isDocked("pluginRack") ||
   2892     ["workspaceLeft", "workspaceRight", "side", "right"].some((k) => Array.isArray(rackLayoutState?.racks?.[k]) && rackLayoutState.racks[k].includes("pluginRack"));
   2893   if (!pluginRackPlaced) {
   2894     rackLayoutState.docked.bottom = Array.isArray(rackLayoutState?.docked?.bottom) ? rackLayoutState.docked.bottom : [];
   2895     if (!rackLayoutState.docked.bottom.includes("pluginRack")) rackLayoutState.docked.bottom.push("pluginRack");
   2896     saveRackLayoutState();
   2897   }
   2898 
   2899   // Side racks behave like summonable hotbars: hide/show without changing panel layout state.
   2900   toggleSideRackEl && (toggleSideRackEl.disabled = false);
   2901   toggleRightRackEl && (toggleRightRackEl.disabled = false);
   2902 
   2903   if (showSideRackBtn) {
   2904     showSideRackBtn.classList.remove("hidden");
   2905     showSideRackBtn.onclick = () => setSideCollapsed(false);
   2906   }
   2907   if (showRightRackBtn) {
   2908     showRightRackBtn.classList.remove("hidden");
   2909     showRightRackBtn.onclick = () => setRightCollapsed(false);
   2910   }
   2911 
   2912   if (toggleSideRackEl) {
   2913     toggleSideRackEl.onchange = () => {
   2914       if (!rackLayoutEnabled) return;
   2915       setSideCollapsed(!Boolean(toggleSideRackEl.checked));
   2916     };
   2917   }
   2918   if (toggleRightRackEl) {
   2919     toggleRightRackEl.onchange = () => {
   2920       if (!rackLayoutEnabled) return;
   2921       setRightCollapsed(!Boolean(toggleRightRackEl.checked));
   2922     };
   2923   }
   2924 
   2925   setSideCollapsed(readBoolPref(RACK_SIDE_COLLAPSED_KEY, false), { persist: false });
   2926   setRightCollapsed(readBoolPref(RACK_RIGHT_COLLAPSED_KEY, false), { persist: false });
   2927 
   2928   applyRackStateToDom();
   2929   const hasOnboardingPlacement =
   2930     (Array.isArray(rackLayoutState?.racks?.workspaceLeft) && rackLayoutState.racks.workspaceLeft.includes("onboarding")) ||
   2931     (Array.isArray(rackLayoutState?.racks?.workspaceRight) && rackLayoutState.racks.workspaceRight.includes("onboarding")) ||
   2932     (Array.isArray(rackLayoutState?.racks?.side) && rackLayoutState.racks.side.includes("onboarding")) ||
   2933     (Array.isArray(rackLayoutState?.racks?.right) && rackLayoutState.racks.right.includes("onboarding")) ||
   2934     (Array.isArray(rackLayoutState?.docked?.bottom) && rackLayoutState.docked.bottom.includes("onboarding"));
   2935   if ((rackLayoutState?.presetId || "") === "onboardingDefault" && !hasOnboardingPlacement) {
   2936     applyPreset("onboardingDefault");
   2937   }
   2938   installPanelMinimizeButtons();
   2939   enableRackDnD();
   2940   installWorkspaceInteractions();
   2941   enforceWorkspaceRules();
   2942   renderProfilePanel();
   2943 
   2944   // Hotbar interactions
   2945   if (dockHotbarEl) {
   2946     dockHotbarEl.onmouseenter = () => showHotbar(true);
   2947     dockHotbarEl.onmouseleave = () => showHotbar(false);
   2948     // Docked items must be restored via drag-and-drop (click does nothing), but the "+" orb is clickable.
   2949     dockHotbarEl.onclick = (e) => {
   2950       if (dockHotbarEl.dataset.dragging === "1") return;
   2951       const plus = e.target.closest?.("[data-hotbarplus]");
   2952       if (!plus) return;
   2953       if (hotbarPlusMenuEl) closeHotbarPlusMenu();
   2954       else openHotbarPlusMenu(plus);
   2955     };
   2956   }
   2957 
   2958   // Close the "+" menu when clicking elsewhere.
   2959   if (appRoot && appRoot.dataset.hotbarPlusClose !== "1") {
   2960     appRoot.dataset.hotbarPlusClose = "1";
   2961     document.addEventListener("pointerdown", (e) => {
   2962       if (!hotbarPlusMenuEl && !pluginRackAddMenuEl && !workspaceAddMenuEl) return;
   2963       const t = e.target;
   2964       if (t) {
   2965         if (hotbarPlusMenuEl && hotbarPlusMenuEl.contains(t)) return;
   2966         if (pluginRackAddMenuEl && pluginRackAddMenuEl.contains(t)) return;
   2967         if (workspaceAddMenuEl && workspaceAddMenuEl.contains(t)) return;
   2968         if (dockHotbarEl && dockHotbarEl.contains(t)) return;
   2969         if (t.closest?.("[data-workspaceadd]")) return;
   2970       }
   2971       closeHotbarPlusMenu();
   2972       closePluginRackAddMenu();
   2973       closeWorkspaceAddMenu();
   2974     });
   2975   }
   2976 
   2977   // Drag orbs back into the rack to restore (MVP: restore to end of rack).
   2978   if (dockHotbarEl) {
   2979     let orbDragId = "";
   2980     let orbPointer = null;
   2981     let orbStart = null;
   2982     let orbMoved = false;
   2983     let orbPlaceholder = null;
   2984     let orbActiveRack = null;
   2985 
   2986     const lockHotbarVisible = (lock) => {
   2987       dockHotbarEl.dataset.lockVisible = lock ? "1" : "0";
   2988       dockHotbarEl.dataset.dragging = lock ? "1" : "0";
   2989       // While dragging an orb, keep both workspace slots visible as drop targets.
   2990       if (appRoot) {
   2991         if (lock) {
   2992           appRoot.classList.add("rackIsDragging");
   2993           appRoot.dataset.orbDragging = "1";
   2994         } else if (appRoot.dataset.orbDragging === "1") {
   2995           delete appRoot.dataset.orbDragging;
   2996           appRoot.classList.remove("rackIsDragging");
   2997         }
   2998       }
   2999       if (lock) showHotbar(true);
   3000     };
   3001 
   3002     const resolveOrbDropRack = (panelId, rackEl) => {
   3003       const id = String(panelId || "").trim();
   3004       if (!id) return rackEl;
   3005       if (rackEl && rackEl.id === "pluginRackWidgetsRack") {
   3006         if (panelIsHostableInPluginRack(id)) return rackEl;
   3007         const left = ensureWorkspaceLeftRack();
   3008         const right = ensureWorkspaceRightRack();
   3009         const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false;
   3010         const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false;
   3011         return leftEmpty ? left : rightEmpty ? right : left;
   3012       }
   3013       // Skinny racks (side/right) only allow skinny-capable panels.
   3014       if (rackEl && (rackEl.id === "mainSideRack" || rackEl.id === "rightRack")) {
   3015         if (panelIsSkinnyCapable(id)) return rackEl;
   3016         const left = ensureWorkspaceLeftRack();
   3017         const right = ensureWorkspaceRightRack();
   3018         const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false;
   3019         const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false;
   3020         return leftEmpty ? left : rightEmpty ? right : left;
   3021       }
   3022       if (panelRole(id) !== "primary") return rackEl;
   3023       const isWorkspaceSlot = rackEl && (rackEl.id === "workspaceLeftSlot" || rackEl.id === "workspaceRightSlot");
   3024       if (isWorkspaceSlot) return rackEl;
   3025       const left = ensureWorkspaceLeftRack();
   3026       const right = ensureWorkspaceRightRack();
   3027       const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false;
   3028       const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false;
   3029       return leftEmpty ? left : rightEmpty ? right : left;
   3030     };
   3031 
   3032     const insertOrbPlaceholderAt = (rack, y) => {
   3033       if (!(rack instanceof HTMLElement) || !(orbPlaceholder instanceof HTMLElement)) return;
   3034       const items = Array.from(rack.querySelectorAll(":scope > .rackPanel")).filter((el) => el !== orbPlaceholder);
   3035       for (const el of items) {
   3036         const r = el.getBoundingClientRect();
   3037         const mid = r.top + r.height / 2;
   3038         if (y < mid) {
   3039           rack.insertBefore(orbPlaceholder, el);
   3040           return;
   3041         }
   3042       }
   3043       rack.appendChild(orbPlaceholder);
   3044     };
   3045 
   3046     const orbRacks = () => {
   3047       const leftRack = ensureWorkspaceLeftRack();
   3048       const rightWorkspaceRack = ensureWorkspaceRightRack();
   3049       const sideRack = ensureMainSideRack();
   3050       const rightRack = ensureRightRack();
   3051       const pluginWidgetsRack = ensurePluginRackWidgetsRack();
   3052       return [leftRack, rightWorkspaceRack, sideRack, rightRack, pluginWidgetsRack].filter((x) => x instanceof HTMLElement);
   3053     };
   3054 
   3055     const rackAtPoint = (x, y) => {
   3056       for (const r of orbRacks()) {
   3057         const rect = r.getBoundingClientRect();
   3058         if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return r;
   3059       }
   3060       return null;
   3061     };
   3062 
   3063       const dropOrbIntoRack = (panelId, targetRack, beforeEl) => {
   3064         const id = String(panelId || "").trim();
   3065         if (!id) return;
   3066         const rack = resolveOrbDropRack(id, targetRack);
   3067         if (!(rack instanceof HTMLElement)) return;
   3068         const panelEl = getPanelElement(id);
   3069         if (!panelEl) return;
   3070 
   3071         // Restoring into a collapsed rack should uncollapse it (hotbar is a summonable launcher).
   3072         if (rack.id === "mainSideRack") setSideCollapsed(false);
   3073         if (rack.id === "rightRack") setRightCollapsed(false);
   3074 
   3075         undockPanel(id);
   3076 
   3077         const isWorkspaceSlot = rack.id === "workspaceLeftSlot" || rack.id === "workspaceRightSlot";
   3078         const isRightRackSlot = rack.id === "rightRack";
   3079         if (isWorkspaceSlot) {
   3080           const existing = rack.querySelector?.(":scope > .rackPanel:not(.hidden)");
   3081           if (existing instanceof HTMLElement && existing !== panelEl) {
   3082             const existingId = String(existing.dataset.panelId || "").trim();
   3083             if (existingId) dockPanel(existingId);
   3084           }
   3085         }
   3086         if (isRightRackSlot) {
   3087           const existing = rack.querySelector?.(":scope > .rackPanel:not(.hidden)");
   3088           if (existing instanceof HTMLElement && existing !== panelEl) {
   3089             const existingId = String(existing.dataset.panelId || "").trim();
   3090             if (existingId) dockPanel(existingId);
   3091           }
   3092         }
   3093 
   3094         const insertBefore =
   3095           beforeEl instanceof HTMLElement && beforeEl.parentElement === rack && beforeEl.classList.contains("rackPanel")
   3096             ? beforeEl
   3097             : null;
   3098         if (panelEl.parentElement !== rack) {
   3099           if (insertBefore) rack.insertBefore(panelEl, insertBefore);
   3100           else rack.appendChild(panelEl);
   3101         }
   3102         if (rack.id === "pluginRackWidgetsRack") panelEl.classList.add("pluginRackWidget");
   3103         rememberPanelLastRack(id, rack.id);
   3104         saveRackLayoutState();
   3105         syncRackStateFromDom();
   3106         enforceWorkspaceRules();
   3107       };
   3108 
   3109     dockHotbarEl.addEventListener("pointerdown", (e) => {
   3110       const orb = e.target.closest?.("[data-undock]");
   3111       if (!orb) return;
   3112       orbDragId = String(orb.getAttribute("data-undock") || "");
   3113       if (!orbDragId) return;
   3114       orbPointer = e.pointerId;
   3115       orbStart = { x: e.clientX, y: e.clientY };
   3116       orbMoved = false;
   3117       orbActiveRack = null;
   3118       orb.classList.add("dragging");
   3119       orb.setPointerCapture?.(orbPointer);
   3120       lockHotbarVisible(true);
   3121       e.preventDefault();
   3122 
   3123       // Placeholder shows drop position while dragging.
   3124       orbPlaceholder = document.createElement("div");
   3125       orbPlaceholder.className = "rackPlaceholder";
   3126       orbPlaceholder.style.height = "52px";
   3127     });
   3128     window.addEventListener("pointermove", (e) => {
   3129       if (!orbDragId || e.pointerId !== orbPointer) return;
   3130       if (!orbStart) return;
   3131       const dx = Math.abs(e.clientX - orbStart.x);
   3132       const dy = Math.abs(e.clientY - orbStart.y);
   3133       if (dx + dy > 6) orbMoved = true;
   3134 
   3135       if (orbMoved && orbPlaceholder) {
   3136         const r = rackAtPoint(e.clientX, e.clientY) || orbActiveRack;
   3137         if (r && orbPlaceholder.parentElement !== r) r.appendChild(orbPlaceholder);
   3138         if (r) {
   3139           orbActiveRack = r;
   3140           insertOrbPlaceholderAt(r, e.clientY);
   3141         }
   3142       }
   3143     });
   3144     dockHotbarEl.addEventListener("pointerup", (e) => {
   3145       if (!orbDragId || e.pointerId !== orbPointer) return;
   3146       const orb = dockHotbarEl.querySelector(`[data-undock="${CSS.escape(orbDragId)}"]`);
   3147       if (orb) orb.classList.remove("dragging");
   3148       const targetRack = orbMoved ? (rackAtPoint(e.clientX, e.clientY) || orbActiveRack) : null;
   3149       const beforeEl =
   3150         orbMoved && orbPlaceholder && targetRack instanceof HTMLElement && orbPlaceholder.parentElement === targetRack
   3151           ? orbPlaceholder.nextSibling
   3152           : null;
   3153       if (orbMoved && targetRack) dropOrbIntoRack(orbDragId, targetRack, beforeEl);
   3154       orbDragId = "";
   3155       orbPointer = null;
   3156       orbStart = null;
   3157       orbMoved = false;
   3158       orbActiveRack = null;
   3159       if (orbPlaceholder && orbPlaceholder.parentElement) orbPlaceholder.parentElement.removeChild(orbPlaceholder);
   3160       orbPlaceholder = null;
   3161       lockHotbarVisible(false);
   3162     });
   3163     dockHotbarEl.addEventListener("pointercancel", () => {
   3164       orbDragId = "";
   3165       orbPointer = null;
   3166       orbStart = null;
   3167       orbMoved = false;
   3168       orbActiveRack = null;
   3169       if (orbPlaceholder && orbPlaceholder.parentElement) orbPlaceholder.parentElement.removeChild(orbPlaceholder);
   3170       orbPlaceholder = null;
   3171       lockHotbarVisible(false);
   3172       dockHotbarEl.querySelectorAll(".dockOrb.dragging").forEach((x) => x.classList.remove("dragging"));
   3173     });
   3174   }
   3175 
   3176   // Reveal hotbar when cursor is near bottom if there are docked items.
   3177   window.addEventListener("mousemove", (e) => {
   3178     if (!dockHotbarEl) return;
   3179     if (!rackLayoutEnabled) return;
   3180     const nearBottom = e.clientY > window.innerHeight - 80;
   3181     showHotbar(Boolean(nearBottom));
   3182   });
   3183 
   3184   // First enable: seed state from the selected preset so users immediately get a sensible layout.
   3185   if (!hadState) {
   3186     const preset = resolvePresetKey(rackLayoutState.presetId || (layoutPresetEl ? String(layoutPresetEl.value || "") : "") || "onboardingDefault");
   3187     applyPreset(preset);
   3188   }
   3189 
   3190   applyDockState();
   3191   enforceWorkspaceRules();
   3192 }
   3193 let activeProfileUsername = "";
   3194 let activeProfile = null;
   3195 let lastRequestedProfileUsername = "";
   3196 let isEditingProfile = false;
   3197 let replyToMessage = null;
   3198 let chatResizeDragging = false;
   3199 let chatResizeStartX = 0;
   3200 let chatResizeStartWidth = 0;
   3201 const CHAT_WIDTH_KEY = "bzl_chatWidth";
   3202 const CHAT_WIDTH_DEFAULT = 640;
   3203 let sidebarResizeDragging = false;
   3204 let sidebarResizeStartX = 0;
   3205 let sidebarResizeStartWidth = 0;
   3206 const SIDEBAR_WIDTH_KEY = "bzl_sidebarWidth";
   3207 const SIDEBAR_WIDTH_DEFAULT = 320;
   3208 let modResizeDragging = false;
   3209 let modResizeStartX = 0;
   3210 let modResizeStartWidth = 0;
   3211 const MOD_WIDTH_KEY = "bzl_modWidth";
   3212 const MOD_WIDTH_DEFAULT = 360;
   3213 let peopleResizeDragging = false;
   3214 let peopleResizeStartX = 0;
   3215 let peopleResizeStartWidth = 0;
   3216 const PEOPLE_WIDTH_KEY = "bzl_peopleWidth";
   3217 const PEOPLE_WIDTH_DEFAULT = 360;
   3218 let editContext = null;
   3219 let mentionState = { open: false, query: "", selected: 0, items: [], anchorRect: null };
   3220 
   3221 const STAY_CONNECTED_KEY = "bzl_stayConnected";
   3222 function readStayConnectedPref() {
   3223   return readBoolPref(STAY_CONNECTED_KEY, false);
   3224 }
   3225 function writeStayConnectedPref(on) {
   3226   writeBoolPref(STAY_CONNECTED_KEY, Boolean(on));
   3227 }
   3228 const ENABLE_HINTS_KEY = "bzl_enableHints";
   3229 const CHAT_ENTER_MODE_KEY = "bzl_chatEnterMode"; // "ctrlEnter" | "enter"
   3230 function readHintsEnabledPref() {
   3231   const raw = localStorage.getItem(ENABLE_HINTS_KEY);
   3232   if (raw == null) return true;
   3233   return raw !== "0";
   3234 }
   3235 function writeHintsEnabledPref(on) {
   3236   const enabled = Boolean(on);
   3237   localStorage.setItem(ENABLE_HINTS_KEY, enabled ? "1" : "0");
   3238   appRoot?.classList.toggle("hintsEnabled", enabled);
   3239 }
   3240 
   3241 function readChatEnterModePref() {
   3242   const raw = readStringPref(CHAT_ENTER_MODE_KEY, "ctrlEnter");
   3243   return raw === "enter" ? "enter" : "ctrlEnter";
   3244 }
   3245 
   3246 function writeChatEnterModePref(mode) {
   3247   const next = String(mode || "").trim().toLowerCase();
   3248   writeStringPref(CHAT_ENTER_MODE_KEY, next === "enter" ? "enter" : "ctrlEnter");
   3249 }
   3250 
   3251 let instanceBranding = { title: "Bzl", subtitle: "Ephemeral hives + chat", allowMemberPermanentPosts: false, appearance: {} };
   3252 let onboardingState = {
   3253   enabled: true,
   3254   rulesVersion: 1,
   3255   requireAcceptance: false,
   3256   blockReadUntilAccepted: false,
   3257   acceptedRulesVersion: 0,
   3258   acceptedAt: 0,
   3259   tutorialVersion: 1,
   3260   tutorialCompletedVersion: 0,
   3261   selectedRoleIds: [],
   3262   needsAcceptance: false,
   3263 };
   3264 let serverInfo = null;
   3265 let serverHealth = null;
   3266 let serverInfoStatus = { loading: false, at: 0, error: "" };
   3267 let pluginAdminStatus = "";
   3268 let pluginAdminBusy = false;
   3269 const pluginEnableInFlight = new Set();
   3270 
   3271 const THEME_PRESETS = [
   3272   {
   3273     id: "bzl_original",
   3274     name: "Bzl (Original)",
   3275     appearance: {
   3276       bg: "#060611",
   3277       panel: "#0c0c18",
   3278       text: "#f6f0ff",
   3279       accent: "#ff3ea5",
   3280       accent2: "#b84bff",
   3281       good: "#3ddc97",
   3282       bad: "#ff4d8a",
   3283       fontBody: "system",
   3284       fontMono: "mono",
   3285       mutedPct: 65,
   3286       linePct: 10,
   3287       panel2Pct: 2
   3288     }
   3289   },
   3290   {
   3291     id: "midnight_cyan",
   3292     name: "Midnight Cyan",
   3293     appearance: {
   3294       bg: "#060a12",
   3295       panel: "#0a1220",
   3296       text: "#eaf4ff",
   3297       accent: "#2bf5d6",
   3298       accent2: "#4aa0ff",
   3299       good: "#2bf5d6",
   3300       bad: "#ff4d8a",
   3301       fontBody: "system",
   3302       fontMono: "mono",
   3303       mutedPct: 64,
   3304       linePct: 10,
   3305       panel2Pct: 2
   3306     }
   3307   },
   3308   {
   3309     id: "warm_amber",
   3310     name: "Warm Amber",
   3311     appearance: {
   3312       bg: "#0b0706",
   3313       panel: "#17100e",
   3314       text: "#fff2ea",
   3315       accent: "#ffb020",
   3316       accent2: "#ff3ea5",
   3317       good: "#3ddc97",
   3318       bad: "#ff4d8a",
   3319       fontBody: "serif",
   3320       fontMono: "mono",
   3321       mutedPct: 66,
   3322       linePct: 11,
   3323       panel2Pct: 3
   3324     }
   3325   },
   3326   {
   3327     id: "slate_violet",
   3328     name: "Slate Violet",
   3329     appearance: {
   3330       bg: "#080a10",
   3331       panel: "#101522",
   3332       text: "#eef0ff",
   3333       accent: "#9b8cff",
   3334       accent2: "#ff3ea5",
   3335       good: "#3ddc97",
   3336       bad: "#ff4d8a",
   3337       fontBody: "system",
   3338       fontMono: "mono",
   3339       mutedPct: 62,
   3340       linePct: 9,
   3341       panel2Pct: 2
   3342     }
   3343   },
   3344   {
   3345     id: "terminal_green",
   3346     name: "Terminal Green",
   3347     appearance: {
   3348       bg: "#040805",
   3349       panel: "#070f08",
   3350       text: "#d7ffe6",
   3351       accent: "#2bff88",
   3352       accent2: "#20d3ff",
   3353       good: "#2bff88",
   3354       bad: "#ff4d8a",
   3355       fontBody: "mono",
   3356       fontMono: "mono",
   3357       mutedPct: 58,
   3358       linePct: 12,
   3359       panel2Pct: 2
   3360     }
   3361   },
   3362   {
   3363     id: "high_contrast",
   3364     name: "High Contrast",
   3365     appearance: {
   3366       bg: "#000000",
   3367       panel: "#0a0a0a",
   3368       text: "#ffffff",
   3369       accent: "#ffd300",
   3370       accent2: "#00d3ff",
   3371       good: "#00ff85",
   3372       bad: "#ff2d55",
   3373       fontBody: "system",
   3374       fontMono: "mono",
   3375       mutedPct: 70,
   3376       linePct: 16,
   3377       panel2Pct: 3
   3378     }
   3379   },
   3380   {
   3381     id: "lavender_mist",
   3382     name: "Lavender Mist",
   3383     appearance: {
   3384       bg: "#070611",
   3385       panel: "#120c1b",
   3386       text: "#f7f3ff",
   3387       accent: "#c9a3ff",
   3388       accent2: "#ff79c6",
   3389       good: "#3ddc97",
   3390       bad: "#ff4d8a",
   3391       fontBody: "system",
   3392       fontMono: "mono",
   3393       mutedPct: 68,
   3394       linePct: 10,
   3395       panel2Pct: 3
   3396     }
   3397   }
   3398 ];
   3399 
   3400 const SFX = {
   3401   open: "/assets/sfx/Select_B7.wav",
   3402   post: "/assets/sfx/Select_B7.wav",
   3403   ping: "/assets/sfx/Select_C3.wav",
   3404 };
   3405 const sfxCache = new Map();
   3406 let pendingOpenSfx = true;
   3407 let lastSfxAt = 0;
   3408 
   3409 function getSfx(url) {
   3410   const key = String(url || "");
   3411   if (!key) return null;
   3412   if (sfxCache.has(key)) return sfxCache.get(key);
   3413   const a = new Audio(key);
   3414   a.preload = "auto";
   3415   sfxCache.set(key, a);
   3416   return a;
   3417 }
   3418 
   3419 async function playSfx(name, { volume = 0.32 } = {}) {
   3420   const url = SFX[name];
   3421   if (!url) return false;
   3422   const now = Date.now();
   3423   if (now - lastSfxAt < 120) return false;
   3424   lastSfxAt = now;
   3425 
   3426   const a = getSfx(url);
   3427   if (!a) return false;
   3428   try {
   3429     a.pause();
   3430     a.currentTime = 0;
   3431     a.volume = Math.max(0, Math.min(1, Number(volume) || 0.32));
   3432     await a.play();
   3433     return true;
   3434   } catch {
   3435     return false;
   3436   }
   3437 }
   3438 
   3439 function normalizeInstanceBranding(raw) {
   3440   const title = String(raw?.title || "").replace(/\s+/g, " ").trim().slice(0, 32);
   3441   const subtitle = String(raw?.subtitle || "").replace(/\s+/g, " ").trim().slice(0, 80);
   3442   const allowMemberPermanentPosts = Boolean(raw?.allowMemberPermanentPosts);
   3443   const appearanceRaw = raw?.appearance && typeof raw.appearance === "object" ? raw.appearance : {};
   3444   const bg = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.bg || "")) ? String(appearanceRaw.bg).toLowerCase() : "#060611";
   3445   const panel = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.panel || "")) ? String(appearanceRaw.panel).toLowerCase() : "#0c0c18";
   3446   const text = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.text || "")) ? String(appearanceRaw.text).toLowerCase() : "#f6f0ff";
   3447   const accent = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.accent || "")) ? String(appearanceRaw.accent).toLowerCase() : "#ff3ea5";
   3448   const accent2 = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.accent2 || "")) ? String(appearanceRaw.accent2).toLowerCase() : "#b84bff";
   3449   const good = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.good || "")) ? String(appearanceRaw.good).toLowerCase() : "#3ddc97";
   3450   const bad = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.bad || "")) ? String(appearanceRaw.bad).toLowerCase() : "#ff4d8a";
   3451   const fontBody = ["system", "serif", "mono"].includes(String(appearanceRaw.fontBody || "")) ? String(appearanceRaw.fontBody) : "system";
   3452   const fontMono = ["mono", "system"].includes(String(appearanceRaw.fontMono || "")) ? String(appearanceRaw.fontMono) : "mono";
   3453   const clampPct = (n, fallback) => {
   3454     const v = Math.floor(Number(n));
   3455     if (!Number.isFinite(v)) return fallback;
   3456     return Math.max(0, Math.min(100, v));
   3457   };
   3458   const mutedPct = clampPct(appearanceRaw.mutedPct, 65);
   3459   const linePct = clampPct(appearanceRaw.linePct, 10);
   3460   const panel2Pct = clampPct(appearanceRaw.panel2Pct, 2);
   3461   const onboardingRaw = raw?.onboarding && typeof raw.onboarding === "object" ? raw.onboarding : {};
   3462   const aboutRaw = onboardingRaw.about && typeof onboardingRaw.about === "object" ? onboardingRaw.about : {};
   3463   const rulesRaw = onboardingRaw.rules && typeof onboardingRaw.rules === "object" ? onboardingRaw.rules : {};
   3464   const roleSelectRaw = onboardingRaw.roleSelect && typeof onboardingRaw.roleSelect === "object" ? onboardingRaw.roleSelect : {};
   3465   const tutorialRaw = onboardingRaw.tutorial && typeof onboardingRaw.tutorial === "object" ? onboardingRaw.tutorial : {};
   3466   const ruleItems = Array.isArray(rulesRaw.items)
   3467     ? rulesRaw.items
   3468         .map((r, idx) => ({
   3469           id: String(r?.id || `r${idx + 1}`).trim().slice(0, 40),
   3470           order: Number.isFinite(Number(r?.order)) ? Math.max(1, Math.floor(Number(r.order))) : idx + 1,
   3471           name: String(r?.name || "").trim().slice(0, 60),
   3472           shortDescription: String(r?.shortDescription || "").trim().slice(0, 180),
   3473           description: typeof r?.description === "string" ? r.description : "",
   3474           severity: ["info", "warn", "critical"].includes(String(r?.severity || "").trim().toLowerCase())
   3475             ? String(r.severity).trim().toLowerCase()
   3476             : "info",
   3477         }))
   3478         .filter((r) => r.id)
   3479         .slice(0, 200)
   3480         .sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || String(a.id || "").localeCompare(String(b.id || "")))
   3481     : [];
   3482   return {
   3483     title: title || "Bzl",
   3484     subtitle: subtitle || "Ephemeral hives + chat",
   3485     allowMemberPermanentPosts,
   3486     onboarding: {
   3487       enabled: Object.prototype.hasOwnProperty.call(onboardingRaw, "enabled") ? Boolean(onboardingRaw.enabled) : true,
   3488       about: {
   3489         content: typeof aboutRaw.content === "string" ? aboutRaw.content : "",
   3490         updatedAt: Number(aboutRaw.updatedAt || 0) || 0,
   3491         updatedBy: String(aboutRaw.updatedBy || "").trim().toLowerCase(),
   3492       },
   3493       rules: {
   3494         version: Math.max(1, Math.floor(Number(rulesRaw.version || 1))),
   3495         requireAcceptance: Boolean(rulesRaw.requireAcceptance),
   3496         blockReadUntilAccepted: Boolean(rulesRaw.blockReadUntilAccepted),
   3497         items: ruleItems,
   3498       },
   3499       roleSelect: {
   3500         enabled: Object.prototype.hasOwnProperty.call(roleSelectRaw, "enabled") ? Boolean(roleSelectRaw.enabled) : true,
   3501         selfAssignableRoleIds: Array.isArray(roleSelectRaw.selfAssignableRoleIds)
   3502           ? roleSelectRaw.selfAssignableRoleIds.map((x) => String(x || "").trim().toLowerCase()).filter(Boolean).slice(0, 64)
   3503           : [],
   3504       },
   3505       tutorial: {
   3506         enabled: Object.prototype.hasOwnProperty.call(tutorialRaw, "enabled") ? Boolean(tutorialRaw.enabled) : true,
   3507         version: Math.max(1, Math.floor(Number(tutorialRaw.version || 1))),
   3508       },
   3509     },
   3510     appearance: { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct },
   3511   };
   3512 }
   3513 
   3514 function normalizeOnboardingState(raw) {
   3515   const src = raw && typeof raw === "object" ? raw : {};
   3516   return {
   3517     enabled: Object.prototype.hasOwnProperty.call(src, "enabled") ? Boolean(src.enabled) : Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.enabled),
   3518     rulesVersion: Math.max(1, Math.floor(Number(src.rulesVersion || normalizeInstanceBranding(instanceBranding).onboarding?.rules?.version || 1))),
   3519     requireAcceptance: Object.prototype.hasOwnProperty.call(src, "requireAcceptance")
   3520       ? Boolean(src.requireAcceptance)
   3521       : Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.rules?.requireAcceptance),
   3522     blockReadUntilAccepted: Object.prototype.hasOwnProperty.call(src, "blockReadUntilAccepted")
   3523       ? Boolean(src.blockReadUntilAccepted)
   3524       : Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.rules?.blockReadUntilAccepted),
   3525     acceptedRulesVersion: Math.max(0, Math.floor(Number(src.acceptedRulesVersion || 0))),
   3526     acceptedAt: Number(src.acceptedAt || 0) || 0,
   3527     tutorialVersion: Math.max(1, Math.floor(Number(src.tutorialVersion || normalizeInstanceBranding(instanceBranding).onboarding?.tutorial?.version || 1))),
   3528     tutorialCompletedVersion: Math.max(0, Math.floor(Number(src.tutorialCompletedVersion || 0))),
   3529     selectedRoleIds: Array.isArray(src.selectedRoleIds) ? src.selectedRoleIds.map((x) => String(x || "").trim().toLowerCase()).filter(Boolean).slice(0, 64) : [],
   3530     needsAcceptance: Boolean(src.needsAcceptance),
   3531   };
   3532 }
   3533 
   3534 function applyInstanceAppearance(appearanceOverride = null) {
   3535   const b = normalizeInstanceBranding(appearanceOverride ? { ...instanceBranding, appearance: appearanceOverride } : instanceBranding);
   3536   const a = b.appearance || {};
   3537   const fontStacks = {
   3538     system:
   3539       'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"',
   3540     serif: 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif',
   3541     mono: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
   3542   };
   3543   const fontBodyStack = fontStacks[a.fontBody] || fontStacks.system;
   3544   const fontMonoStack = fontStacks[a.fontMono] || fontStacks.mono;
   3545   document.documentElement.style.setProperty("--bg", a.bg || "#060611");
   3546   document.documentElement.style.setProperty("--panel", a.panel || "#0c0c18");
   3547   document.documentElement.style.setProperty("--text", a.text || "#f6f0ff");
   3548   document.documentElement.style.setProperty("--accent", a.accent || "#ff3ea5");
   3549   document.documentElement.style.setProperty("--accent2", a.accent2 || "#b84bff");
   3550   document.documentElement.style.setProperty("--good", a.good || "#3ddc97");
   3551   document.documentElement.style.setProperty("--bad", a.bad || "#ff4d8a");
   3552   document.documentElement.style.setProperty("--font-body", fontBodyStack);
   3553   document.documentElement.style.setProperty("--font-mono", fontMonoStack);
   3554   document.documentElement.style.setProperty("--muted-pct", String(Number(a.mutedPct ?? 65)));
   3555   document.documentElement.style.setProperty("--line-pct", String(Number(a.linePct ?? 10)));
   3556   document.documentElement.style.setProperty("--panel2-pct", String(Number(a.panel2Pct ?? 2)));
   3557 }
   3558 
   3559 function renderInstanceBranding() {
   3560   const b = normalizeInstanceBranding(instanceBranding);
   3561   if (instanceTitleEl) instanceTitleEl.textContent = b.title;
   3562   if (instanceSubtitleEl) instanceSubtitleEl.textContent = b.subtitle;
   3563 }
   3564 
   3565 function formatLocalTime(ts) {
   3566   const n = Number(ts || 0);
   3567   if (!n) return "";
   3568   try {
   3569     return new Date(n).toLocaleString();
   3570   } catch {
   3571     return "";
   3572   }
   3573 }
   3574 
   3575 async function requestServerInfo() {
   3576   if (serverInfoStatus.loading) return;
   3577   serverInfoStatus = { loading: true, at: Date.now(), error: "" };
   3578   renderModPanel();
   3579   try {
   3580     const [infoRes, healthRes] = await Promise.all([
   3581       fetch("/api/info", { cache: "no-store" }),
   3582       fetch("/api/health", { cache: "no-store" })
   3583     ]);
   3584     if (!infoRes.ok) throw new Error(`Failed to load /api/info (${infoRes.status})`);
   3585     if (!healthRes.ok) throw new Error(`Failed to load /api/health (${healthRes.status})`);
   3586     serverInfo = await infoRes.json();
   3587     serverHealth = await healthRes.json();
   3588     serverInfoStatus = { loading: false, at: Date.now(), error: "" };
   3589     renderModPanel();
   3590   } catch (e) {
   3591     serverInfoStatus = { loading: false, at: Date.now(), error: e?.message || "Failed to load server info." };
   3592     renderModPanel();
   3593   }
   3594 }
   3595 
   3596 function normalizeDmThread(raw) {
   3597   if (!raw || typeof raw !== "object") return null;
   3598   const id = String(raw.id || "").trim();
   3599   const other = String(raw.other || "").trim().toLowerCase();
   3600   const status = String(raw.status || "").trim();
   3601   if (!id || !other) return null;
   3602   return {
   3603     id,
   3604     other,
   3605     status: status || "unknown",
   3606     requestedBy: String(raw.requestedBy || ""),
   3607     pendingFor: String(raw.pendingFor || ""),
   3608     createdAt: Number(raw.createdAt || 0),
   3609     updatedAt: Number(raw.updatedAt || 0),
   3610     lastMessageAt: Number(raw.lastMessageAt || 0),
   3611   };
   3612 }
   3613 
   3614 function normalizeDmMessage(raw) {
   3615   if (!raw || typeof raw !== "object") return null;
   3616   const id = String(raw.id || "").trim();
   3617   if (!id) return null;
   3618   return {
   3619     id,
   3620     fromUser: String(raw.fromUser || raw.from || "").trim().toLowerCase(),
   3621     asMod: Boolean(raw.asMod) || String(raw.fromUser || raw.from || "").trim().toLowerCase() === "mod",
   3622     createdAt: Number(raw.createdAt || 0),
   3623     text: typeof raw.text === "string" ? raw.text : "",
   3624     html: typeof raw.html === "string" ? raw.html : "",
   3625   };
   3626 }
   3627 
   3628 function dmActivityAt(thread) {
   3629   if (!thread) return 0;
   3630   return Math.max(Number(thread.lastMessageAt || 0), Number(thread.updatedAt || 0), Number(thread.createdAt || 0));
   3631 }
   3632 
   3633 function shortTimeAgo(ts) {
   3634   const t = Number(ts || 0);
   3635   if (!Number.isFinite(t) || t <= 0) return "";
   3636   const deltaMs = Math.max(0, Date.now() - t);
   3637   const mins = Math.floor(deltaMs / 60000);
   3638   if (mins < 1) return "now";
   3639   if (mins < 60) return `${mins}m`;
   3640   const hours = Math.floor(mins / 60);
   3641   if (hours < 24) return `${hours}h`;
   3642   const days = Math.floor(hours / 24);
   3643   return `${days}d`;
   3644 }
   3645 
   3646 function postChatActivityAt(postId, post) {
   3647   const id = String(postId || "").trim();
   3648   const list = id ? chatByPost.get(id) : null;
   3649   const lastChatAt =
   3650     Array.isArray(list) && list.length
   3651       ? Math.max(
   3652           ...list.map((m) => Math.max(Number(m?.createdAt || 0), Number(m?.editedAt || 0), Number(m?.deletedAt || 0)))
   3653         )
   3654       : 0;
   3655   return Math.max(lastChatAt, Number(post?.createdAt || 0), Number(post?.updatedAt || 0));
   3656 }
   3657 
   3658 function pushRecentUnique(list, id, limit = CHAT_RECENTS_LIMIT) {
   3659   const value = String(id || "").trim();
   3660   if (!value) return list;
   3661   const next = [value, ...list.filter((x) => x !== value)];
   3662   if (next.length > limit) next.length = limit;
   3663   return next;
   3664 }
   3665 
   3666 function touchRecentHiveChat(postId) {
   3667   const id = String(postId || "").trim();
   3668   if (!id) return;
   3669   recentHiveChatIds = pushRecentUnique(recentHiveChatIds, id);
   3670 }
   3671 
   3672 function touchRecentDmChat(threadId) {
   3673   const id = String(threadId || "").trim();
   3674   if (!id) return;
   3675   recentDmChatThreadIds = pushRecentUnique(recentDmChatThreadIds, id);
   3676 }
   3677 
   3678 function activeDmThreadsSorted() {
   3679   return dmThreads
   3680     .filter((t) => t && String(t.status || "") === "active")
   3681     .sort((a, b) => dmActivityAt(b) - dmActivityAt(a));
   3682 }
   3683 
   3684 function blurFocusedChatComposer() {
   3685   const activeEl = document.activeElement;
   3686   if (!(activeEl instanceof HTMLElement)) return;
   3687   if (activeEl === chatEditor || activeEl.closest?.(".chatEditor")) activeEl.blur();
   3688 }
   3689 
   3690 function openChatContextValue(rawValue, opts = null) {
   3691   const raw = String(rawValue || "").trim();
   3692   if (!raw) return false;
   3693   const options = opts && typeof opts === "object" ? opts : {};
   3694   const preserveFocus = Boolean(options.preserveFocus);
   3695   if (raw.startsWith("dm:")) {
   3696     const id = raw.slice(3);
   3697     if (!id) return false;
   3698     openDmThread(id, { preserveFocus });
   3699     return true;
   3700   }
   3701   if (raw.startsWith("post:")) {
   3702     const id = raw.slice(5);
   3703     if (!id) return false;
   3704     openChat(id, { preserveFocus });
   3705     return true;
   3706   }
   3707   return false;
   3708 }
   3709 
   3710 function renderChatContextSelect() {
   3711   if (!(chatContextSelectEl instanceof HTMLSelectElement)) return;
   3712   const prevValue = String(chatContextSelectEl.value || "").trim();
   3713   const dmThreadsActive = activeDmThreadsSorted();
   3714   const dmById = new Map(dmThreadsActive.map((t) => [t.id, t]));
   3715   recentDmChatThreadIds = recentDmChatThreadIds.filter((id) => dmById.has(id));
   3716   const dmRecent = [activeDmThreadId, ...recentDmChatThreadIds]
   3717     .map((id) => dmById.get(String(id || "")))
   3718     .filter(Boolean)
   3719     .filter((t, i, arr) => arr.findIndex((x) => x.id === t.id) === i);
   3720 
   3721   const postsById = new Map(Array.from(posts.values()).map((p) => [String(p.id), p]));
   3722   const openPanelPostIds = Array.from(chatPanelInstances.values())
   3723     .map((inst) => String(inst?.postId || "").trim())
   3724     .filter(Boolean);
   3725   recentHiveChatIds = recentHiveChatIds.filter((id) => {
   3726     const p = postsById.get(String(id));
   3727     return Boolean(p && !p.deleted);
   3728   });
   3729   const knownChatPostIds = Array.from(chatByPost.keys()).map((id) => String(id || "").trim()).filter(Boolean);
   3730   const postRecent = [activeChatPostId, ...openPanelPostIds, ...recentHiveChatIds, ...knownChatPostIds]
   3731     .map((id) => postsById.get(String(id || "")))
   3732     .filter((p) => p && !p.deleted)
   3733     .filter((p, i, arr) => arr.findIndex((x) => String(x.id) === String(p.id)) === i);
   3734 
   3735   const hasAny = Boolean(dmRecent.length || postRecent.length || activeDmThreadId || activeChatPostId);
   3736   if (!hasAny) {
   3737     chatContextSelectEl.classList.add("hidden");
   3738     chatContextSelectEl.innerHTML = "";
   3739     return;
   3740   }
   3741 
   3742   const activeDmValue = activeDmThreadId ? `dm:${activeDmThreadId}` : "";
   3743   const activePostValue = activeChatPostId ? `post:${activeChatPostId}` : "";
   3744   const selected = activeDmValue || activePostValue || prevValue;
   3745 
   3746   syncingChatContextSelect = true;
   3747   chatContextSelectEl.classList.remove("hidden");
   3748   chatContextSelectEl.replaceChildren();
   3749   const topPlaceholder = document.createElement("option");
   3750   topPlaceholder.value = "";
   3751   topPlaceholder.textContent = "Open chats...";
   3752   chatContextSelectEl.appendChild(topPlaceholder);
   3753 
   3754   if (dmRecent.length) {
   3755     const dmGroup = document.createElement("optgroup");
   3756     dmGroup.label = "DMs";
   3757     for (const thread of dmRecent) {
   3758       const opt = document.createElement("option");
   3759       opt.value = `dm:${String(thread.id || "").trim()}`;
   3760       const when = shortTimeAgo(dmActivityAt(thread));
   3761       opt.textContent = `@${String(thread.other || "unknown")}${when ? ` β€’ ${when}` : ""}`;
   3762       dmGroup.appendChild(opt);
   3763     }
   3764     chatContextSelectEl.appendChild(dmGroup);
   3765   }
   3766 
   3767   if (postRecent.length) {
   3768     const postGroup = document.createElement("optgroup");
   3769     postGroup.label = "Hive Chats";
   3770     for (const post of postRecent) {
   3771       const postId = String(post.id || "").trim();
   3772       if (!postId) continue;
   3773       const opt = document.createElement("option");
   3774       opt.value = `post:${postId}`;
   3775       const unread = Number(unreadByPostId.get(postId) || 0);
   3776       const unreadLabel = unread > 0 ? ` (${unread})` : "";
   3777       const when = shortTimeAgo(postChatActivityAt(postId, post));
   3778       opt.textContent = `${postTitle(post)}${unreadLabel}${when ? ` β€’ ${when}` : ""}${post.author ? ` - @${String(post.author || "")}` : ""}`;
   3779       postGroup.appendChild(opt);
   3780     }
   3781     chatContextSelectEl.appendChild(postGroup);
   3782   }
   3783 
   3784   chatContextSelectEl.value =
   3785     selected && chatContextSelectEl.querySelector(`option[value="${cssEscape(selected)}"]`) ? selected : "";
   3786   syncingChatContextSelect = false;
   3787 }
   3788 
   3789 function setDmThreads(list) {
   3790   dmThreads = Array.isArray(list) ? list.map(normalizeDmThread).filter(Boolean) : [];
   3791   dmThreadsById = new Map(dmThreads.map((t) => [t.id, t]));
   3792   if (pendingOpenDmThreadId) {
   3793     const pending = dmThreadsById.get(pendingOpenDmThreadId) || null;
   3794     if (pending && String(pending.status || "") === "active") {
   3795       openDmThread(pending.id);
   3796     }
   3797   }
   3798   if (activeDmThreadId && !dmThreadsById.has(activeDmThreadId)) {
   3799     activeDmThreadId = null;
   3800   }
   3801   renderPeoplePanel();
   3802   renderChatPanel();
   3803 }
   3804 
   3805 function applyChatDock() {
   3806   if (!appRoot) return;
   3807   appRoot.classList.toggle("chatRight", chatDock === "right");
   3808 }
   3809 
   3810 function upsertDmThread(rawThread) {
   3811   const t = normalizeDmThread(rawThread);
   3812   if (!t) return;
   3813   dmThreadsById.set(t.id, t);
   3814   dmThreads = dmThreads.filter((x) => x.id !== t.id);
   3815   dmThreads.push(t);
   3816   dmThreads.sort((a, b) => dmActivityAt(b) - dmActivityAt(a));
   3817   renderPeoplePanel();
   3818   renderChatPanel();
   3819 }
   3820 
   3821 function setModModalOpen(open) {
   3822   if (!modModal) return;
   3823   modModal.classList.toggle("hidden", !open);
   3824   if (!open) {
   3825     modModalContext = null;
   3826     if (modModalBody) modModalBody.innerHTML = "";
   3827     if (modModalStatus) modModalStatus.textContent = "";
   3828     if (modModalPrimary) modModalPrimary.classList.remove("hidden");
   3829   }
   3830 }
   3831 
   3832 function setMediaModalOpen(open) {
   3833   if (!mediaModal) return;
   3834   mediaModal.classList.toggle("hidden", !open);
   3835   if (!open) {
   3836     if (mediaModalImg) mediaModalImg.src = "";
   3837     if (mediaModalOpenLink) mediaModalOpenLink.href = "#";
   3838     if (mediaModalStatus) mediaModalStatus.textContent = "";
   3839     if (mediaModalTitle) mediaModalTitle.textContent = "Media";
   3840   }
   3841 }
   3842 
   3843 function setShortcutHelpOpen(open) {
   3844   if (!shortcutHelpModal) return;
   3845   shortcutHelpModal.classList.toggle("hidden", !open);
   3846 }
   3847 
   3848 function openMediaModal(url) {
   3849   const src = String(url || "").trim();
   3850   if (!src) return;
   3851   if (!mediaModalImg) return;
   3852   mediaModalImg.src = src;
   3853   if (mediaModalOpenLink) mediaModalOpenLink.href = src;
   3854   if (mediaModalStatus) mediaModalStatus.textContent = "";
   3855   setMediaModalOpen(true);
   3856 }
   3857 
   3858 function gateTokenLabel(token) {
   3859   const t = String(token || "").trim().toLowerCase();
   3860   if (!t) return { label: "", color: "" };
   3861   if (t === "owner" || t === "moderator" || t === "member") return { label: t, color: "" };
   3862   if (t.startsWith("role:")) {
   3863     const key = t.slice("role:".length);
   3864     const def = roleDefByKey(key);
   3865     if (def) return { label: def.label || `role:${key}`, color: def.color || "" };
   3866     return { label: `role:${key}`, color: "" };
   3867   }
   3868   return { label: t, color: "" };
   3869 }
   3870 
   3871 function openCollectionGateModal(collectionId) {
   3872   if (!canModerate) return;
   3873   const id = String(collectionId || "");
   3874   const col = collections.find((c) => c.id === id);
   3875   if (!col) {
   3876     toast("Collections", "Collection not found.");
   3877     return;
   3878   }
   3879   modModalContext = { kind: "collectionGate", collectionId: id };
   3880   if (modModalTitle) modModalTitle.textContent = `Gate /${col.name || col.id}`;
   3881   if (modModalStatus) modModalStatus.textContent = "";
   3882   if (modModalPrimary) modModalPrimary.textContent = "Save";
   3883 
   3884   const visibility = col.visibility === "gated" ? "gated" : "public";
   3885   const allowed = new Set(Array.isArray(col.allowedRoles) ? col.allowedRoles : []);
   3886   const tokens = availableGateTokens();
   3887 
   3888   const optionsHtml = tokens
   3889     .map((token) => {
   3890       const meta = gateTokenLabel(token);
   3891       const swatch = meta.color ? `<span class="roleSwatch" style="background:${escapeHtml(meta.color)}"></span>` : "";
   3892       const checked = allowed.has(token) ? "checked" : "";
   3893       return `<label class="gateOption">
   3894         <span class="gateOptionLeft">${swatch}<span>${escapeHtml(meta.label)}</span></span>
   3895         <input type="checkbox" data-gatetoken="${escapeHtml(token)}" ${checked} />
   3896       </label>`;
   3897     })
   3898     .join("");
   3899 
   3900   if (modModalBody) {
   3901     modModalBody.innerHTML = `
   3902       <div class="row" style="gap:12px;align-items:center">
   3903         <label class="gateOption" style="flex:1">
   3904           <span>Public</span>
   3905           <input type="radio" name="gateVisibility" value="public" ${visibility === "public" ? "checked" : ""} />
   3906         </label>
   3907         <label class="gateOption" style="flex:1">
   3908           <span>Gated</span>
   3909           <input type="radio" name="gateVisibility" value="gated" ${visibility === "gated" ? "checked" : ""} />
   3910         </label>
   3911       </div>
   3912       <div class="small muted">If gated, pick one or more roles that can view this collection.</div>
   3913       <div class="gateList" id="gateListWrap">${optionsHtml || `<div class="muted">No roles available.</div>`}</div>
   3914     `;
   3915   }
   3916   setModModalOpen(true);
   3917   updateGateModalVisibility();
   3918 }
   3919 
   3920 function openUserRolesModal(username) {
   3921   if (!canModerate) return;
   3922   const target = String(username || "").toLowerCase();
   3923   if (!target) return;
   3924   const member = (peopleMembers || []).find((m) => m && m.username === target);
   3925   const assigned = new Set(Array.isArray(member?.customRoles) ? member.customRoles : []);
   3926   modModalContext = { kind: "userRoles", username: target };
   3927   if (modModalTitle) modModalTitle.textContent = `Custom roles for @${target}`;
   3928   if (modModalPrimary) modModalPrimary.classList.add("hidden");
   3929   if (modModalStatus) modModalStatus.textContent = "Toggles apply immediately.";
   3930 
   3931   const rows = customRoles.length
   3932     ? customRoles
   3933         .map((r) => {
   3934           const checked = assigned.has(r.key) ? "checked" : "";
   3935           const swatch = r.color ? `<span class="roleSwatch" style="background:${escapeHtml(r.color)}"></span>` : "";
   3936           return `<label class="gateOption">
   3937             <span class="gateOptionLeft">${swatch}<span>${escapeHtml(r.label)}</span> <span class="roleKey">${escapeHtml(
   3938               r.key
   3939             )}</span></span>
   3940             <input type="checkbox" data-userrolekey="${escapeHtml(r.key)}" ${checked} />
   3941           </label>`;
   3942         })
   3943         .join("")
   3944     : `<div class="muted">No custom roles created yet.</div>`;
   3945 
   3946   if (modModalBody) modModalBody.innerHTML = `<div class="gateList">${rows}</div>`;
   3947   setModModalOpen(true);
   3948 }
   3949 
   3950 function openCollectionCreateModal() {
   3951   if (!canModerate) return;
   3952   modModalContext = { kind: "collectionCreate" };
   3953   if (modModalTitle) modModalTitle.textContent = "Create collection";
   3954   if (modModalPrimary) modModalPrimary.textContent = "Create";
   3955   if (modModalStatus) modModalStatus.textContent = "";
   3956   if (modModalBody) {
   3957     modModalBody.innerHTML = `
   3958       <label>
   3959         <span>Name</span>
   3960         <input id="modModalCollectionName" maxlength="40" placeholder="Example: music" />
   3961       </label>
   3962       <div class="small muted">Collections appear as tabs and can be gated.</div>
   3963     `;
   3964   }
   3965   setModModalOpen(true);
   3966   setTimeout(() => document.getElementById("modModalCollectionName")?.focus(), 0);
   3967 }
   3968 
   3969 function updateGateModalVisibility() {
   3970   const listWrap = document.getElementById("gateListWrap");
   3971   if (!listWrap) return;
   3972   const v = String(modModalBody?.querySelector("input[name='gateVisibility']:checked")?.value || "public");
   3973   listWrap.classList.toggle("hidden", v !== "gated");
   3974 }
   3975 
   3976 function getSessionToken() {
   3977   try {
   3978     return localStorage.getItem(SESSION_TOKEN_KEY) || "";
   3979   } catch {
   3980     return "";
   3981   }
   3982 }
   3983 
   3984 function setSessionToken(token) {
   3985   try {
   3986     if (!token) localStorage.removeItem(SESSION_TOKEN_KEY);
   3987     else localStorage.setItem(SESSION_TOKEN_KEY, token);
   3988   } catch {
   3989     // ignore
   3990   }
   3991 }
   3992 
   3993 function fallbackPeopleFromProfiles() {
   3994   const out = [];
   3995   for (const [username, p] of Object.entries(profiles || {})) {
   3996     if (!username) continue;
   3997     out.push({
   3998       username,
   3999       image: typeof p?.image === "string" ? p.image : "",
   4000       color: typeof p?.color === "string" ? p.color : "",
   4001       role: "member",
   4002       online: false,
   4003       status: "offline"
   4004     });
   4005   }
   4006   if (loggedInUser && !out.some((m) => m.username === loggedInUser)) {
   4007     const me = getProfile(loggedInUser);
   4008     out.push({
   4009       username: loggedInUser,
   4010       image: me.image || "",
   4011       color: me.color || "",
   4012       role: loggedInRole || "member",
   4013       online: true,
   4014       status: "online"
   4015     });
   4016   }
   4017   out.sort((a, b) => a.username.localeCompare(b.username));
   4018   return out;
   4019 }
   4020 
   4021 function ensurePeopleFallback() {
   4022   if (Array.isArray(peopleMembers) && peopleMembers.length > 0) return;
   4023   peopleMembers = fallbackPeopleFromProfiles();
   4024 }
   4025 
   4026 const toastHost = (() => {
   4027   const el = document.createElement("div");
   4028   el.className = "toastHost";
   4029   document.body.appendChild(el);
   4030   return el;
   4031 })();
   4032 
   4033 /** @type {Set<string>} */
   4034 const newPostAnimIds = new Set();
   4035 /** @type {Map<string, number>} */
   4036 const buzzTimers = new Map();
   4037 
   4038 function syncProtectedUi() {
   4039   if (!isProtectedEl || !postPasswordEl) return;
   4040   const on = Boolean(isProtectedEl.checked);
   4041   postPasswordEl.disabled = !on;
   4042   if (!on) postPasswordEl.value = "";
   4043 }
   4044 
   4045 syncProtectedUi();
   4046 isProtectedEl?.addEventListener("change", () => {
   4047   syncProtectedUi();
   4048   if (isProtectedEl?.checked) postPasswordEl?.focus();
   4049 });
   4050 
   4051 function setSidebarHidden(hidden) {
   4052   if (!appRoot) return;
   4053   appRoot.classList.toggle("sidebarHidden", hidden);
   4054   if (toggleSidebarBtn) {
   4055     toggleSidebarBtn.textContent = "Hide";
   4056     toggleSidebarBtn.title = "Hide sidebar";
   4057   }
   4058   if (showSidebarBtn) {
   4059     showSidebarBtn.classList.toggle("hidden", !hidden);
   4060     showSidebarBtn.textContent = "Show";
   4061     showSidebarBtn.title = "Show sidebar";
   4062   }
   4063   try {
   4064     localStorage.setItem("bzl_sidebarHidden", hidden ? "1" : "0");
   4065   } catch {
   4066     // ignore
   4067   }
   4068 }
   4069 
   4070 function getSidebarHidden() {
   4071   try {
   4072     return localStorage.getItem("bzl_sidebarHidden") === "1";
   4073   } catch {
   4074     return false;
   4075   }
   4076 }
   4077 
   4078 function setPeopleOpen(open) {
   4079   const inRackMode = Boolean(appRoot?.classList.contains("rackMode"));
   4080   peopleOpen = inRackMode ? true : Boolean(open);
   4081   if (!peopleDrawerEl) return;
   4082   // In rack mode, "People" is a normal dockable panel; don't hide it behind a special toggle.
   4083   peopleDrawerEl.classList.toggle("hidden", !peopleOpen && !inRackMode);
   4084   if (togglePeopleBtn) {
   4085     if (inRackMode) {
   4086       togglePeopleBtn.classList.add("hidden");
   4087     } else {
   4088       togglePeopleBtn.classList.remove("hidden");
   4089       togglePeopleBtn.textContent = peopleOpen ? "Hide people" : "People";
   4090       togglePeopleBtn.title = peopleOpen ? "Hide people" : "Show people";
   4091     }
   4092   }
   4093   if (peopleOpen && ws.readyState === WebSocket.OPEN) {
   4094     ws.send(JSON.stringify({ type: "peopleList" }));
   4095   }
   4096   if (inRackMode) return;
   4097   try {
   4098     localStorage.setItem("bzl_peopleOpen", peopleOpen ? "1" : "0");
   4099   } catch {
   4100     // ignore
   4101   }
   4102 }
   4103 
   4104 function getPeopleOpen() {
   4105   try {
   4106     return localStorage.getItem("bzl_peopleOpen") === "1";
   4107   } catch {
   4108     return false;
   4109   }
   4110 }
   4111 
   4112 function setComposerOpen(open) {
   4113   composerOpen = Boolean(open);
   4114   if (pollinatePanel) pollinatePanel.classList.toggle("composerCollapsed", !composerOpen);
   4115   if (toggleComposerBtn) {
   4116     toggleComposerBtn.textContent = composerOpen ? "Hide Creator" : "New Hive";
   4117     toggleComposerBtn.title = composerOpen ? "Hide hive creator" : "Open hive creator";
   4118   }
   4119   renderCenterPanels();
   4120   updateSideRackEmptyState();
   4121   try {
   4122     localStorage.setItem("bzl_composerOpen", composerOpen ? "1" : "0");
   4123   } catch {
   4124     // ignore
   4125   }
   4126 }
   4127 
   4128 function getComposerOpen() {
   4129   try {
   4130     return localStorage.getItem("bzl_composerOpen") === "1";
   4131   } catch {
   4132     return false;
   4133   }
   4134 }
   4135 
   4136 function readStoredChatWidth() {
   4137   try {
   4138     const raw = Number(localStorage.getItem(CHAT_WIDTH_KEY) || 0);
   4139     return Number.isFinite(raw) && raw > 0 ? raw : CHAT_WIDTH_DEFAULT;
   4140   } catch {
   4141     return CHAT_WIDTH_DEFAULT;
   4142   }
   4143 }
   4144 
   4145 function readStoredSidebarWidth() {
   4146   try {
   4147     const raw = Number(localStorage.getItem(SIDEBAR_WIDTH_KEY) || 0);
   4148     return Number.isFinite(raw) && raw > 0 ? raw : SIDEBAR_WIDTH_DEFAULT;
   4149   } catch {
   4150     return SIDEBAR_WIDTH_DEFAULT;
   4151   }
   4152 }
   4153 
   4154 function readStoredModWidth() {
   4155   try {
   4156     const raw = Number(localStorage.getItem(MOD_WIDTH_KEY) || 0);
   4157     return Number.isFinite(raw) && raw > 0 ? raw : MOD_WIDTH_DEFAULT;
   4158   } catch {
   4159     return MOD_WIDTH_DEFAULT;
   4160   }
   4161 }
   4162 
   4163 function readStoredPeopleWidth() {
   4164   try {
   4165     const raw = Number(localStorage.getItem(PEOPLE_WIDTH_KEY) || 0);
   4166     return Number.isFinite(raw) && raw > 0 ? raw : PEOPLE_WIDTH_DEFAULT;
   4167   } catch {
   4168     return PEOPLE_WIDTH_DEFAULT;
   4169   }
   4170 }
   4171 
   4172 function clampChatWidth(px) {
   4173   const maxByViewport = Math.floor(window.innerWidth * 0.72);
   4174   return Math.max(380, Math.min(maxByViewport, Math.floor(Number(px || CHAT_WIDTH_DEFAULT))));
   4175 }
   4176 
   4177 function clampSidebarWidth(px) {
   4178   const maxByViewport = Math.floor(window.innerWidth * 0.42);
   4179   return Math.max(240, Math.min(maxByViewport, Math.floor(Number(px || SIDEBAR_WIDTH_DEFAULT))));
   4180 }
   4181 
   4182 function clampModWidth(px) {
   4183   const maxByViewport = Math.floor(window.innerWidth * 0.44);
   4184   return Math.max(280, Math.min(maxByViewport, Math.floor(Number(px || MOD_WIDTH_DEFAULT))));
   4185 }
   4186 
   4187 function clampPeopleWidth(px) {
   4188   const maxByViewport = Math.floor(window.innerWidth * 0.62);
   4189   return Math.max(320, Math.min(maxByViewport, Math.floor(Number(px || PEOPLE_WIDTH_DEFAULT))));
   4190 }
   4191 
   4192 function applyChatWidth(px, persist = true) {
   4193   if (!appRoot) return;
   4194   const next = clampChatWidth(px);
   4195   appRoot.style.setProperty("--chat-width", `${next}px`);
   4196   if (persist) {
   4197     try {
   4198       localStorage.setItem(CHAT_WIDTH_KEY, String(next));
   4199     } catch {
   4200       // ignore
   4201     }
   4202   }
   4203 }
   4204 
   4205 function applySidebarWidth(px, persist = true) {
   4206   if (!appRoot) return;
   4207   const next = clampSidebarWidth(px);
   4208   appRoot.style.setProperty("--sidebar-width", `${next}px`);
   4209   if (persist) {
   4210     try {
   4211       localStorage.setItem(SIDEBAR_WIDTH_KEY, String(next));
   4212     } catch {
   4213       // ignore
   4214     }
   4215   }
   4216 }
   4217 
   4218 function applyModWidth(px, persist = true) {
   4219   if (!appRoot) return;
   4220   const next = clampModWidth(px);
   4221   appRoot.style.setProperty("--mod-width", `${next}px`);
   4222   if (persist) {
   4223     try {
   4224       localStorage.setItem(MOD_WIDTH_KEY, String(next));
   4225     } catch {
   4226       // ignore
   4227     }
   4228   }
   4229 }
   4230 
   4231 function applyPeopleWidth(px, persist = true) {
   4232   const next = clampPeopleWidth(px);
   4233   document.documentElement.style.setProperty("--people-width", `${next}px`);
   4234   if (persist) {
   4235     try {
   4236       localStorage.setItem(PEOPLE_WIDTH_KEY, String(next));
   4237     } catch {
   4238       // ignore
   4239     }
   4240   }
   4241 }
   4242 
   4243 function canResizeChatNow() {
   4244   return !isMobileSwipeMode();
   4245 }
   4246 
   4247 function canResizeSidebarNow() {
   4248   return !isMobileSwipeMode();
   4249 }
   4250 
   4251 function canResizeModNow() {
   4252   return !isMobileSwipeMode() && canModerate;
   4253 }
   4254 
   4255 function canResizePeopleNow() {
   4256   return !isMobileSwipeMode();
   4257 }
   4258 
   4259 function stopAnyPanelResize() {
   4260   if (!chatResizeDragging && !sidebarResizeDragging && !modResizeDragging && !peopleResizeDragging) return;
   4261   chatResizeDragging = false;
   4262   sidebarResizeDragging = false;
   4263   modResizeDragging = false;
   4264   peopleResizeDragging = false;
   4265   appRoot?.classList.remove("isResizing");
   4266 }
   4267 
   4268 function startChatResize(clientX) {
   4269   if (!canResizeChatNow() || !chatPanelEl) return false;
   4270   chatResizeDragging = true;
   4271   chatResizeStartX = clientX;
   4272   chatResizeStartWidth = chatPanelEl.getBoundingClientRect().width || readStoredChatWidth();
   4273   appRoot?.classList.add("isResizing");
   4274   return true;
   4275 }
   4276 
   4277 function startSidebarResize(clientX) {
   4278   if (!canResizeSidebarNow() || !sidebarPanelEl || appRoot?.classList.contains("sidebarHidden")) return false;
   4279   sidebarResizeDragging = true;
   4280   sidebarResizeStartX = clientX;
   4281   sidebarResizeStartWidth = sidebarPanelEl.getBoundingClientRect().width || readStoredSidebarWidth();
   4282   appRoot?.classList.add("isResizing");
   4283   return true;
   4284 }
   4285 
   4286 function startModResize(clientX) {
   4287   if (!canResizeModNow() || !modPanelEl || modPanelEl.classList.contains("hidden")) return false;
   4288   modResizeDragging = true;
   4289   modResizeStartX = clientX;
   4290   modResizeStartWidth = modPanelEl.getBoundingClientRect().width || readStoredModWidth();
   4291   appRoot?.classList.add("isResizing");
   4292   return true;
   4293 }
   4294 
   4295 function startPeopleResize(clientX) {
   4296   if (!canResizePeopleNow() || !peopleDrawerEl || peopleDrawerEl.classList.contains("hidden")) return false;
   4297   peopleResizeDragging = true;
   4298   peopleResizeStartX = clientX;
   4299   peopleResizeStartWidth = peopleDrawerEl.getBoundingClientRect().width || readStoredPeopleWidth();
   4300   appRoot?.classList.add("isResizing");
   4301   return true;
   4302 }
   4303 
   4304 function setEditModalOpen(open) {
   4305   if (!editModal) return;
   4306   editModal.classList.toggle("hidden", !open);
   4307   if (editModalStatus) editModalStatus.textContent = "";
   4308   if (!open) {
   4309     editContext = null;
   4310     if (editModalEditor) editModalEditor.innerHTML = "";
   4311     if (editModalPostTitleInput) editModalPostTitleInput.value = "";
   4312     if (editModalPostMeta) editModalPostMeta.classList.add("hidden");
   4313     if (editModalKeywordsInput) editModalKeywordsInput.value = "";
   4314     if (editModalCollectionSelect) editModalCollectionSelect.innerHTML = "";
   4315     if (editModalProtectedToggle) editModalProtectedToggle.checked = false;
   4316     if (editModalWalkieToggle) editModalWalkieToggle.checked = false;
   4317     if (editModalPasswordInput) editModalPasswordInput.value = "";
   4318     if (editModalPasswordRow) editModalPasswordRow.classList.add("hidden");
   4319   }
   4320 }
   4321 
   4322 function parseKeywordsInput(value) {
   4323   const raw = String(value || "")
   4324     .split(",")
   4325     .map((x) => x.trim().toLowerCase())
   4326     .filter(Boolean);
   4327   const out = [];
   4328   for (const k of raw) {
   4329     const cleaned = k.replace(/[^a-z0-9_-]/g, "").slice(0, 20);
   4330     if (!cleaned) continue;
   4331     if (!out.includes(cleaned)) out.push(cleaned);
   4332     if (out.length >= 6) break;
   4333   }
   4334   return out;
   4335 }
   4336 
   4337 function fillCollectionSelect(selectEl, currentId) {
   4338   if (!selectEl) return;
   4339   const active = activeCollections();
   4340   const current = String(currentId || "") || "general";
   4341   const list = active.length ? active : [{ id: "general", name: "General" }];
   4342   const hasCurrent = list.some((c) => c.id === current);
   4343   selectEl.innerHTML =
   4344     (hasCurrent ? "" : `<option value="${escapeHtml(current)}">${escapeHtml(current)}</option>`) +
   4345     list.map((c) => `<option value="${escapeHtml(c.id)}">${escapeHtml(c.name || c.id)}</option>`).join("");
   4346   selectEl.value = current;
   4347 }
   4348 
   4349 function openEditModalForPost(post) {
   4350   if (!post || post.deleted || post.locked) return;
   4351   if (!loggedInUser || post.author !== loggedInUser) return;
   4352   editContext = { kind: "post", postId: post.id };
   4353   if (editModalTitle) editModalTitle.textContent = "Edit post";
   4354   if (editModalPostTitleRow) editModalPostTitleRow.classList.remove("hidden");
   4355   if (editModalPostMeta) editModalPostMeta.classList.remove("hidden");
   4356   if (editModalPostTitleInput) editModalPostTitleInput.value = String(post.title || "").slice(0, 96);
   4357   if (editModalKeywordsInput) editModalKeywordsInput.value = (post.keywords || []).join(", ");
   4358   fillCollectionSelect(editModalCollectionSelect, String(post.collectionId || "general"));
   4359   if (editModalProtectedToggle) editModalProtectedToggle.checked = Boolean(post.protected);
   4360   if (editModalWalkieToggle) editModalWalkieToggle.checked = String(post.mode || post.chatMode || "").toLowerCase() === "walkie";
   4361   if (editModalPasswordRow) editModalPasswordRow.classList.toggle("hidden", !Boolean(post.protected));
   4362   if (editModalPasswordInput) editModalPasswordInput.value = "";
   4363   if (editModalEditor) editModalEditor.innerHTML = String(post.contentHtml || "").trim() || escapeHtml(post.content || "");
   4364   setEditModalOpen(true);
   4365   setTimeout(() => editModalEditor?.focus(), 0);
   4366 }
   4367 
   4368 function openEditModalForChatMessage(message, postId) {
   4369   if (!message || message.deleted) return;
   4370   if (!loggedInUser || message.fromUser !== loggedInUser) return;
   4371   editContext = { kind: "chat", messageId: message.id, postId };
   4372   if (editModalTitle) editModalTitle.textContent = "Edit message";
   4373   if (editModalPostTitleRow) editModalPostTitleRow.classList.add("hidden");
   4374   if (editModalPostTitleInput) editModalPostTitleInput.value = "";
   4375   if (editModalPostMeta) editModalPostMeta.classList.add("hidden");
   4376   if (editModalKeywordsInput) editModalKeywordsInput.value = "";
   4377   if (editModalCollectionSelect) editModalCollectionSelect.innerHTML = "";
   4378   if (editModalProtectedToggle) editModalProtectedToggle.checked = false;
   4379   if (editModalWalkieToggle) editModalWalkieToggle.checked = false;
   4380   if (editModalPasswordInput) editModalPasswordInput.value = "";
   4381   if (editModalPasswordRow) editModalPasswordRow.classList.add("hidden");
   4382   if (editModalEditor) editModalEditor.innerHTML = String(message.html || "").trim() || escapeHtml(message.text || "");
   4383   setEditModalOpen(true);
   4384   setTimeout(() => editModalEditor?.focus(), 0);
   4385 }
   4386 
   4387 editModalProtectedToggle?.addEventListener("change", () => {
   4388   const on = Boolean(editModalProtectedToggle?.checked);
   4389   if (editModalPasswordRow) editModalPasswordRow.classList.toggle("hidden", !on);
   4390   if (!on && editModalPasswordInput) editModalPasswordInput.value = "";
   4391 });
   4392 
   4393 function collectEditorPayload(targetEditor) {
   4394   const html = String(targetEditor?.innerHTML || "").trim();
   4395   const text = String(targetEditor?.innerText || "")
   4396     .replace(/\s+/g, " ")
   4397     .trim();
   4398   const hasImg = Boolean(targetEditor?.querySelector?.("img"));
   4399   const hasAudio = Boolean(targetEditor?.querySelector?.("audio"));
   4400   return { html, text, hasImg, hasAudio };
   4401 }
   4402 
   4403 function syncProfileSongPreview(url) {
   4404   if (!profileThemeSongPreview || !profileThemeSongUrlInput) return;
   4405   const safe = asProfileLink(url) || (String(url || "").startsWith("/uploads/") ? String(url || "") : "");
   4406   if (!safe) {
   4407     profileThemeSongPreview.classList.add("hidden");
   4408     profileThemeSongPreview.removeAttribute("src");
   4409     profileThemeSongUrlInput.value = "";
   4410     return;
   4411   }
   4412   profileThemeSongPreview.classList.remove("hidden");
   4413   profileThemeSongPreview.src = safe;
   4414   profileThemeSongPreview.load();
   4415   profileThemeSongUrlInput.value = safe;
   4416 }
   4417 
   4418 function renderProfileLinksEditor(links) {
   4419   if (!profileLinksEditor) return;
   4420   const list = normalizeProfileLinks(links);
   4421   if (!list.length) {
   4422     profileLinksEditor.innerHTML = `<div class="small muted">No links yet.</div>`;
   4423     return;
   4424   }
   4425   profileLinksEditor.innerHTML = list
   4426     .map(
   4427       (entry, index) => `<div class="profileLinkEditRow">
   4428       <input data-linklabel="${index}" value="${escapeHtml(entry.label)}" maxlength="40" placeholder="Label" />
   4429       <input data-linkurl="${index}" value="${escapeHtml(entry.url)}" maxlength="280" placeholder="https://..." />
   4430       <button type="button" class="ghost smallBtn" data-linkremove="${index}">Remove</button>
   4431     </div>`
   4432     )
   4433     .join("");
   4434 }
   4435 
   4436 function profileLinksFromEditor() {
   4437   if (!profileLinksEditor) return [];
   4438   const rows = Array.from(profileLinksEditor.querySelectorAll(".profileLinkEditRow"));
   4439   if (!rows.length) return [];
   4440   const out = [];
   4441   for (const row of rows) {
   4442     const label = String(row.querySelector("[data-linklabel]")?.value || "")
   4443       .replace(/\s+/g, " ")
   4444       .trim()
   4445       .slice(0, 40);
   4446     const url = asProfileLink(row.querySelector("[data-linkurl]")?.value || "");
   4447     if (!url) continue;
   4448     out.push({ label: label || "Link", url });
   4449     if (out.length >= 8) break;
   4450   }
   4451   return out;
   4452 }
   4453 
   4454 function renderProfileCard() {
   4455   if (!profileCard) return;
   4456   if (!activeProfile || !activeProfile.username) {
   4457     profileCard.innerHTML = `<div class="small muted">Profile unavailable.</div>`;
   4458     return;
   4459   }
   4460   const p = normalizeProfileData(activeProfile);
   4461   const headerStyle = p.color ? ` style="--profile-accent:${escapeHtml(p.color)}"` : "";
   4462   const pronouns = p.pronouns ? `<div class="small muted pronouns">${escapeHtml(p.pronouns)}</div>` : "";
   4463   const usernameLower = String(p.username || "").toLowerCase();
   4464   const selfLower = String(loggedInUser || "").toLowerCase();
   4465   const canDm = Boolean(loggedInUser && usernameLower && usernameLower !== selfLower);
   4466   const ignored = prefSet("ignoredUsers").has(usernameLower);
   4467   const blocked = prefSet("blockedUsers").has(usernameLower);
   4468   const dmBtn = canDm
   4469     ? `<button type="button" class="primary smallBtn" data-dmrequest="${escapeHtml(p.username)}" ${blocked ? "disabled" : ""}>DM</button>`
   4470     : "";
   4471   const modDmBtn = canModerate && canDm
   4472     ? `<button type="button" class="ghost smallBtn" data-moddm="${escapeHtml(p.username)}">Mod DM</button>`
   4473     : "";
   4474   const member = peopleMembers.find((m) => String(m.username || "").toLowerCase() === usernameLower) || null;
   4475   const role = roleLabel(member?.role);
   4476   const isStaff = role === "owner" || role === "moderator";
   4477   const canMuteUser = Boolean(loggedInUser && usernameLower && usernameLower !== selfLower && !isStaff);
   4478   const ignoreBtn = canMuteUser
   4479     ? ignored
   4480       ? `<button type="button" class="ghost smallBtn" data-unignoreuser="${escapeHtml(p.username)}">Unignore</button>`
   4481       : `<button type="button" class="ghost smallBtn" data-ignoreuser="${escapeHtml(p.username)}">Ignore</button>`
   4482     : "";
   4483   const blockBtn = canMuteUser
   4484     ? blocked
   4485       ? `<button type="button" class="ghost smallBtn" data-unblockuser="${escapeHtml(p.username)}">Unblock</button>`
   4486       : `<button type="button" class="ghost smallBtn" data-blockuser="${escapeHtml(p.username)}">Block</button>`
   4487     : "";
   4488   const blockNote = canDm && blocked ? `<div class="small muted">Blocked: DMs + content hidden.</div>` : "";
   4489   const bio = p.bioHtml ? `<div class="profileBio">${p.bioHtml}</div>` : `<div class="small muted">No bio yet.</div>`;
   4490   const theme = p.themeSongUrl ? `<audio controls preload="none" src="${escapeHtml(p.themeSongUrl)}"></audio>` : `<div class="small muted">No theme song set.</div>`;
   4491   const links = p.links.length
   4492     ? p.links
   4493         .map(
   4494           (entry) =>
   4495             `<a class="tag profileLinkTag" href="${escapeHtml(entry.url)}" target="_blank" rel="noopener noreferrer nofollow">${escapeHtml(entry.label)}</a>`
   4496         )
   4497         .join("")
   4498     : `<div class="small muted">No links yet.</div>`;
   4499   profileCard.innerHTML = `<div class="profileHeader"${headerStyle}>
   4500     <span class="pfp profileHeroPfp">${p.image ? `<img alt="" src="${escapeHtml(p.image)}" />` : ""}</span>
   4501     <div class="profileIdentity">
   4502       <div class="profileHandle" ${p.color ? `style="color:${escapeHtml(safeTextColorFromHex(p.color))}"` : ""}>@${escapeHtml(p.username)}</div>
   4503       ${pronouns}
   4504     </div>
   4505     ${dmBtn || modDmBtn || ignoreBtn || blockBtn ? `<div class="profileActions">${dmBtn}${modDmBtn}${ignoreBtn}${blockBtn}</div>` : ""}
   4506   </div>
   4507   ${blockNote}
   4508   <div class="profileSection">
   4509     <div class="small muted">Bio</div>
   4510     ${bio}
   4511   </div>
   4512   <div class="profileSection">
   4513     <div class="small muted">Theme song</div>
   4514     ${theme}
   4515   </div>
   4516   <div class="profileSection">
   4517     <div class="small muted">Links</div>
   4518     <div class="profileLinksWrap">${links}</div>
   4519   </div>`;
   4520   const bioEl = profileCard.querySelector(".profileBio");
   4521   if (bioEl) decorateYouTubeEmbedsInElement(bioEl);
   4522 }
   4523 
   4524 function renderProfileEditor() {
   4525   const canEdit = Boolean(loggedInUser && activeProfile && activeProfile.username === loggedInUser);
   4526   if (profileEditToggleBtn) profileEditToggleBtn.classList.toggle("hidden", !canEdit);
   4527   if (!profileEditPanel || !profilePronounsInput || !profileBioEditor) return;
   4528   profileEditPanel.classList.toggle("hidden", !(canEdit && isEditingProfile));
   4529   if (!canEdit || !activeProfile) return;
   4530   profilePronounsInput.value = String(activeProfile.pronouns || "");
   4531   profileBioEditor.innerHTML = String(activeProfile.bioHtml || "");
   4532   renderProfileLinksEditor(activeProfile.links);
   4533   syncProfileSongPreview(activeProfile.themeSongUrl || "");
   4534 }
   4535 
   4536 function renderCenterPanels() {
   4537   // In rack mode, panels are independent. Profile shouldn't "replace" the Hives panel.
   4538   if (rackLayoutEnabled) {
   4539     if (pollinatePanel) {
   4540       pollinatePanel.classList.remove("hidden");
   4541       pollinatePanel.classList.toggle("panelCollapsed", !composerOpen);
   4542       pollinatePanel.dataset.panelDisplay = composerOpen ? "full" : "collapsed";
   4543     }
   4544     renderProfilePanel();
   4545     updateSideRackEmptyState();
   4546     return;
   4547   }
   4548 
   4549   const profileMode = centerView === "profile";
   4550   if (profileViewPanel) profileViewPanel.classList.toggle("hidden", !profileMode);
   4551   if (feedEl?.closest("section")) feedEl.closest("section").classList.toggle("hidden", profileMode);
   4552   if (pollinatePanel) {
   4553     if (profileMode) pollinatePanel.classList.add("hidden");
   4554     else pollinatePanel.classList.toggle("hidden", !composerOpen);
   4555   }
   4556   if (!profileMode) return;
   4557   renderProfilePanel();
   4558 }
   4559 
   4560 function renderProfilePanel() {
   4561   if (!profileViewPanel) return;
   4562   if (!activeProfileUsername && !activeProfile && loggedInUser) {
   4563     activeProfileUsername = String(loggedInUser || "").trim().toLowerCase();
   4564   }
   4565 
   4566   const username = String(activeProfile?.username || activeProfileUsername || "")
   4567     .trim()
   4568     .toLowerCase();
   4569 
   4570   if (username) {
   4571     // Ensure we always have *some* profile data to show immediately.
   4572     if (!activeProfile || String(activeProfile.username || "").toLowerCase() !== username) {
   4573       const basic = getProfile(username);
   4574       activeProfile = normalizeProfileData({ username, image: basic.image || "", color: basic.color || "" });
   4575     }
   4576 
   4577     // Pull the full profile from the server (bio/links/song) once per username selection.
   4578     try {
   4579       if (ws?.readyState === WebSocket.OPEN && lastRequestedProfileUsername !== username) {
   4580         lastRequestedProfileUsername = username;
   4581         ws.send(JSON.stringify({ type: "getUserProfile", username }));
   4582       }
   4583     } catch {
   4584       // ignore
   4585     }
   4586   }
   4587 
   4588   if (profileViewTitle) profileViewTitle.textContent = username ? `@${username}` : "Profile";
   4589   if (profileViewMeta) profileViewMeta.textContent = username === loggedInUser ? "Your profile" : "Community profile";
   4590   renderProfileCard();
   4591   renderProfileEditor();
   4592 }
   4593 
   4594 function setCenterView(next, username = "") {
   4595   if (rackLayoutEnabled) {
   4596     // Keep the legacy centerView on "hives" in rack mode; just update profile context.
   4597     const wantsProfile = next === "profile";
   4598     if (wantsProfile) {
   4599       activeProfileUsername = String(username || activeProfileUsername || "")
   4600         .trim()
   4601         .toLowerCase();
   4602       isEditingProfile = false;
   4603       if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile";
   4604 
   4605       // Make sure the profile panel is actually visible as its own panel.
   4606       undockPanel("profile");
   4607       profileViewPanel.classList.remove("panelCollapsed");
   4608       profileViewPanel.dataset.panelDisplay = "full";
   4609       enforceWorkspaceRules();
   4610       renderProfilePanel();
   4611     } else {
   4612       activeProfileUsername = "";
   4613       activeProfile = null;
   4614       isEditingProfile = false;
   4615       if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile";
   4616       renderProfilePanel();
   4617     }
   4618     return;
   4619   }
   4620 
   4621   centerView = next === "profile" ? "profile" : "hives";
   4622   if (centerView === "hives") {
   4623     activeProfileUsername = "";
   4624     activeProfile = null;
   4625     isEditingProfile = false;
   4626     if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile";
   4627   } else {
   4628     activeProfileUsername = String(username || activeProfileUsername || "")
   4629       .trim()
   4630       .toLowerCase();
   4631     isEditingProfile = false;
   4632     if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile";
   4633   }
   4634   renderCenterPanels();
   4635 }
   4636 
   4637 function openUserProfile(username) {
   4638   const normalized = String(username || "")
   4639     .trim()
   4640     .toLowerCase();
   4641   if (!normalized) return;
   4642   const basic = getProfile(normalized);
   4643   activeProfile = normalizeProfileData({ username: normalized, image: basic.image || "", color: basic.color || "" });
   4644   setCenterView("profile", normalized);
   4645   ws.send(JSON.stringify({ type: "getUserProfile", username: normalized }));
   4646   if (isMobileSwipeMode()) setMobileScreen("profile");
   4647 }
   4648 
   4649 function isMobileSwipeMode() {
   4650   // Mobile UX should kick in for touch-first devices, including landscape phones.
   4651   // (Many phones exceed 760px in landscape, so max-width alone is not sufficient.)
   4652   const mqNarrow = "(max-width: 760px)";
   4653   const mqPortrait = "(hover: none) and (pointer: coarse) and (max-width: 900px)";
   4654   const mqLandscape = "(hover: none) and (pointer: coarse) and (max-height: 520px)";
   4655   return window.matchMedia(mqNarrow).matches || window.matchMedia(mqPortrait).matches || window.matchMedia(mqLandscape).matches;
   4656 }
   4657 
   4658 function isMobileScreenMode() {
   4659   // Keep this consistent with CSS mobile screen media queries.
   4660   const mqNarrow = "(max-width: 760px)";
   4661   const mqPortrait = "(hover: none) and (pointer: coarse) and (max-width: 900px)";
   4662   const mqLandscape = "(hover: none) and (pointer: coarse) and (max-height: 520px)";
   4663   return window.matchMedia(mqNarrow).matches || window.matchMedia(mqPortrait).matches || window.matchMedia(mqLandscape).matches;
   4664 }
   4665 
   4666 function loadMobileLayout() {
   4667   const defaults = () => {
   4668     const pinned = ["account", "hives", "chat", "people", "profile"];
   4669     const onboardingEnabled = Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.enabled);
   4670     const active = onboardingEnabled ? "onboarding" : pinned[0] || "account";
   4671     return { version: 1, pinned, active, history: [], tools: { composerOpen: false, profileOpen: false, pluginRackOpen: false } };
   4672   };
   4673   const sanitizeId = (id) => {
   4674     const raw = String(id || "")
   4675       .trim()
   4676       .toLowerCase();
   4677     if (!raw) return "";
   4678     if (raw === "maps" || raw === "library") return "";
   4679     if (raw === "mod") return canModerate ? "moderation" : "";
   4680     if (raw === "sidebar") return "account";
   4681     if (raw === "main" || raw === "workspace") return "hives";
   4682     if (raw === "account" || raw === "hives" || raw === "chat" || raw === "people" || raw === "profile" || raw === "onboarding") return raw;
   4683     if (raw === "moderation") return canModerate ? "moderation" : "";
   4684     if (panelRegistry.has(raw)) return raw;
   4685     return "";
   4686   };
   4687   try {
   4688     const raw = localStorage.getItem(MOBILE_LAYOUT_KEY);
   4689     if (!raw) return defaults();
   4690     const parsed = JSON.parse(raw);
   4691     const pinned = Array.isArray(parsed?.pinned) ? parsed.pinned.map((x) => sanitizeId(x)).filter(Boolean) : null;
   4692     const active = sanitizeId(parsed?.active);
   4693     const history = Array.isArray(parsed?.history) ? parsed.history.map((x) => sanitizeId(x)).filter(Boolean) : [];
   4694     const base = defaults();
   4695     if (pinned && pinned.length) base.pinned = pinned.slice(0, 5);
   4696     if (active) base.active = active;
   4697     base.history = history.slice(0, 12);
   4698     return base;
   4699   } catch {
   4700     return defaults();
   4701   }
   4702 }
   4703 
   4704 function saveMobileLayout(layout) {
   4705   try {
   4706     localStorage.setItem(MOBILE_LAYOUT_KEY, JSON.stringify(layout));
   4707   } catch {
   4708     // ignore
   4709   }
   4710 }
   4711 
   4712 function availableMobileScreens() {
   4713   const out = [];
   4714   out.push({ id: "account", title: "Account", core: true });
   4715   if (Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.enabled)) out.push({ id: "onboarding", title: "Onboarding", core: true });
   4716   out.push({ id: "hives", title: "Hives", core: true });
   4717   out.push({ id: "chat", title: "Chat", core: true });
   4718   out.push({ id: "people", title: "People", core: true });
   4719   out.push({ id: "profile", title: "Profile", core: true });
   4720   if (canModerate) out.push({ id: "moderation", title: "Moderation", core: true });
   4721 
   4722   // Plugin screens: include primary-ish panels that exist.
   4723   for (const [id, entry] of panelRegistry.entries()) {
   4724     if (!id || typeof id !== "string") continue;
   4725     if (id === "maps" || id === "library") continue;
   4726     if (id === "hives" || id === "chat" || id === "people" || id === "moderation" || id === "profile" || id === "composer" || id === "pluginRack") continue;
   4727     const role = typeof entry?.role === "string" ? entry.role : "";
   4728     if (role && role !== "primary") continue;
   4729     const hasElement = entry?.element instanceof HTMLElement;
   4730     const canRender = typeof pluginPanelDefsByPanelId.get(id)?.render === "function";
   4731     if (!hasElement && !canRender) continue;
   4732     out.push({ id, title: panelTitle(id), core: false });
   4733   }
   4734 
   4735   // Prefer stable ordering.
   4736   const byTitle = (a, b) => String(a.title || "").localeCompare(String(b.title || ""));
   4737   const core = out.filter((x) => x.core).sort(byTitle);
   4738   const plugins = out.filter((x) => !x.core).sort(byTitle);
   4739   return { core, plugins };
   4740 }
   4741 
   4742 function mobileScreenFromLegacyPanel(next) {
   4743   const raw = String(next || "").trim();
   4744   if (!raw) return "hives";
   4745   if (raw === "maps" || raw === "library") return "hives";
   4746   if (raw === "sidebar") return "account";
   4747   if (raw === "main" || raw === "workspace") return "hives";
   4748   if (raw === "chat") return "chat";
   4749   if (raw === "people") return "people";
   4750   if (raw === "profile") return "profile";
   4751   if (raw === "onboarding") return "onboarding";
   4752   if (raw === "moderation" || raw === "mod") return canModerate ? "moderation" : "hives";
   4753   if (raw === "hives" || raw === "account" || raw === "people" || raw === "profile" || raw === "onboarding" || raw === "moderation") return raw;
   4754   // Plugin panel id can be treated as a screen.
   4755   if (panelRegistry.has(raw)) return raw;
   4756   return "hives";
   4757 }
   4758 
   4759 function setMobileMoreOpen(open) {
   4760   mobileMoreOpen = Boolean(open);
   4761   if (mobileMoreSheetEl) mobileMoreSheetEl.classList.toggle("hidden", !mobileMoreOpen);
   4762   if (mobileNavEl) {
   4763     const moreBtn = mobileNavEl.querySelector?.('[data-mobilescreen="more"]');
   4764     if (moreBtn instanceof HTMLElement) {
   4765       moreBtn.classList.toggle("primary", mobileMoreOpen);
   4766       moreBtn.classList.toggle("ghost", !mobileMoreOpen);
   4767     }
   4768   }
   4769 }
   4770 
   4771 function restoreHostedPanelIfAny() {
   4772   const ids = Array.from(mobileHostedPanelIds);
   4773   if (mobileHostPanelId && !ids.includes(mobileHostPanelId)) ids.push(mobileHostPanelId);
   4774   if (!ids.length) return;
   4775   mobileHostedPanelIds.clear();
   4776   mobileHostPanelId = "";
   4777   for (const id of ids) {
   4778     const el = getPanelElement(id);
   4779     const parent = mobileHostRestoreParentByPanelId.get(id) || null;
   4780     mobileHostRestoreParentByPanelId.delete(id);
   4781     if (!(el instanceof HTMLElement)) continue;
   4782     if (!parent && mobileHostEphemeralPanelIds.has(id)) {
   4783       mobileHostEphemeralPanelIds.delete(id);
   4784       try {
   4785         el.remove();
   4786       } catch {
   4787         // ignore
   4788       }
   4789       const prev = panelRegistry.get(id);
   4790       if (prev) panelRegistry.set(id, { ...prev, element: null });
   4791       continue;
   4792     }
   4793     if (parent instanceof HTMLElement && parent.isConnected) {
   4794       parent.appendChild(el);
   4795       continue;
   4796     }
   4797     const def = panelRegistry.get(id);
   4798     const wantsMain = String(def?.defaultRack || "").toLowerCase() === "main";
   4799     const rack = wantsMain ? ensureMainSideRack() : ensureRightRack();
   4800     if (rack) rack.appendChild(el);
   4801   }
   4802 }
   4803 
   4804 function ensureMobileHostedPluginPanel(panelId) {
   4805   const id = String(panelId || "").trim();
   4806   if (!id) return null;
   4807   const existing = getPanelElement(id);
   4808   if (existing instanceof HTMLElement) return existing;
   4809   const entry = panelRegistry.get(id);
   4810   const src = typeof entry?.source === "string" ? entry.source : "";
   4811   if (!src.startsWith("plugin:")) return null;
   4812   const def = pluginPanelDefsByPanelId.get(id);
   4813   const render = def?.render;
   4814   if (typeof render !== "function") return null;
   4815 
   4816   const shell = document.createElement("section");
   4817   shell.className = "panel panelFill pluginPanel mobileHostedPluginPanel";
   4818   shell.dataset.panelId = id;
   4819   shell.innerHTML = `
   4820     <div class="panelHeader">
   4821       <div class="panelTitle">${escapeHtml(def?.title || id)}</div>
   4822       <div class="row"></div>
   4823     </div>
   4824     <div class="panelBody" data-pluginmount="1"></div>
   4825   `;
   4826 
   4827   const mount = shell.querySelector("[data-pluginmount]");
   4828   if (mount instanceof HTMLElement) {
   4829     const pluginId = String(def?.pluginId || "").trim();
   4830     const api = {
   4831       toast,
   4832       send: (eventName, payload) => {
   4833         const ev = String(eventName || "").trim();
   4834         if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false;
   4835         const wsRef = window.__bzlWs;
   4836         if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false;
   4837         const msg = payload && typeof payload === "object" ? payload : {};
   4838         wsRef.send(JSON.stringify({ ...msg, type: `plugin:${pluginId}:${ev}` }));
   4839         return true;
   4840       },
   4841       getUser: () => loggedInUser,
   4842       getRole: () => loggedInRole,
   4843       storage: {
   4844         get(key) {
   4845           try {
   4846             return localStorage.getItem(`bzl_panel_${id}_${String(key || "")}`);
   4847           } catch {
   4848             return null;
   4849           }
   4850         },
   4851         set(key, value) {
   4852           try {
   4853             localStorage.setItem(`bzl_panel_${id}_${String(key || "")}`, String(value ?? ""));
   4854             return true;
   4855           } catch {
   4856             return false;
   4857           }
   4858         }
   4859       }
   4860     };
   4861     try {
   4862       const cleanup = render(mount, api);
   4863       if (typeof cleanup === "function") shell.__panelCleanup = cleanup;
   4864     } catch (e) {
   4865       console.warn(`Plugin ${pluginId} panel render failed:`, e?.message || e);
   4866       mount.textContent = `Failed to render panel "${id}".`;
   4867     }
   4868   }
   4869 
   4870   panelRegistry.set(id, {
   4871     ...(entry || { id, title: def?.title || id, icon: def?.icon || "", source: `plugin:${def?.pluginId || ""}`, role: def?.role || "aux", defaultRack: def?.defaultRack || "right" }),
   4872     title: def?.title || (entry?.title || id),
   4873     icon: def?.icon || (entry?.icon || ""),
   4874     role: def?.role || (entry?.role || "aux"),
   4875     defaultRack: def?.defaultRack || (entry?.defaultRack || "right"),
   4876     element: shell
   4877   });
   4878   mobileHostEphemeralPanelIds.add(id);
   4879   return shell;
   4880 }
   4881 
   4882 function hostPanelInMobileScreen(panelId) {
   4883   const id = String(panelId || "").trim();
   4884   if (!id) return false;
   4885   if (!(mobileScreenHostEl instanceof HTMLElement)) return false;
   4886   if (rackLayoutEnabled && isDocked(id)) {
   4887     undockPanel(id);
   4888     applyDockState();
   4889   }
   4890   let el = getPanelElement(id);
   4891   if (!(el instanceof HTMLElement)) el = ensureMobileHostedPluginPanel(id);
   4892   if (!(el instanceof HTMLElement)) return false;
   4893   el.classList.remove("hidden");
   4894 
   4895   restoreHostedPanelIfAny();
   4896   const parent = el.parentElement;
   4897   if (parent instanceof HTMLElement) mobileHostRestoreParentByPanelId.set(id, parent);
   4898   mobileHostPanelId = id;
   4899   mobileHostedPanelIds.clear();
   4900   mobileHostedPanelIds.add(id);
   4901   mobileScreenHostEl.innerHTML = "";
   4902   mobileScreenHostEl.appendChild(el);
   4903   return true;
   4904 }
   4905 
   4906 function hostHivesInMobileScreen() {
   4907   if (!(mobileScreenHostEl instanceof HTMLElement)) return false;
   4908   if (rackLayoutEnabled) {
   4909     if (isDocked("hives")) undockPanel("hives");
   4910     applyDockState();
   4911   }
   4912   const hivesEl = getPanelElement("hives");
   4913   if (!(hivesEl instanceof HTMLElement)) return false;
   4914 
   4915   restoreHostedPanelIfAny();
   4916 
   4917   const hivesParent = hivesEl.parentElement;
   4918   if (hivesParent instanceof HTMLElement) mobileHostRestoreParentByPanelId.set("hives", hivesParent);
   4919 
   4920   mobileScreenHostEl.innerHTML = "";
   4921   hivesEl.classList.remove("hidden");
   4922   mobileScreenHostEl.appendChild(hivesEl);
   4923 
   4924   mobileHostedPanelIds.clear();
   4925   mobileHostedPanelIds.add("hives");
   4926   mobileHostPanelId = "hives";
   4927 
   4928   return true;
   4929 }
   4930 
   4931 function setMobileScreen(screenId, { pushHistory = true } = {}) {
   4932   if (!appRoot) return;
   4933   const screen = mobileScreenFromLegacyPanel(screenId);
   4934   if (onboardingNeedsAcceptanceNow() && screen !== "onboarding" && screen !== "account") {
   4935     setMobileScreen("onboarding", { pushHistory: false });
   4936     return;
   4937   }
   4938   const nextIsMore = screen === "more";
   4939   if (nextIsMore) {
   4940     setMobileMoreOpen(true);
   4941     return;
   4942   }
   4943 
   4944   if (pushHistory) {
   4945     const current = String(appRoot.getAttribute("data-mobile-screen") || "").trim();
   4946     if (current && current !== "more" && current !== screen) {
   4947       const layout = loadMobileLayout();
   4948       layout.history = [current, ...(layout.history || [])].filter((x, idx, arr) => x && arr.indexOf(x) === idx).slice(0, 12);
   4949       saveMobileLayout(layout);
   4950     }
   4951   }
   4952 
   4953   setMobileMoreOpen(false);
   4954 
   4955   // Core screens map directly.
   4956   if (screen === "people") {
   4957     setPeopleOpen(true);
   4958     peopleDrawerEl?.classList.remove("hidden");
   4959     renderPeoplePanel();
   4960     if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "peopleList" }));
   4961   } else {
   4962     setPeopleOpen(false);
   4963   }
   4964 
   4965   if (screen === "moderation" && !canModerate) {
   4966     appRoot.setAttribute("data-mobile-screen", "hives");
   4967     return;
   4968   }
   4969 
   4970   if (screen === "account") {
   4971     restoreHostedPanelIfAny();
   4972     appRoot.setAttribute("data-mobile-screen", screen);
   4973     return;
   4974   }
   4975 
   4976   if (screen === "people") {
   4977     const hosted = hostPanelInMobileScreen("people");
   4978     appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "people");
   4979     return;
   4980   }
   4981 
   4982   if (screen === "profile") {
   4983     const target = String(activeProfileUsername || loggedInUser || "").trim().toLowerCase();
   4984     if (target) setCenterView("profile", target);
   4985     else renderProfilePanel();
   4986     const hosted = hostPanelInMobileScreen("profile");
   4987     appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives");
   4988     return;
   4989   }
   4990 
   4991   if (screen === "onboarding") {
   4992     const hosted = hostPanelInMobileScreen("onboarding");
   4993     appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives");
   4994     return;
   4995   }
   4996 
   4997   if (screen === "hives") {
   4998     const hosted = hostHivesInMobileScreen();
   4999     appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives");
   5000     return;
   5001   }
   5002 
   5003   const hostableCorePanelId = screen === "chat" ? "chat" : screen === "moderation" ? "moderation" : "";
   5004   if (hostableCorePanelId) {
   5005     const hosted = hostPanelInMobileScreen(hostableCorePanelId);
   5006     appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives");
   5007     return;
   5008   }
   5009 
   5010   // Plugin screen: host it.
   5011   const hosted = hostPanelInMobileScreen(screen);
   5012   appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives");
   5013 }
   5014 
   5015 function setMobilePanel(next) {
   5016   if (!appRoot) return;
   5017   // Back-compat shim: old callers still call setMobilePanel("chat"/"main"/etc).
   5018   if (!isMobileScreenMode()) return;
   5019   mobilePanel = mobileScreenFromLegacyPanel(next);
   5020   setMobileScreen(mobilePanel, { pushHistory: true });
   5021 }
   5022 
   5023 function applyMobileMode() {
   5024   if (!appRoot) return;
   5025   const wasMobile = appRoot.classList.contains("mobileScreens");
   5026   const mobile = isMobileScreenMode();
   5027   appRoot.classList.toggle("mobileScreens", mobile);
   5028   if (mobileNavEl) mobileNavEl.classList.toggle("hidden", !mobile);
   5029   if (mobile) stopAnyPanelResize();
   5030 
   5031   if (!mobile) {
   5032     setMobileMoreOpen(false);
   5033     restoreHostedPanelIfAny();
   5034     return;
   5035   }
   5036 
   5037   if (mobileFourthBtn instanceof HTMLElement) {
   5038     mobileFourthBtn.textContent = "People";
   5039     mobileFourthBtn.setAttribute("data-mobilescreen", "people");
   5040   }
   5041 
   5042   // Apply persisted layout only when entering mobile mode (avoid resetting state on keyboard/URL-bar resizes).
   5043   const current = String(appRoot.getAttribute("data-mobile-screen") || "").trim();
   5044   if (!wasMobile || !current) {
   5045     const layout = loadMobileLayout();
   5046     const desired = onboardingNeedsAcceptanceNow() ? "onboarding" : mobileScreenFromLegacyPanel(layout.active || "hives");
   5047     setMobileScreen(desired, { pushHistory: false });
   5048   }
   5049   renderMobileNav();
   5050   if (mobileMoreOpen) renderMobileMoreList();
   5051 
   5052   if (!wasMobile) {
   5053     if (canResizeSidebarNow()) applySidebarWidth(readStoredSidebarWidth(), false);
   5054     if (canResizeChatNow()) applyChatWidth(readStoredChatWidth(), false);
   5055     if (canResizeModNow()) applyModWidth(readStoredModWidth(), false);
   5056     if (canResizePeopleNow()) applyPeopleWidth(readStoredPeopleWidth(), false);
   5057     setComposerOpen(composerOpen);
   5058   }
   5059 }
   5060 
   5061 function shiftMobilePanel(delta) {
   5062   if (!isMobileScreenMode()) return;
   5063   const order = canModerate
   5064     ? ["account", "onboarding", "hives", "chat", "people", "profile", "moderation"]
   5065     : ["account", "onboarding", "hives", "chat", "people", "profile"];
   5066   const current = mobileScreenFromLegacyPanel(appRoot?.getAttribute("data-mobile-screen") || "hives");
   5067   const idx = order.indexOf(current);
   5068   const at = idx >= 0 ? idx : 0;
   5069   const nextIdx = Math.max(0, Math.min(order.length - 1, at + delta));
   5070   setMobileScreen(order[nextIdx]);
   5071   const layout = loadMobileLayout();
   5072   layout.active = order[nextIdx];
   5073   saveMobileLayout(layout);
   5074   renderMobileNav();
   5075 }
   5076 
   5077 function renderMobileNav() {
   5078   if (!(mobileNavEl instanceof HTMLElement)) return;
   5079   if (!appRoot) return;
   5080   const active = String(appRoot.getAttribute("data-mobile-screen") || "hives").trim();
   5081   const buttons = Array.from(mobileNavEl.querySelectorAll("[data-mobilescreen]"));
   5082   for (const btn of buttons) {
   5083     const id = String(btn.getAttribute("data-mobilescreen") || "").trim();
   5084     const on = id !== "more" && (active === id || (active === "host" && id === mobileHostPanelId));
   5085     btn.classList.toggle("primary", on);
   5086     btn.classList.toggle("ghost", !on);
   5087   }
   5088 }
   5089 
   5090 function toast(title, body, timeoutMs = 2800) {
   5091   const el = document.createElement("div");
   5092   el.className = "toast";
   5093   el.innerHTML = `<div class="toastTitle">${escapeHtml(title)}</div><div class="toastBody">${escapeHtml(body)}</div>`;
   5094   toastHost.appendChild(el);
   5095   setTimeout(() => el.remove(), timeoutMs);
   5096 }
   5097 
   5098 function sendDevLog(level, scope, message, data) {
   5099   try {
   5100     if (!canModerate) return false;
   5101     const wsRef = window.__bzlWs;
   5102     if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false;
   5103     wsRef.send(JSON.stringify({ type: "devLogClient", level, scope, message, data }));
   5104     return true;
   5105   } catch {
   5106     return false;
   5107   }
   5108 }
   5109 
   5110 window.bzlDevLog = sendDevLog;
   5111 
   5112 // Plugin event handlers: pluginId -> eventName -> Set<fn(msg)>
   5113 const pluginClientHandlers = new Map();
   5114 // Moderation plugin tabs: fullTabId -> { title, ownerOnly, render(mount, api), pluginId }
   5115 const modPluginTabs = new Map();
   5116 // Plugin panels by panelId (so mobile can render plugin screens even when rack layout is off).
   5117 const pluginPanelDefsByPanelId = new Map();
   5118 
   5119 // Minimal plugin host (client-side). Plugins are trusted by the owner who installs them.
   5120 // Plugin scripts can call `window.BzlPluginHost.register("pluginId", (ctx) => { ... })`.
   5121 if (!window.BzlPluginHost) {
   5122   const pluginInits = new Map();
   5123   window.BzlPluginHost = {
   5124     apiVersion: 3,
   5125     register(pluginId, initFn) {
   5126       const id = String(pluginId || "").trim().toLowerCase();
   5127       if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(id)) throw new Error("Invalid plugin id");
   5128       if (typeof initFn !== "function") throw new Error("init must be a function");
   5129       if (pluginInits.has(id)) return false;
   5130       pluginInits.set(id, initFn);
   5131       try {
   5132         initFn({
   5133           id,
   5134           toast,
   5135           getUser: () => loggedInUser,
   5136           getRole: () => loggedInRole,
   5137           on(eventName, handler) {
   5138             const ev = String(eventName || "").trim();
   5139             if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) throw new Error("Invalid event name");
   5140             if (typeof handler !== "function") throw new Error("handler must be a function");
   5141             let byEvent = pluginClientHandlers.get(id);
   5142             if (!byEvent) {
   5143               byEvent = new Map();
   5144               pluginClientHandlers.set(id, byEvent);
   5145             }
   5146             let set = byEvent.get(ev);
   5147             if (!set) {
   5148               set = new Set();
   5149               byEvent.set(ev, set);
   5150             }
   5151             set.add(handler);
   5152             return () => {
   5153               try {
   5154                 set.delete(handler);
   5155               } catch {
   5156                 // ignore
   5157               }
   5158             };
   5159           },
   5160           ui: {
   5161             registerModTab(tabDef) {
   5162               const tabId = String(tabDef?.id || id).trim().toLowerCase();
   5163               if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(tabId)) throw new Error("Invalid tab id");
   5164               const title = typeof tabDef?.title === "string" ? tabDef.title.trim().slice(0, 22) : tabId;
   5165               const ownerOnly = Boolean(tabDef?.ownerOnly);
   5166               const render = tabDef?.render;
   5167               if (typeof render !== "function") throw new Error("render must be a function");
   5168 
   5169               const fullId = `plugin:${id}:${tabId}`;
   5170               modPluginTabs.set(fullId, { title, ownerOnly, render, pluginId: id });
   5171 
   5172               const tabsEl = modPanelEl?.querySelector?.(".modTabs");
   5173               if (tabsEl && !tabsEl.querySelector(`[data-modtab="${CSS.escape(fullId)}"]`)) {
   5174                 const btn = document.createElement("button");
   5175                 btn.type = "button";
   5176                 btn.className = "ghost";
   5177                 btn.textContent = title;
   5178                 btn.setAttribute("data-modtab", fullId);
   5179                 btn.dataset.ownerOnly = ownerOnly ? "1" : "0";
   5180                 tabsEl.appendChild(btn);
   5181               }
   5182 
   5183               // If the tab isn't visible for this user, don't allow it to become active.
   5184               if (ownerOnly && loggedInRole !== "owner" && modTab === fullId) {
   5185                 modTab = "server";
   5186                 renderModPanel();
   5187               }
   5188               return true;
   5189             },
   5190             registerPanel(panelDef) {
   5191               const panelId = String(panelDef?.id || id).trim().toLowerCase();
   5192               if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(panelId)) throw new Error("Invalid panel id");
   5193               const title = typeof panelDef?.title === "string" ? panelDef.title.trim().slice(0, 40) : panelId;
   5194               const icon = typeof panelDef?.icon === "string" ? panelDef.icon.trim().slice(0, 10) : "";
   5195               const defaultRack =
   5196                 typeof panelDef?.defaultRack === "string" && /^(main|right)$/i.test(panelDef.defaultRack)
   5197                   ? panelDef.defaultRack.toLowerCase()
   5198                   : "right";
   5199               const role =
   5200                 typeof panelDef?.role === "string" && /^(primary|aux|transient|utility)$/i.test(panelDef.role)
   5201                   ? panelDef.role.toLowerCase()
   5202                   : "aux";
   5203               const source = `plugin:${id}`;
   5204               const render = typeof panelDef?.render === "function" ? panelDef.render : null;
   5205 
   5206               pluginPanelDefsByPanelId.set(panelId, { pluginId: id, panelId, title, icon, defaultRack, role, render });
   5207 
   5208               // Create a visible shell only when rack layout is enabled (for now).
   5209               // Otherwise, plugins should continue using their existing DOM hooks.
   5210               let element = null;
   5211               if (rackLayoutEnabled) {
   5212                 const shell = ensurePluginPanelShell(panelId, title, icon, defaultRack, role);
   5213                 element = shell;
   5214                 const mount = shell ? shell.querySelector("[data-pluginmount]") : null;
   5215                 if (mount) {
   5216                   mount.innerHTML = "";
   5217                   const api = {
   5218                     toast,
   5219                     send: (eventName, payload) => {
   5220                       const ev = String(eventName || "").trim();
   5221                       if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false;
   5222                       const wsRef = window.__bzlWs;
   5223                       if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false;
   5224                       const msg = payload && typeof payload === "object" ? payload : {};
   5225                       wsRef.send(JSON.stringify({ ...msg, type: `plugin:${id}:${ev}` }));
   5226                       return true;
   5227                     },
   5228                     getUser: () => loggedInUser,
   5229                     getRole: () => loggedInRole,
   5230                     storage: {
   5231                       get(key) {
   5232                         try {
   5233                           return localStorage.getItem(`bzl_panel_${panelId}_${String(key || "")}`);
   5234                         } catch {
   5235                           return null;
   5236                         }
   5237                       },
   5238                       set(key, value) {
   5239                         try {
   5240                           localStorage.setItem(`bzl_panel_${panelId}_${String(key || "")}`, String(value ?? ""));
   5241                           return true;
   5242                         } catch {
   5243                           return false;
   5244                         }
   5245                       },
   5246                     },
   5247                   };
   5248                   try {
   5249                     const cleanup = render ? render(mount, api) : null;
   5250                     if (typeof cleanup === "function") {
   5251                       // Store cleanup on the shell so future hot-reload / uninstall can call it.
   5252                       shell.__panelCleanup = cleanup;
   5253                     }
   5254                   } catch (e) {
   5255                     console.warn(`Plugin ${id} panel render failed:`, e?.message || e);
   5256                     mount.textContent = `Failed to render panel "${panelId}".`;
   5257                   }
   5258                 }
   5259 
   5260                 enableRackDnD();
   5261               }
   5262 
   5263               panelRegistry.set(panelId, { id: panelId, title, icon, source, role, defaultRack, element });
   5264               applyPluginPresetHint(panelDef);
   5265               applyDockState();
   5266               syncRackStateFromDom();
   5267               return true;
   5268             },
   5269           },
   5270           devLog: (level, message, data) => sendDevLog(level, `plugin:${id}`, message, data),
   5271           send(eventName, payload) {
   5272             const ev = String(eventName || "").trim();
   5273             if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false;
   5274             const wsRef = window.__bzlWs;
   5275             if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false;
   5276             const msg = payload && typeof payload === "object" ? payload : {};
   5277             wsRef.send(JSON.stringify({ ...msg, type: `plugin:${id}:${ev}` }));
   5278             return true;
   5279           },
   5280         });
   5281       } catch (e) {
   5282         console.warn(`Plugin ${id} init failed:`, e?.message || e);
   5283         toast("Plugin error", `Failed to init "${id}".`);
   5284       }
   5285       return true;
   5286     },
   5287   };
   5288 }
   5289 
   5290 function renderTypingIndicator() {
   5291   if (!typingIndicator) return;
   5292   if (!activeChatPostId) {
   5293     typingIndicator.textContent = "";
   5294     return;
   5295   }
   5296   const set = typingUsersByPostId.get(activeChatPostId);
   5297   if (!set || set.size === 0) {
   5298     typingIndicator.textContent = "";
   5299     return;
   5300   }
   5301   const names = Array.from(set.values());
   5302   let text = "";
   5303   if (names.length === 1) text = `@${names[0]} is typing`;
   5304   else if (names.length === 2) text = `@${names[0]} and @${names[1]} are typing`;
   5305   else text = `@${names[0]}, @${names[1]} and ${names.length - 2} others are typing`;
   5306   typingIndicator.innerHTML = `${escapeHtml(text)} <span class="typingDots"><span>.</span><span>.</span><span>.</span></span>`;
   5307 }
   5308 
   5309 function highlightMentionsInText(text) {
   5310   const escaped = escapeHtml(text || "");
   5311   if (!escaped) return "";
   5312   return escaped.replace(/(^|[\s(>])@([a-z0-9][a-z0-9_.-]{0,31})/gi, (full, lead, name) => {
   5313     const normalized = String(name || "").toLowerCase();
   5314     const mine = loggedInUser && normalized === loggedInUser ? " mentionTokenMe" : "";
   5315     return `${lead}<span class="mentionToken${mine}">@${escapeHtml(name)}</span>`;
   5316   });
   5317 }
   5318 
   5319 function decorateMentionNodesInElement(rootEl) {
   5320   if (!rootEl) return;
   5321   const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT);
   5322   const targets = [];
   5323   for (let node = walker.nextNode(); node; node = walker.nextNode()) {
   5324     const parent = node.parentElement;
   5325     if (!parent) continue;
   5326     if (parent.closest(".mentionToken")) continue;
   5327     if (parent.closest("a")) continue;
   5328     const text = String(node.nodeValue || "");
   5329     if (!/@[a-z0-9_][a-z0-9_.-]{0,31}/i.test(text)) continue;
   5330     targets.push(node);
   5331   }
   5332   for (const node of targets) {
   5333     const text = String(node.nodeValue || "");
   5334     const re = /(^|[\s(>])@([a-z0-9_][a-z0-9_.-]{0,31})/gi;
   5335     let match;
   5336     let last = 0;
   5337     const frag = document.createDocumentFragment();
   5338     let changed = false;
   5339     while ((match = re.exec(text))) {
   5340       const start = match.index;
   5341       const lead = match[1] || "";
   5342       const rawName = match[2] || "";
   5343       const mentionStart = start + lead.length;
   5344       if (mentionStart > last) {
   5345         frag.appendChild(document.createTextNode(text.slice(last, mentionStart)));
   5346       }
   5347       const normalized = String(rawName).toLowerCase();
   5348       const span = document.createElement("span");
   5349       span.className = `mentionToken${loggedInUser && normalized === loggedInUser ? " mentionTokenMe" : ""}`;
   5350       span.textContent = `@${rawName}`;
   5351       frag.appendChild(span);
   5352       last = mentionStart + 1 + rawName.length;
   5353       changed = true;
   5354     }
   5355     if (!changed) continue;
   5356     if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last)));
   5357     node.parentNode?.replaceChild(frag, node);
   5358   }
   5359 }
   5360 
   5361 function youtubeVideoIdFromUrl(rawUrl) {
   5362   const raw = String(rawUrl || "").trim();
   5363   if (!raw) return "";
   5364   const urlText = /^https?:\/\//i.test(raw) ? raw : `https://${raw.replace(/^\/+/, "")}`;
   5365   let url;
   5366   try {
   5367     url = new URL(urlText);
   5368   } catch {
   5369     return "";
   5370   }
   5371 
   5372   const host = String(url.hostname || "").toLowerCase();
   5373   const path = String(url.pathname || "");
   5374   const isYouTube =
   5375     host === "youtu.be" ||
   5376     host.endsWith(".youtu.be") ||
   5377     host === "youtube.com" ||
   5378     host.endsWith(".youtube.com") ||
   5379     host === "youtube-nocookie.com" ||
   5380     host.endsWith(".youtube-nocookie.com");
   5381   if (!isYouTube) return "";
   5382 
   5383   let id = "";
   5384   if (host.includes("youtu.be")) {
   5385     id = path.split("/").filter(Boolean)[0] || "";
   5386   } else {
   5387     const v = url.searchParams.get("v");
   5388     if (v) id = v;
   5389     if (!id) {
   5390       const parts = path.split("/").filter(Boolean);
   5391       if (parts[0] === "shorts") id = parts[1] || "";
   5392       if (!id && parts[0] === "embed") id = parts[1] || "";
   5393     }
   5394   }
   5395 
   5396   id = String(id || "").trim();
   5397   if (!/^[a-zA-Z0-9_-]{11}$/.test(id)) return "";
   5398   return id;
   5399 }
   5400 
   5401 function buildYouTubeEmbedEl(videoId) {
   5402   const id = String(videoId || "").trim();
   5403   if (!/^[a-zA-Z0-9_-]{11}$/.test(id)) return null;
   5404   const wrap = document.createElement("div");
   5405   wrap.className = "ytEmbed";
   5406   const iframe = document.createElement("iframe");
   5407   iframe.setAttribute("title", "YouTube video");
   5408   iframe.setAttribute("loading", "lazy");
   5409   iframe.setAttribute("allowfullscreen", "true");
   5410   iframe.setAttribute(
   5411     "allow",
   5412     "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
   5413   );
   5414   iframe.setAttribute("referrerpolicy", "strict-origin-when-cross-origin");
   5415   iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-presentation allow-popups");
   5416   iframe.src = `https://www.youtube-nocookie.com/embed/${id}`;
   5417   wrap.appendChild(iframe);
   5418   return wrap;
   5419 }
   5420 
   5421 function decorateYouTubeEmbedsInElement(rootEl) {
   5422   if (!rootEl) return;
   5423   const existing = rootEl.querySelectorAll(".ytEmbed iframe[src*=\"youtube-nocookie.com/embed/\"]");
   5424   if (existing && existing.length) return;
   5425 
   5426   const anchors = Array.from(rootEl.querySelectorAll("a[href]"));
   5427   for (const a of anchors) {
   5428     const href = a.getAttribute("href") || "";
   5429     const id = youtubeVideoIdFromUrl(href);
   5430     if (!id) continue;
   5431     const next = a.nextElementSibling;
   5432     if (next && next.classList.contains("ytEmbed")) continue;
   5433     const embed = buildYouTubeEmbedEl(id);
   5434     if (!embed) continue;
   5435     a.insertAdjacentElement("afterend", embed);
   5436   }
   5437 
   5438   const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT);
   5439   const nodes = [];
   5440   for (let node = walker.nextNode(); node; node = walker.nextNode()) {
   5441     const parent = node.parentElement;
   5442     if (!parent) continue;
   5443     if (parent.closest("a")) continue;
   5444     if (parent.closest(".ytEmbed")) continue;
   5445     const text = String(node.nodeValue || "");
   5446     if (!/(youtu\.be\/|youtube\.com\/|youtube-nocookie\.com\/)/i.test(text)) continue;
   5447     nodes.push(node);
   5448   }
   5449 
   5450   for (const node of nodes) {
   5451     const text = String(node.nodeValue || "");
   5452     const re = /(https?:\/\/[^\s<]+|www\.[^\s<]+)/gi;
   5453     let match;
   5454     let last = 0;
   5455     const frag = document.createDocumentFragment();
   5456     let changed = false;
   5457     while ((match = re.exec(text))) {
   5458       const urlToken = String(match[0] || "");
   5459       const start = match.index;
   5460       if (start > last) frag.appendChild(document.createTextNode(text.slice(last, start)));
   5461       const id = youtubeVideoIdFromUrl(urlToken);
   5462       if (!id) {
   5463         frag.appendChild(document.createTextNode(urlToken));
   5464         last = start + urlToken.length;
   5465         continue;
   5466       }
   5467       changed = true;
   5468       const a = document.createElement("a");
   5469       const href = /^https?:\/\//i.test(urlToken) ? urlToken : `https://${urlToken}`;
   5470       a.href = href;
   5471       a.target = "_blank";
   5472       a.rel = "noopener noreferrer nofollow";
   5473       a.textContent = urlToken;
   5474       frag.appendChild(a);
   5475       frag.appendChild(buildYouTubeEmbedEl(id));
   5476       last = start + urlToken.length;
   5477     }
   5478     if (!changed) continue;
   5479     if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last)));
   5480     node.parentNode?.replaceChild(frag, node);
   5481   }
   5482 }
   5483 
   5484 function findChatMessage(postId, messageId) {
   5485   const list = chatByPost.get(postId) || [];
   5486   return list.find((m) => m && m.id === messageId) || null;
   5487 }
   5488 
   5489 function setReplyToMessage(message) {
   5490   replyToMessage = message || null;
   5491   if (!chatReplyBanner || !chatReplyWho || !chatReplyText) return;
   5492   if (!replyToMessage) {
   5493     chatReplyBanner.classList.add("hidden");
   5494     chatReplyWho.textContent = "";
   5495     chatReplyText.textContent = "";
   5496     return;
   5497   }
   5498   chatReplyBanner.classList.remove("hidden");
   5499   const who = replyToMessage.fromUser ? `@${replyToMessage.fromUser}` : "unknown";
   5500   chatReplyWho.textContent = who;
   5501   const text = String(replyToMessage.text || "").replace(/\s+/g, " ").trim();
   5502   chatReplyText.textContent = text ? `- ${text.slice(0, 96)}` : "- [media]";
   5503 }
   5504 
   5505 function listMentionCandidates(query) {
   5506   const q = String(query || "")
   5507     .trim()
   5508     .toLowerCase()
   5509     .replace(/^@+/, "");
   5510   const list = Array.isArray(peopleMembers) && peopleMembers.length ? peopleMembers : fallbackPeopleFromProfiles();
   5511   const filtered = list
   5512     .map((m) => String(m.username || "").toLowerCase())
   5513     .filter(Boolean)
   5514     .filter((u) => (q ? u.includes(q) : true))
   5515     .slice(0, 8);
   5516   return Array.from(new Set(filtered));
   5517 }
   5518 
   5519 function getCaretRect() {
   5520   const sel = window.getSelection();
   5521   if (!sel || sel.rangeCount === 0) return null;
   5522   const range = sel.getRangeAt(0).cloneRange();
   5523   range.collapse(true);
   5524   const rects = range.getClientRects();
   5525   if (rects && rects.length) return rects[0];
   5526   const node = range.startContainer && range.startContainer.parentElement ? range.startContainer.parentElement : null;
   5527   return node ? node.getBoundingClientRect() : null;
   5528 }
   5529 
   5530 function renderMentionMenu() {
   5531   if (!mentionMenuEl) return;
   5532   const open = Boolean(mentionState.open && mentionState.items.length);
   5533   mentionMenuEl.classList.toggle("hidden", !open);
   5534   if (!open) {
   5535     mentionMenuEl.innerHTML = "";
   5536     return;
   5537   }
   5538   const rect = mentionState.anchorRect || getCaretRect();
   5539   if (rect) {
   5540     const top = Math.min(window.innerHeight - 180, rect.bottom + 6);
   5541     const left = Math.min(window.innerWidth - 220, rect.left);
   5542     mentionMenuEl.style.top = `${Math.max(10, top)}px`;
   5543     mentionMenuEl.style.left = `${Math.max(10, left)}px`;
   5544   }
   5545   mentionMenuEl.innerHTML = mentionState.items
   5546     .map((u, idx) => {
   5547       const on = idx === mentionState.selected;
   5548       return `<div class="mentionItem ${on ? "isOn" : ""}" role="option" data-mentionpick="${escapeHtml(u)}">@${escapeHtml(u)}</div>`;
   5549     })
   5550     .join("");
   5551 }
   5552 
   5553 mentionMenuEl?.addEventListener("mousedown", (e) => {
   5554   const item = e.target.closest("[data-mentionpick]");
   5555   if (!item) return;
   5556   e.preventDefault(); // keep focus in editor
   5557   const picked = item.getAttribute("data-mentionpick") || "";
   5558   if (!picked) return;
   5559   replaceCurrentMentionToken(picked);
   5560   closeMentionMenu();
   5561   chatEditor?.focus();
   5562 });
   5563 
   5564 function closeMentionMenu() {
   5565   mentionState = { open: false, query: "", selected: 0, items: [], anchorRect: null };
   5566   renderMentionMenu();
   5567 }
   5568 
   5569 function replaceCurrentMentionToken(username) {
   5570   const sel = window.getSelection();
   5571   if (!sel || sel.rangeCount === 0) return;
   5572   const range = sel.getRangeAt(0);
   5573   if (!range.collapsed) return;
   5574   const node = range.startContainer;
   5575   if (!node || node.nodeType !== Node.TEXT_NODE) return;
   5576   const text = String(node.nodeValue || "");
   5577   const caret = range.startOffset;
   5578   const before = text.slice(0, caret);
   5579   const after = text.slice(caret);
   5580   const atIndex = before.lastIndexOf("@");
   5581   if (atIndex < 0) return;
   5582   const prefix = before.slice(0, atIndex);
   5583   const next = `${prefix}@${username} ${after}`;
   5584   node.nodeValue = next;
   5585   const newOffset = (prefix + `@${username} `).length;
   5586   const newRange = document.createRange();
   5587   newRange.setStart(node, Math.min(newOffset, node.nodeValue.length));
   5588   newRange.collapse(true);
   5589   sel.removeAllRanges();
   5590   sel.addRange(newRange);
   5591 }
   5592 
   5593 function wsUrl() {
   5594   const isHttps = location.protocol === "https:";
   5595   const proto = isHttps ? "wss:" : "ws:";
   5596   return `${proto}//${location.host}/ws`;
   5597 }
   5598 
   5599 function setConn(state) {
   5600   if (state === "open") {
   5601     connBadge.textContent = "Connected";
   5602     connBadge.className = "badge badge-good";
   5603   } else if (state === "closed") {
   5604     connBadge.textContent = "Disconnected";
   5605     connBadge.className = "badge badge-bad";
   5606   } else {
   5607     connBadge.textContent = "Connecting...";
   5608     connBadge.className = "badge badge-warn";
   5609   }
   5610 }
   5611 
   5612 function escapeHtml(str) {
   5613   return String(str)
   5614     .replaceAll("&", "&amp;")
   5615     .replaceAll("<", "&lt;")
   5616     .replaceAll(">", "&gt;")
   5617     .replaceAll('"', "&quot;")
   5618     .replaceAll("'", "&#039;");
   5619 }
   5620 
   5621 function cssEscape(str) {
   5622   const raw = String(str ?? "");
   5623   if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(raw);
   5624   return raw.replace(/[^a-zA-Z0-9_-]/g, (m) => `\\${m}`);
   5625 }
   5626 
   5627 function parseKeywords(str) {
   5628   if (!str) return [];
   5629   const parts = str
   5630     .split(",")
   5631     .map((s) => s.trim().toLowerCase())
   5632     .filter(Boolean);
   5633   return Array.from(new Set(parts)).slice(0, 6);
   5634 }
   5635 
   5636 function formatCountdown(expiresAt) {
   5637   if (!Number(expiresAt || 0) || Number(expiresAt) <= 0) return "permanent";
   5638   const ms = expiresAt - Date.now();
   5639   if (ms <= 0) return "expired";
   5640   const totalSeconds = Math.floor(ms / 1000);
   5641   const seconds = totalSeconds % 60;
   5642   const totalMinutes = Math.floor(totalSeconds / 60);
   5643   const minutes = totalMinutes % 60;
   5644   const hours = Math.floor(totalMinutes / 60);
   5645   if (hours > 0) return `${hours}h ${minutes}m`;
   5646   if (minutes > 0) return `${minutes}m ${seconds}s`;
   5647   return `${seconds}s`;
   5648 }
   5649 
   5650 function formatBoostRemaining(boostUntil) {
   5651   const ms = boostUntil - Date.now();
   5652   if (ms <= 0) return "";
   5653   const totalSeconds = Math.floor(ms / 1000);
   5654   const seconds = totalSeconds % 60;
   5655   const totalMinutes = Math.floor(totalSeconds / 60);
   5656   const minutes = totalMinutes % 60;
   5657   const hours = Math.floor(totalMinutes / 60);
   5658   if (hours > 0) return `${hours}h ${minutes}m`;
   5659   if (minutes > 0) return `${minutes}m ${seconds}s`;
   5660   return `${seconds}s`;
   5661 }
   5662 
   5663 function rankTime(post) {
   5664   return Math.max(Number(post.lastActivityAt || post.createdAt || 0), Number(post.boostUntil || 0));
   5665 }
   5666 
   5667 function normalizePrefs(raw) {
   5668   const starred = Array.isArray(raw?.starredPostIds) ? raw.starredPostIds.filter((x) => typeof x === "string" && x) : [];
   5669   const hidden = Array.isArray(raw?.hiddenPostIds) ? raw.hiddenPostIds.filter((x) => typeof x === "string" && x) : [];
   5670   const ignored = Array.isArray(raw?.ignoredUsers) ? raw.ignoredUsers.filter((x) => typeof x === "string" && x) : [];
   5671   const blocked = Array.isArray(raw?.blockedUsers) ? raw.blockedUsers.filter((x) => typeof x === "string" && x) : [];
   5672   const cleanUsers = (list) =>
   5673     [...new Set(list.map((u) => String(u).trim().toLowerCase().replace(/^@+/, "")).filter(Boolean))].slice(0, 400);
   5674   return {
   5675     starredPostIds: [...new Set(starred)],
   5676     hiddenPostIds: [...new Set(hidden)],
   5677     ignoredUsers: cleanUsers(ignored),
   5678     blockedUsers: cleanUsers(blocked),
   5679   };
   5680 }
   5681 
   5682 function setUserPrefs(raw) {
   5683   userPrefs = normalizePrefs(raw || {});
   5684   if (!loggedInUser && activeHiveView !== "all") activeHiveView = "all";
   5685 }
   5686 
   5687 function normalizeCollections(rawList) {
   5688   const list = Array.isArray(rawList) ? rawList : [];
   5689   const out = [];
   5690   for (const item of list) {
   5691     if (!item || typeof item !== "object") continue;
   5692     const id = String(item.id || "").trim();
   5693     const name = String(item.name || "").trim();
   5694     if (!id || !name) continue;
   5695     out.push({
   5696       id,
   5697       name,
   5698       order: Number(item.order || 0) || 0,
   5699       archived: Boolean(item.archived),
   5700       visibility: item.visibility === "gated" ? "gated" : "public",
   5701       allowedRoles: Array.isArray(item.allowedRoles) ? item.allowedRoles.map((x) => String(x || "").toLowerCase()).filter(Boolean) : []
   5702     });
   5703   }
   5704   out.sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || a.name.localeCompare(b.name));
   5705   return out;
   5706 }
   5707 
   5708 function activeCollections() {
   5709   return collections.filter((c) => !c.archived);
   5710 }
   5711 
   5712 function ensureActiveCollectionView() {
   5713   if (!String(activeHiveView).startsWith("collection:")) return;
   5714   const id = String(activeHiveView).slice("collection:".length);
   5715   if (!activeCollections().some((c) => c.id === id)) activeHiveView = "all";
   5716 }
   5717 
   5718 function renderCollectionSelect() {
   5719   if (!postCollectionEl) return;
   5720   const list = activeCollections();
   5721   const opts = list.map((c) => `<option value="${escapeHtml(c.id)}">${escapeHtml(c.name)}</option>`).join("");
   5722   postCollectionEl.innerHTML = opts;
   5723   if (!postCollectionEl.value && list.length) postCollectionEl.value = list[0].id;
   5724 }
   5725 
   5726 function normalizeRoleDefs(rawList) {
   5727   const list = Array.isArray(rawList) ? rawList : [];
   5728   const out = [];
   5729   for (const item of list) {
   5730     if (!item || typeof item !== "object") continue;
   5731     const key = String(item.key || "").trim().toLowerCase();
   5732     const label = String(item.label || "").trim();
   5733     if (!key || !label) continue;
   5734     out.push({
   5735       key,
   5736       label,
   5737       color: /^#[0-9a-f]{6}$/i.test(String(item.color || "")) ? String(item.color).toLowerCase() : "",
   5738       order: Number(item.order || 0) || 0
   5739     });
   5740   }
   5741   out.sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || a.label.localeCompare(b.label));
   5742   return out;
   5743 }
   5744 
   5745 function normalizePlugins(rawList) {
   5746   const list = Array.isArray(rawList) ? rawList : [];
   5747   const out = [];
   5748   for (const item of list) {
   5749     if (!item || typeof item !== "object") continue;
   5750     const id = String(item.id || "").trim().toLowerCase();
   5751     if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(id)) continue;
   5752     out.push({
   5753       id,
   5754       name: String(item.name || id).trim().slice(0, 64) || id,
   5755       version: String(item.version || "0.0.0").trim().slice(0, 32),
   5756       description: String(item.description || "").trim().slice(0, 240),
   5757       enabled: Boolean(item.enabled),
   5758       entryClient: String(item.entryClient || "").trim(),
   5759       entryServer: String(item.entryServer || "").trim(),
   5760       permissions: Array.isArray(item.permissions)
   5761         ? item.permissions.filter((p) => typeof p === "string" && p.trim()).map((p) => p.trim().slice(0, 64)).slice(0, 24)
   5762         : [],
   5763       error: String(item.error || "").trim().slice(0, 280),
   5764     });
   5765   }
   5766   out.sort((a, b) => a.name.localeCompare(b.name));
   5767   return out;
   5768 }
   5769 
   5770 function isOwnerUser() {
   5771   return Boolean(loggedInUser && loggedInRole === "owner");
   5772 }
   5773 
   5774 function canManagePlugins() {
   5775   return Boolean(loggedInUser && (loggedInRole === "owner" || loggedInRole === "moderator"));
   5776 }
   5777 
   5778 function renderPluginsAdminHtml() {
   5779   if (!canManagePlugins()) return `<div class="muted small">Moderator/owner only.</div>`;
   5780   const status = pluginAdminStatus ? `<div class="small muted">${escapeHtml(pluginAdminStatus)}</div>` : "";
   5781   const busyLine = pluginAdminBusy ? `<div class="small muted">Working...</div>` : "";
   5782   const listHtml = !plugins.length
   5783     ? `<div class="muted small">No plugins installed yet.</div>`
   5784     : plugins
   5785     .map((p) => {
   5786       const badges = [];
   5787       if (p.entryClient) badges.push(`<span class="pluginBadge">client</span>`);
   5788       if (p.entryServer) badges.push(`<span class="pluginBadge">server</span>`);
   5789       for (const perm of p.permissions || []) badges.push(`<span class="pluginBadge">${escapeHtml(perm)}</span>`);
   5790       const err = p.error ? `<div class="pluginError">${escapeHtml(p.error)}</div>` : "";
   5791       return `<div class="pluginRow">
   5792         <div class="pluginLeft">
   5793           <div class="pluginName">${escapeHtml(p.name)} <span class="muted small">v${escapeHtml(p.version)}</span></div>
   5794           ${p.description ? `<div class="pluginDesc">${escapeHtml(p.description)}</div>` : ""}
   5795           ${badges.length ? `<div class="pluginBadges">${badges.join("")}</div>` : ""}
   5796           ${err}
   5797         </div>
   5798         <div class="pluginRight">
   5799           <label class="checkRow" style="justify-content:flex-end; gap:10px">
   5800             <span>Enabled</span>
   5801             <input type="checkbox" data-pluginenable="${escapeHtml(p.id)}" ${p.enabled ? "checked" : ""} ${
   5802               pluginEnableInFlight.has(p.id) || pluginAdminBusy ? "disabled" : ""
   5803             } />
   5804           </label>
   5805           <button type="button" class="danger smallBtn" data-pluginuninstall="${escapeHtml(p.id)}">Uninstall</button>
   5806         </div>
   5807       </div>`;
   5808     })
   5809     .join("");
   5810   return `
   5811     <div class="small muted">Moderator/owner only. Install optional plugins to extend your instance.</div>
   5812     <div class="pluginInstallRow" style="margin-top:10px">
   5813       <input data-pluginzip="1" type="file" accept=".zip,application/zip" />
   5814       <button data-plugininstall="1" class="ghost" type="button">Install</button>
   5815       <button data-pluginreload="1" class="ghost" type="button">Reload</button>
   5816     </div>
   5817     ${busyLine}
   5818     ${status}
   5819     <div class="pluginsList">${listHtml}</div>
   5820   `;
   5821 }
   5822 
   5823 function ensureEnabledPluginClientScripts() {
   5824   if (!Array.isArray(plugins) || !plugins.length) return;
   5825   for (const p of plugins) {
   5826     if (!p || !p.enabled) continue;
   5827     if (!p.entryClient) continue;
   5828     const wantVersion = String(p.version || "0");
   5829     const loadedVersion = loadedPluginClientVersionById.get(p.id) || "";
   5830     if (loadedVersion && loadedVersion === wantVersion) continue;
   5831     const src = `/plugins/${encodeURIComponent(p.id)}/${p.entryClient}?v=${encodeURIComponent(p.version || "0")}`;
   5832     const script = document.createElement("script");
   5833     script.src = src;
   5834     script.defer = true;
   5835     script.onload = () => {
   5836       loadedPluginClientVersionById.set(p.id, wantVersion);
   5837     };
   5838     script.onerror = () => {
   5839       pluginAdminStatus = `Failed to load plugin "${p.id}".`;
   5840       toast("Plugins", pluginAdminStatus);
   5841       renderModPanel();
   5842     };
   5843     document.head.appendChild(script);
   5844   }
   5845 }
   5846 
   5847 function setPlugins(rawList) {
   5848   plugins = normalizePlugins(rawList);
   5849   ensureEnabledPluginClientScripts();
   5850   if (canModerate && modTab === "server") renderModPanel();
   5851 }
   5852 
   5853 function roleDefByKey(key) {
   5854   return customRoles.find((r) => r.key === key) || null;
   5855 }
   5856 
   5857 function roleTokenLabel(token) {
   5858   const t = String(token || "");
   5859   if (t === "owner" || t === "moderator" || t === "member") return t;
   5860   if (t.startsWith("role:")) {
   5861     const key = t.slice("role:".length);
   5862     const found = roleDefByKey(key);
   5863     return found ? found.label : key;
   5864   }
   5865   return t;
   5866 }
   5867 
   5868 function userCustomRoleKeys(username) {
   5869   const member = (peopleMembers || []).find((m) => m && m.username === username);
   5870   const keys = Array.isArray(member?.customRoles) ? member.customRoles : [];
   5871   return keys.filter((x) => typeof x === "string" && x);
   5872 }
   5873 
   5874 function renderCustomRoleBadges(username) {
   5875   const keys = userCustomRoleKeys(username);
   5876   if (!keys.length) return "";
   5877   const parts = keys
   5878     .map((key) => {
   5879       const def = roleDefByKey(key);
   5880       if (!def) return `<span class="modStatus">${escapeHtml(key)}</span>`;
   5881       const style = def.color ? ` style="border-color:${escapeHtml(def.color)}66;color:${escapeHtml(def.color)}"` : "";
   5882       return `<span class="modStatus"${style}>${escapeHtml(def.label)}</span>`;
   5883     })
   5884     .join(" ");
   5885   return `<span class="customRoleRow">${parts}</span>`;
   5886 }
   5887 
   5888 function availableGateTokens() {
   5889   const base = ["member", "moderator", "owner"];
   5890   const custom = customRoles.map((r) => `role:${r.key}`);
   5891   return [...base, ...custom];
   5892 }
   5893 
   5894 function prefSet(key) {
   5895   return new Set(Array.isArray(userPrefs?.[key]) ? userPrefs[key] : []);
   5896 }
   5897 
   5898 function totalReactions(post) {
   5899   const reactions = post?.reactions && typeof post.reactions === "object" ? post.reactions : {};
   5900   let total = 0;
   5901   for (const count of Object.values(reactions)) total += Number(count || 0);
   5902   return total;
   5903 }
   5904 
   5905 function sortPosts(list) {
   5906   const mode = String(sortByEl?.value || "activity");
   5907   if (mode === "popular") {
   5908     return list.sort((a, b) => totalReactions(b) - totalReactions(a) || rankTime(b) - rankTime(a) || b.createdAt - a.createdAt);
   5909   }
   5910   if (mode === "expiring") {
   5911     const exp = (p) => {
   5912       const t = Number(p?.expiresAt || 0) || 0;
   5913       return t > 0 ? t : Number.MAX_SAFE_INTEGER;
   5914     };
   5915     return list.sort((a, b) => exp(a) - exp(b) || rankTime(b) - rankTime(a));
   5916   }
   5917   return list.sort((a, b) => rankTime(b) - rankTime(a) || b.createdAt - a.createdAt);
   5918 }
   5919 
   5920 function currentSortMode() {
   5921   return String(sortByEl?.value || "activity");
   5922 }
   5923 
   5924 function updateMobileSortCycleLabel() {
   5925   if (!(mobileSortCycleBtn instanceof HTMLElement)) return;
   5926   const mode = currentSortMode();
   5927   const label = mode === "popular" ? "Popular" : mode === "expiring" ? "Ending" : "Recent";
   5928   mobileSortCycleBtn.textContent = label;
   5929 }
   5930 
   5931 function getProfile(username) {
   5932   if (!username) return { image: "", color: "" };
   5933   const p = profiles[username] || {};
   5934   return { image: p.image || "", color: p.color || "" };
   5935 }
   5936 
   5937 function normalizeProfileLinks(list) {
   5938   if (!Array.isArray(list)) return [];
   5939   const out = [];
   5940   for (const item of list) {
   5941     if (!item || typeof item !== "object") continue;
   5942     const label = String(item.label || "")
   5943       .replace(/\s+/g, " ")
   5944       .trim()
   5945       .slice(0, 40);
   5946     const url = String(item.url || "").trim().slice(0, 280);
   5947     if (!/^https?:\/\//i.test(url)) continue;
   5948     out.push({ label: label || "Link", url });
   5949     if (out.length >= 8) break;
   5950   }
   5951   return out;
   5952 }
   5953 
   5954 function normalizeProfileData(raw, fallbackUsername = "") {
   5955   const username = String(raw?.username || fallbackUsername || "")
   5956     .trim()
   5957     .toLowerCase();
   5958   const image = typeof raw?.image === "string" ? raw.image : getProfile(username).image || "";
   5959   const colorRaw = typeof raw?.color === "string" ? raw.color : getProfile(username).color || "";
   5960   const color = /^#[0-9a-f]{6}$/i.test(colorRaw) ? colorRaw.toLowerCase() : "";
   5961   const pronouns = String(raw?.pronouns || "")
   5962     .replace(/\s+/g, " ")
   5963     .trim()
   5964     .slice(0, 40);
   5965   const bioHtml = typeof raw?.bioHtml === "string" ? raw.bioHtml : "";
   5966   const themeSongUrl = typeof raw?.themeSongUrl === "string" ? raw.themeSongUrl.trim() : "";
   5967   const links = normalizeProfileLinks(raw?.links);
   5968   return { username, image, color, pronouns, bioHtml, themeSongUrl, links };
   5969 }
   5970 
   5971 function asProfileLink(url) {
   5972   const value = String(url || "").trim();
   5973   if (!/^https?:\/\//i.test(value)) return "";
   5974   return value;
   5975 }
   5976 
   5977 function renderUserPill(username) {
   5978   if (!username) return `<span class="muted small">anon</span>`;
   5979   const p = getProfile(username);
   5980   const image = typeof p.image === "string" ? p.image : "";
   5981   const color = p.color && /^#[0-9a-f]{6}$/i.test(p.color) ? p.color : "";
   5982   const safeTextColor = color ? safeTextColorFromHex(color) : "";
   5983   const style = safeTextColor ? `style="color:${escapeHtml(safeTextColor)}"` : "";
   5984   const img = image ? `<img alt="" src="${escapeHtml(image)}" />` : "";
   5985   const extra = renderCustomRoleBadges(username);
   5986   const normalized = String(username || "").trim().toLowerCase();
   5987   return `<button type="button" class="userPill userPillLink" data-viewprofile="${escapeHtml(
   5988     normalized
   5989   )}" title="View profile"><span class="pfp">${img}</span><span ${style}>@${escapeHtml(username)}</span>${extra}</button>`;
   5990 }
   5991 
   5992 function hexToRgb(hex) {
   5993   const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex || "");
   5994   if (!m) return null;
   5995   return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) };
   5996 }
   5997 
   5998 function srgbToLinear(x) {
   5999   const c = x / 255;
   6000   if (c <= 0.04045) return c / 12.92;
   6001   return Math.pow((c + 0.055) / 1.055, 2.4);
   6002 }
   6003 
   6004 function relativeLuminanceFromRgb(rgb) {
   6005   if (!rgb) return 1;
   6006   const r = srgbToLinear(rgb.r);
   6007   const g = srgbToLinear(rgb.g);
   6008   const b = srgbToLinear(rgb.b);
   6009   return 0.2126 * r + 0.7152 * g + 0.0722 * b;
   6010 }
   6011 
   6012 function rgbToHex(rgb) {
   6013   const clamp = (n) => Math.max(0, Math.min(255, Math.round(n)));
   6014   const to2 = (n) => clamp(n).toString(16).padStart(2, "0");
   6015   return `#${to2(rgb.r)}${to2(rgb.g)}${to2(rgb.b)}`;
   6016 }
   6017 
   6018 function mixRgb(a, b, t) {
   6019   const k = Math.max(0, Math.min(1, Number(t) || 0));
   6020   return {
   6021     r: a.r + (b.r - a.r) * k,
   6022     g: a.g + (b.g - a.g) * k,
   6023     b: a.b + (b.b - a.b) * k,
   6024   };
   6025 }
   6026 
   6027 function safeTextColorFromHex(hex) {
   6028   const rgb = hexToRgb(hex);
   6029   if (!rgb) return "";
   6030   const baseLum = relativeLuminanceFromRgb(rgb);
   6031   if (baseLum >= 0.38) return rgbToHex(rgb);
   6032   const white = { r: 255, g: 255, b: 255 };
   6033   let best = rgb;
   6034   for (let t = 0.10; t <= 0.85; t += 0.08) {
   6035     const mixed = mixRgb(rgb, white, t);
   6036     if (relativeLuminanceFromRgb(mixed) >= 0.42) {
   6037       best = mixed;
   6038       break;
   6039     }
   6040     best = mixed;
   6041   }
   6042   return rgbToHex(best);
   6043 }
   6044 
   6045 function tintStylesFromHex(hex) {
   6046   const rgb = hexToRgb(hex);
   6047   if (!rgb) return "";
   6048   const bg = `rgba(${rgb.r},${rgb.g},${rgb.b},0.10)`;
   6049   const border = `rgba(${rgb.r},${rgb.g},${rgb.b},0.22)`;
   6050   return `style="background:${bg};border-color:${border}"`;
   6051 }
   6052 
   6053 function cardTintStylesFromHex(hex) {
   6054   const rgb = hexToRgb(hex);
   6055   if (!rgb) return "";
   6056   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)`;
   6057   const border = `rgba(${rgb.r},${rgb.g},${rgb.b},0.34)`;
   6058   const glow = `0 10px 24px rgba(${rgb.r},${rgb.g},${rgb.b},0.14)`;
   6059   return `style="background:${bg};border-color:${border};box-shadow:${glow}"`;
   6060 }
   6061 
   6062 function matchesFilter(post, filterSet, authorQuery, hiddenSet, starredSet, ignoreUserSet, visibleCollectionIds) {
   6063   if (visibleCollectionIds && !visibleCollectionIds.has(String(post.collectionId || ""))) return false;
   6064   if (hiddenSet.has(post.id) && activeHiveView !== "hidden") return false;
   6065   if (activeHiveView === "starred" && !starredSet.has(post.id)) return false;
   6066   if (activeHiveView === "hidden" && !hiddenSet.has(post.id)) return false;
   6067   const author = String(post.author || "").toLowerCase();
   6068   if (author && ignoreUserSet && ignoreUserSet.has(author) && (!loggedInUser || author !== String(loggedInUser).toLowerCase())) return false;
   6069   if (String(activeHiveView).startsWith("collection:")) {
   6070     const collectionId = String(activeHiveView).slice("collection:".length);
   6071     if ((post.collectionId || "") !== collectionId) return false;
   6072   }
   6073   if (filterSet.size > 0) {
   6074     let matched = false;
   6075     for (const kw of post.keywords || []) {
   6076       if (filterSet.has(kw)) {
   6077         matched = true;
   6078         break;
   6079       }
   6080     }
   6081     if (!matched) return false;
   6082   }
   6083   if (authorQuery && !author.includes(authorQuery)) return false;
   6084   return true;
   6085 }
   6086 
   6087 function postTitle(post) {
   6088   if (post.locked) return "Protected post";
   6089   const text = String(post.title || post.content || "").replace(/\s+/g, " ").trim();
   6090   if (!text) return "(untitled)";
   6091   return text.length > 96 ? `${text.slice(0, 96)}...` : text;
   6092 }
   6093 
   6094 function myReactKey(kind, id, emoji) {
   6095   return `${kind}:${id}:${emoji}`;
   6096 }
   6097 
   6098 function toggleMyReact(kind, id, emoji) {
   6099   const key = myReactKey(kind, id, emoji);
   6100   if (myReacts.has(key)) myReacts.delete(key);
   6101   else myReacts.add(key);
   6102 }
   6103 
   6104 function markReactPulse(kind, id, emoji) {
   6105   const key = myReactKey(kind, id, emoji);
   6106   reactPulseByKey.set(key, Date.now());
   6107 }
   6108 
   6109 function renderReactionButtons({ kind, id, reactions, postId }) {
   6110   if (!showReactions) return "";
   6111   const r = reactions && typeof reactions === "object" ? reactions : {};
   6112   const emojis = kind === "post" ? allowedPostReactions : allowedChatReactions;
   6113   return `<div class="reactionsRow">
   6114     ${emojis
   6115       .map((emoji) => {
   6116         const count = Number(r[emoji] || 0);
   6117         const key = myReactKey(kind, id, emoji);
   6118         const isOn = myReacts.has(key);
   6119         const pulseAt = reactPulseByKey.get(key) || 0;
   6120         const pulse = pulseAt && Date.now() - pulseAt < 650;
   6121         if (pulseAt && !pulse) reactPulseByKey.delete(key);
   6122         const cls = `${isOn ? "reactBtn isOn" : "reactBtn"}${pulse ? " pulse" : ""}`;
   6123         const attrs =
   6124           kind === "post"
   6125             ? `data-react="1" data-kind="post" data-postid="${escapeHtml(id)}" data-emoji="${escapeHtml(emoji)}"`
   6126             : `data-react="1" data-kind="chat" data-postid="${escapeHtml(postId || "")}" data-msgid="${escapeHtml(
   6127                 id
   6128               )}" data-emoji="${escapeHtml(emoji)}"`;
   6129         return `<span class="${cls}" ${attrs}>${escapeHtml(emoji)} <span class="count">${count || ""}</span></span>`;
   6130       })
   6131       .join("")}
   6132   </div>`;
   6133 }
   6134 
   6135 function markRead(postId) {
   6136   if (!postId) return;
   6137   unreadByPostId.delete(postId);
   6138 }
   6139 
   6140 function bumpUnread(postId) {
   6141   const current = unreadByPostId.get(postId) || 0;
   6142   unreadByPostId.set(postId, Math.min(99, current + 1));
   6143 }
   6144 
   6145 function notifSupported() {
   6146   return typeof window.Notification !== "undefined";
   6147 }
   6148 
   6149 function notifState() {
   6150   if (!notifSupported()) return "unsupported";
   6151   return Notification.permission; // default | denied | granted
   6152 }
   6153 
   6154 function updateNotifUi() {
   6155   if (!enableNotifsBtn || !notifStatus) return;
   6156   const state = notifState();
   6157   const secure = location.protocol === "https:";
   6158   const hint = secure ? "" : " (requires HTTPS: use tunnel)";
   6159 
   6160   if (state === "unsupported") {
   6161     enableNotifsBtn.classList.add("hidden");
   6162     notifStatus.textContent = "Notifications not supported in this browser.";
   6163     return;
   6164   }
   6165 
   6166   enableNotifsBtn.classList.remove("hidden");
   6167   if (!secure) {
   6168     enableNotifsBtn.disabled = true;
   6169     notifStatus.textContent = `Notifications disabled on HTTP${hint}.`;
   6170     return;
   6171   }
   6172 
   6173   enableNotifsBtn.disabled = state === "granted";
   6174   enableNotifsBtn.textContent = state === "granted" ? "Notifications enabled" : "Enable notifications";
   6175   notifStatus.textContent =
   6176     state === "granted" ? "You'll get pings when activity happens." : state === "denied" ? "Blocked in browser settings." : "";
   6177 }
   6178 
   6179 function maybeNotify(title, body, data) {
   6180   if (notifState() !== "granted") return;
   6181   if (windowFocused && !document.hidden) return;
   6182   try {
   6183     const n = new Notification(title, { body, data });
   6184     n.onclick = () => {
   6185       window.focus();
   6186       if (data?.postId) openChat(data.postId);
   6187       if (data?.threadId) openDmThread(data.threadId);
   6188       n.close();
   6189     };
   6190   } catch {
   6191     // ignore
   6192   }
   6193 }
   6194 
   6195 function renderLanHint() {
   6196   if (!lanHint) return;
   6197   if (!canModerate || !Array.isArray(lanUrls) || lanUrls.length === 0) {
   6198     lanHint.textContent = "";
   6199     return;
   6200   }
   6201   lanHint.innerHTML = `LAN: <span class="muted">${lanUrls.map(escapeHtml).join(" | ")}</span>`;
   6202 }
   6203 
   6204 function renderFeed() {
   6205   const filter = parseKeywords(filterKeywordsEl.value);
   6206   const filterSet = new Set(filter);
   6207   const authorQuery = String(filterAuthorEl?.value || "")
   6208     .trim()
   6209     .replace(/^@+/, "")
   6210     .toLowerCase();
   6211   ensureActiveCollectionView();
   6212   const hiddenSet = prefSet("hiddenPostIds");
   6213   const starredSet = prefSet("starredPostIds");
   6214   const ignoreUserSet = new Set([...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase()));
   6215   const visibleCollectionIds = new Set(activeCollections().map((c) => c.id));
   6216   if (!loggedInUser && activeHiveView !== "all") activeHiveView = "all";
   6217   if (hiveTabsEl) {
   6218     const collectionTabs = activeCollections()
   6219       .map((c) => {
   6220         const view = `collection:${c.id}`;
   6221         const on = view === activeHiveView;
   6222         const cls = on ? "primary" : "ghost";
   6223         return `<button type="button" data-hiveview="${escapeHtml(view)}" class="${cls}">${escapeHtml(c.name)}</button>`;
   6224       })
   6225       .join("");
   6226     const allOn = activeHiveView === "all";
   6227     const starredOn = activeHiveView === "starred";
   6228     const hiddenOn = activeHiveView === "hidden";
   6229     hiveTabsEl.innerHTML = `
   6230       <button type="button" data-hiveview="all" class="${allOn ? "primary" : "ghost"}">All</button>
   6231       ${collectionTabs}
   6232       <button type="button" data-hiveview="starred" class="${starredOn ? "primary" : "ghost"}" ${loggedInUser ? "" : "disabled"}>Starred</button>
   6233       <button type="button" data-hiveview="hidden" class="${hiddenOn ? "primary" : "ghost"}" ${loggedInUser ? "" : "disabled"}>Hidden</button>
   6234     `;
   6235   }
   6236 
   6237   const list = sortPosts(Array.from(posts.values())).filter((p) =>
   6238     matchesFilter(p, filterSet, authorQuery, hiddenSet, starredSet, ignoreUserSet, visibleCollectionIds)
   6239   );
   6240 
   6241   if (list.length === 0) {
   6242     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>`;
   6243     return;
   6244   }
   6245 
   6246   feedEl.innerHTML = list
   6247     .map((p) => {
   6248       const tags = (p.keywords || []).map((k) => `<span class="tag">#${escapeHtml(k)}</span>`).join("");
   6249       const collectionName = activeCollections().find((c) => c.id === p.collectionId)?.name || "General";
   6250       const collectionTag = `<span class="tag">/${escapeHtml(collectionName)}</span>`;
   6251       const postedLine = `<div class="small muted">posted ${escapeHtml(new Date(p.createdAt).toLocaleString())}</div>`;
   6252       const editedLine =
   6253         Number(p.editCount || 0) > 0
   6254           ? `<div class="small muted">edited (${Number(p.editCount || 0)}) at ${escapeHtml(
   6255               new Date(Number(p.editedAt || p.createdAt)).toLocaleString()
   6256             )}</div>`
   6257           : "";
   6258       const deletedLine = p.deleted
   6259         ? `<div class="small muted">Post was deleted${
   6260             p.deletedBy ? ` by @${escapeHtml(p.deletedBy)}` : ""
   6261           } at ${escapeHtml(new Date(Number(p.deletedAt || Date.now())).toLocaleString())}${
   6262             p.deleteReason ? ` (${escapeHtml(p.deleteReason)})` : ""
   6263           }</div>`
   6264         : "";
   6265       const authorLine = p.author ? `<div class="small postAuthor">${renderUserPill(p.author)}</div>` : "";
   6266       const boostText = formatBoostRemaining(Number(p.boostUntil || 0));
   6267       const boostLine = boostText ? `<div class="countdown boost" data-boost="${p.id}">boost ${boostText}</div>` : "";
   6268 
   6269       const canBoost = Boolean(loggedInUser && !p.locked && !p.deleted && p.author && loggedInUser !== p.author);
   6270       const canManageOwnPost = Boolean(loggedInUser && !p.locked && !p.deleted && p.author && loggedInUser === p.author);
   6271       const boostControls = canBoost
   6272         ? `<div class="boostRow">
   6273              <select data-boostsel="${p.id}">
   6274                <option value="300000">+5m</option>
   6275                <option value="900000">+15m</option>
   6276                <option value="1800000">+30m</option>
   6277                <option value="3600000" selected>+1h</option>
   6278                <option value="7200000">+2h</option>
   6279              </select>
   6280              <button type="button" data-boostbtn="${p.id}">Boost</button>
   6281            </div>`
   6282         : "";
   6283 
   6284       const reactionsHtml = p.locked || p.deleted ? "" : renderReactionButtons({ kind: "post", id: p.id, reactions: p.reactions || {} });
   6285       const isHidden = hiddenSet.has(p.id);
   6286       const menuItems = `
   6287         ${canManageOwnPost ? `<button type="button" class="ghost" data-editpost="${p.id}">Edit</button>` : ""}
   6288         ${canManageOwnPost ? `<button type="button" class="ghost danger" data-deletepost="${p.id}">Delete</button>` : ""}
   6289         ${loggedInUser ? `<button type="button" class="ghost" data-hidepost="${p.id}">${isHidden ? "Unhide" : "Hide"}</button>` : ""}
   6290         ${loggedInUser && !p.deleted ? `<button type="button" class="ghost" data-reportpost="${p.id}">Report</button>` : ""}
   6291       `.trim();
   6292       const hasMenu = Boolean(menuItems);
   6293       const kebabBtn = hasMenu
   6294         ? `<button type="button" class="ghost smallBtn kebabBtn" data-postmenu="${p.id}" aria-haspopup="menu" aria-expanded="false" title="More">&#8942;</button>`
   6295         : "";
   6296       const postMenu = hasMenu
   6297         ? `<div class="postMenu hidden" role="menu" data-postmenu-panel="${p.id}">${menuItems}</div>`
   6298         : "";
   6299 
   6300       const unread = unreadByPostId.get(p.id) || 0;
   6301       const unreadDot = unread ? `<span class="badgeDot" title="${unread} unread"></span>` : "";
   6302       const unreadClass = unread ? " isUnread" : "";
   6303       const newClass = newPostAnimIds.has(p.id) ? " isNew" : "";
   6304       const buzzClass = buzzTimers.has(p.id) ? " isBuzz" : "";
   6305       const lockLine = p.locked ? `<div class="small muted">πŸ”’ password protected</div>` : "";
   6306       const cardTint = p.author ? cardTintStylesFromHex(getProfile(p.author).color) : "";
   6307       const contentHtml = typeof p.contentHtml === "string" && p.contentHtml.trim() ? p.contentHtml : "";
   6308       const contentText = typeof p.content === "string" && p.content.trim() ? escapeHtml(p.content) : "";
   6309       const content = contentHtml ? contentHtml : contentText ? `<div class="muted">${contentText}</div>` : "";
   6310       const contentBlock = content ? `<div class="postContent">${content}</div>` : "";
   6311       const lastChat = (chatByPost.get(p.id) || []).filter((m) => !m?.deleted).slice(-1)[0] || null;
   6312       const lastChatFrom = lastChat ? String(lastChat.fromUser || "").trim() : "";
   6313       const lastChatText = lastChat ? String(lastChat.text || "").replace(/\s+/g, " ").trim().slice(0, 92) : "";
   6314       const lastChatWho = lastChat
   6315         ? (lastChatFrom && lastChatFrom.toLowerCase() === "mod" ? "MOD" : `@${escapeHtml(lastChatFrom || "unknown")}`)
   6316         : "";
   6317       const lastChatLine = lastChat
   6318         ? `<div class="small muted postLastChat">Last chat: ${lastChatWho}${lastChatText ? ` β€” ${escapeHtml(lastChatText)}` : ""}</div>`
   6319         : "";
   6320       const typersSet = typingUsersByPostId.get(p.id);
   6321       const typingUsers = typersSet ? Array.from(typersSet.values()).slice(0, 2) : [];
   6322       const typingMore = typersSet && typersSet.size > typingUsers.length ? ` +${typersSet.size - typingUsers.length}` : "";
   6323       const typingLine = typingUsers.length
   6324         ? `<div class="small muted postTypingLine">${typingUsers.map((u) => `@${escapeHtml(u)}`).join(", ")}${typingMore} typing...</div>`
   6325         : "";
   6326 
   6327       return `
   6328       <article class="post${unreadClass}${newClass}${buzzClass}" data-id="${p.id}" ${cardTint}>
   6329         <div class="postTop">
   6330           <div class="postTitleRow">
   6331             <div class="postTitle">${escapeHtml(postTitle(p))}</div>
   6332             ${postedLine}
   6333             ${authorLine}
   6334             ${lockLine}
   6335           </div>
   6336           <div class="rightCol">
   6337             ${unreadDot}
   6338             <div class="countdown" data-countdown="${p.id}">${formatCountdown(p.expiresAt)}</div>
   6339             ${boostLine}
   6340             ${boostControls}
   6341             <div class="postActionsRow">
   6342               <button type="button" data-chat="${p.id}">${p.locked ? "Unlock" : p.deleted ? "View" : "Chat"}</button>
   6343               ${kebabBtn}
   6344               ${postMenu}
   6345             </div>
   6346           </div>
   6347         </div>
   6348         ${deletedLine}
   6349         ${editedLine}
   6350         ${contentBlock}
   6351         ${typingLine}
   6352         ${lastChatLine}
   6353         <div class="postMeta">${collectionTag}${tags ? ` ${tags}` : ""}</div>
   6354         ${reactionsHtml}
   6355       </article>`;
   6356     })
   6357     .join("");
   6358 
   6359   try {
   6360     feedEl.querySelectorAll?.(".postContent").forEach((el) => decorateYouTubeEmbedsInElement(el));
   6361   } catch {
   6362     // ignore
   6363   }
   6364 }
   6365 
   6366 function isMobileChatScreenActive() {
   6367   if (!isMobileScreenMode() || !appRoot) return false;
   6368   const screen = String(appRoot.getAttribute("data-mobile-screen") || "").trim();
   6369   return screen === "chat" || (screen === "host" && mobileHostPanelId === "chat");
   6370 }
   6371 
   6372 function renderMobileChatListHtml() {
   6373   const dmActive = activeDmThreadsSorted().slice(0, 30);
   6374   const recentPostIds = recentHiveChatIds.slice(0, 24);
   6375   const recentPosts = recentPostIds.map((id) => posts.get(id)).filter((p) => p && !p.deleted);
   6376   const recentPostIdSet = new Set(recentPosts.map((p) => String(p.id)));
   6377   const availablePosts = sortPosts(Array.from(posts.values()))
   6378     .filter((p) => p && !p.deleted && !recentPostIdSet.has(String(p.id)))
   6379     .slice(0, 60);
   6380 
   6381   if (!dmActive.length && !recentPosts.length && !availablePosts.length) {
   6382     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>`;
   6383   }
   6384 
   6385   const dmSection = dmActive.length
   6386     ? `<div class="mobileChatSection">
   6387         <div class="small muted">DMs</div>
   6388         ${dmActive
   6389           .map((t) => {
   6390             const who = `@${escapeHtml(String(t.other || "unknown"))}`;
   6391             const when = dmActivityAt(t) ? new Date(dmActivityAt(t)).toLocaleTimeString() : "active";
   6392             return `<button type="button" class="ghost mobileChatListItem" data-dmopen="${escapeHtml(t.id)}">
   6393               <span class="mobileChatListTop">${who}</span>
   6394               <span class="mobileChatListMeta">private chat Β· ${escapeHtml(when)}</span>
   6395             </button>`;
   6396           })
   6397           .join("")}
   6398       </div>`
   6399     : "";
   6400 
   6401   const postItem = (p) => {
   6402     const title = escapeHtml(postTitle(p));
   6403     const author = p.author ? `@${escapeHtml(String(p.author || ""))}` : "anon";
   6404     const exp = formatCountdown(p.expiresAt);
   6405     const lock = p.locked ? " Β· locked" : "";
   6406     return `<button type="button" class="ghost mobileChatListItem" data-mobilechatopen="${escapeHtml(p.id)}">
   6407       <span class="mobileChatListTop">${title}</span>
   6408       <span class="mobileChatListMeta">${author} Β· ${escapeHtml(exp)}${lock}</span>
   6409     </button>`;
   6410   };
   6411 
   6412   const recentSection = recentPosts.length
   6413     ? `<div class="mobileChatSection">
   6414         <div class="small muted">Recent Hive Chats</div>
   6415         ${recentPosts.map(postItem).join("")}
   6416       </div>`
   6417     : "";
   6418 
   6419   const hivesSection = availablePosts.length
   6420     ? `<div class="mobileChatSection">
   6421         <div class="small muted">Available Hives</div>
   6422         ${availablePosts.map(postItem).join("")}
   6423       </div>`
   6424     : "";
   6425 
   6426   return `<div class="mobileChatList">${dmSection}${recentSection}${hivesSection}</div>`;
   6427 }
   6428 
   6429 function onboardingRequiresAcceptance() {
   6430   return Boolean(onboardingState.enabled && onboardingState.requireAcceptance);
   6431 }
   6432 
   6433 function onboardingNeedsAcceptanceNow() {
   6434   if (!onboardingRequiresAcceptance()) return false;
   6435   return Boolean(onboardingState.needsAcceptance || Number(onboardingState.acceptedRulesVersion || 0) < Number(onboardingState.rulesVersion || 1));
   6436 }
   6437 
   6438 function onboardingSeverityLabel(severity) {
   6439   const s = String(severity || "").toLowerCase();
   6440   if (s === "critical") return "Critical";
   6441   if (s === "warn") return "Warn";
   6442   return "Info";
   6443 }
   6444 
   6445 function onboardingSeverityBadge(severity) {
   6446   const s = String(severity || "info").toLowerCase();
   6447   const cls = s === "critical" ? "onbSeverityCritical" : s === "warn" ? "onbSeverityWarn" : "onbSeverityInfo";
   6448   return `<span class="tag ${cls}">${escapeHtml(onboardingSeverityLabel(s))}</span>`;
   6449 }
   6450 
   6451 function onboardingRuleListFromConfig(cfg) {
   6452   const list = Array.isArray(cfg?.rules?.items) ? cfg.rules.items : [];
   6453   return list
   6454     .map((r, index) => ({
   6455       id: String(r?.id || `r${index + 1}`).trim().slice(0, 40) || `r${index + 1}`,
   6456       order: Number.isFinite(Number(r?.order)) ? Math.max(1, Math.floor(Number(r.order))) : index + 1,
   6457       name: String(r?.name || "").trim().slice(0, 60) || `Rule ${index + 1}`,
   6458       shortDescription: String(r?.shortDescription || "").trim().slice(0, 180),
   6459       description: String(r?.description || "").slice(0, 6000),
   6460       severity: ["info", "warn", "critical"].includes(String(r?.severity || "").toLowerCase())
   6461         ? String(r.severity).toLowerCase()
   6462         : "info",
   6463     }))
   6464     .sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || String(a.id || "").localeCompare(String(b.id || "")));
   6465 }
   6466 
   6467 function onboardingDraftStampFromConfig(cfg) {
   6468   return JSON.stringify({
   6469     enabled: Boolean(cfg?.enabled),
   6470     aboutUpdatedAt: Number(cfg?.about?.updatedAt || 0),
   6471     rulesVersion: Number(cfg?.rules?.version || 1),
   6472     itemCount: Array.isArray(cfg?.rules?.items) ? cfg.rules.items.length : 0,
   6473     roleSelectEnabled: Boolean(cfg?.roleSelect?.enabled),
   6474     selfAssignableCount: Array.isArray(cfg?.roleSelect?.selfAssignableRoleIds) ? cfg.roleSelect.selfAssignableRoleIds.length : 0,
   6475   });
   6476 }
   6477 
   6478 function syncOnboardingAdminDraft(force = false) {
   6479   const cfg = normalizeInstanceBranding(instanceBranding).onboarding || {};
   6480   const stamp = onboardingDraftStampFromConfig(cfg);
   6481   if (!force && stamp === onboardingAdminDraftStamp) return;
   6482   onboardingAdminDraft = {
   6483     enabled: Boolean(cfg?.enabled),
   6484     aboutContent: String(cfg?.about?.content || ""),
   6485     requireAcceptance: Boolean(cfg?.rules?.requireAcceptance),
   6486     blockReadUntilAccepted: Boolean(cfg?.rules?.blockReadUntilAccepted),
   6487     roleSelectEnabled: Boolean(cfg?.roleSelect?.enabled),
   6488     selfAssignableRoleIds: Array.isArray(cfg?.roleSelect?.selfAssignableRoleIds)
   6489       ? cfg.roleSelect.selfAssignableRoleIds.map((x) => String(x || "").trim().toLowerCase()).filter(Boolean)
   6490       : [],
   6491     rules: onboardingRuleListFromConfig(cfg),
   6492   };
   6493   onboardingAdminDraftStamp = stamp;
   6494   onboardingAdminExpandedRuleIds.clear();
   6495   if (onboardingAdminDraft.rules[0]?.id) onboardingAdminExpandedRuleIds.add(onboardingAdminDraft.rules[0].id);
   6496 }
   6497 
   6498 function normalizeOnboardingDraftRules() {
   6499   onboardingAdminDraft.rules = (Array.isArray(onboardingAdminDraft.rules) ? onboardingAdminDraft.rules : [])
   6500     .map((r, index) => ({
   6501       id: String(r?.id || `r${index + 1}`).trim().slice(0, 40) || `r${index + 1}`,
   6502       order: index + 1,
   6503       name: String(r?.name || "").trim().slice(0, 60) || `Rule ${index + 1}`,
   6504       shortDescription: String(r?.shortDescription || "").trim().slice(0, 180),
   6505       description: String(r?.description || "").slice(0, 6000),
   6506       severity: ["info", "warn", "critical"].includes(String(r?.severity || "").toLowerCase())
   6507         ? String(r.severity).toLowerCase()
   6508         : "info",
   6509     }))
   6510     .slice(0, 200);
   6511 }
   6512 
   6513 function renderOnboardingPanel() {
   6514   if (!(onboardingPanelEl instanceof HTMLElement) || !(onboardingPanelBodyEl instanceof HTMLElement)) return;
   6515   const cfg = normalizeInstanceBranding(instanceBranding).onboarding || {};
   6516   if (!cfg.enabled) {
   6517     onboardingPanelEl.classList.add("hidden");
   6518     onboardingPanelBodyEl.innerHTML = `<div class="small muted">Onboarding is disabled for this server.</div>`;
   6519     if (onboardingPanelAcceptBtn instanceof HTMLButtonElement) onboardingPanelAcceptBtn.classList.add("hidden");
   6520     return;
   6521   }
   6522 
   6523   onboardingPanelEl.classList.remove("hidden");
   6524   const needs = onboardingNeedsAcceptanceNow();
   6525   const rules = onboardingRuleListFromConfig(cfg);
   6526   const about = typeof cfg?.about?.content === "string" ? cfg.about.content.trim() : "";
   6527   const roleIds = Array.isArray(cfg?.roleSelect?.selfAssignableRoleIds) ? cfg.roleSelect.selfAssignableRoleIds : [];
   6528   const roleItems = roleIds
   6529     .map((key) => customRoles.find((r) => String(r?.key || "") === String(key)))
   6530     .filter(Boolean)
   6531     .map((r) => `<span class="tag">${escapeHtml(String(r.label || r.key || ""))}</span>`)
   6532     .join(" ");
   6533 
   6534   onboardingPanelBodyEl.innerHTML = `
   6535     <div class="onbTabs">
   6536       <button type="button" class="${onboardingViewerTab === "about" ? "primary" : "ghost"} smallBtn" data-onbtab="about">About</button>
   6537       <button type="button" class="${onboardingViewerTab === "rules" ? "primary" : "ghost"} smallBtn" data-onbtab="rules">Rules</button>
   6538       <button type="button" class="${onboardingViewerTab === "roles" ? "primary" : "ghost"} smallBtn" data-onbtab="roles">Roles</button>
   6539     </div>
   6540     ${
   6541       onboardingViewerTab === "about"
   6542         ? about
   6543           ? `<div class="onboardingAbout">${about}</div>`
   6544           : `<div class="small muted">No About content published yet.</div>`
   6545         : onboardingViewerTab === "rules"
   6546           ? rules.length
   6547             ? `<div class="onbRuleList">${rules
   6548                 .map(
   6549                   (r) => `<article class="onbRuleViewerCard">
   6550                       <div class="row" style="justify-content:space-between;align-items:center;">
   6551                         <b>${escapeHtml(r.name || "Rule")}</b>
   6552                         ${onboardingSeverityBadge(r.severity)}
   6553                       </div>
   6554                       ${r.shortDescription ? `<div class="small muted">${escapeHtml(r.shortDescription)}</div>` : ""}
   6555                       ${r.description ? `<div class="small">${r.description}</div>` : ""}
   6556                     </article>`
   6557                 )
   6558                 .join("")}</div>`
   6559             : `<div class="small muted">No rules configured.</div>`
   6560           : cfg?.roleSelect?.enabled
   6561             ? roleItems
   6562               ? `<div class="row" style="flex-wrap:wrap;gap:8px;">${roleItems}</div>`
   6563               : `<div class="small muted">No self-assignable roles configured.</div>`
   6564             : `<div class="small muted">Role select is disabled.</div>`
   6565     }
   6566     <div class="small ${needs ? "badText" : "goodText"}" style="margin-top:10px;">
   6567       ${
   6568         onboardingRequiresAcceptance()
   6569           ? needs
   6570             ? "Rules acceptance required before posting/chat."
   6571             : `Rules accepted${onboardingState.acceptedAt ? ` at ${escapeHtml(formatLocalTime(onboardingState.acceptedAt))}` : "."}`
   6572           : "Rules acceptance is optional on this server."
   6573       }
   6574     </div>`;
   6575 
   6576   if (onboardingPanelAcceptBtn instanceof HTMLButtonElement) {
   6577     onboardingPanelAcceptBtn.classList.toggle("hidden", !onboardingRequiresAcceptance());
   6578     onboardingPanelAcceptBtn.disabled = !loggedInUser || !needs;
   6579     onboardingPanelAcceptBtn.textContent = needs ? "Accept and continue" : "Accepted";
   6580   }
   6581 }
   6582 
   6583 function renderOnboardingCard() {
   6584   if (!(onboardingCard instanceof HTMLElement) || !(onboardingBody instanceof HTMLElement)) return;
   6585   // Onboarding now lives as a first-class workspace panel; keep the old account card hidden.
   6586   onboardingCard.classList.add("hidden");
   6587   onboardingBody.innerHTML = "";
   6588   if (onboardingAcceptBtn instanceof HTMLButtonElement) {
   6589     onboardingAcceptBtn.classList.add("hidden");
   6590     onboardingAcceptBtn.disabled = true;
   6591   }
   6592   renderOnboardingPanel();
   6593   return;
   6594 
   6595   const cfg = normalizeInstanceBranding(instanceBranding).onboarding || {};
   6596   if (!cfg.enabled) {
   6597     onboardingCard.classList.add("hidden");
   6598     onboardingBody.innerHTML = "";
   6599     return;
   6600   }
   6601   onboardingCard.classList.remove("hidden");
   6602   const needs = onboardingNeedsAcceptanceNow();
   6603   const rules = onboardingRuleListFromConfig(cfg).slice(0, 6);
   6604   const about = typeof cfg?.about?.content === "string" ? cfg.about.content.trim() : "";
   6605   const aboutBlock = about ? `<div class="onboardingAbout">${about}</div>` : `<div class="small muted">No About text set yet.</div>`;
   6606   const rulesBlock = rules.length
   6607     ? `<ol class="onboardingRules">${rules
   6608         .map(
   6609           (r) =>
   6610             `<li><b>${escapeHtml(r.name || "Rule")}</b>${r.shortDescription ? `<div class="small muted">${escapeHtml(r.shortDescription)}</div>` : ""}</li>`
   6611         )
   6612         .join("")}</ol>`
   6613     : `<div class="small muted">No rules published yet.</div>`;
   6614   onboardingBody.innerHTML = `
   6615     ${aboutBlock}
   6616     <div class="small" style="margin-top:10px;"><b>Rules</b></div>
   6617     ${rulesBlock}
   6618     ${
   6619       onboardingRequiresAcceptance()
   6620         ? `<div class="small ${needs ? "badText" : "goodText"}" style="margin-top:10px;">
   6621              ${needs ? "Rules acceptance required before posting/chat." : `Rules accepted${onboardingState.acceptedAt ? ` at ${escapeHtml(formatLocalTime(onboardingState.acceptedAt))}` : "."}`}
   6622            </div>`
   6623         : `<div class="small muted" style="margin-top:10px;">Rules acceptance is optional on this server.</div>`
   6624     }
   6625   `;
   6626   if (onboardingAcceptBtn instanceof HTMLButtonElement) {
   6627     onboardingAcceptBtn.classList.toggle("hidden", !onboardingRequiresAcceptance());
   6628     onboardingAcceptBtn.disabled = !loggedInUser || !needs;
   6629     onboardingAcceptBtn.textContent = needs ? "Accept and continue" : "Accepted";
   6630   }
   6631   renderOnboardingPanel();
   6632 }
   6633 
   6634 function setAuthUi() {
   6635   if (loggedInUser) {
   6636     userLabel.innerHTML = renderUserPill(loggedInUser);
   6637     logoutBtn.classList.remove("hidden");
   6638     const roleText = loggedInRole && loggedInRole !== "member" ? ` (${loggedInRole})` : "";
   6639     authHint.textContent = onboardingNeedsAcceptanceNow()
   6640       ? `Signed in${roleText}. Accept server rules to unlock posting/chat.`
   6641       : `Signed in${roleText}. You can post, chat, and boost others.`;
   6642   } else {
   6643     userLabel.textContent = "Signed out";
   6644     logoutBtn.classList.add("hidden");
   6645     authHint.textContent = registrationEnabled
   6646       ? "Sign in or create an account with the registration code."
   6647       : canRegisterFirstUser
   6648         ? "No users exist yet. Create the first user from this computer."
   6649         : "Sign in to post, chat, and boost.";
   6650   }
   6651   applyInstanceAppearance();
   6652 
   6653   const canMakePermanent =
   6654     Boolean(loggedInUser) &&
   6655     (loggedInRole === "owner" || loggedInRole === "moderator" || Boolean(normalizeInstanceBranding(instanceBranding).allowMemberPermanentPosts));
   6656   if (ttlMinutesEl) {
   6657     ttlMinutesEl.min = canMakePermanent ? "0" : "1";
   6658     if (!canMakePermanent && Number(ttlMinutesEl.value || 0) <= 0) ttlMinutesEl.value = "60";
   6659   }
   6660 
   6661   codeRow.classList.toggle("hidden", !registrationEnabled);
   6662   registerBtn.classList.toggle("hidden", !(registrationEnabled || canRegisterFirstUser));
   6663   renderOnboardingCard();
   6664   renderModPanel();
   6665 }
   6666 
   6667 function roleLabel(role) {
   6668   const r = String(role || "member");
   6669   return r === "owner" || r === "moderator" ? r : "member";
   6670 }
   6671 
   6672 function peopleOnlineCardStyle(member) {
   6673   if (!member?.online) return "";
   6674   const rgb = hexToRgb(member.color || "");
   6675   if (!rgb) {
   6676     return `style="border-color:rgba(255,62,165,0.35);box-shadow:0 10px 24px rgba(255,62,165,0.12);"`;
   6677   }
   6678   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);"`;
   6679 }
   6680 
   6681 function renderPeoplePanel() {
   6682   if (!peopleDrawerEl || !peopleListEl) return;
   6683   ensurePeopleFallback();
   6684   const membersTabOn = peopleTab === "members";
   6685   peopleMembersTabBtn?.classList.toggle("primary", membersTabOn);
   6686   peopleMembersTabBtn?.classList.toggle("ghost", !membersTabOn);
   6687   peopleDmsTabBtn?.classList.toggle("primary", !membersTabOn);
   6688   peopleDmsTabBtn?.classList.toggle("ghost", membersTabOn);
   6689   peopleMembersViewEl?.classList.toggle("hidden", !membersTabOn);
   6690   peopleDmsViewEl?.classList.toggle("hidden", membersTabOn);
   6691   if (!membersTabOn) {
   6692     if (!peopleDmsViewEl) return;
   6693     if (!loggedInUser) {
   6694       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>`;
   6695       return;
   6696     }
   6697 
   6698     const blockedSet = prefSet("blockedUsers");
   6699     const eligibleMembers = peopleMembers
   6700       .filter((m) => m?.username && String(m.username).toLowerCase() !== String(loggedInUser).toLowerCase())
   6701       .filter((m) => !blockedSet.has(String(m.username || "").toLowerCase()))
   6702       .map((m) => String(m.username))
   6703       .sort((a, b) => a.localeCompare(b))
   6704       .slice(0, 250);
   6705 
   6706     const picker =
   6707       eligibleMembers.length > 0
   6708         ? `<div class="dmNewRow">
   6709             <select class="dmToSelect" data-dmto="1">
   6710               <option value="">New DM...</option>
   6711               ${eligibleMembers.map((u) => `<option value="${escapeHtml(u)}">@${escapeHtml(u)}</option>`).join("")}
   6712             </select>
   6713             <button type="button" class="primary" data-dmrequestfromselect="1">Request</button>
   6714           </div>`
   6715         : `<div class="muted">No other members yet.</div>`;
   6716 
   6717     const threads = Array.isArray(dmThreads) ? [...dmThreads].sort((a, b) => dmActivityAt(b) - dmActivityAt(a)) : [];
   6718     const listHtml = threads.length
   6719       ? threads
   6720           .map((t) => {
   6721             const other = String(t.other || "");
   6722             const isBlocked = blockedSet.has(other.toLowerCase());
   6723             const status = String(t.status || "unknown");
   6724             const when = dmActivityAt(t);
   6725             const whenTxt = when ? new Date(when).toLocaleString() : "";
   6726             const statusBadge =
   6727               status === "incoming"
   6728                 ? `<span class="tag dmTag dmTagIncoming">request</span>`
   6729                 : status === "outgoing"
   6730                   ? `<span class="tag dmTag dmTagPending">pending</span>`
   6731                   : status === "active"
   6732                     ? `<span class="tag dmTag dmTagActive">active</span>`
   6733                     : status === "declined"
   6734                       ? `<span class="tag dmTag dmTagDeclined">declined</span>`
   6735                       : `<span class="tag dmTag">unknown</span>`;
   6736 
   6737             const blockedBadge = isBlocked ? `<span class="tag dmTag dmTagDeclined">blocked</span>` : "";
   6738 
   6739             let actions =
   6740               status === "incoming"
   6741                 ? `<div class="row" style="gap:8px;justify-content:flex-end">
   6742                     <button type="button" class="primary smallBtn" data-dmaccept="${escapeHtml(t.id)}">Accept</button>
   6743                     <button type="button" class="ghost smallBtn" data-dmdecline="${escapeHtml(t.id)}">Decline</button>
   6744                   </div>`
   6745                 : status === "active"
   6746                   ? `<button type="button" class="primary smallBtn" data-dmopen="${escapeHtml(t.id)}">Open</button>`
   6747                   : status === "declined"
   6748                     ? `<button type="button" class="ghost smallBtn" data-dmrequest="${escapeHtml(other)}">Request again</button>`
   6749                     : `<span class="muted small">Waiting...</span>`;
   6750 
   6751             if (isBlocked) {
   6752               actions =
   6753                 status === "active"
   6754                   ? `<button type="button" class="primary smallBtn" data-dmopen="${escapeHtml(t.id)}" disabled>Open</button>`
   6755                   : `<span class="muted small">Blocked</span>`;
   6756             }
   6757             if (canModerate && other) {
   6758               actions += ` <button type="button" class="ghost smallBtn" data-moddm="${escapeHtml(other)}">Mod DM</button>`;
   6759             }
   6760 
   6761             return `<div class="dmThreadCard">
   6762               <div class="dmThreadTop">
   6763                 <div class="dmThreadLeft">
   6764                   ${renderUserPill(other)}
   6765                   ${statusBadge}
   6766                   ${blockedBadge}
   6767                 </div>
   6768                 <div class="dmThreadRight">${actions}</div>
   6769               </div>
   6770               <div class="small muted">${whenTxt ? `Last activity: ${escapeHtml(whenTxt)}` : "No messages yet."} <span class="muted">β€’</span> DMs purge daily.</div>
   6771             </div>`;
   6772           })
   6773           .join("")
   6774       : `<div class="muted">No DMs yet. Start one from the Members tab or a profile.</div>`;
   6775 
   6776     peopleDmsViewEl.innerHTML = `
   6777       <div class="dmHeader">
   6778         <div class="small muted">Private 1:1 chats (encrypted at rest). Incoming requests must be accepted.</div>
   6779         ${picker}
   6780       </div>
   6781       <div class="dmThreadList">${listHtml}</div>
   6782     `;
   6783     return;
   6784   }
   6785 
   6786   const q = (peopleSearchEl?.value || "").trim().toLowerCase();
   6787   const list = peopleMembers
   6788     .filter((m) => (q ? String(m.username || "").toLowerCase().includes(q) : true))
   6789     .sort((a, b) => Number(Boolean(b.online)) - Number(Boolean(a.online)) || String(a.username).localeCompare(String(b.username)));
   6790 
   6791   if (!list.length) {
   6792     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>`;
   6793     return;
   6794   }
   6795   peopleListEl.innerHTML = list
   6796     .map((m) => {
   6797       const username = String(m.username || "");
   6798       const status = String(m.status || (m.online ? "online" : "offline"));
   6799       const role = roleLabel(m.role);
   6800       const statusText = `${status}${m.online ? "" : ""}`;
   6801       const cardStyle = peopleOnlineCardStyle(m);
   6802       const canDm = Boolean(loggedInUser && username && String(username).toLowerCase() !== String(loggedInUser).toLowerCase());
   6803       const canModDm = Boolean(canModerate && username && String(username).toLowerCase() !== String(loggedInUser || "").toLowerCase());
   6804       return `<div class="peopleCard" data-viewprofile="${escapeHtml(username)}" ${cardStyle}>
   6805         <div class="peopleCardTop">
   6806           <div>${renderUserPill(username)} <span class="modStatus">${escapeHtml(role)}</span></div>
   6807           <div class="peopleStatus">${escapeHtml(statusText)}</div>
   6808         </div>
   6809         <div class="peopleCardActions">
   6810           <button type="button" class="ghost smallBtn" data-viewprofile="${escapeHtml(username)}">Profile</button>
   6811           <button type="button" class="ghost smallBtn" data-dmrequest="${escapeHtml(username)}" ${canDm ? "" : "disabled"}>DM</button>
   6812           ${canModDm ? `<button type="button" class="ghost smallBtn" data-moddm="${escapeHtml(username)}">Mod DM</button>` : ""}
   6813         </div>
   6814       </div>`;
   6815     })
   6816     .join("");
   6817 }
   6818 
   6819 function statusBadge(status) {
   6820   const s = String(status || "");
   6821   if (!s) return `<span class="muted">-</span>`;
   6822   return `<span class="modStatus">${escapeHtml(s)}</span>`;
   6823 }
   6824 
   6825 function userStateText(user) {
   6826   const t = Date.now();
   6827   if (user.banned) return "banned";
   6828   if (Number(user.suspendedUntil || 0) > t) return `suspended until ${new Date(user.suspendedUntil).toLocaleString()}`;
   6829   if (Number(user.mutedUntil || 0) > t) return `muted until ${new Date(user.mutedUntil).toLocaleString()}`;
   6830   return "active";
   6831 }
   6832 
   6833 function promptReason(actionLabel) {
   6834   const value = prompt(`Reason for ${actionLabel}:`);
   6835   if (!value) return "";
   6836   return value.trim();
   6837 }
   6838 
   6839 function requestModData() {
   6840   if (!canModerate) return;
   6841   ws.send(JSON.stringify({ type: "modListUsers", limit: 200 }));
   6842   ws.send(JSON.stringify({ type: "modListLog", limit: 200 }));
   6843   ws.send(JSON.stringify({ type: "devLogList", limit: 300 }));
   6844   const status = modReportStatusEl ? modReportStatusEl.value : "open";
   6845   ws.send(JSON.stringify({ type: "modListReports", status, limit: 200 }));
   6846 }
   6847 
   6848 function renderModPanel() {
   6849   if (!modPanelEl || !modBodyEl) return;
   6850   modPanelEl.classList.toggle("hidden", !canModerate);
   6851   if (appRoot) appRoot.classList.toggle("hasMod", canModerate);
   6852   if (!canModerate) {
   6853     modBodyEl.innerHTML = "";
   6854     if (isMobileScreenMode() && appRoot?.getAttribute("data-mobile-screen") === "moderation") setMobileScreen("hives", { pushHistory: false });
   6855     return;
   6856   }
   6857   if (modReportStatusEl) modReportStatusEl.classList.toggle("hidden", modTab !== "reports");
   6858 
   6859   const tabs = Array.from(modPanelEl.querySelectorAll("[data-modtab]"));
   6860   for (const btn of tabs) {
   6861     const on = btn.getAttribute("data-modtab") === modTab;
   6862     btn.classList.toggle("primary", on);
   6863     btn.classList.toggle("ghost", !on);
   6864     // Owner-only plugin tabs should not show for non-owners.
   6865     const ownerOnly = btn.dataset.ownerOnly === "1";
   6866     btn.classList.toggle("hidden", Boolean(ownerOnly && loggedInRole !== "owner"));
   6867   }
   6868 
   6869   // Plugin-provided moderation tabs (render into modBody).
   6870   if (modPluginTabs.has(modTab)) {
   6871     const def = modPluginTabs.get(modTab);
   6872     if (def?.ownerOnly && loggedInRole !== "owner") {
   6873       modTab = "server";
   6874       renderModPanel();
   6875       return;
   6876     }
   6877     modBodyEl.innerHTML = `
   6878       <div class="modCard">
   6879         <div class="modRowTop"><div><b>${escapeHtml(def?.title || "Plugin")}</b></div></div>
   6880         <div id="modPluginMount" class="modActions"></div>
   6881       </div>
   6882     `;
   6883     const mount = modBodyEl.querySelector("#modPluginMount");
   6884     if (mount) {
   6885       const api = {
   6886         toast,
   6887         send: (eventName, payload) => {
   6888           const ev = String(eventName || "").trim();
   6889           if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false;
   6890           const wsRef = window.__bzlWs;
   6891           if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false;
   6892           const msg = payload && typeof payload === "object" ? payload : {};
   6893           wsRef.send(JSON.stringify({ ...msg, type: `plugin:${def.pluginId}:${ev}` }));
   6894           return true;
   6895         },
   6896         getUser: () => loggedInUser,
   6897         getRole: () => loggedInRole,
   6898       };
   6899       try {
   6900         def.render(mount, api);
   6901       } catch (e) {
   6902         mount.textContent = "Failed to render plugin tab.";
   6903         console.warn(`Plugin tab render failed (${modTab}):`, e?.message || e);
   6904       }
   6905     }
   6906     return;
   6907   }
   6908 
   6909   if (modTab === "server") {
   6910     const isOwner = loggedInRole === "owner";
   6911     const canEditAppearance = loggedInRole === "owner" || loggedInRole === "moderator";
   6912     const b = normalizeInstanceBranding(instanceBranding);
   6913     const a = b.appearance || {};
   6914     const loading = Boolean(serverInfoStatus.loading);
   6915     const err = String(serverInfoStatus.error || "");
   6916     const info = serverInfo && typeof serverInfo === "object" ? serverInfo : null;
   6917     const health = serverHealth && typeof serverHealth === "object" ? serverHealth : null;
   6918     const stats = health?.stats && typeof health.stats === "object" ? health.stats : null;
   6919     const rl = info?.config?.rateLimits && typeof info.config.rateLimits === "object" ? info.config.rateLimits : null;
   6920     const updatedAt = serverInfoStatus.at ? formatLocalTime(serverInfoStatus.at) : "";
   6921 
   6922     const statusLine = loading
   6923       ? `<span class="muted">Loading...</span>`
   6924       : err
   6925         ? `<span class="bad">${escapeHtml(err)}</span>`
   6926         : updatedAt
   6927           ? `<span class="muted">Updated: ${escapeHtml(updatedAt)}</span>`
   6928           : `<span class="muted">Not loaded yet.</span>`;
   6929 
   6930     const fontBodyOptions = [
   6931       { value: "system", label: "System (sans)" },
   6932       { value: "serif", label: "Serif" },
   6933       { value: "mono", label: "Monospace" },
   6934     ]
   6935       .map((o) => `<option value="${o.value}" ${a.fontBody === o.value ? "selected" : ""}>${escapeHtml(o.label)}</option>`)
   6936       .join("");
   6937     const fontMonoOptions = [
   6938       { value: "mono", label: "Monospace" },
   6939       { value: "system", label: "System" },
   6940     ]
   6941       .map((o) => `<option value="${o.value}" ${a.fontMono === o.value ? "selected" : ""}>${escapeHtml(o.label)}</option>`)
   6942       .join("");
   6943 
   6944     const instanceOwnerControls = `<label>
   6945            <span>Title</span>
   6946            <input data-instance-title maxlength="32" value="${escapeHtml(b.title)}" />
   6947          </label>
   6948          <label>
   6949            <span>Subtitle</span>
   6950            <input data-instance-subtitle maxlength="80" value="${escapeHtml(b.subtitle)}" />
   6951          </label>
   6952          <label class="row" style="gap:10px; align-items:center">
   6953            <input data-instance-allowpermanent type="checkbox" ${b.allowMemberPermanentPosts ? "checked" : ""} />
   6954            <span>Allow members to create permanent hives</span>
   6955          </label>`;
   6956 
   6957     const themePresetRow = `
   6958          <div class="row" style="gap:10px">
   6959            <label style="flex:1">
   6960              <span>Theme preset</span>
   6961              <select data-theme-preset>
   6962                <option value="">(choose...)</option>
   6963                ${THEME_PRESETS.map((p) => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)}</option>`).join("")}
   6964              </select>
   6965            </label>
   6966            <div class="row" style="align-items:flex-end">
   6967              <button type="button" class="ghost" data-theme-reset="1">Reset</button>
   6968            </div>
   6969          </div>
   6970     `;
   6971 
   6972     const appearanceControls = `
   6973          ${themePresetRow}
   6974          <div class="row" style="gap:10px">
   6975            <label style="flex:1">
   6976              <span>Background</span>
   6977              <input data-instance-bg type="color" value="${escapeHtml(a.bg || "#060611")}" />
   6978            </label>
   6979            <label style="flex:1">
   6980              <span>Panel</span>
   6981              <input data-instance-panel type="color" value="${escapeHtml(a.panel || "#0c0c18")}" />
   6982            </label>
   6983          </div>
   6984          <div class="row" style="gap:10px">
   6985            <label style="flex:1">
   6986              <span>Text</span>
   6987              <input data-instance-text type="color" value="${escapeHtml(a.text || "#f6f0ff")}" />
   6988            </label>
   6989            <label style="flex:1">
   6990              <span>Success / Danger</span>
   6991              <div class="row" style="gap:10px">
   6992                <input data-instance-good type="color" value="${escapeHtml(a.good || "#3ddc97")}" />
   6993                <input data-instance-bad type="color" value="${escapeHtml(a.bad || "#ff4d8a")}" />
   6994              </div>
   6995            </label>
   6996          </div>
   6997          <div class="row" style="gap:10px">
   6998            <label style="flex:1">
   6999              <span>Accent</span>
   7000              <input data-instance-accent type="color" value="${escapeHtml(a.accent || "#ff3ea5")}" />
   7001            </label>
   7002            <label style="flex:1">
   7003              <span>Accent 2</span>
   7004              <input data-instance-accent2 type="color" value="${escapeHtml(a.accent2 || "#b84bff")}" />
   7005            </label>
   7006          </div>
   7007          <div class="row" style="gap:10px">
   7008            <label style="flex:1">
   7009              <span>Muted %</span>
   7010              <input data-instance-mutedpct type="number" min="0" max="100" value="${escapeHtml(String(a.mutedPct ?? 65))}" />
   7011            </label>
   7012            <label style="flex:1">
   7013              <span>Divider %</span>
   7014              <input data-instance-linepct type="number" min="0" max="100" value="${escapeHtml(String(a.linePct ?? 10))}" />
   7015            </label>
   7016            <label style="flex:1">
   7017              <span>Panel tint %</span>
   7018              <input data-instance-panel2pct type="number" min="0" max="100" value="${escapeHtml(String(a.panel2Pct ?? 2))}" />
   7019            </label>
   7020          </div>
   7021          <div class="row" style="gap:10px">
   7022            <label style="flex:1">
   7023              <span>Body font</span>
   7024              <select data-instance-fontbody>${fontBodyOptions}</select>
   7025            </label>
   7026            <label style="flex:1">
   7027              <span>Mono font</span>
   7028              <select data-instance-fontmono>${fontMonoOptions}</select>
   7029            </label>
   7030          </div>
   7031     `;
   7032 
   7033     const instanceControls = isOwner
   7034       ? `${instanceOwnerControls}
   7035          ${appearanceControls}
   7036          <div class="row" style="gap:8px">
   7037            <button type="button" class="primary" data-instance-save="1">Save</button>
   7038            <button type="button" class="ghost" data-server-refresh="1">Refresh server</button>
   7039          </div>`
   7040       : canEditAppearance
   7041         ? `<div class="small muted">Owner-only: title/subtitle and permanent-hive setting.</div>
   7042            <div class="small">Title: <b>${escapeHtml(b.title)}</b></div>
   7043            <div class="small">Subtitle: <b>${escapeHtml(b.subtitle)}</b></div>
   7044            <div class="small">Members can create permanent hives: <b>${b.allowMemberPermanentPosts ? "yes" : "no"}</b></div>
   7045            <div class="panelDivider"></div>
   7046            ${appearanceControls}
   7047            <div class="row" style="gap:8px">
   7048              <button type="button" class="primary" data-instance-saveappearance="1">Save theme</button>
   7049              <button type="button" class="ghost" data-server-refresh="1">Refresh server</button>
   7050            </div>`
   7051         : `<div class="small muted">Only moderators can edit appearance. Only the owner can edit core instance settings.</div>
   7052            <div class="small">Title: <b>${escapeHtml(b.title)}</b></div>
   7053            <div class="small">Subtitle: <b>${escapeHtml(b.subtitle)}</b></div>
   7054            <div class="small">Members can create permanent hives: <b>${b.allowMemberPermanentPosts ? "yes" : "no"}</b></div>
   7055            <div class="row" style="gap:8px; margin-top:8px">
   7056              <button type="button" class="ghost" data-server-refresh="1">Refresh server</button>
   7057            </div>`;
   7058 
   7059     const serverLines = [
   7060       info?.port ? `Port: ${Number(info.port)}` : "",
   7061       typeof info?.registrationEnabled === "boolean" ? `Registration enabled: ${info.registrationEnabled ? "yes" : "no"}` : "",
   7062       typeof health?.uptimeSec === "number" ? `Uptime: ${Math.floor(health.uptimeSec)}s` : "",
   7063       typeof stats?.sockets === "number" ? `Sockets: ${Math.floor(stats.sockets)}` : "",
   7064       typeof stats?.activePosts === "number" ? `Active hives: ${Math.floor(stats.activePosts)}` : "",
   7065       typeof stats?.users === "number" ? `Users: ${Math.floor(stats.users)}` : "",
   7066       typeof stats?.activeRateLimitBuckets === "number" ? `Active rate limit buckets: ${Math.floor(stats.activeRateLimitBuckets)}` : "",
   7067     ].filter(Boolean);
   7068 
   7069     const rlLines = rl
   7070       ? [
   7071           `Mod actions: ${rl.mod?.max ?? "?"} / ${rl.mod?.windowMs ?? "?"}ms`,
   7072           `Login: ${rl.login?.max ?? "?"} / ${rl.login?.windowMs ?? "?"}ms`,
   7073           `Register: ${rl.register?.max ?? "?"} / ${rl.register?.windowMs ?? "?"}ms`,
   7074           `Resume: ${rl.resume?.max ?? "?"} / ${rl.resume?.windowMs ?? "?"}ms`,
   7075           `Reports: ${rl.report?.max ?? "?"} / ${rl.report?.windowMs ?? "?"}ms`,
   7076         ]
   7077       : [];
   7078 
   7079     modBodyEl.innerHTML = `
   7080       <div class="modCard">
   7081         <div class="modRowTop">
   7082           <div><b>Server</b></div>
   7083           <div class="small">${statusLine}</div>
   7084         </div>
   7085         <div class="small muted">Server status, appearance, and plugins.</div>
   7086       </div>
   7087       <div class="modCard">
   7088         <div class="modRowTop"><div><b>Instance settings</b></div></div>
   7089         <div class="modActions">${instanceControls}</div>
   7090       </div>
   7091       <div class="modCard">
   7092         <div class="modRowTop"><div><b>Plugins</b></div></div>
   7093         <div class="modActions">${renderPluginsAdminHtml()}</div>
   7094       </div>
   7095       <div class="modCard">
   7096         <div class="modRowTop"><div><b>Runtime</b></div></div>
   7097         <div class="small">${serverLines.length ? serverLines.map((x) => `<div>${escapeHtml(x)}</div>`).join("") : `<div class="muted">No data yet.</div>`}</div>
   7098         ${
   7099           rlLines.length
   7100             ? `<div class="small muted" style="margin-top:10px">Rate limits</div>
   7101                <div class="small">${rlLines.map((x) => `<div>${escapeHtml(x)}</div>`).join("")}</div>`
   7102             : ""
   7103         }
   7104       </div>
   7105     `;
   7106     return;
   7107   }
   7108 
   7109   if (modTab === "onboarding") {
   7110     const isOwner = loggedInRole === "owner";
   7111     const canEdit = loggedInRole === "owner" || loggedInRole === "moderator";
   7112     syncOnboardingAdminDraft(false);
   7113     normalizeOnboardingDraftRules();
   7114     const roleOptions = customRoles
   7115       .map(
   7116         (r) =>
   7117           `<label class="checkRow">
   7118             <span>${escapeHtml(String(r.label || r.key || ""))}</span>
   7119             <input type="checkbox" data-onboarding-rolecheck="${escapeHtml(String(r.key || ""))}" ${
   7120               onboardingAdminDraft.selfAssignableRoleIds.includes(String(r.key || "")) ? "checked" : ""
   7121             } />
   7122           </label>`
   7123       )
   7124       .join("");
   7125     const rulesCards = onboardingAdminDraft.rules.length
   7126       ? onboardingAdminDraft.rules
   7127           .map((r, idx) => {
   7128             const expanded = onboardingAdminExpandedRuleIds.has(r.id);
   7129             return `<article class="onbRuleEditorCard" data-onb-ruleid="${escapeHtml(r.id)}">
   7130                 <div class="row" style="justify-content:space-between;align-items:center;">
   7131                   <button type="button" class="ghost smallBtn" data-onb-ruletoggle="${escapeHtml(r.id)}">${expanded ? "β–Ύ" : "β–Έ"} Rule ${idx + 1}</button>
   7132                   <div class="row" style="gap:6px;">
   7133                     <button type="button" class="ghost smallBtn" data-onb-ruleup="${escapeHtml(r.id)}" ${idx <= 0 ? "disabled" : ""}>↑</button>
   7134                     <button type="button" class="ghost smallBtn" data-onb-ruledown="${escapeHtml(r.id)}" ${
   7135                       idx >= onboardingAdminDraft.rules.length - 1 ? "disabled" : ""
   7136                     }>↓</button>
   7137                     <button type="button" class="ghost smallBtn" data-onb-ruledelete="${escapeHtml(r.id)}">Delete</button>
   7138                   </div>
   7139                 </div>
   7140                 ${
   7141                   expanded
   7142                     ? `<div class="onbRuleEditorBody">
   7143                          <label><span>Name</span><input data-onb-rulefield="name" data-onb-ruleid="${escapeHtml(r.id)}" value="${escapeHtml(
   7144                            r.name
   7145                          )}" maxlength="60" /></label>
   7146                          <label><span>Short description</span><input data-onb-rulefield="shortDescription" data-onb-ruleid="${escapeHtml(
   7147                            r.id
   7148                          )}" value="${escapeHtml(r.shortDescription)}" maxlength="180" /></label>
   7149                          <label><span>Full description</span><textarea data-onb-rulefield="description" data-onb-ruleid="${escapeHtml(
   7150                            r.id
   7151                          )}" rows="4">${escapeHtml(r.description)}</textarea></label>
   7152                          <label><span>Severity</span>
   7153                            <select data-onb-rulefield="severity" data-onb-ruleid="${escapeHtml(r.id)}">
   7154                              <option value="info" ${r.severity === "info" ? "selected" : ""}>Info</option>
   7155                              <option value="warn" ${r.severity === "warn" ? "selected" : ""}>Warn</option>
   7156                              <option value="critical" ${r.severity === "critical" ? "selected" : ""}>Critical</option>
   7157                            </select>
   7158                          </label>
   7159                        </div>`
   7160                     : ""
   7161                 }
   7162               </article>`;
   7163           })
   7164           .join("")
   7165       : `<div class="small muted">No rules yet. Add your first rule.</div>`;
   7166 
   7167     modBodyEl.innerHTML = `
   7168       <div class="modCard">
   7169         <div class="modRowTop"><div><b>Onboarding</b></div></div>
   7170         <div class="small muted">Configure About, Rules, and Role Select.</div>
   7171         <div class="onbTabs" style="margin-top:8px;">
   7172           <button type="button" class="${onboardingAdminTab === "about" ? "primary" : "ghost"} smallBtn" data-onb-admin-tab="about">About</button>
   7173           <button type="button" class="${onboardingAdminTab === "rules" ? "primary" : "ghost"} smallBtn" data-onb-admin-tab="rules">Rules</button>
   7174           <button type="button" class="${onboardingAdminTab === "roles" ? "primary" : "ghost"} smallBtn" data-onb-admin-tab="roles">Roles</button>
   7175         </div>
   7176       </div>
   7177       <div class="modCard">
   7178         ${
   7179           onboardingAdminTab === "about"
   7180             ? `<label class="checkRow">
   7181                  <span>Enable onboarding panel</span>
   7182                  <input type="checkbox" data-onboarding-enabled ${onboardingAdminDraft.enabled ? "checked" : ""} ${canEdit ? "" : "disabled"} />
   7183                </label>
   7184                <label>
   7185                  <span>About (rich text allowed)</span>
   7186                  <textarea data-onboarding-about rows="10" ${canEdit ? "" : "disabled"}>${escapeHtml(onboardingAdminDraft.aboutContent)}</textarea>
   7187                </label>
   7188                <div class="small muted">Updated by: ${escapeHtml(String(normalizeInstanceBranding(instanceBranding).onboarding?.about?.updatedBy || "n/a"))}</div>
   7189                <div class="small muted">Updated at: ${escapeHtml(
   7190                  formatLocalTime(normalizeInstanceBranding(instanceBranding).onboarding?.about?.updatedAt || 0) || "n/a"
   7191                )}</div>`
   7192             : onboardingAdminTab === "rules"
   7193               ? `<label class="checkRow">
   7194                    <span>Require rules acceptance before posting/chat</span>
   7195                    <input type="checkbox" data-onboarding-require ${onboardingAdminDraft.requireAcceptance ? "checked" : ""} ${
   7196                   canEdit ? "" : "disabled"
   7197                 } />
   7198                  </label>
   7199                  <label class="checkRow">
   7200                    <span>Block reading hives until accepted ${isOwner ? "" : "(owner only)"}</span>
   7201                    <input type="checkbox" data-onboarding-blockread ${onboardingAdminDraft.blockReadUntilAccepted ? "checked" : ""} ${
   7202                   canEdit && isOwner ? "" : "disabled"
   7203                 } />
   7204                  </label>
   7205                  <div class="row" style="justify-content:space-between;align-items:center;margin:8px 0;">
   7206                    <div><b>Rules</b></div>
   7207                    <button type="button" class="primary smallBtn" data-onb-ruleadd="1" ${canEdit ? "" : "disabled"}>+ Add Rule</button>
   7208                  </div>
   7209                  <div class="onbRuleEditorList">${rulesCards}</div>`
   7210               : `<label class="checkRow">
   7211                    <span>Enable custom role select in onboarding</span>
   7212                    <input type="checkbox" data-onboarding-roleenabled ${onboardingAdminDraft.roleSelectEnabled ? "checked" : ""} ${
   7213                   canEdit ? "" : "disabled"
   7214                 } />
   7215                  </label>
   7216                  <div class="small muted">Choose self-assignable roles:</div>
   7217                  <div class="onbRoleGrid">${roleOptions || `<div class="small muted">No custom roles defined.</div>`}</div>`
   7218         }
   7219       </div>
   7220       <div class="modCard">
   7221         <div class="row" style="gap:8px;">
   7222           <button type="button" class="primary" data-onboarding-save="1" ${canEdit ? "" : "disabled"}>Save</button>
   7223           <button type="button" class="ghost" data-onboarding-publish="1" ${canEdit ? "" : "disabled"}>Publish</button>
   7224           <button type="button" class="ghost" data-onboarding-refresh="1">Reload</button>
   7225         </div>
   7226       </div>
   7227     `;
   7228     return;
   7229   }
   7230 
   7231   if (modTab === "users") {
   7232     const roleList = customRoles.length
   7233       ? customRoles
   7234           .map((r) => {
   7235             const swatch = r.color ? `<span class="roleSwatch" style="background:${escapeHtml(r.color)}"></span>` : "";
   7236             return `<div class="roleRow">
   7237               <div class="roleRowLeft">
   7238                 ${swatch}
   7239                 <div class="roleMeta">
   7240                   <div><b>${escapeHtml(r.label)}</b></div>
   7241                   <div class="roleKey">${escapeHtml(r.key)}</div>
   7242                 </div>
   7243               </div>
   7244               <div class="row" style="gap:8px">
   7245                 <button type="button" class="ghost smallBtn" data-rolearchive="${escapeHtml(r.key)}">Archive</button>
   7246               </div>
   7247             </div>`;
   7248           })
   7249           .join("")
   7250       : `<div class="muted">No custom roles yet.</div>`;
   7251 
   7252     const roleAdminCard = `<div class="modCard">
   7253       <div class="modRowTop">
   7254         <div><b>Custom roles</b></div>
   7255       </div>
   7256       <div class="roleCreateRow" style="margin-bottom:10px">
   7257         <label>
   7258           <span>Key</span>
   7259           <input data-rolekey maxlength="18" placeholder="vip" />
   7260         </label>
   7261         <label>
   7262           <span>Label</span>
   7263           <input data-rolelabel maxlength="24" placeholder="VIP" />
   7264         </label>
   7265         <label>
   7266           <span>Color</span>
   7267           <input data-rolecolor type="color" value="#ff3ea5" />
   7268         </label>
   7269         <button type="button" data-rolecreate="1">Create</button>
   7270       </div>
   7271       <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">owner</span>, or <span class="tag">role:yourkey</span>.</div>
   7272       <div class="gateList">${roleList}</div>
   7273     </div>`;
   7274     if (!modUsers.length) {
   7275       modBodyEl.innerHTML = `${roleAdminCard}<div class="muted">No users found.</div>`;
   7276       return;
   7277     }
   7278     modBodyEl.innerHTML =
   7279       roleAdminCard +
   7280       modUsers
   7281       .map((u) => {
   7282         const role = u.role || "member";
   7283         const status = userStateText(u);
   7284         const canPromote = loggedInRole === "owner" && u.username !== loggedInUser;
   7285         const canManageCustomRoles = canModerate && u.username !== loggedInUser;
   7286         const canResetPassword =
   7287           canModerate &&
   7288           u.username !== loggedInUser &&
   7289           role !== "owner" &&
   7290           (role !== "moderator" || loggedInRole === "owner");
   7291         const customBadges = renderCustomRoleBadges(u.username);
   7292         return `<div class="modCard">
   7293           <div class="modRowTop">
   7294             <div><b>@${escapeHtml(u.username)}</b> ${statusBadge(role)}</div>
   7295             <div class="muted">${escapeHtml(status)}</div>
   7296           </div>
   7297           <div class="small muted">custom roles: ${customBadges || `<span class="muted">(none)</span>`}</div>
   7298           <div class="modActions">
   7299             <button type="button" data-modaction="user_mute" data-targettype="user" data-targetid="${escapeHtml(u.username)}" data-minutes="30">Mute 30m</button>
   7300             <button type="button" data-modaction="user_unmute" data-targettype="user" data-targetid="${escapeHtml(u.username)}">Unmute</button>
   7301             <button type="button" data-modaction="user_suspend" data-targettype="user" data-targetid="${escapeHtml(u.username)}" data-minutes="120">Suspend 2h</button>
   7302             <button type="button" data-modaction="user_unsuspend" data-targettype="user" data-targetid="${escapeHtml(u.username)}">Unsuspend</button>
   7303             <button type="button" data-modaction="user_ban" data-targettype="user" data-targetid="${escapeHtml(u.username)}">Ban</button>
   7304             <button type="button" data-modaction="user_unban" data-targettype="user" data-targetid="${escapeHtml(u.username)}">Unban</button>
   7305             ${canResetPassword ? `<button type="button" data-modaction="user_password_reset" data-targettype="user" data-targetid="${escapeHtml(u.username)}">Reset password</button>` : ""}
   7306             ${
   7307               canPromote && role === "member"
   7308                 ? `<button type="button" data-modaction="user_role_set" data-targettype="user" data-targetid="${escapeHtml(
   7309                     u.username
   7310                   )}" data-role="moderator">Make mod</button>`
   7311                 : ""
   7312             }
   7313             ${
   7314               canPromote && role === "moderator"
   7315                 ? `<button type="button" class="danger" data-modaction="user_role_set" data-targettype="user" data-targetid="${escapeHtml(
   7316                     u.username
   7317                   )}" data-role="member">Remove mod</button>`
   7318                 : ""
   7319             }
   7320             ${
   7321               canManageCustomRoles
   7322                 ? `<button type="button" data-usermanageroles="${escapeHtml(u.username)}">Manage custom roles</button>`
   7323                 : ""
   7324             }
   7325           </div>
   7326         </div>`;
   7327       })
   7328       .join("");
   7329     return;
   7330   }
   7331 
   7332   if (modTab === "hives") {
   7333     const hives = Array.from(posts.values()).sort((a, b) => rankTime(b) - rankTime(a) || b.createdAt - a.createdAt);
   7334     const collectionControls = canModerate
   7335       ? `<div class="modCard">
   7336            <div class="modRowTop">
   7337              <div><b>Collections</b></div>
   7338              <button type="button" data-createcollection="1">Create collection</button>
   7339            </div>
   7340            <div class="modActions">
   7341              ${activeCollections()
   7342                .map((c) => {
   7343                  const canArchive = c.id !== "general";
   7344                   const gateLabel =
   7345                     c.visibility === "gated"
   7346                       ? `gated: ${(c.allowedRoles || []).map((t) => roleTokenLabel(t)).join(", ") || "(none)"}`
   7347                       : "public";
   7348                   return `<span class="tag">/${escapeHtml(c.name)}</span>${
   7349                     c.id !== "general"
   7350                       ? `<button type="button" data-collectiongate="${escapeHtml(c.id)}">Gate...</button>
   7351                          <button type="button" data-collectionpublic="${escapeHtml(c.id)}">Make public</button>`
   7352                       : ""
   7353                   }
   7354                   <span class="small muted">${escapeHtml(gateLabel)}</span>${
   7355                    canArchive
   7356                      ? `<button type="button" data-archivecollection="${escapeHtml(c.id)}">Archive ${escapeHtml(c.name)}</button>`
   7357                      : ""
   7358                  }`;
   7359                })
   7360                .join(" ")}
   7361            </div>
   7362          </div>`
   7363       : "";
   7364     if (!hives.length) {
   7365       modBodyEl.innerHTML = `${collectionControls}<div class="muted">No active hives.</div>`;
   7366       return;
   7367     }
   7368     modBodyEl.innerHTML =
   7369       collectionControls +
   7370       hives
   7371       .map((p) => {
   7372         const title = postTitle(p);
   7373         const author = p.author ? `@${p.author}` : "unknown";
   7374         const collection = activeCollections().find((c) => c.id === p.collectionId)?.name || "General";
   7375         const openReports = modReports.filter(
   7376           (r) => r && r.status === "open" && (r.postId === p.id || (r.targetType === "post" && r.targetId === p.id))
   7377         ).length;
   7378         return `<div class="modCard">
   7379           <div class="modRowTop">
   7380             <div><b>${escapeHtml(title)}</b></div>
   7381             <div class="muted">${formatCountdown(p.expiresAt)}</div>
   7382           </div>
   7383           <div class="small">author: ${escapeHtml(author)} | collection: /${escapeHtml(collection)} | id: ${escapeHtml(p.id)}</div>
   7384           <div class="small muted">open reports: ${openReports}</div>
   7385           <div class="modActions">
   7386             <button type="button" data-chat="${p.id}">Open chat</button>
   7387             <button type="button" data-modaction="post_ttl_set" data-targettype="post" data-targetid="${escapeHtml(
   7388               p.id
   7389             )}" data-ttl="0">Permanent</button>
   7390             <button type="button" data-modaction="post_ttl_set" data-targettype="post" data-targetid="${escapeHtml(
   7391               p.id
   7392             )}" data-ttl="60">TTL 1h</button>
   7393             <button type="button" data-modaction="post_ttl_set" data-targettype="post" data-targetid="${escapeHtml(
   7394               p.id
   7395             )}" data-ttl="1440">TTL 1d</button>
   7396             <button type="button" data-modaction="post_ttl_set" data-targettype="post" data-targetid="${escapeHtml(
   7397               p.id
   7398             )}" data-ttlprompt="1">Set TTL...</button>
   7399             ${
   7400               p.readOnly
   7401                 ? `<button type="button" data-modaction="post_readonly_set" data-targettype="post" data-targetid="${escapeHtml(
   7402                     p.id
   7403                   )}" data-readonly="0">Make writable</button>`
   7404                 : `<button type="button" data-modaction="post_readonly_set" data-targettype="post" data-targetid="${escapeHtml(
   7405                     p.id
   7406                   )}" data-readonly="1">Read-only</button>`
   7407             }
   7408             ${
   7409               p.protected
   7410                 ? `<button type="button" data-modaction="post_protection_set" data-targettype="post" data-targetid="${escapeHtml(
   7411                     p.id
   7412                   )}" data-unprotect="1">Unprotect</button>
   7413                    <button type="button" data-modaction="post_protection_set" data-targettype="post" data-targetid="${escapeHtml(
   7414                      p.id
   7415                    )}" data-protect="1">Change password...</button>`
   7416                 : `<button type="button" data-modaction="post_protection_set" data-targettype="post" data-targetid="${escapeHtml(
   7417                     p.id
   7418                   )}" data-protect="1">Protect...</button>`
   7419             }
   7420             <button type="button" data-modaction="message_purge_recent" data-targettype="post" data-targetid="${escapeHtml(
   7421               p.id
   7422             )}" data-count="25">Purge 25 msgs</button>
   7423             <button type="button" data-modaction="message_purge_recent" data-targettype="post" data-targetid="${escapeHtml(
   7424               p.id
   7425             )}" data-count="50">Purge 50 msgs</button>
   7426             ${
   7427               p.deleted
   7428                 ? `<button type="button" data-modaction="post_restore" data-targettype="post" data-targetid="${escapeHtml(
   7429                     p.id
   7430                   )}" ${p.restoreAvailable ? "" : "disabled"}>${p.restoreAvailable ? "Restore hive" : "No restore snapshot"}</button>`
   7431                 : `<button type="button" data-modaction="post_delete" data-targettype="post" data-targetid="${escapeHtml(
   7432                     p.id
   7433                   )}">Delete hive</button>`
   7434             }
   7435             <button type="button" class="danger" data-modaction="post_erase" data-targettype="post" data-targetid="${escapeHtml(
   7436               p.id
   7437             )}">Erase</button>
   7438           </div>
   7439         </div>`;
   7440       })
   7441       .join("");
   7442     return;
   7443   }
   7444 
   7445   if (modTab === "log") {
   7446     const isOwner = loggedInRole === "owner";
   7447     const viewTabs = `
   7448       <div class="row" style="gap:10px; flex-wrap:wrap; margin-bottom:10px;">
   7449         <button type="button" class="${modLogView === "dev" ? "primary" : "ghost"} smallBtn" data-modlogview="dev">Server dev log</button>
   7450         <button type="button" class="${modLogView === "moderation" ? "primary" : "ghost"} smallBtn" data-modlogview="moderation">Moderation log</button>
   7451       </div>
   7452     `;
   7453     const nukeCard = isOwner
   7454       ? `<div class="modCard">
   7455            <div class="modRowTop">
   7456              <div><b>NUKE</b></div>
   7457              <button type="button" class="danger" data-nuke="1" disabled>NUKE</button>
   7458            </div>
   7459            <div class="small muted" style="margin-bottom:10px">Clears all hives, reports, moderation log, and hive media uploads. Keeps users + profiles.</div>
   7460            <label class="row small" style="gap:10px;align-items:center;justify-content:flex-start">
   7461              <input type="checkbox" data-nukeconfirm="1" />
   7462              <span>ARE YOU SURE?</span>
   7463            </label>
   7464           </div>`
   7465       : "";
   7466 
   7467     if (modLogView === "dev") {
   7468       const lines = devLog
   7469         .slice(0, 300)
   7470         .reverse()
   7471         .map((e) => {
   7472           const ts = e?.createdAt ? new Date(e.createdAt).toLocaleString() : "";
   7473           const lvl = String(e?.level || "info").toUpperCase();
   7474           const scope = String(e?.scope || "server");
   7475           const msg = String(e?.message || "");
   7476           const data = String(e?.data || "");
   7477           const extra = data ? ` ${data}` : "";
   7478           return `[${ts}] ${lvl} ${scope}: ${msg}${extra}`;
   7479         })
   7480         .join("\n");
   7481 
   7482       modBodyEl.innerHTML = `
   7483         ${viewTabs}
   7484         <div class="modCard">
   7485           <div class="modRowTop">
   7486             <div><b>Dev log</b></div>
   7487             <div class="row" style="gap:10px; flex-wrap:wrap; justify-content:flex-end">
   7488               <button type="button" class="ghost smallBtn" data-devlogrefresh="1">Refresh</button>
   7489               <button type="button" class="ghost smallBtn" data-devlogcopy="1">Copy</button>
   7490               ${isOwner ? `<button type="button" class="danger smallBtn" data-devlogclear="1">Clear</button>` : ""}
   7491             </div>
   7492           </div>
   7493           <label class="row small muted" style="gap:10px; align-items:center; justify-content:flex-start; margin-bottom:10px;">
   7494             <input type="checkbox" data-devlogautoscroll="1" ${devLogAutoScroll ? "checked" : ""} />
   7495             <span>Auto-scroll</span>
   7496             <button type="button" class="ghost smallBtn" data-devlogtest="1" style="margin-left:auto;">Test log</button>
   7497           </label>
   7498           <pre class="devLogPre" id="devLogPre">${escapeHtml(lines || "(empty)")}</pre>
   7499         </div>
   7500       `;
   7501 
   7502       const pre = document.getElementById("devLogPre");
   7503       if (pre && devLogAutoScroll) pre.scrollTop = pre.scrollHeight;
   7504       return;
   7505     }
   7506 
   7507     if (!modLog.length) {
   7508       modBodyEl.innerHTML = `${viewTabs}${nukeCard}<div class="muted">No moderation log entries yet.</div>`;
   7509       return;
   7510     }
   7511     modBodyEl.innerHTML =
   7512       viewTabs +
   7513       nukeCard +
   7514       modLog
   7515         .map(
   7516           (entry) => `<div class="modCard">
   7517         <div class="modRowTop">
   7518           <div><b>${escapeHtml(entry.actionType || "action")}</b> ${statusBadge(entry.targetType || "")}</div>
   7519           <div class="muted">${new Date(entry.createdAt).toLocaleString()}</div>
   7520         </div>
   7521         <div class="small">by @${escapeHtml(entry.actor || "unknown")} on ${escapeHtml(entry.targetId || "(none)")}</div>
   7522         <div class="small muted">${escapeHtml(entry.reason || "")}</div>
   7523         ${
   7524           entry?.metadata?.beforePreview || entry?.metadata?.beforeText
   7525             ? `<div class="small muted">content: ${escapeHtml(entry.metadata.beforePreview || entry.metadata.beforeText || "")}</div>`
   7526             : ""
   7527         }
   7528         ${
   7529           entry?.metadata?.editCount
   7530             ? `<div class="small muted">edits: ${escapeHtml(String(entry.metadata.editCount))}</div>`
   7531             : ""
   7532         }
   7533         ${
   7534           entry?.targetType === "post" && (entry?.actionType === "post_delete" || entry?.actionType === "self_post_delete")
   7535             ? `<div class="modActions">
   7536                  <button type="button" data-modaction="post_restore" data-targettype="post" data-targetid="${escapeHtml(
   7537                    entry.targetId || ""
   7538                  )}">Restore hive</button>
   7539                </div>`
   7540             : ""
   7541         }
   7542         ${
   7543           entry?.targetType === "chat" &&
   7544           (entry?.actionType === "message_delete" || entry?.actionType === "self_message_delete")
   7545             ? `<div class="modActions">
   7546                  <button type="button" data-modaction="message_restore" data-targettype="chat" data-targetid="${escapeHtml(
   7547                    entry.targetId || ""
   7548                  )}">Restore message</button>
   7549                </div>`
   7550             : ""
   7551         }
   7552       </div>`
   7553         )
   7554         .join("");
   7555     return;
   7556   }
   7557 
   7558   if (!modReports.length) {
   7559     modBodyEl.innerHTML = `<div class="muted">No reports for this filter.</div>`;
   7560     return;
   7561   }
   7562   modBodyEl.innerHTML = modReports
   7563     .map((r) => {
   7564       const status = r.status || "open";
   7565       const canAct = status === "open";
   7566       return `<div class="modCard">
   7567         <div class="modRowTop">
   7568           <div><b>${escapeHtml(r.targetType || "target")}</b> ${statusBadge(status)}</div>
   7569           <div class="muted">${new Date(r.createdAt).toLocaleString()}</div>
   7570         </div>
   7571         <div class="small">target: ${escapeHtml(r.targetId || "")}</div>
   7572         <div class="small">reporter: @${escapeHtml(r.reporter || "")}</div>
   7573         <div class="small muted">${escapeHtml(r.reason || "")}</div>
   7574         ${
   7575           canAct
   7576             ? `<div class="modActions">
   7577                 <button type="button" data-modaction="report_resolve" data-targettype="report" data-targetid="${escapeHtml(r.id)}">Resolve</button>
   7578                 <button type="button" data-modaction="report_dismiss" data-targettype="report" data-targetid="${escapeHtml(r.id)}">Dismiss</button>
   7579               </div>`
   7580             : ""
   7581         }
   7582       </div>`;
   7583     })
   7584     .join("");
   7585 }
   7586 
   7587 function isMapChatActive() {
   7588   return Boolean(!activeDmThreadId && !activeChatPostId && activeMapsRoomId);
   7589 }
   7590 
   7591 function normalizeMapChatScope(scope) {
   7592   const s = String(scope || "").trim().toLowerCase();
   7593   return s === "global" ? "global" : "local";
   7594 }
   7595 
   7596 function mapChatListFor(mapId, scope) {
   7597   const mid = String(mapId || "").trim().toLowerCase();
   7598   if (!mid) return [];
   7599   const sc = normalizeMapChatScope(scope);
   7600   const store = sc === "global" ? mapsChatGlobalByMapId : mapsChatLocalByMapId;
   7601   const arr = store.get(mid);
   7602   return Array.isArray(arr) ? arr : [];
   7603 }
   7604 
   7605 function pushMapChatMessage(mapId, scope, message) {
   7606   const mid = String(mapId || "").trim().toLowerCase();
   7607   if (!mid) return;
   7608   const sc = normalizeMapChatScope(scope);
   7609   const store = sc === "global" ? mapsChatGlobalByMapId : mapsChatLocalByMapId;
   7610   const prev = store.get(mid);
   7611   const arr = Array.isArray(prev) ? prev.slice() : [];
   7612   arr.push(message);
   7613   if (arr.length > 240) arr.splice(0, arr.length - 240);
   7614   store.set(mid, arr);
   7615 }
   7616 
   7617 function renderChatPanel(forceScroll = false) {
   7618   updateChatModToggleVisibility();
   7619   renderChatContextSelect();
   7620   const mobileChatScreen = isMobileChatScreenActive();
   7621   const mediaState = captureMediaState(chatMessagesEl);
   7622   if (activeDmThreadId) {
   7623     const thread = dmThreadsById.get(activeDmThreadId) || null;
   7624     if (!thread) {
   7625       activeDmThreadId = null;
   7626     } else {
   7627       const atBottomBefore =
   7628         chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 24;
   7629       chatTitle.textContent = `@${thread.other}`;
   7630       if (chatBackToListBtn) chatBackToListBtn.classList.toggle("hidden", !mobileChatScreen);
   7631       const status = String(thread.status || "unknown");
   7632       const statusTxt =
   7633         status === "incoming"
   7634           ? "DM request (accept to chat)"
   7635           : status === "outgoing"
   7636             ? "DM request pending"
   7637             : status === "declined"
   7638               ? "DM request declined"
   7639               : "Private chat";
   7640       chatMeta.textContent = `with @${thread.other} | ${statusTxt} | purged daily`;
   7641 
   7642       const messages = dmMessagesByThreadId.get(activeDmThreadId) || [];
   7643       if (status !== "active" && messages.length === 0) {
   7644         const promptHtml =
   7645           status === "incoming"
   7646             ? `<div class="row" style="gap:8px;justify-content:flex-start">
   7647                 <button type="button" class="primary smallBtn" data-dmaccept="${escapeHtml(thread.id)}">Accept</button>
   7648                 <button type="button" class="ghost smallBtn" data-dmdecline="${escapeHtml(thread.id)}">Decline</button>
   7649               </div>`
   7650             : status === "declined"
   7651               ? `<button type="button" class="ghost smallBtn" data-dmrequest="${escapeHtml(thread.other)}">Request again</button>`
   7652               : `<div class="muted">Waiting for @${escapeHtml(thread.other)}...</div>`;
   7653         chatMessagesEl.innerHTML = `<div class="small muted">${promptHtml}</div>`;
   7654         restoreMediaState(chatMessagesEl, mediaState);
   7655         setReplyToMessage(null);
   7656         return;
   7657       }
   7658 
   7659       chatMessagesEl.innerHTML = messages
   7660         .map((m, index) => {
   7661           const from = m.fromUser || "";
   7662           const isYou = loggedInUser && from && from === loggedInUser;
   7663           const isModMsg = Boolean(m?.asMod) || String(from || "").toLowerCase() === "mod";
   7664           const rail = chatRailClass({
   7665             fromUser: from,
   7666             isModMessage: isModMsg
   7667           });
   7668           const prev = index > 0 ? messages[index - 1] : null;
   7669           const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from);
   7670           const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || "");
   7671           const youTag = isModMsg ? "" : isYou ? `<span class="muted">(you)</span>` : "";
   7672           const time = new Date(m.createdAt).toLocaleTimeString();
   7673           const tint = tintStylesFromHex(getProfile(from).color);
   7674           const html = typeof m.html === "string" && m.html.trim() ? m.html : "";
   7675           const content = html ? html : highlightMentionsInText(m.text || "");
   7676           return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${rail}" data-msgid="${escapeHtml(m.id)}" ${tint}>
   7677             <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div>
   7678             <div class="content">${content}</div>
   7679           </div>`;
   7680         })
   7681         .join("");
   7682       for (const contentEl of chatMessagesEl.querySelectorAll(".chatMsg .content")) {
   7683         decorateMentionNodesInElement(contentEl);
   7684         decorateYouTubeEmbedsInElement(contentEl);
   7685       }
   7686       restoreMediaState(chatMessagesEl, mediaState);
   7687       if (forceScroll || atBottomBefore) chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
   7688       return;
   7689     }
   7690   }
   7691 
   7692   const post = activeChatPostId ? posts.get(activeChatPostId) : null;
   7693   if (!post) {
   7694     if (isMapChatActive()) {
   7695       const mapId = String(activeMapsRoomId || "").trim().toLowerCase();
   7696       const scope = normalizeMapChatScope(activeMapsChatScope);
   7697       const atBottomBefore =
   7698         chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 24;
   7699 
   7700       const title = activeMapsRoomTitle ? `Map: ${activeMapsRoomTitle}` : `Map: ${mapId}`;
   7701       chatTitle.textContent = activeMapsRoomTitle ? `Map: ${activeMapsRoomTitle}` : "Map chat";
   7702       if (chatBackToListBtn) chatBackToListBtn.classList.toggle("hidden", !mobileChatScreen);
   7703       chatMeta.innerHTML = `
   7704         <span class="muted">${escapeHtml(title)}</span>
   7705         <span class="muted">|</span>
   7706         <span class="mapChatToggle">
   7707           <button type="button" class="${scope === "local" ? "primary" : "ghost"} smallBtn" data-mapchatscope="local" title="Local chat (nearby)">Local</button>
   7708           <button type="button" class="${scope === "global" ? "primary" : "ghost"} smallBtn" data-mapchatscope="global" title="Global chat (entire map)">Global</button>
   7709         </span>
   7710       `;
   7711 
   7712       if (chatPanelEl) chatPanelEl.classList.remove("walkie");
   7713       if (walkieBarEl) walkieBarEl.classList.add("hidden");
   7714       if (chatForm) chatForm.classList.remove("hidden");
   7715 
   7716       const messages = mapChatListFor(mapId, scope);
   7717       if (!messages.length) {
   7718         chatMessagesEl.innerHTML = `<div class="small muted">${
   7719           scope === "local" ? "Local chat is proximity-based. Say something nearby." : "No messages yet. Say hello!"
   7720         }</div>`;
   7721         restoreMediaState(chatMessagesEl, mediaState);
   7722         setReplyToMessage(null);
   7723         return;
   7724       }
   7725 
   7726       chatMessagesEl.innerHTML = messages
   7727         .map((m, index) => {
   7728           const from = String(m.fromUser || "");
   7729           const isYou = loggedInUser && from && from === loggedInUser;
   7730           const rail = chatRailClass({
   7731             fromUser: from,
   7732             isModMessage: Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod"
   7733           });
   7734           const prev = index > 0 ? messages[index - 1] : null;
   7735           const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from);
   7736           const who = renderUserPill(from || "");
   7737           const youTag = isYou ? `<span class="muted">(you)</span>` : "";
   7738           const time = new Date(Number(m.createdAt || 0) || Date.now()).toLocaleTimeString();
   7739           const tint = tintStylesFromHex(getProfile(from).color);
   7740           const content = highlightMentionsInText(String(m.text || ""));
   7741           return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${rail}" data-msgid="${escapeHtml(String(m.id || ""))}" ${tint}>
   7742             <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div>
   7743             <div class="content">${content}</div>
   7744           </div>`;
   7745         })
   7746         .join("");
   7747       for (const contentEl of chatMessagesEl.querySelectorAll(".chatMsg .content")) {
   7748         decorateMentionNodesInElement(contentEl);
   7749         decorateYouTubeEmbedsInElement(contentEl);
   7750       }
   7751       restoreMediaState(chatMessagesEl, mediaState);
   7752       if (forceScroll || atBottomBefore) chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
   7753       setReplyToMessage(null);
   7754       return;
   7755     }
   7756 
   7757     if (chatBackToListBtn) chatBackToListBtn.classList.add("hidden");
   7758     if (mobileChatScreen) {
   7759       chatTitle.textContent = "Chats";
   7760       chatMeta.textContent = "Select a hive chat.";
   7761       if (chatPanelEl) chatPanelEl.classList.remove("walkie");
   7762       if (walkieBarEl) walkieBarEl.classList.add("hidden");
   7763       if (chatForm) chatForm.classList.add("hidden");
   7764       chatMessagesEl.innerHTML = renderMobileChatListHtml();
   7765       restoreMediaState(chatMessagesEl, mediaState);
   7766       setReplyToMessage(null);
   7767       return;
   7768     }
   7769     chatTitle.textContent = "Chat";
   7770     chatMeta.textContent = "Select a post to chat.";
   7771     if (chatPanelEl) chatPanelEl.classList.remove("walkie");
   7772     if (walkieBarEl) walkieBarEl.classList.add("hidden");
   7773     if (chatForm) chatForm.classList.remove("hidden");
   7774     chatMessagesEl.innerHTML = `<div class="small muted">No chat selected.</div>
   7775       <div class="uiHint">Open a hive and press <b>Chat</b>, or use People -> DMs to open a private thread.</div>
   7776       <div class="row" style="gap:8px;justify-content:flex-start;margin-top:8px;">
   7777         <button type="button" class="ghost smallBtn" data-chatemptyopen="hives">Open Hives</button>
   7778         <button type="button" class="ghost smallBtn" data-chatemptyopen="people">Open People</button>
   7779       </div>`;
   7780     restoreMediaState(chatMessagesEl, mediaState);
   7781     setReplyToMessage(null);
   7782     return;
   7783   }
   7784 
   7785   updateChatModToggleVisibility();
   7786   const isWalkie = String(post.mode || post.chatMode || "").toLowerCase() === "walkie";
   7787   if (chatPanelEl) chatPanelEl.classList.toggle("walkie", isWalkie);
   7788   if (walkieBarEl) walkieBarEl.classList.toggle("hidden", !isWalkie);
   7789   if (chatForm) chatForm.classList.toggle("hidden", isWalkie);
   7790   if (walkieRecordBtn) walkieRecordBtn.disabled = !(isWalkie && loggedInUser);
   7791   if (isWalkie && walkieStatusEl && !loggedInUser) walkieStatusEl.textContent = "Sign in to talk.";
   7792   if (!isWalkie && walkieStatusEl) walkieStatusEl.textContent = "";
   7793 
   7794   const atBottomBefore =
   7795     chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 24;
   7796   chatTitle.textContent = postTitle(post);
   7797   if (chatBackToListBtn) chatBackToListBtn.classList.toggle("hidden", !mobileChatScreen);
   7798   const tags = (post.keywords || []).map((k) => `#${k}`).join(" ");
   7799   const author = post.author ? `by @${post.author}` : "";
   7800   const exp = formatCountdown(post.expiresAt);
   7801   const ro = post.readOnly ? " | read-only" : "";
   7802   chatMeta.textContent = `${author}${isWalkie ? " | walkie talkie" : ""}${ro} | ${exp === "permanent" ? "permanent" : `expires in ${exp}`} | ${tags}`.trim();
   7803   const canChatWrite = Boolean(loggedInRole === "owner" || loggedInRole === "moderator" || !post.readOnly);
   7804   if (chatEditor) chatEditor.contentEditable = String(Boolean(canChatWrite && !isWalkie));
   7805   const chatSendBtn = chatForm?.querySelector?.("button[type='submit']") || null;
   7806   if (chatSendBtn) chatSendBtn.disabled = !(loggedInUser && canChatWrite && !isWalkie);
   7807   if (post.deleted) {
   7808     chatMessagesEl.innerHTML = `<div class="small muted">Post was deleted.</div>`;
   7809     restoreMediaState(chatMessagesEl, mediaState);
   7810     setReplyToMessage(null);
   7811     return;
   7812   }
   7813 
   7814   const messages = chatByPost.get(post.id) || [];
   7815   const ignoreUserSet = new Set(
   7816     [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase())
   7817   );
   7818   const selfLower = String(loggedInUser || "").toLowerCase();
   7819   const visibleMessages = messages.filter((m) => {
   7820     const fromLower = String(m?.fromUser || "").toLowerCase();
   7821     if (!fromLower || fromLower === selfLower) return true;
   7822     return !ignoreUserSet.has(fromLower);
   7823   });
   7824 
   7825   chatMessagesEl.innerHTML = visibleMessages
   7826     .map((m, index) => {
   7827       const isModMsg = Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod";
   7828       const from = isModMsg ? "MOD" : m.fromUser || "";
   7829       const rail = chatRailClass({ fromUser: from, isModMessage: isModMsg });
   7830       const prev = index > 0 ? visibleMessages[index - 1] : null;
   7831       const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from);
   7832       const mentions = Array.isArray(m.mentions) ? m.mentions.map((u) => String(u || "").toLowerCase()) : [];
   7833       const mentionMe = Boolean(loggedInUser && mentions.includes(loggedInUser));
   7834       const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || "");
   7835       const youTag = !isModMsg && loggedInUser && from && from === loggedInUser ? `<span class="muted">(you)</span>` : "";
   7836       const time = new Date(m.createdAt).toLocaleTimeString();
   7837       const tint = isModMsg ? "" : tintStylesFromHex(getProfile(from).color);
   7838       const html = typeof m.html === "string" && m.html.trim() ? m.html : "";
   7839       const content = html ? html : highlightMentionsInText(m.text || "");
   7840       const replyMeta = m.replyTo && typeof m.replyTo === "object" ? m.replyTo : null;
   7841       const replyBlock = replyMeta
   7842         ? `<div class="chatReplyRef"><span class="small muted">@${escapeHtml(replyMeta.fromUser || "unknown")}</span><div class="small">${escapeHtml(
   7843             String(replyMeta.text || "[media]").slice(0, 120)
   7844           )}</div></div>`
   7845         : "";
   7846       const reacts = renderReactionButtons({ kind: "chat", id: m.id, reactions: m.reactions || {}, postId: post.id });
   7847       const deletedLine = m.deleted
   7848         ? `<div class="small muted">message deleted${
   7849             m.deletedBy ? ` by @${escapeHtml(m.deletedBy)}` : ""
   7850           } at ${escapeHtml(new Date(Number(m.deletedAt || m.createdAt || Date.now())).toLocaleString())}</div>`
   7851         : "";
   7852       const editedLine =
   7853         !m.deleted && Number(m.editCount || 0) > 0
   7854           ? `<div class="small muted">edited (${Number(m.editCount || 0)}) at ${escapeHtml(
   7855               new Date(Number(m.editedAt || m.createdAt || Date.now())).toLocaleTimeString()
   7856             )}</div>`
   7857           : "";
   7858       const reportAction = loggedInUser && !m.deleted
   7859         ? `<button type="button" class="ghost smallBtn" data-reportchat="${escapeHtml(m.id)}" data-postid="${escapeHtml(
   7860             post.id
   7861           )}">Report</button>`
   7862         : "";
   7863       const canManageOwnMessage = Boolean(loggedInUser && m.fromUser && m.fromUser === loggedInUser && !m.deleted);
   7864       const replyAction = loggedInUser && !m.deleted
   7865         ? `<button type="button" class="ghost smallBtn" data-replymsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(post.id)}">Reply</button>`
   7866         : "";
   7867       const ownEditAction = canManageOwnMessage
   7868         ? `<button type="button" class="ghost smallBtn" data-editmsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(post.id)}">Edit</button>`
   7869         : "";
   7870       const ownDeleteAction = canManageOwnMessage
   7871         ? `<button type="button" class="ghost smallBtn" data-deletemsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(post.id)}">Delete</button>`
   7872         : "";
   7873       return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${mentionMe ? "mentionMe" : ""} ${rail} ${isModMsg ? "isModMsg" : ""}" data-msgid="${escapeHtml(m.id)}" ${tint}>
   7874         <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div>
   7875         ${replyBlock}
   7876         ${deletedLine}
   7877         ${editedLine}
   7878         <div class="content">${content}</div>
   7879         <div class="chatActionsRow">
   7880           <div class="chatReactions">${m.deleted ? "" : reacts}</div>
   7881           <div class="chatTools">${replyAction}${ownEditAction}${ownDeleteAction}${reportAction}</div>
   7882         </div>
   7883       </div>`;
   7884     })
   7885     .join("");
   7886   for (const contentEl of chatMessagesEl.querySelectorAll(".chatMsg .content")) {
   7887     decorateMentionNodesInElement(contentEl);
   7888     decorateYouTubeEmbedsInElement(contentEl);
   7889   }
   7890   restoreMediaState(chatMessagesEl, mediaState);
   7891   if (forceScroll || atBottomBefore) chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
   7892 }
   7893 
   7894 function captureMediaState(containerEl) {
   7895   if (!containerEl) return [];
   7896   const list = [];
   7897   for (const el of containerEl.querySelectorAll("audio, video")) {
   7898     try {
   7899       const src = el.currentSrc || el.getAttribute("src") || "";
   7900       if (!src) continue;
   7901       list.push({
   7902         src,
   7903         currentTime: Number(el.currentTime || 0),
   7904         paused: Boolean(el.paused),
   7905         volume: Number.isFinite(el.volume) ? el.volume : 1,
   7906         playbackRate: Number.isFinite(el.playbackRate) ? el.playbackRate : 1
   7907       });
   7908     } catch {
   7909       // ignore
   7910     }
   7911   }
   7912   return list;
   7913 }
   7914 
   7915 function restoreMediaState(containerEl, mediaState) {
   7916   if (!containerEl || !Array.isArray(mediaState) || mediaState.length === 0) return;
   7917   const els = Array.from(containerEl.querySelectorAll("audio, video"));
   7918   for (const s of mediaState) {
   7919     const src = String(s?.src || "");
   7920     if (!src) continue;
   7921     const el = els.find((x) => (x.currentSrc || x.getAttribute("src") || "") === src);
   7922     if (!el) continue;
   7923     try {
   7924       if (Number.isFinite(s.volume)) el.volume = s.volume;
   7925       if (Number.isFinite(s.playbackRate)) el.playbackRate = s.playbackRate;
   7926       if (Number.isFinite(s.currentTime)) el.currentTime = s.currentTime;
   7927       if (!s.paused) el.play().catch(() => {});
   7928     } catch {
   7929       // ignore
   7930     }
   7931   }
   7932 }
   7933 
   7934 function appendChatHtmlAndDecorate(html, atBottomBefore) {
   7935   if (!chatMessagesEl) return null;
   7936   chatMessagesEl.insertAdjacentHTML("beforeend", html);
   7937   const last = chatMessagesEl.lastElementChild;
   7938   if (last && last.classList && last.classList.contains("chatMsg")) {
   7939     const contentEl = last.querySelector(".content");
   7940     if (contentEl) {
   7941       decorateMentionNodesInElement(contentEl);
   7942       decorateYouTubeEmbedsInElement(contentEl);
   7943     }
   7944   }
   7945   if (atBottomBefore) chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
   7946   return last;
   7947 }
   7948 
   7949 function appendPostChatMessageToDom(postId, message) {
   7950   if (!chatMessagesEl) return false;
   7951   const post = postId ? posts.get(postId) : null;
   7952   if (!post || post.deleted) return false;
   7953   if (!activeChatPostId || activeChatPostId !== postId) return false;
   7954   if (activeDmThreadId) return false;
   7955   if (!chatMessagesEl.querySelector(".chatMsg")) return false;
   7956 
   7957   const atBottomBefore =
   7958     chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 24;
   7959 
   7960   const ignoreUserSet = new Set(
   7961     [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase())
   7962   );
   7963   const selfLower = String(loggedInUser || "").toLowerCase();
   7964 
   7965   const messages = chatByPost.get(postId) || [];
   7966   let prevVisible = null;
   7967   for (let i = messages.length - 2; i >= 0; i -= 1) {
   7968     const pm = messages[i];
   7969     const fromLower = String(pm?.fromUser || "").toLowerCase();
   7970     if (!fromLower || fromLower === selfLower || !ignoreUserSet.has(fromLower)) {
   7971       prevVisible = pm;
   7972       break;
   7973     }
   7974   }
   7975 
   7976   const m = message;
   7977   const isModMsg = Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod";
   7978   const from = isModMsg ? "MOD" : m?.fromUser || "";
   7979   const isYou = loggedInUser && from && from === loggedInUser;
   7980   const rail = chatRailClass({ fromUser: from, isModMessage: isModMsg });
   7981   const sameAuthorAsPrev = Boolean(prevVisible && String(prevVisible.fromUser || "") === from);
   7982   const mentions = Array.isArray(m?.mentions) ? m.mentions.map((u) => String(u || "").toLowerCase()) : [];
   7983   const mentionMe = Boolean(loggedInUser && mentions.includes(loggedInUser));
   7984   const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || "");
   7985   const youTag = !isModMsg && isYou ? `<span class="muted">(you)</span>` : "";
   7986   const time = new Date(m.createdAt).toLocaleTimeString();
   7987   const tint = isModMsg ? "" : tintStylesFromHex(getProfile(from).color);
   7988   const html = typeof m.html === "string" && m.html.trim() ? m.html : "";
   7989   const content = html ? html : highlightMentionsInText(m.text || "");
   7990   const replyMeta = m.replyTo && typeof m.replyTo === "object" ? m.replyTo : null;
   7991   const replyBlock = replyMeta
   7992     ? `<div class="chatReplyRef"><span class="small muted">@${escapeHtml(replyMeta.fromUser || "unknown")}</span><div class="small">${escapeHtml(
   7993         String(replyMeta.text || "[media]").slice(0, 120)
   7994       )}</div></div>`
   7995     : "";
   7996   const reacts = renderReactionButtons({ kind: "chat", id: m.id, reactions: m.reactions || {}, postId });
   7997   const deletedLine = m.deleted
   7998     ? `<div class="small muted">message deleted${m.deletedBy ? ` by @${escapeHtml(m.deletedBy)}` : ""} at ${escapeHtml(
   7999         new Date(Number(m.deletedAt || m.createdAt || Date.now())).toLocaleString()
   8000       )}</div>`
   8001     : "";
   8002   const editedLine =
   8003     !m.deleted && Number(m.editCount || 0) > 0
   8004       ? `<div class="small muted">edited (${Number(m.editCount || 0)}) at ${escapeHtml(
   8005           new Date(Number(m.editedAt || m.createdAt || Date.now())).toLocaleTimeString()
   8006         )}</div>`
   8007       : "";
   8008   const reportAction =
   8009     loggedInUser && !m.deleted
   8010       ? `<button type="button" class="ghost smallBtn" data-reportchat="${escapeHtml(m.id)}" data-postid="${escapeHtml(postId)}">Report</button>`
   8011       : "";
   8012   const canManageOwnMessage = Boolean(loggedInUser && m.fromUser && m.fromUser === loggedInUser && !m.deleted);
   8013   const replyAction =
   8014     loggedInUser && !m.deleted
   8015       ? `<button type="button" class="ghost smallBtn" data-replymsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(postId)}">Reply</button>`
   8016       : "";
   8017   const ownEditAction = canManageOwnMessage
   8018     ? `<button type="button" class="ghost smallBtn" data-editmsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(postId)}">Edit</button>`
   8019     : "";
   8020   const ownDeleteAction = canManageOwnMessage
   8021     ? `<button type="button" class="ghost smallBtn" data-deletemsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(postId)}">Delete</button>`
   8022     : "";
   8023 
   8024   const msgHtml = `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${mentionMe ? "mentionMe" : ""} ${rail} ${isModMsg ? "isModMsg" : ""}" data-msgid="${escapeHtml(
   8025     m.id
   8026   )}" ${tint}>
   8027         <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div>
   8028         ${replyBlock}
   8029         ${deletedLine}
   8030         ${editedLine}
   8031         <div class="content">${content}</div>
   8032         <div class="chatActionsRow">
   8033           <div class="chatReactions">${m.deleted ? "" : reacts}</div>
   8034           <div class="chatTools">${replyAction}${ownEditAction}${ownDeleteAction}${reportAction}</div>
   8035         </div>
   8036       </div>`;
   8037 
   8038   appendChatHtmlAndDecorate(msgHtml, atBottomBefore);
   8039   return true;
   8040 }
   8041 
   8042 function appendDmMessageToDom(threadId, message) {
   8043   if (!chatMessagesEl) return false;
   8044   if (!activeDmThreadId || activeDmThreadId !== threadId) return false;
   8045   if (!chatMessagesEl.querySelector(".chatMsg")) return false;
   8046   const thread = dmThreadsById.get(threadId) || null;
   8047   if (!thread || String(thread.status || "unknown") !== "active") return false;
   8048 
   8049   const atBottomBefore =
   8050     chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 24;
   8051 
   8052   const messages = dmMessagesByThreadId.get(threadId) || [];
   8053   const prev = messages.length >= 2 ? messages[messages.length - 2] : null;
   8054 
   8055   const m = message;
   8056   const from = m.fromUser || "";
   8057   const isYou = loggedInUser && from && from === loggedInUser;
   8058   const rail = chatRailClass({ fromUser: from, isModMessage: false });
   8059   const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from);
   8060   const who = renderUserPill(from || "");
   8061   const youTag = isYou ? `<span class="muted">(you)</span>` : "";
   8062   const time = new Date(m.createdAt).toLocaleTimeString();
   8063   const tint = tintStylesFromHex(getProfile(from).color);
   8064   const html = typeof m.html === "string" && m.html.trim() ? m.html : "";
   8065   const content = html ? html : highlightMentionsInText(m.text || "");
   8066 
   8067   const msgHtml = `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${rail}" data-msgid="${escapeHtml(m.id)}" ${tint}>
   8068              <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div>
   8069              <div class="content">${content}</div>
   8070            </div>`;
   8071 
   8072   appendChatHtmlAndDecorate(msgHtml, atBottomBefore);
   8073   return true;
   8074 }
   8075 
   8076 function pulseChatMessage(messageId) {
   8077   if (!chatMessagesEl) return;
   8078   const id = String(messageId || "");
   8079   if (!id) return;
   8080   const el = chatMessagesEl.querySelector(`[data-msgid="${cssEscape(id)}"]`);
   8081   if (!el) return;
   8082   el.classList.add("isNewMsg");
   8083   window.setTimeout(() => el.classList.remove("isNewMsg"), 720);
   8084 }
   8085 
   8086 function updateActiveChatMeta() {
   8087   if (activeDmThreadId) return;
   8088   const post = activeChatPostId ? posts.get(activeChatPostId) : null;
   8089   if (!post) return;
   8090   const tags = (post.keywords || []).map((k) => `#${k}`).join(" ");
   8091   const author = post.author ? `by @${post.author}` : "";
   8092   const exp = formatCountdown(post.expiresAt);
   8093   chatMeta.textContent = `${author} | ${exp === "permanent" ? "permanent" : `expires in ${exp}`} | ${tags}`.trim();
   8094 }
   8095 
   8096 function openDmThread(threadId, opts = null) {
   8097   const id = String(threadId || "").trim();
   8098   if (!id) return;
   8099   const options = opts && typeof opts === "object" ? opts : {};
   8100   if (!options.preserveFocus) blurFocusedChatComposer();
   8101   const thread = dmThreadsById.get(id) || null;
   8102   if (!thread) {
   8103     pendingOpenDmThreadId = id;
   8104     if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "dmList" }));
   8105     toast("DMs", "Thread not found yet. Refreshing DM list.");
   8106     return;
   8107   }
   8108   if (String(thread.status || "") !== "active") {
   8109     pendingOpenDmThreadId = id;
   8110     if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "dmList" }));
   8111     toast("DMs", "DM is not active yet.");
   8112     return;
   8113   }
   8114   pendingOpenDmThreadId = "";
   8115   if (activeChatPostId) ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false }));
   8116   activeChatPostId = null;
   8117   activeDmThreadId = id;
   8118   touchRecentDmChat(id);
   8119   setReplyToMessage(null);
   8120   ws.send(JSON.stringify({ type: "dmHistory", threadId: id }));
   8121   renderChatPanel(true);
   8122   if (isMobileSwipeMode()) {
   8123     setMobileScreen("chat");
   8124     renderMobileNav();
   8125   }
   8126 }
   8127 
   8128 function sendModDmPrompt(rawUsername) {
   8129   const to = String(rawUsername || "")
   8130     .trim()
   8131     .replace(/^@+/, "")
   8132     .toLowerCase();
   8133   if (!to) return;
   8134   if (!loggedInUser) {
   8135     toast("Sign in required", "Sign in to send moderator DMs.");
   8136     return;
   8137   }
   8138   if (!canModerate) {
   8139     toast("Moderator only", "You need moderator permissions.");
   8140     return;
   8141   }
   8142   if (to === String(loggedInUser).toLowerCase()) {
   8143     toast("Unavailable", "Can't send a moderator DM to yourself.");
   8144     return;
   8145   }
   8146   const text = String(prompt(`Send moderator DM to @${to}:`) || "").trim();
   8147   if (!text) return;
   8148   ws.send(JSON.stringify({ type: "dmSendMod", to, text }));
   8149   toast("Moderator DM", `Sent to @${to}.`);
   8150 }
   8151 
   8152 function openChat(postId, opts = null) {
   8153   activeDmThreadId = null;
   8154   stopWalkieRecording();
   8155   const options = opts && typeof opts === "object" ? opts : {};
   8156   if (!options.preserveFocus) blurFocusedChatComposer();
   8157   const sourceEl = options.sourceEl instanceof HTMLElement ? options.sourceEl : null;
   8158   const post = posts.get(postId);
   8159   if (!post) return;
   8160   if (post.deleted) {
   8161     activeChatPostId = postId;
   8162     touchRecentHiveChat(postId);
   8163     renderChatPanel(true);
   8164     if (isMobileSwipeMode()) setMobilePanel("chat");
   8165     return;
   8166   }
   8167   if (post.locked) {
   8168     unlockPostFlow(postId, true);
   8169     return;
   8170   }
   8171 
   8172   // Rack mode: switch the nearest visible chat panel when possible; otherwise use main chat.
   8173   if (rackLayoutEnabled) {
   8174     const nearestInstanceId = nearestVisibleChatInstancePanelId(sourceEl);
   8175     if (nearestInstanceId) {
   8176       touchRecentHiveChat(postId);
   8177       markRead(postId);
   8178       renderFeed();
   8179       ws.send(JSON.stringify({ type: "getChat", postId }));
   8180       setChatInstancePanelPost(nearestInstanceId, postId, true);
   8181       renderChatContextSelect();
   8182       return;
   8183     }
   8184     if (chatPanelEl && typeof isDocked === "function" && !isDocked("chat")) {
   8185       activeChatPostId = postId;
   8186       touchRecentHiveChat(postId);
   8187       markRead(postId);
   8188       renderFeed();
   8189       ws.send(JSON.stringify({ type: "getChat", postId }));
   8190       renderChatPanel(true);
   8191       renderTypingIndicator();
   8192       if (isMobileSwipeMode()) setMobilePanel("chat");
   8193       return;
   8194     }
   8195   }
   8196   if (activeChatPostId && activeChatPostId !== postId) {
   8197     ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false }));
   8198     setReplyToMessage(null);
   8199   }
   8200   activeChatPostId = postId;
   8201   touchRecentHiveChat(postId);
   8202   markRead(postId);
   8203   renderFeed();
   8204   ws.send(JSON.stringify({ type: "getChat", postId }));
   8205   renderChatPanel(true);
   8206   renderTypingIndicator();
   8207   if (isMobileSwipeMode()) setMobilePanel("chat");
   8208 }
   8209 
   8210 let pendingOpenChatAfterUnlock = null;
   8211 function unlockPostFlow(postId, openChatAfter) {
   8212   const pw = prompt("Password for this post:");
   8213   if (!pw) return;
   8214   pendingOpenChatAfterUnlock = openChatAfter ? postId : null;
   8215   ws.send(JSON.stringify({ type: "unlockPost", postId, password: pw }));
   8216 }
   8217 
   8218 function runCmd(target, cmd) {
   8219   target.focus();
   8220   document.execCommand(cmd);
   8221 }
   8222 
   8223 function runLink(target) {
   8224   target.focus();
   8225   const url = prompt("Link URL (https://...)");
   8226   if (!url) return;
   8227   document.execCommand("createLink", false, url);
   8228 }
   8229 
   8230 function runEmoji(target) {
   8231   target.focus();
   8232   const raw = prompt("Emoji to insert (example: πŸ˜€πŸ”₯πŸ’–)");
   8233   const emoji = String(raw || "").trim();
   8234   if (!emoji) return;
   8235   document.execCommand("insertText", false, emoji);
   8236 }
   8237 
   8238 function readFileAsDataUrl(file) {
   8239   return new Promise((resolve, reject) => {
   8240     const reader = new FileReader();
   8241     reader.onload = () => resolve(String(reader.result || ""));
   8242     reader.onerror = () => reject(new Error("Failed to read file"));
   8243     reader.readAsDataURL(file);
   8244   });
   8245 }
   8246 
   8247 async function resizeImageToSquareDataUrl(file, sizePx) {
   8248   const dataUrl = await readFileAsDataUrl(file);
   8249   const img = new Image();
   8250   img.src = dataUrl;
   8251   await img.decode();
   8252   const canvas = document.createElement("canvas");
   8253   canvas.width = sizePx;
   8254   canvas.height = sizePx;
   8255   const ctx = canvas.getContext("2d");
   8256   if (!ctx) return "";
   8257   const side = Math.min(img.width, img.height);
   8258   const sx = Math.floor((img.width - side) / 2);
   8259   const sy = Math.floor((img.height - side) / 2);
   8260   ctx.drawImage(img, sx, sy, side, side, 0, 0, sizePx, sizePx);
   8261   // Preserve transparency for avatars (JPEG strips alpha).
   8262   const webp = canvas.toDataURL("image/webp", 0.9);
   8263   if (typeof webp === "string" && webp.startsWith("data:image/webp")) return webp;
   8264   return canvas.toDataURL("image/png");
   8265 }
   8266 
   8267 async function uploadMediaFile(file, kind) {
   8268   if (!file) return "";
   8269   const maxBytes = kind === "audio" ? CLIENT_AUDIO_UPLOAD_MAX_BYTES : CLIENT_IMAGE_UPLOAD_MAX_BYTES;
   8270   if (file.size > maxBytes) {
   8271     toast("File too large", `${kind === "audio" ? "Audio" : "Image"} is too large for this server.`);
   8272     return "";
   8273   }
   8274   const token = getSessionToken();
   8275   if (!token) {
   8276     toast("Sign in required", "Please sign in before uploading files.");
   8277     return "";
   8278   }
   8279   const loweredName = String(file.name || "").toLowerCase();
   8280   let contentType = (file.type || "").toLowerCase();
   8281   if (!contentType) {
   8282     if (kind === "image") {
   8283       if (loweredName.endsWith(".gif")) contentType = "image/gif";
   8284       else if (loweredName.endsWith(".png")) contentType = "image/png";
   8285       else if (loweredName.endsWith(".webp")) contentType = "image/webp";
   8286       else if (loweredName.endsWith(".jpg") || loweredName.endsWith(".jpeg")) contentType = "image/jpeg";
   8287     } else if (kind === "audio") {
   8288       if (loweredName.endsWith(".mp3")) contentType = "audio/mpeg";
   8289       else if (loweredName.endsWith(".wav")) contentType = "audio/wav";
   8290       else if (loweredName.endsWith(".ogg")) contentType = "audio/ogg";
   8291       else if (loweredName.endsWith(".webm")) contentType = "audio/webm";
   8292       else if (loweredName.endsWith(".aac")) contentType = "audio/aac";
   8293       else if (loweredName.endsWith(".m4a") || loweredName.endsWith(".mp4")) contentType = "audio/mp4";
   8294     }
   8295   }
   8296   const headers = {
   8297     Authorization: `Bearer ${token}`,
   8298     "Content-Type": contentType || "application/octet-stream"
   8299   };
   8300   try {
   8301     const res = await fetch(`/api/upload?kind=${encodeURIComponent(kind)}`, {
   8302       method: "POST",
   8303       headers,
   8304       body: file
   8305     });
   8306     const payload = await res.json().catch(() => ({}));
   8307     if (!res.ok) {
   8308       toast("Upload failed", payload?.error || "Upload failed.");
   8309       return "";
   8310     }
   8311     if (!payload?.url) {
   8312       toast("Upload failed", "Server did not return a media URL.");
   8313       return "";
   8314     }
   8315     return String(payload.url);
   8316   } catch {
   8317     toast("Upload failed", "Network error while uploading file.");
   8318     return "";
   8319   }
   8320 }
   8321 
   8322 async function ensureWalkieContext() {
   8323   if (walkieCtx) return walkieCtx;
   8324   const ctx = new (window.AudioContext || window.webkitAudioContext)();
   8325   walkieCtx = ctx;
   8326   return ctx;
   8327 }
   8328 
   8329 async function ensureWalkieDispatchBuffer() {
   8330   if (walkieDispatchBuffer) return walkieDispatchBuffer;
   8331   const ctx = await ensureWalkieContext();
   8332   try {
   8333     const res = await fetch("/assets/walkie/dispatch.mp3");
   8334     const arr = await res.arrayBuffer();
   8335     walkieDispatchBuffer = await ctx.decodeAudioData(arr);
   8336     return walkieDispatchBuffer;
   8337   } catch {
   8338     walkieDispatchBuffer = null;
   8339     return null;
   8340   }
   8341 }
   8342 
   8343 async function ensureWalkieGraph() {
   8344   const ctx = await ensureWalkieContext();
   8345   if (walkieMixNode && walkieDestNode) return { ctx, mix: walkieMixNode, dest: walkieDestNode };
   8346 
   8347   if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") {
   8348     throw new Error("Microphone is not supported in this browser.");
   8349   }
   8350   const host = String(location.hostname || "").toLowerCase();
   8351   const isLocal =
   8352     host === "localhost" ||
   8353     host === "127.0.0.1" ||
   8354     host === "::1" ||
   8355     host.startsWith("192.168.") ||
   8356     host.startsWith("10.") ||
   8357     host.startsWith("172.16.") ||
   8358     host.startsWith("172.17.") ||
   8359     host.startsWith("172.18.") ||
   8360     host.startsWith("172.19.") ||
   8361     host.startsWith("172.20.") ||
   8362     host.startsWith("172.21.") ||
   8363     host.startsWith("172.22.") ||
   8364     host.startsWith("172.23.") ||
   8365     host.startsWith("172.24.") ||
   8366     host.startsWith("172.25.") ||
   8367     host.startsWith("172.26.") ||
   8368     host.startsWith("172.27.") ||
   8369     host.startsWith("172.28.") ||
   8370     host.startsWith("172.29.") ||
   8371     host.startsWith("172.30.") ||
   8372     host.startsWith("172.31.");
   8373   if (!window.isSecureContext && !isLocal) {
   8374     throw new Error("Microphone requires HTTPS (or localhost). Use your Cloudflare tunnel URL.");
   8375   }
   8376 
   8377   if (!walkieMicStream) {
   8378     walkieMicStream = await navigator.mediaDevices.getUserMedia({
   8379       audio: {
   8380         echoCancellation: true,
   8381         noiseSuppression: true,
   8382         autoGainControl: true,
   8383       },
   8384     });
   8385   }
   8386 
   8387   const micSource = new MediaStreamAudioSourceNode(ctx, { mediaStream: walkieMicStream });
   8388   const mix = new GainNode(ctx, { gain: 1 });
   8389   micSource.connect(mix);
   8390 
   8391   let head = mix;
   8392   let tail = null;
   8393   let usedWorklet = false;
   8394 
   8395   if (ctx.audioWorklet) {
   8396     try {
   8397       await ctx.audioWorklet.addModule("/assets/walkie/transmission-processor.js");
   8398       const pre = new AudioWorkletNode(ctx, "transmission-sat", { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1] });
   8399       const hp1 = new BiquadFilterNode(ctx, { type: "highpass", Q: 0.9, frequency: 420 });
   8400       const hp2 = new BiquadFilterNode(ctx, { type: "highpass", Q: 0.9, frequency: 420 });
   8401       const lp1 = new BiquadFilterNode(ctx, { type: "lowpass", Q: 0.9, frequency: 4200 });
   8402       const lp2 = new BiquadFilterNode(ctx, { type: "lowpass", Q: 0.9, frequency: 4200 });
   8403       const dip = new BiquadFilterNode(ctx, { type: "peaking", frequency: 680, Q: 0.8, gain: -1.1 });
   8404       const mid = new BiquadFilterNode(ctx, { type: "peaking", frequency: 1550, Q: 1.25, gain: 2.0 });
   8405       const post = new AudioWorkletNode(ctx, "transmission-post", { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1] });
   8406 
   8407       head.connect(pre);
   8408       pre.connect(hp1);
   8409       hp1.connect(hp2);
   8410       hp2.connect(lp1);
   8411       lp1.connect(lp2);
   8412       lp2.connect(dip);
   8413       dip.connect(mid);
   8414       mid.connect(post);
   8415       tail = post;
   8416 
   8417       pre.parameters.get("drive")?.setValueAtTime(0.32, ctx.currentTime);
   8418       pre.parameters.get("asym")?.setValueAtTime(0.12, ctx.currentTime);
   8419       pre.parameters.get("mix")?.setValueAtTime(1, ctx.currentTime);
   8420 
   8421       post.parameters.get("drive")?.setValueAtTime(0.42, ctx.currentTime);
   8422       post.parameters.get("asym")?.setValueAtTime(0.12, ctx.currentTime);
   8423       post.parameters.get("comp")?.setValueAtTime(0.38, ctx.currentTime);
   8424       post.parameters.get("crush")?.setValueAtTime(0.04, ctx.currentTime);
   8425       post.parameters.get("badAmount")?.setValueAtTime(0.22, ctx.currentTime);
   8426       post.parameters.get("wowDepth")?.setValueAtTime(0.18, ctx.currentTime);
   8427       post.parameters.get("dropRate")?.setValueAtTime(0.18, ctx.currentTime);
   8428       post.parameters.get("dropDepth")?.setValueAtTime(0.25, ctx.currentTime);
   8429       post.parameters.get("crackle")?.setValueAtTime(0.22, ctx.currentTime);
   8430       post.parameters.get("lfoRate")?.setValueAtTime(0.75, ctx.currentTime);
   8431       post.parameters.get("noise")?.setValueAtTime(0.18, ctx.currentTime);
   8432       post.parameters.get("hiss")?.setValueAtTime(0.16, ctx.currentTime);
   8433       post.parameters.get("noiseColor")?.setValueAtTime(0.15, ctx.currentTime);
   8434       post.parameters.get("outGain")?.setValueAtTime(0.92, ctx.currentTime);
   8435 
   8436       usedWorklet = true;
   8437     } catch {
   8438       usedWorklet = false;
   8439     }
   8440   }
   8441 
   8442   if (!usedWorklet) {
   8443     const hp = new BiquadFilterNode(ctx, { type: "highpass", Q: 0.85, frequency: 420 });
   8444     const lp = new BiquadFilterNode(ctx, { type: "lowpass", Q: 0.85, frequency: 4200 });
   8445     const comp = new DynamicsCompressorNode(ctx, { threshold: -22, knee: 28, ratio: 5.2, attack: 0.004, release: 0.18 });
   8446     const shaper = new WaveShaperNode(ctx, {
   8447       curve: (() => {
   8448         const n = 512;
   8449         const c = new Float32Array(n);
   8450         for (let i = 0; i < n; i++) {
   8451           const x = (i / (n - 1)) * 2 - 1;
   8452           c[i] = Math.tanh(x * 2.4);
   8453         }
   8454         return c;
   8455       })(),
   8456       oversample: "2x",
   8457     });
   8458     head.connect(hp);
   8459     hp.connect(lp);
   8460     lp.connect(comp);
   8461     comp.connect(shaper);
   8462     tail = shaper;
   8463   }
   8464 
   8465   const dest = new MediaStreamAudioDestinationNode(ctx);
   8466   tail.connect(dest);
   8467 
   8468   walkieMixNode = mix;
   8469   walkieDestNode = dest;
   8470   return { ctx, mix, dest };
   8471 }
   8472 
   8473 function shouldHandleWalkieHotkey(evt) {
   8474   if (!evt) return false;
   8475   if (evt.repeat) return false;
   8476   if (evt.code !== "Backquote") return false;
   8477   const tag = String(document.activeElement?.tagName || "").toLowerCase();
   8478   if (tag === "input" || tag === "textarea") return false;
   8479   if (document.activeElement?.isContentEditable) {
   8480     const el = document.activeElement;
   8481     if (el && el === chatEditor && canWalkieTalkNow()) return true;
   8482     return false;
   8483   }
   8484   return true;
   8485 }
   8486 
   8487 function isTextEntryFocused() {
   8488   const el = document.activeElement;
   8489   if (!el) return false;
   8490   const tag = String(el.tagName || "").toLowerCase();
   8491   if (tag === "textarea") return true;
   8492   if (tag === "input") {
   8493     const type = String(el.getAttribute?.("type") || "text").toLowerCase();
   8494     return !["button", "checkbox", "color", "file", "hidden", "radio", "range", "reset", "submit"].includes(type);
   8495   }
   8496   return Boolean(el.isContentEditable);
   8497 }
   8498 
   8499 function shouldSubmitChatOnEnter(evt) {
   8500   if (!evt || evt.key !== "Enter") return false;
   8501   const mode = readChatEnterModePref();
   8502   if (mode === "enter") return !(evt.shiftKey || evt.altKey || evt.ctrlKey || evt.metaKey);
   8503   return Boolean(evt.ctrlKey || evt.metaKey);
   8504 }
   8505 
   8506 function cycleLayoutPresetBy(step) {
   8507   if (!layoutPresetEl || !rackLayoutEnabled || layoutPresetEl.disabled) return;
   8508   const options = Array.from(layoutPresetEl.options || [])
   8509     .map((opt) => String(opt.value || "").trim())
   8510     .filter((v) => v);
   8511   if (!options.length) return;
   8512   const current = resolvePresetKey(String(layoutPresetEl.value || rackLayoutState?.presetId || "onboardingDefault"));
   8513   let idx = options.indexOf(current);
   8514   if (idx < 0) idx = 0;
   8515   const len = options.length;
   8516   const next = options[(idx + step + len) % len];
   8517   if (!next) return;
   8518   layoutPresetEl.value = next;
   8519   applyPreset(next);
   8520 }
   8521 
   8522 let hotkeyPanelContext = "";
   8523 function updateHotkeyPanelContextFromTarget(target) {
   8524   const el = target instanceof HTMLElement ? target : null;
   8525   if (!el) return;
   8526   if (el.closest("#hivesPanel")) {
   8527     hotkeyPanelContext = "hives";
   8528     return;
   8529   }
   8530   if (el.closest("aside.chat") || el.closest(".chatInstance") || el.closest("[data-panel-id^='chat:post:']")) {
   8531     hotkeyPanelContext = "chat";
   8532   }
   8533 }
   8534 
   8535 function activePanelContextForHotkeys() {
   8536   if (isMobileScreenMode() && appRoot) {
   8537     const mobile = String(appRoot.getAttribute("data-mobile-screen") || "").trim();
   8538     if (mobile === "hives") return "hives";
   8539     if (mobile === "chat" || (mobile === "host" && mobileHostPanelId === "chat")) return "chat";
   8540   }
   8541   const ae = document.activeElement instanceof HTMLElement ? document.activeElement : null;
   8542   if (ae) {
   8543     if (ae.closest("#hivesPanel")) return "hives";
   8544     if (ae.closest("aside.chat") || ae.closest(".chatInstance") || ae.closest("[data-panel-id^='chat:post:']")) return "chat";
   8545   }
   8546   return hotkeyPanelContext || "";
   8547 }
   8548 
   8549 function cycleHiveViewBy(step) {
   8550   if (!hiveTabsEl) return false;
   8551   const views = Array.from(hiveTabsEl.querySelectorAll("button[data-hiveview]:not([disabled])"))
   8552     .map((b) => String(b.getAttribute("data-hiveview") || "").trim())
   8553     .filter(Boolean);
   8554   if (!views.length) return false;
   8555   let idx = views.indexOf(String(activeHiveView || "all"));
   8556   if (idx < 0) idx = 0;
   8557   const len = views.length;
   8558   const next = views[(idx + step + len) % len];
   8559   if (!next || next === activeHiveView) return false;
   8560   activeHiveView = next;
   8561   renderFeed();
   8562   return true;
   8563 }
   8564 
   8565 function cycleChatContextBy(step) {
   8566   renderChatContextSelect();
   8567   if (!(chatContextSelectEl instanceof HTMLSelectElement)) return false;
   8568   const items = [
   8569     "__list__",
   8570     ...Array.from(chatContextSelectEl.options || [])
   8571       .map((o) => String(o.value || "").trim())
   8572       .filter((v) => v && (v.startsWith("dm:") || v.startsWith("post:"))),
   8573   ];
   8574   if (items.length <= 1) return false;
   8575   const current = activeDmThreadId ? `dm:${activeDmThreadId}` : activeChatPostId ? `post:${activeChatPostId}` : "__list__";
   8576   let idx = items.indexOf(current);
   8577   if (idx < 0) idx = 0;
   8578   const len = items.length;
   8579   const next = items[(idx + step + len) % len];
   8580   if (!next || next === current) return false;
   8581   if (next === "__list__") {
   8582     if (activeChatPostId && ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false }));
   8583     activeChatPostId = null;
   8584     activeDmThreadId = null;
   8585     activeMapsRoomId = "";
   8586     activeMapsRoomTitle = "";
   8587     setReplyToMessage(null);
   8588     renderChatPanel(true);
   8589     return true;
   8590   }
   8591   if (next.startsWith("dm:")) {
   8592     return openChatContextValue(next, { preserveFocus: false });
   8593   }
   8594   if (next.startsWith("post:")) {
   8595     return openChatContextValue(next, { preserveFocus: false });
   8596   }
   8597   return false;
   8598 }
   8599 
   8600 function canWalkieTalkNow() {
   8601   if (!loggedInUser || !ws || ws.readyState !== WebSocket.OPEN) return false;
   8602   if (!activeChatPostId) return false;
   8603   const post = posts.get(activeChatPostId);
   8604   if (!post || post.deleted) return false;
   8605   return String(post.mode || post.chatMode || "").toLowerCase() === "walkie";
   8606 }
   8607 
   8608 async function startWalkieRecording() {
   8609   if (walkieRecording) return;
   8610   if (!canWalkieTalkNow()) return;
   8611   try {
   8612     if (walkieStatusEl) walkieStatusEl.textContent = "Requesting microphone...";
   8613     const { ctx, mix, dest } = await ensureWalkieGraph();
   8614     if (ctx.state === "suspended") await ctx.resume();
   8615 
   8616     walkieChunks = [];
   8617     const stream = dest.stream;
   8618     const preferred = [
   8619       "audio/webm;codecs=opus",
   8620       "audio/ogg;codecs=opus",
   8621       "audio/webm",
   8622       "audio/ogg",
   8623     ];
   8624     let mimeType = "";
   8625     for (const t of preferred) {
   8626       if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(t)) {
   8627         mimeType = t;
   8628         break;
   8629       }
   8630     }
   8631     const rec = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
   8632     walkieRecorder = rec;
   8633     walkieStartAt = Date.now();
   8634     walkieRecording = true;
   8635     if (walkieBarEl) walkieBarEl.classList.add("isRecording");
   8636     if (walkieStatusEl) walkieStatusEl.textContent = "Recording... release to send.";
   8637 
   8638     const dispatch = await ensureWalkieDispatchBuffer();
   8639     if (dispatch) {
   8640       const src = new AudioBufferSourceNode(ctx, { buffer: dispatch });
   8641       const g = new GainNode(ctx, { gain: 0.75 });
   8642       src.connect(g);
   8643       g.connect(mix);
   8644       src.start();
   8645       // Local feedback so user hears the click (quiet).
   8646       const mon = new GainNode(ctx, { gain: 0.10 });
   8647       g.connect(mon);
   8648       mon.connect(ctx.destination);
   8649     }
   8650 
   8651     rec.addEventListener("dataavailable", (e) => {
   8652       if (e.data && e.data.size > 0) walkieChunks.push(e.data);
   8653     });
   8654     rec.addEventListener("stop", async () => {
   8655       const tookMs = Date.now() - walkieStartAt;
   8656       walkieRecording = false;
   8657       if (walkieBarEl) walkieBarEl.classList.remove("isRecording");
   8658       if (walkieStatusEl) walkieStatusEl.textContent = "Processing...";
   8659 
   8660       // Give some browsers a tick to deliver the final dataavailable.
   8661       await new Promise((r) => window.setTimeout(r, 0));
   8662       const blob = new Blob(walkieChunks, { type: rec.mimeType || "audio/webm" });
   8663       walkieChunks = [];
   8664       if (!blob || blob.size < 800 || tookMs < 160) {
   8665         if (walkieStatusEl) walkieStatusEl.textContent = "";
   8666         toast("Walkie Talkie", "No audio captured. Check mic permissions/input and try again.");
   8667         return;
   8668       }
   8669 
   8670       const ext = (rec.mimeType || "").includes("ogg") ? "ogg" : "webm";
   8671       const file = new File([blob], `walkie-${Date.now()}.${ext}`, { type: rec.mimeType || blob.type || "audio/webm" });
   8672       if (walkieStatusEl) walkieStatusEl.textContent = "Uploading...";
   8673       const url = await uploadMediaFile(file, "audio");
   8674       if (!url) {
   8675         if (walkieStatusEl) walkieStatusEl.textContent = "";
   8676         return;
   8677       }
   8678       const post = posts.get(activeChatPostId);
   8679       if (!post || post.deleted) {
   8680         if (walkieStatusEl) walkieStatusEl.textContent = "";
   8681         return;
   8682       }
   8683       ws.send(JSON.stringify({ type: "chatMessage", postId: activeChatPostId, text: "", html: `<audio controls preload=\"none\" src=\"${escapeHtml(url)}\"></audio>` }));
   8684       if (walkieStatusEl) walkieStatusEl.textContent = "Sent.";
   8685       window.setTimeout(() => {
   8686         if (walkieStatusEl && walkieStatusEl.textContent === "Sent.") walkieStatusEl.textContent = "";
   8687       }, 900);
   8688       playSfx("ping", { volume: 0.22 });
   8689     });
   8690 
   8691     // Timeslice helps avoid empty blobs in some browsers.
   8692     rec.start(250);
   8693   } catch (e) {
   8694     walkieRecording = false;
   8695     if (walkieBarEl) walkieBarEl.classList.remove("isRecording");
   8696     const name = String(e?.name || "");
   8697     const msg = String(e?.message || "");
   8698     const pretty =
   8699       name === "NotAllowedError"
   8700         ? "Microphone permission denied. Allow mic access in your browser settings."
   8701         : name === "NotFoundError"
   8702           ? "No microphone device found."
   8703           : name === "NotReadableError"
   8704             ? "Microphone is in use by another app."
   8705             : msg || "Microphone recording failed.";
   8706     if (walkieStatusEl) walkieStatusEl.textContent = "";
   8707     toast("Walkie Talkie", pretty);
   8708   }
   8709 }
   8710 
   8711 async function stopWalkieRecording() {
   8712   if (!walkieRecorder || !walkieRecording) return;
   8713   try {
   8714     const { ctx, mix } = await ensureWalkieGraph();
   8715     const dispatch = await ensureWalkieDispatchBuffer();
   8716     if (dispatch) {
   8717       const src = new AudioBufferSourceNode(ctx, { buffer: dispatch });
   8718       const g = new GainNode(ctx, { gain: 0.55 });
   8719       src.connect(g);
   8720       g.connect(mix);
   8721       src.start();
   8722       const mon = new GainNode(ctx, { gain: 0.08 });
   8723       g.connect(mon);
   8724       mon.connect(ctx.destination);
   8725       window.setTimeout(() => {
   8726         try {
   8727           if (walkieRecorder && walkieRecorder.state !== "inactive") walkieRecorder.stop();
   8728         } catch {
   8729           // ignore
   8730         }
   8731         walkieRecorder = null;
   8732       }, 160);
   8733       return;
   8734     }
   8735   } catch {
   8736     // ignore
   8737   }
   8738   try {
   8739     if (walkieRecorder && walkieRecorder.state !== "inactive") walkieRecorder.stop();
   8740   } catch {
   8741     // ignore
   8742   }
   8743   walkieRecorder = null;
   8744 }
   8745 
   8746 function insertAudioTag(target, srcUrl) {
   8747   if (!srcUrl) return;
   8748   target.focus();
   8749   const safe = escapeHtml(srcUrl);
   8750   document.execCommand("insertHTML", false, `<audio controls preload="none" src="${safe}"></audio>`);
   8751 }
   8752 
   8753 function installDropUpload(targetEl, { allowImages = true, allowAudio = true } = {}) {
   8754   if (!targetEl) return;
   8755   const setActive = (on) => {
   8756     try {
   8757       targetEl.classList.toggle("isDropActive", Boolean(on));
   8758     } catch {
   8759       // ignore
   8760     }
   8761   };
   8762   targetEl.addEventListener("dragover", (e) => {
   8763     if (!e.dataTransfer) return;
   8764     if (!e.dataTransfer.types || !Array.from(e.dataTransfer.types).includes("Files")) return;
   8765     e.preventDefault();
   8766     setActive(true);
   8767   });
   8768   targetEl.addEventListener("dragleave", () => setActive(false));
   8769   targetEl.addEventListener("drop", async (e) => {
   8770     setActive(false);
   8771     const files = e.dataTransfer?.files ? Array.from(e.dataTransfer.files) : [];
   8772     if (!files.length) return;
   8773     e.preventDefault();
   8774     e.stopPropagation();
   8775 
   8776     for (const file of files.slice(0, 4)) {
   8777       const type = String(file.type || "").toLowerCase();
   8778       const name = String(file.name || "").toLowerCase();
   8779       const isImg = type.startsWith("image/") || /\.(gif|png|jpe?g|webp)$/.test(name);
   8780       const isAud = type.startsWith("audio/") || /\.(mp3|wav|ogg|m4a|aac|webm)$/.test(name);
   8781       if (isImg && allowImages) {
   8782         const url = await uploadMediaFile(file, "image");
   8783         if (!url) continue;
   8784         targetEl.focus();
   8785         document.execCommand("insertImage", false, url);
   8786       } else if (isAud && allowAudio) {
   8787         const url = await uploadMediaFile(file, "audio");
   8788         if (!url) continue;
   8789         insertAudioTag(targetEl, url);
   8790       }
   8791     }
   8792   });
   8793 }
   8794 
   8795 document.querySelector(".editorShell .toolbar")?.addEventListener("click", (e) => {
   8796   const btn = e.target.closest("button");
   8797   if (!btn) return;
   8798   const cmd = btn.getAttribute("data-cmd");
   8799   if (cmd) {
   8800     runCmd(editor, cmd);
   8801     return;
   8802   }
   8803   if (btn.getAttribute("data-link")) {
   8804     runLink(editor);
   8805     return;
   8806   }
   8807   if (btn.getAttribute("data-postimg")) {
   8808     postImageInput?.click();
   8809     return;
   8810   }
   8811   if (btn.getAttribute("data-postaudio")) {
   8812     postAudioInput?.click();
   8813     return;
   8814   }
   8815   if (btn.getAttribute("data-postemoji")) runEmoji(editor);
   8816 });
   8817 
   8818 document.addEventListener("click", (e) => {
   8819   const btn = e.target.closest?.("button");
   8820   if (!btn) return;
   8821   const toolbar = btn.closest?.(".chatComposer .toolbar");
   8822   if (!toolbar) return;
   8823   const composer = toolbar.closest?.(".chatComposer");
   8824   if (!composer) return;
   8825   const targetEditor = composer.querySelector?.(".chatEditor") || chatEditor;
   8826   if (!(targetEditor instanceof HTMLElement)) return;
   8827   chatUploadTargetEditor = targetEditor;
   8828 
   8829   const cmd = btn.getAttribute("data-chatcmd");
   8830   if (cmd) {
   8831     runCmd(targetEditor, cmd);
   8832     return;
   8833   }
   8834   if (btn.getAttribute("data-chatlink")) {
   8835     runLink(targetEditor);
   8836     return;
   8837   }
   8838   if (btn.getAttribute("data-chatimg")) {
   8839     chatImageInput?.click();
   8840     return;
   8841   }
   8842   if (btn.getAttribute("data-chataudio")) {
   8843     chatAudioInput?.click();
   8844     return;
   8845   }
   8846   if (btn.getAttribute("data-chatemoji")) runEmoji(targetEditor);
   8847 });
   8848 
   8849 profileBioToolbar?.addEventListener("click", (e) => {
   8850   const btn = e.target.closest("button");
   8851   if (!btn) return;
   8852   const cmd = btn.getAttribute("data-profilecmd");
   8853   if (cmd) {
   8854     runCmd(profileBioEditor, cmd);
   8855     return;
   8856   }
   8857   if (btn.getAttribute("data-profilelink")) {
   8858     runLink(profileBioEditor);
   8859     return;
   8860   }
   8861   if (btn.getAttribute("data-profileimg")) {
   8862     profileBioImageFileInput?.click();
   8863     return;
   8864   }
   8865   if (btn.getAttribute("data-profileaudio")) {
   8866     profileBioAudioFileInput?.click();
   8867     return;
   8868   }
   8869   if (btn.getAttribute("data-profileemoji")) runEmoji(profileBioEditor);
   8870 });
   8871 
   8872 editModalToolbar?.addEventListener("click", (e) => {
   8873   const btn = e.target.closest("button");
   8874   if (!btn) return;
   8875   const cmd = btn.getAttribute("data-editcmd");
   8876   if (cmd) {
   8877     runCmd(editModalEditor, cmd);
   8878     return;
   8879   }
   8880   if (btn.getAttribute("data-editlink")) {
   8881     runLink(editModalEditor);
   8882     return;
   8883   }
   8884   if (btn.getAttribute("data-editimg")) {
   8885     editModalImageInput?.click();
   8886     return;
   8887   }
   8888   if (btn.getAttribute("data-editaudio")) {
   8889     editModalAudioInput?.click();
   8890     return;
   8891   }
   8892   if (btn.getAttribute("data-editemoji")) runEmoji(editModalEditor);
   8893 });
   8894 
   8895 editModalImageInput?.addEventListener("change", async () => {
   8896   const file = editModalImageInput.files && editModalImageInput.files[0] ? editModalImageInput.files[0] : null;
   8897   editModalImageInput.value = "";
   8898   if (!file) return;
   8899   const url = await uploadMediaFile(file, "image");
   8900   if (!url) return;
   8901   editModalEditor?.focus();
   8902   document.execCommand("insertImage", false, url);
   8903 });
   8904 
   8905 editModalAudioInput?.addEventListener("change", async () => {
   8906   const file = editModalAudioInput.files && editModalAudioInput.files[0] ? editModalAudioInput.files[0] : null;
   8907   editModalAudioInput.value = "";
   8908   if (!file) return;
   8909   const url = await uploadMediaFile(file, "audio");
   8910   if (!url) return;
   8911   insertAudioTag(editModalEditor, url);
   8912 });
   8913 
   8914 editModal?.addEventListener("click", (e) => {
   8915   if (e.target?.getAttribute?.("data-modalclose")) setEditModalOpen(false);
   8916 });
   8917 
   8918 editModalCloseBtn?.addEventListener("click", () => setEditModalOpen(false));
   8919 editModalCancelBtn?.addEventListener("click", () => setEditModalOpen(false));
   8920 
   8921 editModalSaveBtn?.addEventListener("click", () => {
   8922   if (!editContext) return;
   8923   if (!editModalEditor) return;
   8924   const { html, text, hasImg, hasAudio } = collectEditorPayload(editModalEditor);
   8925   if (!text && !hasImg && !hasAudio) {
   8926     if (editModalStatus) editModalStatus.textContent = "Please add text, an image, or audio.";
   8927     editModalEditor.focus();
   8928     return;
   8929   }
   8930   if (editContext.kind === "post") {
   8931     const title = String(editModalPostTitleInput?.value || "")
   8932       .replace(/\s+/g, " ")
   8933       .trim()
   8934       .slice(0, 96);
   8935     if (!title) {
   8936       if (editModalStatus) editModalStatus.textContent = "Title is required.";
   8937       editModalPostTitleInput?.focus();
   8938       return;
   8939     }
   8940     const post = posts.get(editContext.postId);
   8941     const wasProtected = Boolean(post?.protected);
   8942     const wantsProtected = Boolean(editModalProtectedToggle?.checked);
   8943     const password = String(editModalPasswordInput?.value || "");
   8944     if (wantsProtected && !wasProtected && password.trim().length < 4) {
   8945       if (editModalStatus) editModalStatus.textContent = "Set a password (min 4 chars) to protect this post.";
   8946       editModalPasswordInput?.focus();
   8947       return;
   8948     }
   8949     const keywords = parseKeywordsInput(editModalKeywordsInput?.value || "");
   8950     const collectionId = String(editModalCollectionSelect?.value || post?.collectionId || "general");
   8951     const mode = Boolean(editModalWalkieToggle?.checked) ? "walkie" : "text";
   8952     ws.send(
   8953       JSON.stringify({
   8954         type: "editPost",
   8955         postId: editContext.postId,
   8956         title,
   8957         content: text,
   8958         contentHtml: html,
   8959         keywords,
   8960         collectionId,
   8961         protected: wantsProtected,
   8962         password: password.trim(),
   8963         mode
   8964       })
   8965     );
   8966     setEditModalOpen(false);
   8967     return;
   8968   }
   8969   if (editContext.kind === "chat") {
   8970     ws.send(JSON.stringify({ type: "editChatMessage", postId: editContext.postId, messageId: editContext.messageId, text, html }));
   8971     setEditModalOpen(false);
   8972   }
   8973 });
   8974 
   8975 authForm.addEventListener("submit", (e) => {
   8976   e.preventDefault();
   8977   const username = authUser.value.trim();
   8978   const password = authPass.value;
   8979   ws.send(JSON.stringify({ type: "login", username, password }));
   8980 });
   8981 
   8982 registerBtn.addEventListener("click", () => {
   8983   const username = authUser.value.trim();
   8984   const password = authPass.value;
   8985   const code = authCode.value.trim();
   8986   ws.send(JSON.stringify({ type: "register", username, password, code }));
   8987 });
   8988 
   8989 logoutBtn.addEventListener("click", () => ws.send(JSON.stringify({ type: "logout" })));
   8990 
   8991 profileImageInput.addEventListener("change", async () => {
   8992   profileStatus.textContent = "";
   8993   const file = profileImageInput.files && profileImageInput.files[0] ? profileImageInput.files[0] : null;
   8994   if (!file) return;
   8995   try {
   8996     pendingProfileImage = await resizeImageToSquareDataUrl(file, 96);
   8997     if (pendingProfileImage) {
   8998       profilePreview.src = pendingProfileImage;
   8999       profilePreview.classList.add("hasImg");
   9000     }
   9001   } catch {
   9002     profileStatus.textContent = "Failed to load image.";
   9003   }
   9004 });
   9005 
   9006 removeProfileImageBtn.addEventListener("click", () => {
   9007   pendingProfileImage = "";
   9008   profilePreview.removeAttribute("src");
   9009   profilePreview.classList.remove("hasImg");
   9010 });
   9011 
   9012 saveProfileBtn.addEventListener("click", () => {
   9013   profileStatus.textContent = "";
   9014   const color = nameColorInput.value;
   9015   ws.send(JSON.stringify({ type: "updateProfile", image: pendingProfileImage, color }));
   9016 });
   9017 
   9018 profileBackBtn?.addEventListener("click", () => setCenterView("hives"));
   9019 
   9020 profileEditToggleBtn?.addEventListener("click", () => {
   9021   isEditingProfile = !isEditingProfile;
   9022   if (profileEditToggleBtn) profileEditToggleBtn.textContent = isEditingProfile ? "Close editor" : "Edit profile";
   9023   renderCenterPanels();
   9024 });
   9025 
   9026 profileCancelBtn?.addEventListener("click", () => {
   9027   isEditingProfile = false;
   9028   if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile";
   9029   renderCenterPanels();
   9030 });
   9031 
   9032 profileAddLinkBtn?.addEventListener("click", () => {
   9033   const links = profileLinksFromEditor();
   9034   links.push({ label: "Link", url: "https://" });
   9035   renderProfileLinksEditor(links);
   9036 });
   9037 
   9038 profileLinksEditor?.addEventListener("click", (e) => {
   9039   const btn = e.target.closest("[data-linkremove]");
   9040   if (!btn) return;
   9041   const idx = Number(btn.getAttribute("data-linkremove") || -1);
   9042   const links = profileLinksFromEditor();
   9043   if (idx < 0 || idx >= links.length) return;
   9044   links.splice(idx, 1);
   9045   renderProfileLinksEditor(links);
   9046 });
   9047 
   9048 profileThemeSongUploadBtn?.addEventListener("click", () => profileThemeSongFileInput?.click());
   9049 
   9050 profileThemeSongClearBtn?.addEventListener("click", () => syncProfileSongPreview(""));
   9051 
   9052 profileThemeSongFileInput?.addEventListener("change", async () => {
   9053   const file = profileThemeSongFileInput.files && profileThemeSongFileInput.files[0] ? profileThemeSongFileInput.files[0] : null;
   9054   profileThemeSongFileInput.value = "";
   9055   if (!file) return;
   9056   const url = await uploadMediaFile(file, "audio");
   9057   if (!url) return;
   9058   syncProfileSongPreview(url);
   9059 });
   9060 
   9061 profileBioImageFileInput?.addEventListener("change", async () => {
   9062   const file = profileBioImageFileInput.files && profileBioImageFileInput.files[0] ? profileBioImageFileInput.files[0] : null;
   9063   profileBioImageFileInput.value = "";
   9064   if (!file) return;
   9065   const url = await uploadMediaFile(file, "image");
   9066   if (!url) return;
   9067   profileBioEditor?.focus();
   9068   document.execCommand("insertImage", false, url);
   9069 });
   9070 
   9071 profileBioAudioFileInput?.addEventListener("change", async () => {
   9072   const file = profileBioAudioFileInput.files && profileBioAudioFileInput.files[0] ? profileBioAudioFileInput.files[0] : null;
   9073   profileBioAudioFileInput.value = "";
   9074   if (!file) return;
   9075   const url = await uploadMediaFile(file, "audio");
   9076   if (!url) return;
   9077   insertAudioTag(profileBioEditor, url);
   9078 });
   9079 
   9080 profileSaveBtn?.addEventListener("click", () => {
   9081   if (!loggedInUser || !activeProfile || activeProfile.username !== loggedInUser) return;
   9082   const pronouns = String(profilePronounsInput?.value || "")
   9083     .replace(/\s+/g, " ")
   9084     .trim()
   9085     .slice(0, 40);
   9086   const bioHtml = String(profileBioEditor?.innerHTML || "");
   9087   const themeSongUrl = String(profileThemeSongUrlInput?.value || "").trim();
   9088   const links = profileLinksFromEditor();
   9089   ws.send(JSON.stringify({ type: "updateProfile", pronouns, bioHtml, themeSongUrl, links }));
   9090 });
   9091 
   9092 newPostForm.addEventListener("submit", (e) => {
   9093   e.preventDefault();
   9094   if (onboardingNeedsAcceptanceNow()) {
   9095     toast("Onboarding", "Accept server rules in Account before creating hives.");
   9096     return;
   9097   }
   9098   const title = String(postTitleInput?.value || "")
   9099     .replace(/\s+/g, " ")
   9100     .trim()
   9101     .slice(0, 96);
   9102   if (!title) {
   9103     toast("Post title", "Please add a short title.");
   9104     postTitleInput?.focus();
   9105     return;
   9106   }
   9107   const html = editor.innerHTML.trim();
   9108   const text = editor.innerText.trim();
   9109   const hasImg = Boolean(editor.querySelector("img"));
   9110   const hasAudio = Boolean(editor.querySelector("audio"));
   9111   if (!text && !hasImg && !hasAudio) {
   9112     toast("Post body", "Please add body text, image, or audio.");
   9113     editor.focus();
   9114     return;
   9115   }
   9116 
   9117   const keywords = parseKeywords(keywordsEl.value);
   9118   const collectionId = String(postCollectionEl?.value || "").trim();
   9119   if (!collectionId) {
   9120     toast("Collection", "Please choose a collection.");
   9121     return;
   9122   }
   9123   const ttlMinutes = Number(ttlMinutesEl.value || 60);
   9124   const canMakePermanent =
   9125     loggedInRole === "owner" || loggedInRole === "moderator" || Boolean(normalizeInstanceBranding(instanceBranding).allowMemberPermanentPosts);
   9126   const minMinutes = canMakePermanent ? 0 : 1;
   9127   const ttl = Math.max(minMinutes, Math.min(2880, Math.floor(ttlMinutes))) * 60_000;
   9128 
   9129   const isProtected = Boolean(isProtectedEl?.checked);
   9130   const password = typeof postPasswordEl?.value === "string" ? postPasswordEl.value : "";
   9131   if (isProtected && password.trim().length < 4) {
   9132     toast("Protected post", "Password must be at least 4 characters.");
   9133     return;
   9134   }
   9135   const mode = Boolean(isWalkieEl?.checked) ? "walkie" : "text";
   9136   ws.send(
   9137     JSON.stringify({ type: "newPost", title, collectionId, contentHtml: html, content: text, keywords, ttl, protected: isProtected, password, mode })
   9138   );
   9139   if (postTitleInput) postTitleInput.value = "";
   9140   editor.innerHTML = "";
   9141   if (postPasswordEl) postPasswordEl.value = "";
   9142   if (isProtectedEl) isProtectedEl.checked = false;
   9143   if (isWalkieEl) isWalkieEl.checked = false;
   9144   if (isMobileSwipeMode()) setComposerOpen(false);
   9145 });
   9146 
   9147 toggleComposerBtn?.addEventListener("click", () => {
   9148   if (isMobileScreenMode()) {
   9149     setComposerOpen(true);
   9150     const layout = loadMobileLayout();
   9151     layout.active = "composer";
   9152     saveMobileLayout(layout);
   9153     setMobileScreen("composer");
   9154     renderMobileNav();
   9155     if (composerOpen) (postTitleInput || editor)?.focus();
   9156     return;
   9157   }
   9158   setComposerOpen(!composerOpen);
   9159   if (composerOpen) (postTitleInput || editor)?.focus();
   9160 });
   9161 toggleComposerInlineBtn?.addEventListener("click", () => setComposerOpen(false));
   9162 
   9163 function submitChat() {
   9164   if (onboardingNeedsAcceptanceNow()) {
   9165     toast("Onboarding", "Accept server rules in Account before chatting.");
   9166     return;
   9167   }
   9168   const html = chatEditor.innerHTML.trim();
   9169   const text = chatEditor.innerText.trim();
   9170   const hasImg = Boolean(chatEditor.querySelector("img"));
   9171   const hasAudio = Boolean(chatEditor.querySelector("audio"));
   9172   if (activeDmThreadId) {
   9173     if (!text && !hasImg && !hasAudio) return;
   9174     if (!loggedInUser) {
   9175       toast("Sign in required", "Sign in to send DMs.");
   9176       return;
   9177     }
   9178     const thread = dmThreadsById.get(activeDmThreadId) || null;
   9179     if (!thread) {
   9180       toast("DMs", "This DM thread is unavailable.");
   9181       return;
   9182     }
   9183     if (String(thread.status || "") !== "active") {
   9184       toast("DMs", "You can only send messages after the DM is accepted.");
   9185       return;
   9186     }
   9187     ws.send(JSON.stringify({ type: "dmSend", threadId: activeDmThreadId, text, html }));
   9188     chatEditor.innerHTML = "";
   9189     return;
   9190   }
   9191 
   9192   if (isMapChatActive()) {
   9193     if (!text && !hasImg && !hasAudio) return;
   9194     if (hasImg || hasAudio) {
   9195       toast("Maps chat", "Maps chat is text-only for now.");
   9196       return;
   9197     }
   9198     if (!loggedInUser) {
   9199       toast("Sign in required", "Sign in to chat in maps.");
   9200       return;
   9201     }
   9202     try {
   9203       ws.send(JSON.stringify({ type: "plugin:maps:chatSend", mapId: activeMapsRoomId, scope: normalizeMapChatScope(activeMapsChatScope), text }));
   9204       // Optimistic add so it feels instant (server will also echo back).
   9205       pushMapChatMessage(activeMapsRoomId, activeMapsChatScope, {
   9206         id: `local_${Date.now()}_${Math.random().toString(16).slice(2)}`,
   9207         fromUser: loggedInUser,
   9208         text,
   9209         createdAt: Date.now(),
   9210       });
   9211     } catch {
   9212       // ignore
   9213     }
   9214     chatEditor.innerHTML = "";
   9215     setReplyToMessage(null);
   9216     renderChatPanel(true);
   9217     return;
   9218   }
   9219 
   9220   if (!activeChatPostId || (!text && !hasImg && !hasAudio)) return;
   9221   const post = posts.get(activeChatPostId);
   9222   if (post && String(post.mode || post.chatMode || "").toLowerCase() === "walkie") {
   9223     toast("Walkie Talkie", "This hive is walkie-only. Hold ~ to talk.");
   9224     return;
   9225   }
   9226   if (post?.readOnly && !(loggedInRole === "owner" || loggedInRole === "moderator")) {
   9227     toast("Read-only", "This hive is read-only.");
   9228     return;
   9229   }
   9230   if (post?.deleted) {
   9231     toast("Unavailable", "This post was deleted.");
   9232     return;
   9233   }
   9234   const replyToId = replyToMessage?.id ? String(replyToMessage.id) : "";
   9235   const wantsMod = Boolean(canModerate && chatModToggleEl instanceof HTMLInputElement && chatModToggleEl.checked);
   9236   ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false }));
   9237   ws.send(JSON.stringify({ type: "chatMessage", postId: activeChatPostId, text, html, replyToId, asMod: wantsMod }));
   9238   chatEditor.innerHTML = "";
   9239   setReplyToMessage(null);
   9240 }
   9241 
   9242 filterKeywordsEl.addEventListener("input", () => renderFeed());
   9243 filterAuthorEl?.addEventListener("input", () => renderFeed());
   9244 sortByEl?.addEventListener("change", () => {
   9245   updateMobileSortCycleLabel();
   9246   renderFeed();
   9247 });
   9248 hiveTabsEl?.addEventListener("click", (e) => {
   9249   const btn = e.target.closest("[data-hiveview]");
   9250   if (!btn) return;
   9251   const next = btn.getAttribute("data-hiveview") || "all";
   9252   if (!loggedInUser && next !== "all") {
   9253     toast("Sign in required", "Sign in to use Starred and Hidden views.");
   9254     return;
   9255   }
   9256   activeHiveView = next;
   9257   renderFeed();
   9258 });
   9259 clearFilterBtn.addEventListener("click", () => {
   9260   filterKeywordsEl.value = "";
   9261   if (filterAuthorEl) filterAuthorEl.value = "";
   9262   if (sortByEl) sortByEl.value = "activity";
   9263   updateMobileSortCycleLabel();
   9264   activeHiveView = "all";
   9265   renderFeed();
   9266 });
   9267 
   9268 mobileHiveSearchBtn?.addEventListener("click", () => {
   9269   const initial = String(filterAuthorEl?.value || "").trim()
   9270     ? `@${String(filterAuthorEl?.value || "").trim()}`
   9271     : String(filterKeywordsEl?.value || "").trim();
   9272   const raw = prompt("Search hives by @author or keywords:", initial);
   9273   if (raw === null) return;
   9274   const q = String(raw || "").trim();
   9275   if (!q) {
   9276     if (filterAuthorEl) filterAuthorEl.value = "";
   9277     if (filterKeywordsEl) filterKeywordsEl.value = "";
   9278     renderFeed();
   9279     return;
   9280   }
   9281   const parts = q.split(/\s+/).filter(Boolean);
   9282   const authorPart = parts.find((part) => part.startsWith("@")) || (q.startsWith("@") ? q : "");
   9283   const author = authorPart.replace(/^@+/, "").trim();
   9284   const keywordParts = authorPart ? parts.filter((part) => part !== authorPart) : parts;
   9285   if (filterAuthorEl) filterAuthorEl.value = author || "";
   9286   if (filterKeywordsEl) filterKeywordsEl.value = keywordParts.join(", ");
   9287   renderFeed();
   9288 });
   9289 
   9290 mobileSortCycleBtn?.addEventListener("click", () => {
   9291   if (!sortByEl) return;
   9292   const order = ["activity", "popular", "expiring"];
   9293   const current = String(sortByEl.value || "activity");
   9294   const at = Math.max(0, order.indexOf(current));
   9295   const next = order[(at + 1) % order.length];
   9296   sortByEl.value = next;
   9297   updateMobileSortCycleLabel();
   9298   renderFeed();
   9299 });
   9300 
   9301 feedEl.addEventListener("click", (e) => {
   9302   const profileLink = e.target.closest("[data-viewprofile]");
   9303   if (profileLink) {
   9304     const username = profileLink.getAttribute("data-viewprofile") || "";
   9305     if (username) openUserProfile(username);
   9306     return;
   9307   }
   9308 
   9309   const menuBtn = e.target.closest("button[data-postmenu]");
   9310   if (menuBtn) {
   9311     const postId = menuBtn.getAttribute("data-postmenu") || "";
   9312     if (!postId) return;
   9313     const wasOpen = openPostMenuId === postId;
   9314 
   9315     for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden");
   9316     for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false");
   9317 
   9318     if (!wasOpen) {
   9319       const panel = feedEl.querySelector(`[data-postmenu-panel="${cssEscape(postId)}"]`);
   9320       if (panel) panel.classList.remove("hidden");
   9321       menuBtn.setAttribute("aria-expanded", "true");
   9322       openPostMenuId = postId;
   9323     } else {
   9324       openPostMenuId = "";
   9325     }
   9326     return;
   9327   }
   9328 
   9329   const chatBtn = e.target.closest("button[data-chat]");
   9330   if (chatBtn) {
   9331     if (openPostMenuId) {
   9332       for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden");
   9333       for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false");
   9334       openPostMenuId = "";
   9335     }
   9336     const postId = chatBtn.getAttribute("data-chat");
   9337     const post = postId ? posts.get(postId) : null;
   9338     if (post?.locked) unlockPostFlow(postId, true);
   9339     else openChat(postId, { sourceEl: chatBtn });
   9340     return;
   9341   }
   9342 
   9343   const boostBtn = e.target.closest("button[data-boostbtn]");
   9344   if (boostBtn) {
   9345     const postId = boostBtn.getAttribute("data-boostbtn");
   9346     const card = boostBtn.closest(".post");
   9347     const sel = card ? card.querySelector("select[data-boostsel]") : null;
   9348     const boostMs = sel ? Number(sel.value) : 3_600_000;
   9349     ws.send(JSON.stringify({ type: "boostPost", postId, boostMs }));
   9350     return;
   9351   }
   9352 
   9353   const reportPostBtn = e.target.closest("button[data-reportpost]");
   9354   if (reportPostBtn) {
   9355     if (openPostMenuId) {
   9356       for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden");
   9357       for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false");
   9358       openPostMenuId = "";
   9359     }
   9360     const postId = reportPostBtn.getAttribute("data-reportpost") || "";
   9361     if (!postId) return;
   9362     const post = posts.get(postId);
   9363     if (post?.deleted) {
   9364       toast("Unavailable", "This post was deleted.");
   9365       return;
   9366     }
   9367     const reason = promptReason("post report");
   9368     if (!reason) return;
   9369     ws.send(JSON.stringify({ type: "reportCreate", targetType: "post", targetId: postId, postId, reason }));
   9370     return;
   9371   }
   9372 
   9373   const hideBtn = e.target.closest("button[data-hidepost]");
   9374   if (hideBtn) {
   9375     if (openPostMenuId) {
   9376       for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden");
   9377       for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false");
   9378       openPostMenuId = "";
   9379     }
   9380     const postId = hideBtn.getAttribute("data-hidepost") || "";
   9381     if (!postId) return;
   9382     const hidden = prefSet("hiddenPostIds").has(postId);
   9383     ws.send(JSON.stringify({ type: hidden ? "unhidePost" : "hidePost", postId }));
   9384     return;
   9385   }
   9386 
   9387   const react = e.target.closest("[data-react]");
   9388   if (react && react.getAttribute("data-kind") === "post") {
   9389     if (openPostMenuId) {
   9390       for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden");
   9391       for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false");
   9392       openPostMenuId = "";
   9393     }
   9394     const postId = react.getAttribute("data-postid") || "";
   9395     const emoji = react.getAttribute("data-emoji") || "";
   9396     if (!postId || !emoji) return;
   9397     const post = posts.get(postId);
   9398     if (post?.deleted) {
   9399       toast("Unavailable", "This post was deleted.");
   9400       return;
   9401     }
   9402     markReactPulse("post", postId, emoji);
   9403     toggleMyReact("post", postId, emoji);
   9404     ws.send(JSON.stringify({ type: "react", targetType: "post", postId, emoji }));
   9405     renderFeed();
   9406     return;
   9407   }
   9408 
   9409   const editPostBtn = e.target.closest("button[data-editpost]");
   9410   if (editPostBtn) {
   9411     if (openPostMenuId) {
   9412       for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden");
   9413       for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false");
   9414       openPostMenuId = "";
   9415     }
   9416     const postId = editPostBtn.getAttribute("data-editpost") || "";
   9417   const post = postId ? posts.get(postId) : null;
   9418   if (!post || post.deleted || post.locked) return;
   9419   openEditModalForPost(post);
   9420   return;
   9421 }
   9422 
   9423   const deletePostBtn = e.target.closest("button[data-deletepost]");
   9424   if (deletePostBtn) {
   9425     if (openPostMenuId) {
   9426       for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden");
   9427       for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false");
   9428       openPostMenuId = "";
   9429     }
   9430     const postId = deletePostBtn.getAttribute("data-deletepost") || "";
   9431     if (!postId) return;
   9432     const ok = confirm("Delete this post? It will show as deleted.");
   9433     if (!ok) return;
   9434     ws.send(JSON.stringify({ type: "deletePostSelf", postId }));
   9435   }
   9436 });
   9437 
   9438 window.addEventListener("keydown", (e) => {
   9439   if (e.key !== "Escape") return;
   9440   if (!openPostMenuId) return;
   9441   for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden");
   9442   for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false");
   9443   openPostMenuId = "";
   9444 });
   9445 
   9446 window.addEventListener("keydown", (e) => {
   9447   if (e.defaultPrevented) return;
   9448   if (e.repeat) return;
   9449   if (e.key === "?" && !isTextEntryFocused()) {
   9450     e.preventDefault();
   9451     setShortcutHelpOpen(true);
   9452     return;
   9453   }
   9454   if (e.altKey || e.ctrlKey || e.metaKey) return;
   9455   if (isTextEntryFocused()) return;
   9456   const ctx = activePanelContextForHotkeys();
   9457   const plus = e.key === "=" || e.code === "NumpadAdd";
   9458   const minus = e.key === "-" || e.code === "NumpadSubtract";
   9459   if (ctx === "hives" && (plus || minus)) {
   9460     e.preventDefault();
   9461     cycleHiveViewBy(plus ? 1 : -1);
   9462     return;
   9463   }
   9464   if (ctx === "chat" && (plus || minus)) {
   9465     e.preventDefault();
   9466     cycleChatContextBy(plus ? 1 : -1);
   9467     return;
   9468   }
   9469   if (e.key === "[") {
   9470     e.preventDefault();
   9471     cycleLayoutPresetBy(-1);
   9472     return;
   9473   }
   9474   if (e.key === "]") {
   9475     e.preventDefault();
   9476     cycleLayoutPresetBy(1);
   9477   }
   9478 });
   9479 
   9480 window.addEventListener(
   9481   "pointerdown",
   9482   (e) => {
   9483     updateHotkeyPanelContextFromTarget(e.target);
   9484   },
   9485   true
   9486 );
   9487 
   9488 window.addEventListener("click", (e) => {
   9489   if (!openPostMenuId) return;
   9490   const esc = cssEscape(openPostMenuId);
   9491   const inside = e.target?.closest?.(`[data-postmenu-panel="${esc}"], button[data-postmenu="${esc}"]`);
   9492   if (inside) return;
   9493   for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden");
   9494   for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false");
   9495   openPostMenuId = "";
   9496 });
   9497 
   9498 chatMessagesEl.addEventListener("click", (e) => {
   9499   const emptyActionBtn = e.target.closest("button[data-chatemptyopen]");
   9500   if (emptyActionBtn) {
   9501     const target = String(emptyActionBtn.getAttribute("data-chatemptyopen") || "").trim().toLowerCase();
   9502     if (target === "hives") {
   9503       if (isMobileSwipeMode()) {
   9504         setMobilePanel("hives");
   9505       } else {
   9506         const hivesHeader = hivesPanelEl?.querySelector?.(".panelHeader");
   9507         hivesHeader?.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
   9508       }
   9509       return;
   9510     }
   9511     if (target === "people") {
   9512       const peopleEl = getPanelElement("people") || peopleDrawerEl;
   9513       if (peopleEl && typeof undockPanel === "function" && isDocked("people")) undockPanel("people");
   9514       peopleEl?.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
   9515       return;
   9516     }
   9517   }
   9518 
   9519   const mobileChatOpenBtn = e.target.closest("button[data-mobilechatopen]");
   9520   if (mobileChatOpenBtn) {
   9521     const postId = mobileChatOpenBtn.getAttribute("data-mobilechatopen") || "";
   9522     if (postId) openChat(postId);
   9523     return;
   9524   }
   9525 
   9526   const dmAcceptBtn = e.target.closest("button[data-dmaccept]");
   9527   if (dmAcceptBtn) {
   9528     const threadId = dmAcceptBtn.getAttribute("data-dmaccept") || "";
   9529     if (threadId) {
   9530       pendingOpenDmThreadId = threadId;
   9531       ws.send(JSON.stringify({ type: "dmRequestRespond", threadId, accept: true }));
   9532     }
   9533     return;
   9534   }
   9535   const dmDeclineBtn = e.target.closest("button[data-dmdecline]");
   9536   if (dmDeclineBtn) {
   9537     const threadId = dmDeclineBtn.getAttribute("data-dmdecline") || "";
   9538     if (threadId) ws.send(JSON.stringify({ type: "dmRequestRespond", threadId, accept: false }));
   9539     return;
   9540   }
   9541   const dmOpenBtn = e.target.closest("button[data-dmopen]");
   9542   if (dmOpenBtn) {
   9543     const threadId = dmOpenBtn.getAttribute("data-dmopen") || "";
   9544     if (threadId) openDmThread(threadId);
   9545     return;
   9546   }
   9547   const dmRequestBtn = e.target.closest("button[data-dmrequest]");
   9548   if (dmRequestBtn && activeDmThreadId) {
   9549     const to = String(dmRequestBtn.getAttribute("data-dmrequest") || "")
   9550       .trim()
   9551       .replace(/^@+/, "")
   9552       .toLowerCase();
   9553     if (to) ws.send(JSON.stringify({ type: "dmRequestCreate", to }));
   9554     return;
   9555   }
   9556 
   9557   const profileLink = e.target.closest("[data-viewprofile]");
   9558   if (profileLink) {
   9559     const username = profileLink.getAttribute("data-viewprofile") || "";
   9560     if (username) openUserProfile(username);
   9561     return;
   9562   }
   9563 
   9564   const mention = e.target.closest(".mentionToken");
   9565   if (mention) {
   9566     const raw = String(mention.textContent || "").trim();
   9567     const username = raw.replace(/^@+/, "").toLowerCase();
   9568     if (username) openUserProfile(username);
   9569     return;
   9570   }
   9571 
   9572   const editBtn = e.target.closest("button[data-editmsg]");
   9573   if (editBtn) {
   9574     const messageId = editBtn.getAttribute("data-editmsg") || "";
   9575     const postId = editBtn.getAttribute("data-postid") || activeChatPostId || "";
   9576     if (!messageId || !postId) return;
   9577     const message = findChatMessage(postId, messageId);
   9578     if (!message || message.deleted) return;
   9579     openEditModalForChatMessage(message, postId);
   9580     return;
   9581   }
   9582 
   9583   const deleteBtn = e.target.closest("button[data-deletemsg]");
   9584   if (deleteBtn) {
   9585     const messageId = deleteBtn.getAttribute("data-deletemsg") || "";
   9586     if (!messageId) return;
   9587     const ok = confirm("Delete this message?");
   9588     if (!ok) return;
   9589     ws.send(JSON.stringify({ type: "deleteChatMessageSelf", messageId }));
   9590     return;
   9591   }
   9592 
   9593   const replyBtn = e.target.closest("button[data-replymsg]");
   9594   if (replyBtn) {
   9595     const messageId = replyBtn.getAttribute("data-replymsg") || "";
   9596     const postId = replyBtn.getAttribute("data-postid") || activeChatPostId || "";
   9597     if (!messageId || !postId) return;
   9598     const message = findChatMessage(postId, messageId);
   9599     if (!message) return;
   9600     setReplyToMessage(message);
   9601     chatEditor?.focus();
   9602     return;
   9603   }
   9604 
   9605   const reportChatBtn = e.target.closest("button[data-reportchat]");
   9606   if (reportChatBtn) {
   9607     const messageId = reportChatBtn.getAttribute("data-reportchat") || "";
   9608     const postId = reportChatBtn.getAttribute("data-postid") || activeChatPostId || "";
   9609     if (!messageId || !postId) return;
   9610     const message = findChatMessage(postId, messageId);
   9611     if (!message || message.deleted) {
   9612       toast("Unavailable", "That message was deleted.");
   9613       return;
   9614     }
   9615     const reason = promptReason("message report");
   9616     if (!reason) return;
   9617     ws.send(JSON.stringify({ type: "reportCreate", targetType: "chat", targetId: messageId, postId, reason }));
   9618     return;
   9619   }
   9620 
   9621   const react = e.target.closest("[data-react]");
   9622   if (!react || react.getAttribute("data-kind") !== "chat") return;
   9623   const postId = react.getAttribute("data-postid") || "";
   9624   const messageId = react.getAttribute("data-msgid") || "";
   9625   const emoji = react.getAttribute("data-emoji") || "";
   9626   if (!postId || !messageId || !emoji) return;
   9627   markReactPulse("chat", messageId, emoji);
   9628   toggleMyReact("chat", messageId, emoji);
   9629   ws.send(JSON.stringify({ type: "react", targetType: "chat", postId, messageId, emoji }));
   9630   renderChatPanel();
   9631 });
   9632 
   9633 chatReplyCancelBtn?.addEventListener("click", () => setReplyToMessage(null));
   9634 
   9635 chatBackToListBtn?.addEventListener("click", () => {
   9636   if (activeChatPostId && ws?.readyState === WebSocket.OPEN) {
   9637     ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false }));
   9638   }
   9639   activeChatPostId = null;
   9640   activeDmThreadId = null;
   9641   activeMapsRoomId = "";
   9642   activeMapsRoomTitle = "";
   9643   setReplyToMessage(null);
   9644   renderChatPanel(true);
   9645 });
   9646 
   9647 chatContextSelectEl?.addEventListener("change", () => {
   9648   if (syncingChatContextSelect) return;
   9649   const raw = String(chatContextSelectEl.value || "").trim();
   9650   if (!raw) return;
   9651   openChatContextValue(raw, { preserveFocus: false });
   9652 });
   9653 
   9654 modPanelEl?.addEventListener("click", (e) => {
   9655   const tabBtn = e.target.closest("[data-modtab]");
   9656   if (tabBtn) {
   9657     modTab = tabBtn.getAttribute("data-modtab") || "reports";
   9658     if (modTab === "server") requestServerInfo();
   9659     if (modTab === "onboarding") syncOnboardingAdminDraft(true);
   9660     renderModPanel();
   9661     return;
   9662   }
   9663 });
   9664 
   9665 modRefreshBtn?.addEventListener("click", () => {
   9666   if (!canModerate) return;
   9667   if (modTab === "server") requestServerInfo();
   9668   else if (modTab === "onboarding") {
   9669     if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
   9670     syncOnboardingAdminDraft(true);
   9671     renderModPanel();
   9672   }
   9673   else requestModData();
   9674 });
   9675 modReportStatusEl?.addEventListener("change", () => {
   9676   if (!canModerate) return;
   9677   ws.send(JSON.stringify({ type: "modListReports", status: modReportStatusEl.value || "open", limit: 200 }));
   9678 });
   9679 
   9680 modModal?.addEventListener("click", (e) => {
   9681   if (e.target?.getAttribute?.("data-modmodalclose")) setModModalOpen(false);
   9682 });
   9683 modModalClose?.addEventListener("click", () => setModModalOpen(false));
   9684 modModalCancel?.addEventListener("click", () => setModModalOpen(false));
   9685 
   9686 modModalBody?.addEventListener("change", (e) => {
   9687   if (!modModalContext) return;
   9688   if (modModalContext.kind === "collectionGate") {
   9689     if (e.target?.name === "gateVisibility") updateGateModalVisibility();
   9690     return;
   9691   }
   9692   if (modModalContext.kind !== "userRoles") return;
   9693   const checkbox = e.target?.closest?.("input[type='checkbox'][data-userrolekey]");
   9694   if (!checkbox) return;
   9695   const key = checkbox.getAttribute("data-userrolekey") || "";
   9696   const enabled = Boolean(checkbox.checked);
   9697   if (!key) return;
   9698   ws.send(JSON.stringify({ type: "userCustomRoleSet", targetId: modModalContext.username, key, enabled }));
   9699 });
   9700 
   9701 modModalPrimary?.addEventListener("click", () => {
   9702   if (!modModalContext) return;
   9703   if (modModalStatus) modModalStatus.textContent = "";
   9704   if (modModalContext.kind === "collectionCreate") {
   9705     const name = String(document.getElementById("modModalCollectionName")?.value || "").trim();
   9706     if (!name) {
   9707       if (modModalStatus) modModalStatus.textContent = "Name is required.";
   9708       return;
   9709     }
   9710     ws.send(JSON.stringify({ type: "collectionCreate", name }));
   9711     setModModalOpen(false);
   9712     return;
   9713   }
   9714   if (modModalContext.kind === "collectionGate") {
   9715     const collectionId = String(modModalContext.collectionId || "");
   9716     const visibility = String(modModalBody?.querySelector("input[name='gateVisibility']:checked")?.value || "public");
   9717     if (visibility !== "gated") {
   9718       ws.send(JSON.stringify({ type: "collectionSetGate", collectionId, visibility: "public", allowedRoles: [] }));
   9719       setModModalOpen(false);
   9720       return;
   9721     }
   9722     const allowedRoles = Array.from(modModalBody?.querySelectorAll("input[data-gatetoken]:checked") || []).map((el) =>
   9723       String(el.getAttribute("data-gatetoken") || "")
   9724     );
   9725     if (!allowedRoles.length) {
   9726       if (modModalStatus) modModalStatus.textContent = "Pick at least one allowed role for gated collections.";
   9727       return;
   9728     }
   9729     ws.send(JSON.stringify({ type: "collectionSetGate", collectionId, visibility: "gated", allowedRoles }));
   9730     setModModalOpen(false);
   9731   }
   9732 });
   9733 
   9734 modBodyEl?.addEventListener("click", (e) => {
   9735   const modLogViewBtn = e.target.closest("button[data-modlogview]");
   9736   if (modLogViewBtn) {
   9737     const next = String(modLogViewBtn.getAttribute("data-modlogview") || "dev");
   9738     modLogView = next === "moderation" ? "moderation" : "dev";
   9739     localStorage.setItem("bzl_modLogView", modLogView);
   9740     if (modLogView === "dev" && ws.readyState === WebSocket.OPEN) {
   9741       ws.send(JSON.stringify({ type: "devLogList", limit: 300 }));
   9742     }
   9743     renderModPanel();
   9744     return;
   9745   }
   9746 
   9747   const devLogRefreshBtn = e.target.closest("button[data-devlogrefresh]");
   9748   if (devLogRefreshBtn) {
   9749     if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "devLogList", limit: 300 }));
   9750     return;
   9751   }
   9752 
   9753   const devLogCopyBtn = e.target.closest("button[data-devlogcopy]");
   9754   if (devLogCopyBtn) {
   9755     const text = String(document.getElementById("devLogPre")?.textContent || "").trim();
   9756     if (!text) {
   9757       toast("Dev log", "Nothing to copy.");
   9758       return;
   9759     }
   9760     navigator.clipboard
   9761       .writeText(text)
   9762       .then(() => toast("Dev log", "Copied."))
   9763       .catch(() => toast("Dev log", "Copy failed."));
   9764     return;
   9765   }
   9766 
   9767   const devLogClearBtn = e.target.closest("button[data-devlogclear]");
   9768   if (devLogClearBtn) {
   9769     if (!(canModerate && loggedInRole === "owner")) return;
   9770     const ok = confirm("Clear the server dev log?");
   9771     if (!ok) return;
   9772     ws.send(JSON.stringify({ type: "devLogClear" }));
   9773     return;
   9774   }
   9775 
   9776   const devLogTestBtn = e.target.closest("button[data-devlogtest]");
   9777   if (devLogTestBtn) {
   9778     sendDevLog("info", "ui", "Dev log test", { at: Date.now() });
   9779     return;
   9780   }
   9781 
   9782   const devLogAutoScrollToggle = e.target.closest("input[data-devlogautoscroll]");
   9783   if (devLogAutoScrollToggle) {
   9784     devLogAutoScroll = Boolean(devLogAutoScrollToggle.checked);
   9785     localStorage.setItem("bzl_devLogAutoScroll", devLogAutoScroll ? "1" : "0");
   9786     renderModPanel();
   9787     return;
   9788   }
   9789 
   9790   const serverRefreshBtn = e.target.closest("button[data-server-refresh]");
   9791   if (serverRefreshBtn) {
   9792     requestServerInfo();
   9793     if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
   9794     return;
   9795   }
   9796 
   9797   const onboardingRefreshBtn = e.target.closest("button[data-onboarding-refresh]");
   9798   if (onboardingRefreshBtn) {
   9799     if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
   9800     syncOnboardingAdminDraft(true);
   9801     renderModPanel();
   9802     return;
   9803   }
   9804 
   9805   const onbAdminTabBtn = e.target.closest("button[data-onb-admin-tab]");
   9806   if (onbAdminTabBtn) {
   9807     const tab = String(onbAdminTabBtn.getAttribute("data-onb-admin-tab") || "about").trim();
   9808     if (!["about", "rules", "roles"].includes(tab)) return;
   9809     onboardingAdminTab = tab;
   9810     renderModPanel();
   9811     return;
   9812   }
   9813 
   9814   const onbRuleAddBtn = e.target.closest("button[data-onb-ruleadd]");
   9815   if (onbRuleAddBtn) {
   9816     if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
   9817     normalizeOnboardingDraftRules();
   9818     const nextIndex = onboardingAdminDraft.rules.length + 1;
   9819     const id = `r${Date.now()}_${nextIndex}`;
   9820     onboardingAdminDraft.rules.push({
   9821       id,
   9822       order: nextIndex,
   9823       name: `Rule ${nextIndex}`,
   9824       shortDescription: "",
   9825       description: "",
   9826       severity: "info",
   9827     });
   9828     normalizeOnboardingDraftRules();
   9829     onboardingAdminExpandedRuleIds.add(id);
   9830     onboardingAdminTab = "rules";
   9831     renderModPanel();
   9832     return;
   9833   }
   9834 
   9835   const onbRuleToggleBtn = e.target.closest("button[data-onb-ruletoggle]");
   9836   if (onbRuleToggleBtn) {
   9837     const id = String(onbRuleToggleBtn.getAttribute("data-onb-ruletoggle") || "").trim();
   9838     if (!id) return;
   9839     if (onboardingAdminExpandedRuleIds.has(id)) onboardingAdminExpandedRuleIds.delete(id);
   9840     else onboardingAdminExpandedRuleIds.add(id);
   9841     renderModPanel();
   9842     return;
   9843   }
   9844 
   9845   const onbRuleDeleteBtn = e.target.closest("button[data-onb-ruledelete]");
   9846   if (onbRuleDeleteBtn) {
   9847     if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
   9848     const id = String(onbRuleDeleteBtn.getAttribute("data-onb-ruledelete") || "").trim();
   9849     onboardingAdminDraft.rules = onboardingAdminDraft.rules.filter((r) => r.id !== id);
   9850     onboardingAdminExpandedRuleIds.delete(id);
   9851     normalizeOnboardingDraftRules();
   9852     renderModPanel();
   9853     return;
   9854   }
   9855 
   9856   const onbRuleUpBtn = e.target.closest("button[data-onb-ruleup]");
   9857   if (onbRuleUpBtn) {
   9858     if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
   9859     const id = String(onbRuleUpBtn.getAttribute("data-onb-ruleup") || "").trim();
   9860     const idx = onboardingAdminDraft.rules.findIndex((r) => r.id === id);
   9861     if (idx <= 0) return;
   9862     const tmp = onboardingAdminDraft.rules[idx - 1];
   9863     onboardingAdminDraft.rules[idx - 1] = onboardingAdminDraft.rules[idx];
   9864     onboardingAdminDraft.rules[idx] = tmp;
   9865     normalizeOnboardingDraftRules();
   9866     renderModPanel();
   9867     return;
   9868   }
   9869 
   9870   const onbRuleDownBtn = e.target.closest("button[data-onb-ruledown]");
   9871   if (onbRuleDownBtn) {
   9872     if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
   9873     const id = String(onbRuleDownBtn.getAttribute("data-onb-ruledown") || "").trim();
   9874     const idx = onboardingAdminDraft.rules.findIndex((r) => r.id === id);
   9875     if (idx < 0 || idx >= onboardingAdminDraft.rules.length - 1) return;
   9876     const tmp = onboardingAdminDraft.rules[idx + 1];
   9877     onboardingAdminDraft.rules[idx + 1] = onboardingAdminDraft.rules[idx];
   9878     onboardingAdminDraft.rules[idx] = tmp;
   9879     normalizeOnboardingDraftRules();
   9880     renderModPanel();
   9881     return;
   9882   }
   9883 
   9884   const onboardingSaveBtn = e.target.closest("button[data-onboarding-save],button[data-onboarding-publish]");
   9885   if (onboardingSaveBtn) {
   9886     if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
   9887     const publish = onboardingSaveBtn.hasAttribute("data-onboarding-publish");
   9888     normalizeOnboardingDraftRules();
   9889     ws.send(
   9890       JSON.stringify({
   9891         type: "instanceSetOnboarding",
   9892         publish,
   9893         enabled: Boolean(onboardingAdminDraft.enabled),
   9894         about: { content: String(onboardingAdminDraft.aboutContent || "") },
   9895         rules: {
   9896           requireAcceptance: Boolean(onboardingAdminDraft.requireAcceptance),
   9897           blockReadUntilAccepted: Boolean(onboardingAdminDraft.blockReadUntilAccepted),
   9898           items: onboardingAdminDraft.rules,
   9899         },
   9900         roleSelect: {
   9901           enabled: Boolean(onboardingAdminDraft.roleSelectEnabled),
   9902           selfAssignableRoleIds: onboardingAdminDraft.selfAssignableRoleIds,
   9903         }
   9904       })
   9905     );
   9906     toast("Onboarding", publish ? "Publishing..." : "Saving...");
   9907     return;
   9908   }
   9909 
   9910   const instanceSaveBtn = e.target.closest("button[data-instance-save]");
   9911   if (instanceSaveBtn) {
   9912     if (!(canModerate && loggedInRole === "owner")) return;
   9913     const title = String(modBodyEl.querySelector("input[data-instance-title]")?.value || "").replace(/\s+/g, " ").trim().slice(0, 32);
   9914     const subtitle = String(modBodyEl.querySelector("input[data-instance-subtitle]")?.value || "").replace(/\s+/g, " ").trim().slice(0, 80);
   9915     const allowMemberPermanentPosts = Boolean(modBodyEl.querySelector("input[data-instance-allowpermanent]")?.checked);
   9916     const bg = String(modBodyEl.querySelector("input[data-instance-bg]")?.value || "").trim();
   9917     const panel = String(modBodyEl.querySelector("input[data-instance-panel]")?.value || "").trim();
   9918     const text = String(modBodyEl.querySelector("input[data-instance-text]")?.value || "").trim();
   9919     const good = String(modBodyEl.querySelector("input[data-instance-good]")?.value || "").trim();
   9920     const bad = String(modBodyEl.querySelector("input[data-instance-bad]")?.value || "").trim();
   9921     const accent = String(modBodyEl.querySelector("input[data-instance-accent]")?.value || "").trim();
   9922     const accent2 = String(modBodyEl.querySelector("input[data-instance-accent2]")?.value || "").trim();
   9923     const fontBody = String(modBodyEl.querySelector("select[data-instance-fontbody]")?.value || "").trim();
   9924     const fontMono = String(modBodyEl.querySelector("select[data-instance-fontmono]")?.value || "").trim();
   9925     const mutedPct = String(modBodyEl.querySelector("input[data-instance-mutedpct]")?.value || "").trim();
   9926     const linePct = String(modBodyEl.querySelector("input[data-instance-linepct]")?.value || "").trim();
   9927     const panel2Pct = String(modBodyEl.querySelector("input[data-instance-panel2pct]")?.value || "").trim();
   9928     if (!title) {
   9929       toast("Instance", "Title is required.");
   9930       return;
   9931     }
   9932     ws.send(
   9933       JSON.stringify({
   9934         type: "instanceSetBranding",
   9935         title,
   9936         subtitle,
   9937         allowMemberPermanentPosts,
   9938         appearance: { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct }
   9939       })
   9940     );
   9941     toast("Instance", "Saving...");
   9942     return;
   9943   }
   9944 
   9945   const instanceSaveAppearanceBtn = e.target.closest("button[data-instance-saveappearance]");
   9946   if (instanceSaveAppearanceBtn) {
   9947     if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
   9948     const bg = String(modBodyEl.querySelector("input[data-instance-bg]")?.value || "").trim();
   9949     const panel = String(modBodyEl.querySelector("input[data-instance-panel]")?.value || "").trim();
   9950     const text = String(modBodyEl.querySelector("input[data-instance-text]")?.value || "").trim();
   9951     const good = String(modBodyEl.querySelector("input[data-instance-good]")?.value || "").trim();
   9952     const bad = String(modBodyEl.querySelector("input[data-instance-bad]")?.value || "").trim();
   9953     const accent = String(modBodyEl.querySelector("input[data-instance-accent]")?.value || "").trim();
   9954     const accent2 = String(modBodyEl.querySelector("input[data-instance-accent2]")?.value || "").trim();
   9955     const fontBody = String(modBodyEl.querySelector("select[data-instance-fontbody]")?.value || "").trim();
   9956     const fontMono = String(modBodyEl.querySelector("select[data-instance-fontmono]")?.value || "").trim();
   9957     const mutedPct = String(modBodyEl.querySelector("input[data-instance-mutedpct]")?.value || "").trim();
   9958     const linePct = String(modBodyEl.querySelector("input[data-instance-linepct]")?.value || "").trim();
   9959     const panel2Pct = String(modBodyEl.querySelector("input[data-instance-panel2pct]")?.value || "").trim();
   9960     ws.send(
   9961       JSON.stringify({
   9962         type: "instanceSetAppearance",
   9963         appearance: { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct }
   9964       })
   9965     );
   9966     toast("Theme", "Saving...");
   9967     return;
   9968   }
   9969 
   9970   const themeResetBtn = e.target.closest("button[data-theme-reset]");
   9971   if (themeResetBtn) {
   9972     if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
   9973     applyInstanceAppearance();
   9974     renderModPanel();
   9975     toast("Theme", "Reset to saved theme.");
   9976     return;
   9977   }
   9978 
   9979   const pluginReloadBtn = e.target.closest("button[data-pluginreload]");
   9980   if (pluginReloadBtn) {
   9981     if (!canManagePlugins()) return;
   9982     pluginAdminBusy = true;
   9983     pluginAdminStatus = "Reloading plugins...";
   9984     renderModPanel();
   9985     ws.send(JSON.stringify({ type: "pluginReload" }));
   9986     return;
   9987   }
   9988 
   9989   const pluginUninstallBtn = e.target.closest("button[data-pluginuninstall]");
   9990   if (pluginUninstallBtn) {
   9991     if (!canManagePlugins()) return;
   9992     const id = String(pluginUninstallBtn.getAttribute("data-pluginuninstall") || "").trim().toLowerCase();
   9993     if (!id) return;
   9994     const ok = confirm(`Uninstall "${id}"? This deletes the plugin files from this server.`);
   9995     if (!ok) return;
   9996     pluginAdminBusy = true;
   9997     pluginAdminStatus = `Uninstalling "${id}"...`;
   9998     renderModPanel();
   9999     ws.send(JSON.stringify({ type: "pluginUninstall", id }));
  10000     return;
  10001   }
  10002 
  10003   const pluginInstallBtn = e.target.closest("button[data-plugininstall]");
  10004   if (pluginInstallBtn) {
  10005     if (!canManagePlugins()) return;
  10006     const input = modBodyEl.querySelector("input[type='file'][data-pluginzip]") || null;
  10007     const file = input?.files && input.files[0] ? input.files[0] : null;
  10008     if (!file) {
  10009       pluginAdminStatus = "Choose a .zip file first.";
  10010       renderModPanel();
  10011       return;
  10012     }
  10013     const token = getSessionToken();
  10014     if (!token) {
  10015       pluginAdminStatus = "Session missing. Please sign out/in and try again.";
  10016       renderModPanel();
  10017       return;
  10018     }
  10019     pluginAdminBusy = true;
  10020     pluginAdminStatus = "Uploading plugin...";
  10021     renderModPanel();
  10022     (async () => {
  10023       try {
  10024         const res = await fetch("/api/plugin-install", {
  10025           method: "POST",
  10026           headers: { "Content-Type": "application/zip", Authorization: `Bearer ${token}` },
  10027           body: file,
  10028           credentials: "same-origin",
  10029         });
  10030         const json = await res.json().catch(() => null);
  10031         if (!res.ok || !json || !json.ok) {
  10032           pluginAdminBusy = false;
  10033           pluginAdminStatus = String(json?.error || `Install failed (${res.status}).`);
  10034           renderModPanel();
  10035           return;
  10036         }
  10037         if (input) input.value = "";
  10038         pluginAdminBusy = false;
  10039         pluginAdminStatus = `Installed "${json.plugin?.id || "plugin"}". Enable it below.`;
  10040         toast("Plugins", "Installed. Enable it to activate.");
  10041         renderModPanel();
  10042       } catch (err) {
  10043         pluginAdminBusy = false;
  10044         pluginAdminStatus = "Install failed.";
  10045         renderModPanel();
  10046       }
  10047     })();
  10048     return;
  10049   }
  10050 
  10051   const nukeBtn = e.target.closest("button[data-nuke]");
  10052   if (nukeBtn) {
  10053     if (!(canModerate && loggedInRole === "owner")) return;
  10054     const confirmEl = modBodyEl.querySelector("input[data-nukeconfirm]");
  10055     const okToggle = Boolean(confirmEl?.checked);
  10056     if (!okToggle) {
  10057       toast("NUKE", "Toggle ARE YOU SURE? first.");
  10058       return;
  10059     }
  10060     const ok = confirm("NUKE the board? This clears all hives, reports, moderation log, and hive media uploads.");
  10061     if (!ok) return;
  10062     ws.send(JSON.stringify({ type: "nukeBoard", confirm: true, confirmText: "ARE YOU SURE?" }));
  10063     toast("NUKE", "Working...");
  10064     return;
  10065   }
  10066 
  10067   const openChatBtn = e.target.closest("button[data-chat]");
  10068   if (openChatBtn) {
  10069     const postId = openChatBtn.getAttribute("data-chat") || "";
  10070     if (postId) openChat(postId);
  10071     return;
  10072   }
  10073 
  10074   const createCollectionBtn = e.target.closest("button[data-createcollection]");
  10075   if (createCollectionBtn) {
  10076     openCollectionCreateModal();
  10077     return;
  10078   }
  10079 
  10080   const archiveCollectionBtn = e.target.closest("button[data-archivecollection]");
  10081   if (archiveCollectionBtn) {
  10082     const collectionId = archiveCollectionBtn.getAttribute("data-archivecollection") || "";
  10083     if (!collectionId) return;
  10084     const ok = confirm("Archive this collection? Existing hives stay visible in All.");
  10085     if (!ok) return;
  10086     ws.send(JSON.stringify({ type: "collectionArchive", collectionId }));
  10087     return;
  10088   }
  10089 
  10090   const collectionGateBtn = e.target.closest("button[data-collectiongate]");
  10091   if (collectionGateBtn) {
  10092     const collectionId = collectionGateBtn.getAttribute("data-collectiongate") || "";
  10093     if (!collectionId) return;
  10094     openCollectionGateModal(collectionId);
  10095     return;
  10096   }
  10097 
  10098   const collectionPublicBtn = e.target.closest("button[data-collectionpublic]");
  10099   if (collectionPublicBtn) {
  10100     const collectionId = collectionPublicBtn.getAttribute("data-collectionpublic") || "";
  10101     if (!collectionId) return;
  10102     ws.send(JSON.stringify({ type: "collectionSetGate", collectionId, visibility: "public", allowedRoles: [] }));
  10103     return;
  10104   }
  10105 
  10106   const roleCreateBtn = e.target.closest("button[data-rolecreate]");
  10107   if (roleCreateBtn) {
  10108     const card = roleCreateBtn.closest(".modCard");
  10109     const label = String(card?.querySelector("input[data-rolelabel]")?.value || "").trim();
  10110     let key = String(card?.querySelector("input[data-rolekey]")?.value || "")
  10111       .trim()
  10112       .toLowerCase();
  10113     if (!key && label) {
  10114       key = label
  10115         .toLowerCase()
  10116         .replace(/[^a-z0-9]+/g, "_")
  10117         .replace(/^_+|_+$/g, "")
  10118         .slice(0, 18);
  10119       const keyEl = card?.querySelector("input[data-rolekey]");
  10120       if (keyEl && key) keyEl.value = key;
  10121     }
  10122     const color = String(card?.querySelector("input[data-rolecolor]")?.value || "#ff3ea5").trim();
  10123     if (!key || !label) {
  10124       toast("Roles", "Key and label are required.");
  10125       return;
  10126     }
  10127     ws.send(JSON.stringify({ type: "roleCreate", key, label, color }));
  10128     return;
  10129   }
  10130 
  10131   const roleArchiveBtn = e.target.closest("button[data-rolearchive]");
  10132   if (roleArchiveBtn) {
  10133     const key = roleArchiveBtn.getAttribute("data-rolearchive") || "";
  10134     if (!key) return;
  10135     const ok = confirm(`Archive role "${key}"?`);
  10136     if (!ok) return;
  10137     ws.send(JSON.stringify({ type: "roleArchive", key }));
  10138     return;
  10139   }
  10140 
  10141   const userManageRolesBtn = e.target.closest("button[data-usermanageroles]");
  10142   if (userManageRolesBtn) {
  10143     const targetId = userManageRolesBtn.getAttribute("data-usermanageroles") || "";
  10144     if (!targetId) return;
  10145     openUserRolesModal(targetId);
  10146     return;
  10147   }
  10148 
  10149   const actionBtn = e.target.closest("button[data-modaction]");
  10150   if (!actionBtn) return;
  10151   const actionType = actionBtn.getAttribute("data-modaction") || "";
  10152   const targetType = actionBtn.getAttribute("data-targettype") || "";
  10153   const targetId = actionBtn.getAttribute("data-targetid") || "";
  10154   if (!actionType || !targetType || !targetId) return;
  10155 
  10156   const metadata = {};
  10157 
  10158   if (actionType === "user_password_reset") {
  10159     const pw = prompt("Set a new password (min 4 chars):");
  10160     if (pw === null) return;
  10161     const next = String(pw || "");
  10162     if (next.length < 4) {
  10163       toast("Password reset", "Password must be at least 4 characters.");
  10164       return;
  10165     }
  10166     const ok = confirm("Reset this user's password to the value you entered?");
  10167     if (!ok) return;
  10168     metadata.newPassword = next;
  10169   }
  10170 
  10171   if (actionType === "post_erase") {
  10172     const ok = confirm("Erase this hive permanently? This cannot be restored.");
  10173     if (!ok) return;
  10174   }
  10175 
  10176   if (actionType === "post_readonly_set") {
  10177     metadata.readOnly = actionBtn.getAttribute("data-readonly") === "1";
  10178   }
  10179 
  10180   if (actionType === "post_protection_set") {
  10181     if (actionBtn.hasAttribute("data-unprotect")) {
  10182       metadata.enabled = false;
  10183     } else {
  10184       const pw = prompt("Set post password (min 4 chars):");
  10185       if (pw === null) return;
  10186       const next = String(pw || "");
  10187       if (next.length < 4) {
  10188         toast("Protected post", "Password must be at least 4 characters.");
  10189         return;
  10190       }
  10191       metadata.enabled = true;
  10192       metadata.password = next;
  10193     }
  10194   }
  10195 
  10196   const reason = promptReason(actionType);
  10197   if (!reason) return;
  10198   const minutesAttr = actionBtn.getAttribute("data-minutes");
  10199   const roleAttr = actionBtn.getAttribute("data-role");
  10200   const countAttr = actionBtn.getAttribute("data-count");
  10201   const ttlAttr = actionBtn.getAttribute("data-ttl");
  10202   const ttlPrompt = actionBtn.hasAttribute("data-ttlprompt");
  10203   if (minutesAttr) metadata.minutes = Number(minutesAttr);
  10204   if (roleAttr) metadata.role = roleAttr;
  10205   if (countAttr) metadata.count = Number(countAttr);
  10206   if (ttlAttr) metadata.ttlMinutes = Number(ttlAttr);
  10207   if (ttlPrompt && actionType === "post_ttl_set") {
  10208     const raw = prompt("Set TTL minutes (0 = permanent):", "60");
  10209     if (raw === null) return;
  10210     const n = Math.max(0, Math.min(2880, Math.floor(Number(raw))));
  10211     if (!Number.isFinite(n)) {
  10212       toast("TTL", "Enter a valid number.");
  10213       return;
  10214     }
  10215     metadata.ttlMinutes = n;
  10216   }
  10217   ws.send(JSON.stringify({ type: "modAction", actionType, targetType, targetId, reason, metadata }));
  10218 });
  10219 
  10220 modBodyEl?.addEventListener("change", (e) => {
  10221   const onbEnabled = e.target?.closest?.("input[data-onboarding-enabled]");
  10222   if (onbEnabled) {
  10223     onboardingAdminDraft.enabled = Boolean(onbEnabled.checked);
  10224     return;
  10225   }
  10226   const onbRequire = e.target?.closest?.("input[data-onboarding-require]");
  10227   if (onbRequire) {
  10228     onboardingAdminDraft.requireAcceptance = Boolean(onbRequire.checked);
  10229     return;
  10230   }
  10231   const onbBlockRead = e.target?.closest?.("input[data-onboarding-blockread]");
  10232   if (onbBlockRead) {
  10233     onboardingAdminDraft.blockReadUntilAccepted = Boolean(onbBlockRead.checked);
  10234     return;
  10235   }
  10236   const onbRoleEnabled = e.target?.closest?.("input[data-onboarding-roleenabled]");
  10237   if (onbRoleEnabled) {
  10238     onboardingAdminDraft.roleSelectEnabled = Boolean(onbRoleEnabled.checked);
  10239     return;
  10240   }
  10241   const onbRoleCheck = e.target?.closest?.("input[data-onboarding-rolecheck]");
  10242   if (onbRoleCheck) {
  10243     const key = String(onbRoleCheck.getAttribute("data-onboarding-rolecheck") || "").trim().toLowerCase();
  10244     if (!key) return;
  10245     const set = new Set(onboardingAdminDraft.selfAssignableRoleIds || []);
  10246     if (onbRoleCheck.checked) set.add(key);
  10247     else set.delete(key);
  10248     onboardingAdminDraft.selfAssignableRoleIds = Array.from(set);
  10249     return;
  10250   }
  10251   const onbRuleField = e.target?.closest?.("[data-onb-rulefield]");
  10252   if (onbRuleField) {
  10253     const id = String(onbRuleField.getAttribute("data-onb-ruleid") || "").trim();
  10254     const field = String(onbRuleField.getAttribute("data-onb-rulefield") || "").trim();
  10255     if (!id || !field) return;
  10256     const rule = onboardingAdminDraft.rules.find((r) => r.id === id);
  10257     if (!rule) return;
  10258     if (field === "severity") {
  10259       rule.severity = ["info", "warn", "critical"].includes(String(onbRuleField.value || "").toLowerCase())
  10260         ? String(onbRuleField.value || "").toLowerCase()
  10261         : "info";
  10262       return;
  10263     }
  10264     rule[field] = String(onbRuleField.value || "");
  10265     return;
  10266   }
  10267 
  10268   const presetSelect = e.target?.closest?.("select[data-theme-preset]");
  10269   if (presetSelect) {
  10270     if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
  10271     const id = String(presetSelect.value || "").trim();
  10272     if (!id) return;
  10273     const preset = THEME_PRESETS.find((p) => p.id === id) || null;
  10274     if (!preset) return;
  10275     const a = preset.appearance || {};
  10276     const setValue = (selector, value) => {
  10277       const el = modBodyEl.querySelector(selector);
  10278       if (!el) return;
  10279       el.value = String(value ?? "");
  10280     };
  10281     setValue("input[data-instance-bg]", a.bg);
  10282     setValue("input[data-instance-panel]", a.panel);
  10283     setValue("input[data-instance-text]", a.text);
  10284     setValue("input[data-instance-good]", a.good);
  10285     setValue("input[data-instance-bad]", a.bad);
  10286     setValue("input[data-instance-accent]", a.accent);
  10287     setValue("input[data-instance-accent2]", a.accent2);
  10288     setValue("input[data-instance-mutedpct]", a.mutedPct);
  10289     setValue("input[data-instance-linepct]", a.linePct);
  10290     setValue("input[data-instance-panel2pct]", a.panel2Pct);
  10291     setValue("select[data-instance-fontbody]", a.fontBody);
  10292     setValue("select[data-instance-fontmono]", a.fontMono);
  10293     applyInstanceAppearance(a);
  10294     toast("Theme", `Preset "${preset.name}" applied (preview). Click Save to persist.`);
  10295     return;
  10296   }
  10297 
  10298   const toggle = e.target?.closest?.("input[type='checkbox'][data-pluginenable]");
  10299   if (toggle) {
  10300     if (!canManagePlugins()) return;
  10301     const id = String(toggle.getAttribute("data-pluginenable") || "").trim().toLowerCase();
  10302     if (!id) return;
  10303     const enabled = Boolean(toggle.checked);
  10304     if (pluginEnableInFlight.has(id)) return;
  10305     const wsRef = window.__bzlWs;
  10306     if (!wsRef || wsRef.readyState !== WebSocket.OPEN) {
  10307       toast("Plugins", "Not connected.");
  10308       return;
  10309     }
  10310     pluginEnableInFlight.add(id);
  10311     // Optimistic UI update to avoid flicker/repeated toggles.
  10312     for (const p of plugins) {
  10313       if (p && String(p.id || "").toLowerCase() === id) p.enabled = enabled;
  10314     }
  10315     pluginAdminStatus = enabled ? "Enabling..." : "Disabling...";
  10316     renderModPanel();
  10317     wsRef.send(JSON.stringify({ type: "pluginSetEnabled", id, enabled }));
  10318     return;
  10319   }
  10320 });
  10321 
  10322 modBodyEl?.addEventListener("input", (e) => {
  10323   const aboutEl = e.target?.closest?.("textarea[data-onboarding-about]");
  10324   if (aboutEl) {
  10325     onboardingAdminDraft.aboutContent = String(aboutEl.value || "");
  10326     return;
  10327   }
  10328   const onbRuleField = e.target?.closest?.("input[data-onb-rulefield],textarea[data-onb-rulefield]");
  10329   if (!onbRuleField) return;
  10330   const id = String(onbRuleField.getAttribute("data-onb-ruleid") || "").trim();
  10331   const field = String(onbRuleField.getAttribute("data-onb-rulefield") || "").trim();
  10332   if (!id || !field) return;
  10333   const rule = onboardingAdminDraft.rules.find((r) => r.id === id);
  10334   if (!rule) return;
  10335   rule[field] = String(onbRuleField.value || "");
  10336 });
  10337 
  10338 modBodyEl?.addEventListener("change", (e) => {
  10339   const toggle = e.target?.closest?.("input[data-nukeconfirm]");
  10340   if (!toggle) return;
  10341   const btn = modBodyEl.querySelector("button[data-nuke]");
  10342   if (!btn) return;
  10343   btn.disabled = !Boolean(toggle.checked);
  10344 });
  10345 
  10346 chatForm.addEventListener("submit", (e) => {
  10347   e.preventDefault();
  10348   submitChat();
  10349 });
  10350 
  10351 chatMeta?.addEventListener("click", (e) => {
  10352   const btn = e.target?.closest?.("button[data-mapchatscope]");
  10353   if (!btn) return;
  10354   const scope = normalizeMapChatScope(btn.getAttribute("data-mapchatscope") || "local");
  10355   activeMapsChatScope = scope;
  10356   // Fetch global history on-demand when switching to global.
  10357   if (scope === "global" && activeMapsRoomId) {
  10358     try {
  10359       const wsRef = window.__bzlWs;
  10360       if (wsRef && wsRef.readyState === WebSocket.OPEN) {
  10361         wsRef.send(JSON.stringify({ type: "plugin:maps:chatHistoryReq", mapId: activeMapsRoomId }));
  10362       }
  10363     } catch {
  10364       // ignore
  10365     }
  10366   }
  10367   renderChatPanel(true);
  10368 });
  10369 
  10370 chatEditor.addEventListener("keydown", (e) => {
  10371   if (mentionState.open) {
  10372     if (e.key === "ArrowDown") {
  10373       e.preventDefault();
  10374       mentionState.selected = Math.min(mentionState.items.length - 1, mentionState.selected + 1);
  10375       renderMentionMenu();
  10376       return;
  10377     }
  10378     if (e.key === "ArrowUp") {
  10379       e.preventDefault();
  10380       mentionState.selected = Math.max(0, mentionState.selected - 1);
  10381       renderMentionMenu();
  10382       return;
  10383     }
  10384     if (e.key === "Enter" || e.key === "Tab") {
  10385       e.preventDefault();
  10386       const picked = mentionState.items[mentionState.selected];
  10387       if (picked) replaceCurrentMentionToken(picked);
  10388       closeMentionMenu();
  10389       return;
  10390     }
  10391     if (e.key === "Escape") {
  10392       e.preventDefault();
  10393       closeMentionMenu();
  10394       return;
  10395     }
  10396   }
  10397   if (e.key !== "Enter") return;
  10398   if (!shouldSubmitChatOnEnter(e)) return;
  10399   e.preventDefault();
  10400   submitChat();
  10401 });
  10402 
  10403 chatEditor.addEventListener("input", () => {
  10404   if (!activeChatPostId || !loggedInUser) return;
  10405   const textTail = String(chatEditor.innerText || "").slice(-80);
  10406   const m = /@([a-z0-9_.-]{0,31})$/i.exec(textTail);
  10407   if (m) {
  10408     const query = String(m[1] || "");
  10409     mentionState.open = true;
  10410     mentionState.query = query;
  10411     mentionState.items = listMentionCandidates(query);
  10412     mentionState.selected = 0;
  10413     mentionState.anchorRect = getCaretRect();
  10414     renderMentionMenu();
  10415   } else {
  10416     closeMentionMenu();
  10417   }
  10418 
  10419   const t = Date.now();
  10420   if (t - lastTypingSentAt > 900) {
  10421     ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: true }));
  10422     lastTypingSentAt = t;
  10423   }
  10424   if (typingStopTimer) clearTimeout(typingStopTimer);
  10425   typingStopTimer = setTimeout(() => {
  10426     if (!activeChatPostId) return;
  10427     ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false }));
  10428   }, 1800);
  10429 });
  10430 
  10431 chatEditor.addEventListener("focus", () => {
  10432   chatUploadTargetEditor = chatEditor;
  10433 });
  10434 
  10435 chatEditor.addEventListener("blur", () => {
  10436   if (!activeChatPostId || !loggedInUser) return;
  10437   ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false }));
  10438   setTimeout(() => closeMentionMenu(), 0);
  10439 });
  10440 
  10441 editor.addEventListener("keydown", (e) => {
  10442   if (e.key !== "Enter") return;
  10443   if (!(e.ctrlKey || e.metaKey)) return;
  10444   e.preventDefault();
  10445   newPostForm.requestSubmit();
  10446 });
  10447 
  10448 chatImageInput.addEventListener("change", async () => {
  10449   const file = chatImageInput.files && chatImageInput.files[0] ? chatImageInput.files[0] : null;
  10450   chatImageInput.value = "";
  10451   if (!file) return;
  10452   try {
  10453     const url = await uploadMediaFile(file, "image");
  10454     if (!url) return;
  10455     const target = chatUploadTargetEditor instanceof HTMLElement ? chatUploadTargetEditor : chatEditor;
  10456     target.focus();
  10457     document.execCommand("insertImage", false, url);
  10458   } catch {
  10459     // ignore
  10460   }
  10461 });
  10462 
  10463 postImageInput?.addEventListener("change", async () => {
  10464   const file = postImageInput.files && postImageInput.files[0] ? postImageInput.files[0] : null;
  10465   postImageInput.value = "";
  10466   if (!file) return;
  10467   try {
  10468     const url = await uploadMediaFile(file, "image");
  10469     if (!url) return;
  10470     editor.focus();
  10471     document.execCommand("insertImage", false, url);
  10472   } catch {
  10473     // ignore
  10474   }
  10475 });
  10476 
  10477 chatAudioInput?.addEventListener("change", async () => {
  10478   const file = chatAudioInput.files && chatAudioInput.files[0] ? chatAudioInput.files[0] : null;
  10479   chatAudioInput.value = "";
  10480   if (!file) return;
  10481   try {
  10482     const url = await uploadMediaFile(file, "audio");
  10483     if (!url) return;
  10484     const target = chatUploadTargetEditor instanceof HTMLElement ? chatUploadTargetEditor : chatEditor;
  10485     insertAudioTag(target, url);
  10486   } catch {
  10487     // ignore
  10488   }
  10489 });
  10490 
  10491 postAudioInput?.addEventListener("change", async () => {
  10492   const file = postAudioInput.files && postAudioInput.files[0] ? postAudioInput.files[0] : null;
  10493   postAudioInput.value = "";
  10494   if (!file) return;
  10495   try {
  10496     const url = await uploadMediaFile(file, "audio");
  10497     if (!url) return;
  10498     insertAudioTag(editor, url);
  10499   } catch {
  10500     // ignore
  10501   }
  10502 });
  10503 
  10504 setInterval(() => {
  10505   for (const el of document.querySelectorAll("[data-countdown]")) {
  10506     const id = el.getAttribute("data-countdown");
  10507     const post = posts.get(id);
  10508     if (!post) continue;
  10509     el.textContent = formatCountdown(post.expiresAt);
  10510   }
  10511   for (const el of document.querySelectorAll("[data-boost]")) {
  10512     const id = el.getAttribute("data-boost");
  10513     const post = posts.get(id);
  10514     if (!post) continue;
  10515     const txt = formatBoostRemaining(Number(post.boostUntil || 0));
  10516     if (!txt) {
  10517       el.remove();
  10518       continue;
  10519     }
  10520     el.textContent = `boost ${txt}`;
  10521   }
  10522   if (activeChatPostId) updateActiveChatMeta();
  10523 }, 1000);
  10524 
  10525 function unlockSfxOnce() {
  10526   if (!pendingOpenSfx) return;
  10527   playSfx("open", { volume: 0.34 }).then((ok) => {
  10528     if (ok) pendingOpenSfx = false;
  10529   });
  10530 }
  10531 
  10532 window.addEventListener("pointerdown", unlockSfxOnce, { once: true, capture: true });
  10533 window.addEventListener("keydown", unlockSfxOnce, { once: true, capture: true });
  10534 
  10535 playSfx("open", { volume: 0.34 }).then((ok) => {
  10536   if (ok) pendingOpenSfx = false;
  10537 });
  10538 
  10539 let ws = null;
  10540 let wsKeepaliveTimer = null;
  10541 let wsReconnectTimer = null;
  10542 let wsReconnectAttempt = 0;
  10543 
  10544 function clearWsKeepalive() {
  10545   if (!wsKeepaliveTimer) return;
  10546   try {
  10547     clearInterval(wsKeepaliveTimer);
  10548   } catch {
  10549     // ignore
  10550   }
  10551   wsKeepaliveTimer = null;
  10552 }
  10553 
  10554 function clearWsReconnect() {
  10555   if (!wsReconnectTimer) return;
  10556   try {
  10557     clearTimeout(wsReconnectTimer);
  10558   } catch {
  10559     // ignore
  10560   }
  10561   wsReconnectTimer = null;
  10562 }
  10563 
  10564 function startWsKeepalive(sock) {
  10565   clearWsKeepalive();
  10566   if (!readStayConnectedPref()) return;
  10567   wsKeepaliveTimer = setInterval(() => {
  10568     if (!sock || sock !== ws) return;
  10569     if (sock.readyState !== WebSocket.OPEN) return;
  10570     try {
  10571       sock.send(JSON.stringify({ type: "ping" }));
  10572     } catch {
  10573       // ignore
  10574     }
  10575   }, 25_000);
  10576 }
  10577 
  10578 function scheduleWsReconnect() {
  10579   clearWsReconnect();
  10580   if (!readStayConnectedPref()) return;
  10581   const attempt = Math.min(6, Math.max(0, wsReconnectAttempt));
  10582   const base = 1000 * Math.pow(2, attempt);
  10583   const jitter = Math.floor(Math.random() * 250);
  10584   const delay = Math.min(15_000, base) + jitter;
  10585   wsReconnectAttempt += 1;
  10586   setConn("connecting");
  10587   wsReconnectTimer = setTimeout(() => {
  10588     wsReconnectTimer = null;
  10589     connectWs();
  10590   }, delay);
  10591 }
  10592 
  10593 function connectWs() {
  10594   if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
  10595   clearWsKeepalive();
  10596   setConn("connecting");
  10597   const sock = new WebSocket(wsUrl());
  10598   ws = sock;
  10599   window.__bzlWs = sock;
  10600 
  10601   sock.addEventListener("open", () => {
  10602     if (sock !== ws) return;
  10603     setConn("open");
  10604     wsReconnectAttempt = 0;
  10605     clearWsReconnect();
  10606     startWsKeepalive(sock);
  10607     const token = getSessionToken();
  10608     if (token) {
  10609       try {
  10610         sock.send(JSON.stringify({ type: "resumeSession", token }));
  10611       } catch {
  10612         // ignore
  10613       }
  10614     }
  10615   });
  10616 
  10617   sock.addEventListener("close", () => {
  10618     if (sock !== ws) return;
  10619     setConn("closed");
  10620     clearWsKeepalive();
  10621     scheduleWsReconnect();
  10622   });
  10623 
  10624   sock.addEventListener("error", () => {
  10625     if (sock !== ws) return;
  10626     setConn("closed");
  10627   });
  10628 
  10629   sock.addEventListener("message", onWsMessage);
  10630 }
  10631 
  10632 function onWsMessage(evt) {
  10633   let msg;
  10634   try {
  10635     msg = JSON.parse(evt.data);
  10636   } catch {
  10637     return;
  10638   }
  10639   if (!msg || typeof msg !== "object") return;
  10640 
  10641   if (msg.type === "init") {
  10642     clientId = msg.clientId || null;
  10643     canRegisterFirstUser = Boolean(msg.auth?.canRegisterFirstUser);
  10644     registrationEnabled = Boolean(msg.auth?.registrationEnabled);
  10645     loggedInRole = "member";
  10646     canModerate = false;
  10647     dmThreads = [];
  10648     dmThreadsById = new Map();
  10649     dmMessagesByThreadId.clear();
  10650     activeDmThreadId = null;
  10651     pendingOpenDmThreadId = "";
  10652     lanUrls = [];
  10653     modReports = [];
  10654     modUsers = [];
  10655     modLog = [];
  10656     devLog = [];
  10657     profiles = msg.profiles && typeof msg.profiles === "object" ? msg.profiles : {};
  10658     instanceBranding = normalizeInstanceBranding(msg.instance || {});
  10659     onboardingState = normalizeOnboardingState(msg.auth?.onboarding || {});
  10660     renderInstanceBranding();
  10661     collections = normalizeCollections(msg.collections);
  10662     customRoles = normalizeRoleDefs(msg.roles?.custom);
  10663     setPlugins(msg.plugins);
  10664     renderCollectionSelect();
  10665     peopleMembers = Array.isArray(msg.people?.members) ? msg.people.members : [];
  10666     if (!peopleMembers.length && ws.readyState === WebSocket.OPEN) {
  10667       ws.send(JSON.stringify({ type: "peopleList" }));
  10668     }
  10669     if (msg.reactions?.allowed && Array.isArray(msg.reactions.allowed)) allowedReactions = msg.reactions.allowed;
  10670     if (msg.reactions?.allowedPost && Array.isArray(msg.reactions.allowedPost)) allowedPostReactions = msg.reactions.allowedPost;
  10671     if (msg.reactions?.allowedChat && Array.isArray(msg.reactions.allowedChat)) allowedChatReactions = msg.reactions.allowedChat;
  10672     setUserPrefs({ starredPostIds: [], hiddenPostIds: [] });
  10673     unreadByPostId.clear();
  10674     posts.clear();
  10675     for (const p of msg.posts || []) posts.set(p.id, p);
  10676     setAuthUi();
  10677     renderFeed();
  10678     renderChatPanel();
  10679     renderLanHint();
  10680     renderPeoplePanel();
  10681     renderCenterPanels();
  10682     if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
  10683     return;
  10684   }
  10685 
  10686   // Generic plugin event dispatch: `plugin:<pluginId>:<eventName>`
  10687   // (Maps has some core-handled messages below; for other plugins, dispatch + stop.)
  10688   if (typeof msg.type === "string") {
  10689     const m = msg.type.match(/^plugin:([a-z0-9][a-z0-9_.-]{0,31}):([a-zA-Z0-9][a-zA-Z0-9_.-]{0,63})$/);
  10690     if (m) {
  10691       const pluginId = String(m[1] || "").toLowerCase();
  10692       const ev = String(m[2] || "");
  10693       const byEvent = pluginClientHandlers.get(pluginId);
  10694       const set = byEvent ? byEvent.get(ev) : null;
  10695       if (set && set.size) {
  10696         for (const fn of Array.from(set)) {
  10697           try {
  10698             fn(msg);
  10699           } catch (e) {
  10700             console.warn(`Plugin handler failed (${pluginId}:${ev}):`, e?.message || e);
  10701           }
  10702         }
  10703       }
  10704       if (pluginId !== "maps") return;
  10705     }
  10706   }
  10707 
  10708   if (msg.type === "plugin:maps:joinOk") {
  10709     const map = msg.map && typeof msg.map === "object" ? msg.map : null;
  10710     const mapId = map && typeof map.id === "string" ? map.id.trim().toLowerCase() : "";
  10711     if (mapId) {
  10712       activeMapsRoomId = mapId;
  10713       activeMapsRoomTitle = map && typeof map.title === "string" ? map.title.trim().slice(0, 64) : mapId;
  10714       activeMapsChatScope = "local";
  10715       try {
  10716         if (ws.readyState === WebSocket.OPEN) {
  10717           ws.send(JSON.stringify({ type: "plugin:maps:chatHistoryReq", mapId }));
  10718         }
  10719       } catch {
  10720         // ignore
  10721       }
  10722       if (isMapChatActive()) renderChatPanel(true);
  10723     }
  10724     return;
  10725   }
  10726 
  10727   if (msg.type === "plugin:maps:left") {
  10728     const wasActive = Boolean(activeMapsRoomId);
  10729     activeMapsRoomId = "";
  10730     activeMapsRoomTitle = "";
  10731     activeMapsChatScope = "local";
  10732     if (wasActive && !activeDmThreadId && !activeChatPostId) renderChatPanel(true);
  10733     return;
  10734   }
  10735 
  10736   if (msg.type === "plugin:maps:chatHistory") {
  10737     const mapId = typeof msg.mapId === "string" ? msg.mapId.trim().toLowerCase() : "";
  10738     const scope = normalizeMapChatScope(msg.scope || "global");
  10739     const messages = Array.isArray(msg.messages) ? msg.messages : [];
  10740     if (mapId && scope === "global") {
  10741       mapsChatGlobalByMapId.set(
  10742         mapId,
  10743         messages
  10744           .map((m) => ({
  10745             id: String(m?.id || ""),
  10746             fromUser: String(m?.fromUser || m?.username || ""),
  10747             text: String(m?.text || ""),
  10748             createdAt: Number(m?.createdAt || 0) || Date.now(),
  10749           }))
  10750           .filter((m) => m.id && m.fromUser && m.text)
  10751           .slice(-240)
  10752       );
  10753       if (isMapChatActive()) renderChatPanel(false);
  10754     }
  10755     return;
  10756   }
  10757 
  10758   if (msg.type === "plugin:maps:chatMessage") {
  10759     const mapId = typeof msg.mapId === "string" ? msg.mapId.trim().toLowerCase() : "";
  10760     const scope = normalizeMapChatScope(msg.scope || "local");
  10761     const m = msg.message && typeof msg.message === "object" ? msg.message : null;
  10762     if (mapId && m) {
  10763       pushMapChatMessage(mapId, scope, {
  10764         id: String(m.id || ""),
  10765         fromUser: String(m.fromUser || m.username || ""),
  10766         text: String(m.text || ""),
  10767         createdAt: Number(m.createdAt || 0) || Date.now(),
  10768       });
  10769       if (isMapChatActive()) renderChatPanel(false);
  10770     }
  10771     return;
  10772   }
  10773 
  10774   if (msg.type === "collectionsUpdated") {
  10775     const prevView = activeHiveView;
  10776     collections = normalizeCollections(msg.collections);
  10777     renderCollectionSelect();
  10778     ensureActiveCollectionView();
  10779     if (activeHiveView !== prevView) renderFeed();
  10780     renderModPanel();
  10781     return;
  10782   }
  10783 
  10784   if (msg.type === "instanceUpdated" && msg.instance && typeof msg.instance === "object") {
  10785     instanceBranding = normalizeInstanceBranding(msg.instance);
  10786     onboardingState = normalizeOnboardingState(onboardingState);
  10787     if (modTab === "onboarding") syncOnboardingAdminDraft(true);
  10788     renderInstanceBranding();
  10789     applyInstanceAppearance();
  10790     setAuthUi();
  10791     return;
  10792   }
  10793 
  10794   if (msg.type === "instanceOk" && msg.instance && typeof msg.instance === "object") {
  10795     instanceBranding = normalizeInstanceBranding(msg.instance);
  10796     onboardingState = normalizeOnboardingState(onboardingState);
  10797     if (modTab === "onboarding") syncOnboardingAdminDraft(true);
  10798     renderInstanceBranding();
  10799     applyInstanceAppearance();
  10800     setAuthUi();
  10801     toast("Instance", "Saved.");
  10802     return;
  10803   }
  10804 
  10805   if (msg.type === "postsSnapshot") {
  10806     posts.clear();
  10807     for (const post of Array.isArray(msg.posts) ? msg.posts : []) posts.set(post.id, post);
  10808     if (activeChatPostId && !posts.has(activeChatPostId)) {
  10809       activeChatPostId = null;
  10810     }
  10811     renderFeed();
  10812     renderChatPanel();
  10813     return;
  10814   }
  10815 
  10816   if (msg.type === "boardReset") {
  10817     posts.clear();
  10818     chatByPost.clear();
  10819     unreadByPostId.clear();
  10820     typingUsersByPostId.clear();
  10821     newPostAnimIds.clear();
  10822     if (buzzTimers.size) {
  10823       for (const t of buzzTimers.values()) clearTimeout(t);
  10824       buzzTimers.clear();
  10825     }
  10826     activeChatPostId = null;
  10827     renderFeed();
  10828     renderChatPanel(true);
  10829     renderTypingIndicator();
  10830     renderModPanel();
  10831     if (canModerate) requestModData();
  10832     toast("Board reset", "All hives, reports, and logs were cleared.");
  10833     return;
  10834   }
  10835 
  10836   if (msg.type === "rolesUpdated") {
  10837     customRoles = normalizeRoleDefs(msg.roles);
  10838     renderPeoplePanel();
  10839     renderModPanel();
  10840     return;
  10841   }
  10842 
  10843   if (msg.type === "pluginsUpdated") {
  10844     setPlugins(msg.plugins);
  10845     return;
  10846   }
  10847 
  10848   if (msg.type === "profilesUpdated" && msg.profiles && typeof msg.profiles === "object") {
  10849     const nextProfiles = msg.profiles;
  10850     const nextKeys = Object.keys(nextProfiles);
  10851     const currentKeys = Object.keys(profiles || {});
  10852     if (nextKeys.length === 0 && currentKeys.length > 0) {
  10853       return;
  10854     }
  10855     profiles = nextProfiles;
  10856     setAuthUi();
  10857     renderFeed();
  10858     renderChatPanel();
  10859     renderPeoplePanel();
  10860     if (centerView === "profile") renderCenterPanels();
  10861     return;
  10862   }
  10863 
  10864   if (msg.type === "userProfile" && msg.profile) {
  10865     const profile = normalizeProfileData(msg.profile);
  10866     if (!profile.username) return;
  10867     if (activeProfileUsername && profile.username !== activeProfileUsername) return;
  10868     activeProfile = profile;
  10869     setCenterView("profile", profile.username);
  10870     return;
  10871   }
  10872 
  10873   if (msg.type === "userProfileUpdated" && msg.profile) {
  10874     const profile = normalizeProfileData(msg.profile);
  10875     if (!profile.username) return;
  10876     if (centerView === "profile" && activeProfileUsername === profile.username) {
  10877       activeProfile = profile;
  10878       renderCenterPanels();
  10879     }
  10880     return;
  10881   }
  10882 
  10883   if (msg.type === "newPost" && msg.post) {
  10884     const isNewId = !posts.has(msg.post.id);
  10885     posts.set(msg.post.id, msg.post);
  10886     renderFeed();
  10887     if (isNewId) {
  10888       newPostAnimIds.add(msg.post.id);
  10889       setTimeout(() => {
  10890         newPostAnimIds.delete(msg.post.id);
  10891         renderFeed();
  10892       }, 950);
  10893     }
  10894     const author = msg.post.author || "";
  10895     const title = postTitle(msg.post);
  10896     const authorLower = String(author || "").toLowerCase();
  10897     const selfLower = String(loggedInUser || "").toLowerCase();
  10898     const ignoreUserSet = new Set(
  10899       [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase())
  10900     );
  10901     if (author && loggedInUser && author === loggedInUser) {
  10902       playSfx("post", { volume: 0.36 });
  10903     }
  10904     if (author && author !== loggedInUser && !(authorLower && authorLower !== selfLower && ignoreUserSet.has(authorLower))) {
  10905       if (!windowFocused || document.hidden) {
  10906         maybeNotify(`Bzl: ${title}`, `New post by @${author}`, { postId: msg.post.id });
  10907       } else {
  10908         toast("New post", `${author ? `@${author}: ` : ""}${title}`);
  10909       }
  10910     }
  10911     return;
  10912   }
  10913 
  10914   if (msg.type === "postUpdated" && msg.post) {
  10915     posts.set(msg.post.id, msg.post);
  10916     renderFeed();
  10917     renderChatPanel();
  10918     return;
  10919   }
  10920 
  10921   if (msg.type === "deletePost") {
  10922     if (userPrefs?.starredPostIds) userPrefs.starredPostIds = userPrefs.starredPostIds.filter((id) => id !== msg.id);
  10923     if (userPrefs?.hiddenPostIds) userPrefs.hiddenPostIds = userPrefs.hiddenPostIds.filter((id) => id !== msg.id);
  10924     posts.delete(msg.id);
  10925     chatByPost.delete(msg.id);
  10926     unreadByPostId.delete(msg.id);
  10927     typingUsersByPostId.delete(msg.id);
  10928     if (buzzTimers.has(msg.id)) {
  10929       clearTimeout(buzzTimers.get(msg.id));
  10930       buzzTimers.delete(msg.id);
  10931     }
  10932     if (activeChatPostId === msg.id) activeChatPostId = null;
  10933     renderFeed();
  10934     renderChatPanel();
  10935     renderTypingIndicator();
  10936     return;
  10937   }
  10938 
  10939   if (msg.type === "loginOk") {
  10940     loggedInUser = msg.username || null;
  10941     loggedInRole = typeof msg.role === "string" ? msg.role : "member";
  10942     canModerate = Boolean(msg.canModerate);
  10943     onboardingState = normalizeOnboardingState(msg.onboarding || onboardingState);
  10944     if (typeof msg.sessionToken === "string" && msg.sessionToken) setSessionToken(msg.sessionToken);
  10945     const profile = msg.profile || {};
  10946     pendingProfileImage = typeof profile.image === "string" ? profile.image : "";
  10947     if (pendingProfileImage) {
  10948       profilePreview.src = pendingProfileImage;
  10949       profilePreview.classList.add("hasImg");
  10950     } else {
  10951       profilePreview.removeAttribute("src");
  10952       profilePreview.classList.remove("hasImg");
  10953     }
  10954     if (profile.color) nameColorInput.value = profile.color;
  10955     setUserPrefs(msg.prefs || {});
  10956     authPass.value = "";
  10957     profileStatus.textContent = "";
  10958     setAuthUi();
  10959     renderFeed();
  10960     renderLanHint();
  10961     if (centerView === "profile" && activeProfileUsername === loggedInUser) {
  10962       ws.send(JSON.stringify({ type: "getUserProfile", username: loggedInUser }));
  10963     } else {
  10964       renderCenterPanels();
  10965     }
  10966     if (canModerate) requestModData();
  10967     if (rackLayoutEnabled) applyDockState();
  10968     updateLayoutPresetOptions();
  10969     renderOnboardingCard();
  10970     return;
  10971   }
  10972 
  10973   if (msg.type === "logoutOk") {
  10974     setSessionToken("");
  10975     loggedInUser = null;
  10976     loggedInRole = "member";
  10977     canModerate = false;
  10978     onboardingState = normalizeOnboardingState({ acceptedRulesVersion: 0, acceptedAt: 0, needsAcceptance: false });
  10979     dmThreads = [];
  10980     dmThreadsById = new Map();
  10981     dmMessagesByThreadId.clear();
  10982     activeDmThreadId = null;
  10983     pendingOpenDmThreadId = "";
  10984     stopWalkieRecording();
  10985     lanUrls = [];
  10986     modReports = [];
  10987     modUsers = [];
  10988     modLog = [];
  10989     setUserPrefs({ starredPostIds: [], hiddenPostIds: [] });
  10990     activeHiveView = "all";
  10991     setAuthUi();
  10992     renderFeed();
  10993     renderLanHint();
  10994     renderPeoplePanel();
  10995     renderCenterPanels();
  10996     if (rackLayoutEnabled) applyDockState();
  10997     updateLayoutPresetOptions();
  10998     renderOnboardingCard();
  10999     return;
  11000   }
  11001 
  11002   if (msg.type === "authState") {
  11003     if (!loggedInUser || msg.username !== loggedInUser) return;
  11004     loggedInRole = typeof msg.role === "string" ? msg.role : loggedInRole;
  11005     canModerate = Boolean(msg.canModerate);
  11006     onboardingState = normalizeOnboardingState(msg.onboarding || onboardingState);
  11007     if (!canModerate) lanUrls = [];
  11008     if (msg.prefs && typeof msg.prefs === "object") setUserPrefs(msg.prefs);
  11009     setAuthUi();
  11010     renderLanHint();
  11011     if (rackLayoutEnabled) applyDockState();
  11012     renderPeoplePanel();
  11013     if (canModerate) requestModData();
  11014     updateLayoutPresetOptions();
  11015     renderOnboardingCard();
  11016     return;
  11017   }
  11018 
  11019   if (msg.type === "onboardingState" && msg.onboarding && typeof msg.onboarding === "object") {
  11020     onboardingState = normalizeOnboardingState(msg.onboarding);
  11021     setAuthUi();
  11022     renderOnboardingCard();
  11023     return;
  11024   }
  11025 
  11026   if (msg.type === "sessionInvalid") {
  11027     setSessionToken("");
  11028     setUserPrefs({ starredPostIds: [], hiddenPostIds: [] });
  11029     dmThreads = [];
  11030     dmThreadsById = new Map();
  11031     dmMessagesByThreadId.clear();
  11032     activeDmThreadId = null;
  11033     pendingOpenDmThreadId = "";
  11034     return;
  11035   }
  11036 
  11037   if (msg.type === "userPrefs") {
  11038     setUserPrefs(msg.prefs || {});
  11039     renderFeed();
  11040     return;
  11041   }
  11042 
  11043   if (msg.type === "peopleSnapshot") {
  11044     peopleMembers = Array.isArray(msg.members) ? msg.members : [];
  11045     renderPeoplePanel();
  11046     return;
  11047   }
  11048 
  11049   if (msg.type === "dmSnapshot") {
  11050     setDmThreads(Array.isArray(msg.threads) ? msg.threads : []);
  11051     return;
  11052   }
  11053 
  11054   if (msg.type === "dmThreadOk" && msg.thread) {
  11055     const t = normalizeDmThread(msg.thread);
  11056     if (!t) return;
  11057     upsertDmThread(t);
  11058     if (pendingOpenDmThreadId && pendingOpenDmThreadId === t.id && String(t.status || "") === "active") {
  11059       openDmThread(t.id);
  11060     }
  11061     return;
  11062   }
  11063 
  11064   if (msg.type === "dmThreadUpdated" && msg.thread) {
  11065     const me = String(loggedInUser || "").trim().toLowerCase();
  11066     const a = msg.thread?.a ? normalizeDmThread(msg.thread.a) : null;
  11067     const b = msg.thread?.b ? normalizeDmThread(msg.thread.b) : null;
  11068     const mine = me ? [a, b].find((t) => t && String(t.other || "").toLowerCase() !== me) : a || b;
  11069     if (mine) {
  11070       upsertDmThread(mine);
  11071       if (pendingOpenDmThreadId && pendingOpenDmThreadId === mine.id && String(mine.status || "") === "active") {
  11072         openDmThread(mine.id);
  11073       }
  11074       if (activeDmThreadId && mine.id === activeDmThreadId) {
  11075         const current = dmMessagesByThreadId.get(activeDmThreadId) || null;
  11076         if (!current || current.length === 0) ws.send(JSON.stringify({ type: "dmHistory", threadId: activeDmThreadId }));
  11077       }
  11078     }
  11079     return;
  11080   }
  11081 
  11082   if (msg.type === "dmHistory") {
  11083     const threadId = String(msg.threadId || "").trim();
  11084     if (!threadId) return;
  11085     const messages = Array.isArray(msg.messages) ? msg.messages.map(normalizeDmMessage).filter(Boolean) : [];
  11086     dmMessagesByThreadId.set(threadId, messages);
  11087     if (activeDmThreadId === threadId) renderChatPanel(true);
  11088     return;
  11089   }
  11090 
  11091   if (msg.type === "dmMessage" && msg.threadId && msg.message) {
  11092     const threadId = String(msg.threadId || "").trim();
  11093     const message = normalizeDmMessage(msg.message);
  11094     if (!threadId || !message) return;
  11095     const existing = dmMessagesByThreadId.get(threadId) || [];
  11096     if (!existing.some((m) => m.id === message.id)) {
  11097       existing.push(message);
  11098       dmMessagesByThreadId.set(threadId, existing);
  11099     }
  11100     const sender = String(message.fromUser || "");
  11101     const isFromYou = Boolean(sender && loggedInUser && sender === loggedInUser);
  11102     if (activeDmThreadId === threadId && windowFocused && !document.hidden) {
  11103       if (!appendDmMessageToDom(threadId, message)) renderChatPanel();
  11104       pulseChatMessage(message.id);
  11105     } else {
  11106       if (!isFromYou) {
  11107         const title = `DM from @${sender || "unknown"}`;
  11108         const body = String(message.text || "").slice(0, 160) || "New message";
  11109         if (!windowFocused || document.hidden) maybeNotify(`Bzl: ${title}`, body, { threadId });
  11110         else toast("DM", `${sender ? `@${sender}: ` : ""}${body}`);
  11111         playSfx("ping", { volume: 0.38 });
  11112       }
  11113       renderPeoplePanel();
  11114     }
  11115     return;
  11116   }
  11117 
  11118   if (msg.type === "dmModMessageReceived") {
  11119     const threadId = String(msg.threadId || "").trim();
  11120     if (!threadId) return;
  11121     if (!dmThreadsById.has(threadId) && ws?.readyState === WebSocket.OPEN) {
  11122       pendingOpenDmThreadId = threadId;
  11123       ws.send(JSON.stringify({ type: "dmList" }));
  11124     }
  11125     if (isMobileScreenMode()) {
  11126       const layout = loadMobileLayout();
  11127       layout.active = "chat";
  11128       saveMobileLayout(layout);
  11129       setMobileScreen("chat");
  11130       renderMobileNav();
  11131     }
  11132     if (dmThreadsById.has(threadId)) openDmThread(threadId);
  11133     toast("Moderator message", "Opened priority moderator DM.");
  11134     return;
  11135   }
  11136 
  11137   if (msg.type === "lanInfo") {
  11138     lanUrls = Array.isArray(msg.lanUrls) ? msg.lanUrls : [];
  11139     renderLanHint();
  11140     return;
  11141   }
  11142 
  11143   if (msg.type === "loginError") {
  11144     authHint.textContent = msg.message || "Login failed.";
  11145     return;
  11146   }
  11147 
  11148   if (msg.type === "profileOk") {
  11149     const profile = msg.profile || {};
  11150     pendingProfileImage = typeof profile.image === "string" ? profile.image : pendingProfileImage;
  11151     if (pendingProfileImage) {
  11152       profilePreview.src = pendingProfileImage;
  11153       profilePreview.classList.add("hasImg");
  11154     } else {
  11155       profilePreview.removeAttribute("src");
  11156       profilePreview.classList.remove("hasImg");
  11157     }
  11158     if (profile.color) nameColorInput.value = profile.color;
  11159     profileStatus.textContent = "Saved.";
  11160     const normalized = normalizeProfileData(profile, loggedInUser || "");
  11161     if (loggedInUser && normalized.username === loggedInUser) {
  11162       activeProfile = normalized;
  11163       activeProfileUsername = loggedInUser;
  11164       if (centerView === "profile") {
  11165         isEditingProfile = false;
  11166         if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile";
  11167         renderCenterPanels();
  11168       }
  11169     }
  11170     return;
  11171   }
  11172 
  11173   if (msg.type === "error") {
  11174     const m = msg.message || "Error";
  11175     authHint.textContent = m;
  11176     profileStatus.textContent = m;
  11177     toast("Error", m);
  11178     return;
  11179   }
  11180 
  11181   if (msg.type === "rateLimited") {
  11182     const m = msg.message || "Too many requests. Please wait and try again.";
  11183     toast("Rate limit", m);
  11184     return;
  11185   }
  11186 
  11187   if (msg.type === "permissionDenied") {
  11188     const m = msg.message || "Permission denied.";
  11189     if (/(owner|moderator) access required/i.test(m)) {
  11190       pluginAdminStatus = m;
  11191       pluginAdminBusy = false;
  11192       pluginEnableInFlight.clear();
  11193       renderModPanel();
  11194     }
  11195     toast("Moderation", m);
  11196     return;
  11197   }
  11198 
  11199   if (msg.type === "collectionOk") {
  11200     toast("Collections", "Collection created.");
  11201     return;
  11202   }
  11203 
  11204   if (msg.type === "roleOk") {
  11205     toast("Roles", "Role created.");
  11206     return;
  11207   }
  11208 
  11209   if (msg.type === "pluginOk") {
  11210     if (msg.uninstalled) pluginAdminStatus = "Plugin uninstalled.";
  11211     else if (typeof msg.enabled === "boolean") pluginAdminStatus = msg.enabled ? "Plugin enabled." : "Plugin disabled.";
  11212     else if (msg.reloaded) pluginAdminStatus = "Plugins reloaded.";
  11213     else pluginAdminStatus = "Plugin updated.";
  11214     pluginAdminBusy = false;
  11215     if (msg.id) pluginEnableInFlight.delete(String(msg.id || "").trim().toLowerCase());
  11216     if (modTab === "server") renderModPanel();
  11217     return;
  11218   }
  11219 
  11220   if (msg.type === "postUnlocked") {
  11221     const postId = msg.postId || "";
  11222     if (!postId || !msg.post) return;
  11223     posts.set(postId, msg.post);
  11224     if (Array.isArray(msg.messages)) chatByPost.set(postId, msg.messages);
  11225     renderFeed();
  11226     renderChatPanel();
  11227     renderTypingIndicator();
  11228     if (pendingOpenChatAfterUnlock === postId) {
  11229       pendingOpenChatAfterUnlock = null;
  11230       openChat(postId);
  11231     } else {
  11232       toast("Unlocked", "You can view and chat in this post.");
  11233     }
  11234     return;
  11235   }
  11236 
  11237   if (msg.type === "chatHistory") {
  11238     chatByPost.set(msg.postId, Array.isArray(msg.messages) ? msg.messages : []);
  11239     markRead(msg.postId);
  11240     renderChatPanel(true);
  11241     renderTypingIndicator();
  11242     renderChatInstancesForPost(msg.postId);
  11243     return;
  11244   }
  11245 
  11246   if (msg.type === "modSnapshot") {
  11247     if (Array.isArray(msg.reports)) modReports = msg.reports;
  11248     if (Array.isArray(msg.users)) modUsers = msg.users;
  11249     if (Array.isArray(msg.log)) modLog = msg.log;
  11250     renderModPanel();
  11251     return;
  11252   }
  11253 
  11254   if (msg.type === "devLogSnapshot") {
  11255     if (Array.isArray(msg.log)) devLog = msg.log;
  11256     if (canModerate && modTab === "log" && modLogView === "dev") renderModPanel();
  11257     return;
  11258   }
  11259 
  11260   if (msg.type === "devLogAppended" && msg.entry) {
  11261     devLog.unshift(msg.entry);
  11262     if (devLog.length > 300) devLog.splice(300);
  11263     if (canModerate && modTab === "log" && modLogView === "dev") renderModPanel();
  11264     return;
  11265   }
  11266 
  11267   if (msg.type === "modLogAppended" && msg.entry) {
  11268     modLog.unshift(msg.entry);
  11269     if (modLog.length > 200) modLog.splice(200);
  11270     renderModPanel();
  11271     return;
  11272   }
  11273 
  11274   if (msg.type === "modActionApplied") {
  11275     requestModData();
  11276     renderFeed();
  11277     renderChatPanel();
  11278     renderModPanel();
  11279     return;
  11280   }
  11281 
  11282   if (msg.type === "nukeOk") {
  11283     toast(
  11284       "NUKE complete",
  11285       `Cleared ${Number(msg.deletedPosts || 0)} hives and deleted ${Number(msg.deletedUploads || 0)} uploads (kept ${Number(msg.keptUploads || 0)} profile files).`
  11286     );
  11287     return;
  11288   }
  11289 
  11290   if (msg.type === "reportCreated" && msg.report) {
  11291     if (canModerate) {
  11292       const idx = modReports.findIndex((r) => r.id === msg.report.id);
  11293       if (idx >= 0) modReports[idx] = msg.report;
  11294       else modReports.unshift(msg.report);
  11295       if (modReports.length > 200) modReports.splice(200);
  11296       renderModPanel();
  11297     } else if (msg.report.reporter === loggedInUser) {
  11298       toast("Report submitted", "Thanks. A moderator will review it.");
  11299     }
  11300     return;
  11301   }
  11302 
  11303   if (msg.type === "reportUpdated" && msg.report) {
  11304     const idx = modReports.findIndex((r) => r.id === msg.report.id);
  11305     if (idx >= 0) modReports[idx] = msg.report;
  11306     else modReports.unshift(msg.report);
  11307     if (modReports.length > 200) modReports.splice(200);
  11308     renderModPanel();
  11309     return;
  11310   }
  11311 
  11312   if (msg.type === "reactionUpdated" && msg.targetType === "chat") {
  11313     const postId = msg.postId || "";
  11314     const messageId = msg.messageId || "";
  11315     const reactions = msg.reactions && typeof msg.reactions === "object" ? msg.reactions : {};
  11316     const arr = chatByPost.get(postId) || [];
  11317     const m = arr.find((x) => x && x.id === messageId);
  11318     if (m) m.reactions = reactions;
  11319     if (activeChatPostId === postId) renderChatPanel();
  11320     renderChatInstancesForPost(postId);
  11321     return;
  11322   }
  11323 
  11324   if (msg.type === "typing") {
  11325     const postId = msg.postId || "";
  11326     const username = msg.username || "";
  11327     if (!postId || !username) return;
  11328     if (loggedInUser && username === loggedInUser) return;
  11329     const ignoreUserSet = new Set(
  11330       [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase())
  11331     );
  11332     const usernameLower = String(username || "").toLowerCase();
  11333     const selfLower = String(loggedInUser || "").toLowerCase();
  11334     if (usernameLower && usernameLower !== selfLower && ignoreUserSet.has(usernameLower)) return;
  11335     const isTyping = Boolean(msg.isTyping);
  11336     const set = typingUsersByPostId.get(postId) || new Set();
  11337     if (isTyping) set.add(username);
  11338     else set.delete(username);
  11339     if (set.size === 0) typingUsersByPostId.delete(postId);
  11340     else typingUsersByPostId.set(postId, set);
  11341     if (activeChatPostId === postId) renderTypingIndicator();
  11342     renderChatInstancesForPost(postId);
  11343     return;
  11344   }
  11345 
  11346   if (msg.type === "chatMessage") {
  11347     const arr = chatByPost.get(msg.postId) || [];
  11348     arr.push(msg.message);
  11349     if (arr.length > 200) arr.splice(0, arr.length - 200);
  11350     chatByPost.set(msg.postId, arr);
  11351     const sender = msg.message?.fromUser || "";
  11352     if (sender) {
  11353       const set = typingUsersByPostId.get(msg.postId);
  11354       if (set && set.has(sender)) {
  11355         set.delete(sender);
  11356         if (set.size === 0) typingUsersByPostId.delete(msg.postId);
  11357       }
  11358     }
  11359     const isFromYou = Boolean(sender && loggedInUser && sender === loggedInUser);
  11360     const senderLower = String(sender || "").toLowerCase();
  11361     const selfLower = String(loggedInUser || "").toLowerCase();
  11362     const ignoreUserSet = new Set(
  11363       [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase())
  11364     );
  11365     if (!isFromYou && senderLower && senderLower !== selfLower && ignoreUserSet.has(senderLower)) {
  11366       if (activeChatPostId === msg.postId) renderChatPanel();
  11367       renderChatInstancesForPost(msg.postId);
  11368       return;
  11369     }
  11370     const mentions = Array.isArray(msg.message?.mentions) ? msg.message.mentions.map((u) => String(u || "").toLowerCase()) : [];
  11371     const mentionsYou = Boolean(loggedInUser && mentions.includes(loggedInUser) && !isFromYou);
  11372     if (mentionsYou) playSfx("ping", { volume: 0.42 });
  11373     if (activeChatPostId === msg.postId && windowFocused && !document.hidden) {
  11374       markRead(msg.postId);
  11375       if (!appendPostChatMessageToDom(msg.postId, msg.message)) renderChatPanel();
  11376       pulseChatMessage(msg.message?.id);
  11377       renderTypingIndicator();
  11378       if (mentionsYou) toast("Mentioned", `@${sender} mentioned you.`);
  11379     } else {
  11380       if (!buzzTimers.has(msg.postId)) {
  11381         const t = window.setTimeout(() => {
  11382           buzzTimers.delete(msg.postId);
  11383           renderFeed();
  11384         }, 750);
  11385         buzzTimers.set(msg.postId, t);
  11386       } else {
  11387         clearTimeout(buzzTimers.get(msg.postId));
  11388         const t = window.setTimeout(() => {
  11389           buzzTimers.delete(msg.postId);
  11390           renderFeed();
  11391         }, 750);
  11392         buzzTimers.set(msg.postId, t);
  11393       }
  11394       bumpUnread(msg.postId);
  11395       renderFeed();
  11396       const p = posts.get(msg.postId);
  11397       const title = p ? postTitle(p) : "Chat";
  11398       const body = sender ? `@${sender}: ${msg.message?.text || ""}` : msg.message?.text || "";
  11399       if (!isFromYou) {
  11400         if (!windowFocused || document.hidden) {
  11401           const notifyTitle = mentionsYou ? `Bzl: Mention in ${title}` : `Bzl: ${title}`;
  11402           maybeNotify(notifyTitle, body.slice(0, 160), { postId: msg.postId });
  11403         } else {
  11404           const toastTitle = mentionsYou ? "Mentioned" : title;
  11405           const toastBody = mentionsYou ? `@${sender} mentioned you` : body.slice(0, 120);
  11406           toast(toastTitle, toastBody);
  11407         }
  11408       }
  11409     }
  11410     renderChatInstancesForPost(msg.postId);
  11411   }
  11412 }
  11413 
  11414 setConn("connecting");
  11415 connectWs();
  11416 
  11417 renderLanHint();
  11418 writeHintsEnabledPref(readHintsEnabledPref());
  11419 initDisplayPrefsUi();
  11420 if (stayConnectedEl) {
  11421   stayConnectedEl.checked = readStayConnectedPref();
  11422   stayConnectedEl.addEventListener("change", () => {
  11423     const on = Boolean(stayConnectedEl.checked);
  11424     writeStayConnectedPref(on);
  11425     if (on) {
  11426       if (!ws || ws.readyState === WebSocket.CLOSED) connectWs();
  11427       startWsKeepalive(ws);
  11428     } else {
  11429       clearWsReconnect();
  11430       clearWsKeepalive();
  11431     }
  11432   });
  11433 }
  11434 if (enableHintsEl) {
  11435   enableHintsEl.checked = readHintsEnabledPref();
  11436   enableHintsEl.addEventListener("change", () => {
  11437     writeHintsEnabledPref(Boolean(enableHintsEl.checked));
  11438   });
  11439 }
  11440 if (chatEnterModeEl) {
  11441   chatEnterModeEl.value = readChatEnterModePref();
  11442   chatEnterModeEl.addEventListener("change", () => {
  11443     writeChatEnterModePref(chatEnterModeEl.value);
  11444   });
  11445 }
  11446 if (resetCurrentLayoutBtn) {
  11447   resetCurrentLayoutBtn.addEventListener("click", () => {
  11448     if (!rackLayoutEnabled) return;
  11449     const currentPreset = String(rackLayoutState?.presetId || layoutPresetEl?.value || "defaultSocial");
  11450     applyPreset(currentPreset);
  11451     toast("Layout", "Current preset layout reset.");
  11452   });
  11453 }
  11454 renderPeoplePanel();
  11455 setPeopleOpen(getPeopleOpen());
  11456 composerOpen = getComposerOpen();
  11457 setComposerOpen(composerOpen);
  11458 applySidebarWidth(readStoredSidebarWidth(), false);
  11459 applyChatWidth(readStoredChatWidth(), false);
  11460 applyModWidth(readStoredModWidth(), false);
  11461 applyPeopleWidth(readStoredPeopleWidth(), false);
  11462 applyChatDock();
  11463 
  11464 if (toggleReactionsEl) {
  11465   toggleReactionsEl.checked = showReactions;
  11466   toggleReactionsEl.addEventListener("change", () => {
  11467     showReactions = Boolean(toggleReactionsEl.checked);
  11468     localStorage.setItem("bzl_showReactions", showReactions ? "1" : "0");
  11469     renderFeed();
  11470     renderChatPanel();
  11471   });
  11472 }
  11473 
  11474 if (hivesViewModeEl) {
  11475   const pref = readStringPref(HIVES_VIEW_MODE_KEY, "auto");
  11476   hivesViewModeEl.value = pref === "cards" || pref === "list" ? pref : "auto";
  11477   hivesViewModeEl.addEventListener("change", () => {
  11478     const next = String(hivesViewModeEl.value || "auto").toLowerCase();
  11479     writeStringPref(HIVES_VIEW_MODE_KEY, next === "cards" || next === "list" ? next : "auto");
  11480     applyHivesViewMode();
  11481   });
  11482 }
  11483 installHivesAutoViewMode();
  11484 applyHivesViewMode();
  11485 updateMobileSortCycleLabel();
  11486 
  11487 if (chatHeaderEl && appRoot) {
  11488   chatHeaderEl.setAttribute("draggable", "true");
  11489   chatHeaderEl.title = "Drag left/right to dock chat";
  11490   chatHeaderEl.addEventListener("dragstart", (e) => {
  11491     try {
  11492       e.dataTransfer.effectAllowed = "move";
  11493       e.dataTransfer.setData("text/plain", "bzl:dock:chat");
  11494     } catch {
  11495       // ignore
  11496     }
  11497     appRoot.classList.add("isDocking");
  11498   });
  11499   chatHeaderEl.addEventListener("dragend", () => {
  11500     appRoot.classList.remove("isDocking");
  11501   });
  11502   appRoot.addEventListener("dragover", (e) => {
  11503     if (!appRoot.classList.contains("isDocking")) return;
  11504     e.preventDefault();
  11505     try {
  11506       e.dataTransfer.dropEffect = "move";
  11507     } catch {
  11508       // ignore
  11509     }
  11510   });
  11511   appRoot.addEventListener("drop", (e) => {
  11512     if (!appRoot.classList.contains("isDocking")) return;
  11513     e.preventDefault();
  11514     appRoot.classList.remove("isDocking");
  11515     const next = e.clientX > window.innerWidth * 0.58 ? "right" : "left";
  11516     if (next === chatDock) return;
  11517     chatDock = next;
  11518     localStorage.setItem("bzl_chatDock", chatDock);
  11519     applyChatDock();
  11520   });
  11521 }
  11522 
  11523 installDropUpload(editor, { allowImages: true, allowAudio: true });
  11524 installDropUpload(chatEditor, { allowImages: true, allowAudio: true });
  11525 installDropUpload(profileBioEditor, { allowImages: true, allowAudio: true });
  11526 installDropUpload(editModalEditor, { allowImages: true, allowAudio: true });
  11527 
  11528 mediaModal?.addEventListener("click", (e) => {
  11529   if (e.target?.getAttribute?.("data-mediamodalclose")) setMediaModalOpen(false);
  11530 });
  11531 mediaModalClose?.addEventListener("click", () => setMediaModalOpen(false));
  11532 mediaModalCopyLink?.addEventListener("click", async () => {
  11533   const url = String(mediaModalOpenLink?.href || "").trim();
  11534   if (!url || url === "#") return;
  11535   try {
  11536     await navigator.clipboard.writeText(url);
  11537     if (mediaModalStatus) mediaModalStatus.textContent = "Copied.";
  11538   } catch {
  11539     if (mediaModalStatus) mediaModalStatus.textContent = "Copy failed (clipboard blocked).";
  11540   }
  11541 });
  11542 shortcutHelpModal?.addEventListener("click", (e) => {
  11543   if (e.target?.getAttribute?.("data-shortcutclose")) setShortcutHelpOpen(false);
  11544 });
  11545 shortcutHelpCloseBtn?.addEventListener("click", () => setShortcutHelpOpen(false));
  11546 openShortcutHelpBtn?.addEventListener("click", () => setShortcutHelpOpen(true));
  11547 document.addEventListener("keydown", (e) => {
  11548   if (e.key !== "Escape") return;
  11549   if (mediaModal && !mediaModal.classList.contains("hidden")) {
  11550     setMediaModalOpen(false);
  11551     return;
  11552   }
  11553   if (shortcutHelpModal && !shortcutHelpModal.classList.contains("hidden")) setShortcutHelpOpen(false);
  11554 });
  11555 document.body.addEventListener("click", (e) => {
  11556   const img = e.target?.closest?.("img");
  11557   if (!img) return;
  11558   if (img.id === "profilePreview") return;
  11559   if (img.closest("#mediaModal")) return;
  11560   const inAllowed =
  11561     img.closest(".chatMsg .content") ||
  11562     img.closest(".profileBio") ||
  11563     img.closest(".profileCard") ||
  11564     img.closest(".editor") ||
  11565     img.closest("#editModalEditor");
  11566   if (!inAllowed) return;
  11567   const src = img.getAttribute("src") || "";
  11568   if (!src) return;
  11569   openMediaModal(src);
  11570 });
  11571 
  11572 setSidebarHidden(getSidebarHidden());
  11573 toggleSidebarBtn?.addEventListener("click", () => setSidebarHidden(true));
  11574 showSidebarBtn?.addEventListener("click", () => setSidebarHidden(false));
  11575 togglePeopleBtn?.addEventListener("click", () => setPeopleOpen(!peopleOpen));
  11576 closePeopleBtn?.addEventListener("click", () => setPeopleOpen(false));
  11577 peopleMembersTabBtn?.addEventListener("click", () => {
  11578   peopleTab = "members";
  11579   renderPeoplePanel();
  11580 });
  11581 peopleDmsTabBtn?.addEventListener("click", () => {
  11582   peopleTab = "dms";
  11583   renderPeoplePanel();
  11584 });
  11585 peopleSearchEl?.addEventListener("input", () => renderPeoplePanel());
  11586 peopleListEl?.addEventListener("click", (e) => {
  11587   const modDmBtn = e.target.closest("button[data-moddm]");
  11588   if (modDmBtn) {
  11589     sendModDmPrompt(modDmBtn.getAttribute("data-moddm") || "");
  11590     return;
  11591   }
  11592   const dmBtn = e.target.closest("button[data-dmrequest]");
  11593   if (dmBtn) {
  11594     const to = String(dmBtn.getAttribute("data-dmrequest") || "")
  11595       .trim()
  11596       .replace(/^@+/, "")
  11597       .toLowerCase();
  11598     if (!to) return;
  11599     if (!loggedInUser) {
  11600       toast("Sign in required", "Sign in to start a DM.");
  11601       return;
  11602     }
  11603     if (to === String(loggedInUser).toLowerCase()) return;
  11604     ws.send(JSON.stringify({ type: "dmRequestCreate", to }));
  11605     peopleTab = "dms";
  11606     renderPeoplePanel();
  11607     return;
  11608   }
  11609   const btn = e.target.closest("[data-viewprofile]");
  11610   if (!btn) return;
  11611   const username = btn.getAttribute("data-viewprofile") || "";
  11612   openUserProfile(username);
  11613 });
  11614 
  11615 peopleDmsViewEl?.addEventListener("click", (e) => {
  11616   const modDmBtn = e.target.closest("button[data-moddm]");
  11617   if (modDmBtn) {
  11618     sendModDmPrompt(modDmBtn.getAttribute("data-moddm") || "");
  11619     return;
  11620   }
  11621   const profileLink = e.target.closest("[data-viewprofile]");
  11622   if (profileLink) {
  11623     const username = profileLink.getAttribute("data-viewprofile") || "";
  11624     if (username) openUserProfile(username);
  11625     return;
  11626   }
  11627 
  11628   const openBtn = e.target.closest("button[data-dmopen]");
  11629   if (openBtn) {
  11630     const threadId = openBtn.getAttribute("data-dmopen") || "";
  11631     if (!threadId) return;
  11632     openDmThread(threadId);
  11633     return;
  11634   }
  11635 
  11636   const acceptBtn = e.target.closest("button[data-dmaccept]");
  11637   if (acceptBtn) {
  11638     const threadId = acceptBtn.getAttribute("data-dmaccept") || "";
  11639     if (!threadId) return;
  11640     pendingOpenDmThreadId = threadId;
  11641     ws.send(JSON.stringify({ type: "dmRequestRespond", threadId, accept: true }));
  11642     return;
  11643   }
  11644 
  11645   const declineBtn = e.target.closest("button[data-dmdecline]");
  11646   if (declineBtn) {
  11647     const threadId = declineBtn.getAttribute("data-dmdecline") || "";
  11648     if (!threadId) return;
  11649     ws.send(JSON.stringify({ type: "dmRequestRespond", threadId, accept: false }));
  11650     return;
  11651   }
  11652 
  11653   const requestAgainBtn = e.target.closest("button[data-dmrequest]");
  11654   if (requestAgainBtn) {
  11655     const to = String(requestAgainBtn.getAttribute("data-dmrequest") || "")
  11656       .trim()
  11657       .replace(/^@+/, "")
  11658       .toLowerCase();
  11659     if (!to || !loggedInUser) return;
  11660     if (to === String(loggedInUser).toLowerCase()) return;
  11661     ws.send(JSON.stringify({ type: "dmRequestCreate", to }));
  11662     return;
  11663   }
  11664 
  11665   const requestFromSelectBtn = e.target.closest("button[data-dmrequestfromselect]");
  11666   if (requestFromSelectBtn) {
  11667     const sel = peopleDmsViewEl.querySelector("select[data-dmto]");
  11668     const to = String(sel?.value || "")
  11669       .trim()
  11670       .replace(/^@+/, "")
  11671       .toLowerCase();
  11672     if (!to) return;
  11673     if (!loggedInUser) {
  11674       toast("Sign in required", "Sign in to start a DM.");
  11675       return;
  11676     }
  11677     if (to === String(loggedInUser).toLowerCase()) return;
  11678     ws.send(JSON.stringify({ type: "dmRequestCreate", to }));
  11679     if (sel) sel.value = "";
  11680     return;
  11681   }
  11682 });
  11683 
  11684 onboardingAcceptBtn?.addEventListener("click", () => {
  11685   if (!loggedInUser) {
  11686     toast("Sign in required", "Sign in to accept server rules.");
  11687     return;
  11688   }
  11689   ws.send(JSON.stringify({ type: "onboardingAcceptRules" }));
  11690 });
  11691 
  11692 onboardingRefreshBtn?.addEventListener("click", () => {
  11693   if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
  11694 });
  11695 
  11696 onboardingPanelAcceptBtn?.addEventListener("click", () => {
  11697   if (!loggedInUser) {
  11698     toast("Sign in required", "Sign in to accept server rules.");
  11699     return;
  11700   }
  11701   ws.send(JSON.stringify({ type: "onboardingAcceptRules" }));
  11702 });
  11703 
  11704 onboardingPanelRefreshBtn?.addEventListener("click", () => {
  11705   if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
  11706 });
  11707 
  11708 onboardingPanelBodyEl?.addEventListener("click", (e) => {
  11709   const tabBtn = e.target.closest?.("button[data-onbtab]");
  11710   if (!tabBtn) return;
  11711   const tab = String(tabBtn.getAttribute("data-onbtab") || "about").trim();
  11712   if (!["about", "rules", "roles"].includes(tab)) return;
  11713   onboardingViewerTab = tab;
  11714   renderOnboardingPanel();
  11715 });
  11716 
  11717 profileCard?.addEventListener("click", (e) => {
  11718   const modDmBtn = e.target.closest("button[data-moddm]");
  11719   if (modDmBtn) {
  11720     sendModDmPrompt(modDmBtn.getAttribute("data-moddm") || "");
  11721     return;
  11722   }
  11723   const dmBtn = e.target.closest("button[data-dmrequest]");
  11724   if (!dmBtn) return;
  11725   const to = String(dmBtn.getAttribute("data-dmrequest") || "")
  11726     .trim()
  11727     .replace(/^@+/, "")
  11728     .toLowerCase();
  11729   if (!to) return;
  11730   if (!loggedInUser) {
  11731     toast("Sign in required", "Sign in to start a DM.");
  11732     return;
  11733   }
  11734   if (to === String(loggedInUser).toLowerCase()) return;
  11735   ws.send(JSON.stringify({ type: "dmRequestCreate", to }));
  11736   peopleTab = "dms";
  11737   setPeopleOpen(true);
  11738   renderPeoplePanel();
  11739 });
  11740 profileCard?.addEventListener("click", (e) => {
  11741   const ignoreBtn = e.target.closest("button[data-ignoreuser],button[data-unignoreuser],button[data-blockuser],button[data-unblockuser]");
  11742   if (!ignoreBtn) return;
  11743   const raw =
  11744     ignoreBtn.getAttribute("data-ignoreuser") ||
  11745     ignoreBtn.getAttribute("data-unignoreuser") ||
  11746     ignoreBtn.getAttribute("data-blockuser") ||
  11747     ignoreBtn.getAttribute("data-unblockuser") ||
  11748     "";
  11749   const username = String(raw).trim().replace(/^@+/, "").toLowerCase();
  11750   if (!username || !loggedInUser) return;
  11751   if (username === String(loggedInUser).toLowerCase()) return;
  11752   if (ignoreBtn.hasAttribute("data-ignoreuser")) ws.send(JSON.stringify({ type: "ignoreUser", username }));
  11753   else if (ignoreBtn.hasAttribute("data-unignoreuser")) ws.send(JSON.stringify({ type: "unignoreUser", username }));
  11754   else if (ignoreBtn.hasAttribute("data-blockuser")) ws.send(JSON.stringify({ type: "blockUser", username }));
  11755   else if (ignoreBtn.hasAttribute("data-unblockuser")) ws.send(JSON.stringify({ type: "unblockUser", username }));
  11756 });
  11757 chatResizeHandle?.addEventListener("mousedown", (e) => {
  11758   e.preventDefault();
  11759   startChatResize(e.clientX);
  11760 });
  11761 chatResizeHandle?.addEventListener("dblclick", () => applyChatWidth(CHAT_WIDTH_DEFAULT));
  11762 sidebarResizeHandle?.addEventListener("mousedown", (e) => {
  11763   e.preventDefault();
  11764   startSidebarResize(e.clientX);
  11765 });
  11766 sidebarResizeHandle?.addEventListener("dblclick", () => applySidebarWidth(SIDEBAR_WIDTH_DEFAULT));
  11767 mainResizeHandle?.addEventListener("mousedown", (e) => {
  11768   e.preventDefault();
  11769   startModResize(e.clientX);
  11770 });
  11771 mainResizeHandle?.addEventListener("dblclick", () => applyModWidth(MOD_WIDTH_DEFAULT));
  11772 peopleResizeHandle?.addEventListener("mousedown", (e) => {
  11773   e.preventDefault();
  11774   startPeopleResize(e.clientX);
  11775 });
  11776 peopleResizeHandle?.addEventListener("dblclick", () => applyPeopleWidth(PEOPLE_WIDTH_DEFAULT));
  11777 sidebarPanelEl?.addEventListener("mousedown", (e) => {
  11778   if (e.button !== 0 || isMobileSwipeMode()) return;
  11779   const rect = sidebarPanelEl.getBoundingClientRect();
  11780   if (Math.abs(e.clientX - rect.right) > 12) return;
  11781   e.preventDefault();
  11782   startSidebarResize(e.clientX);
  11783 });
  11784 chatPanelEl?.addEventListener("mousedown", (e) => {
  11785   if (e.button !== 0 || isMobileSwipeMode()) return;
  11786   const rect = chatPanelEl.getBoundingClientRect();
  11787   if (Math.abs(e.clientX - rect.right) > 12) return;
  11788   e.preventDefault();
  11789   startChatResize(e.clientX);
  11790 });
  11791 modPanelEl?.addEventListener("mousedown", (e) => {
  11792   if (e.button !== 0 || isMobileSwipeMode() || modPanelEl.classList.contains("hidden")) return;
  11793   const rect = modPanelEl.getBoundingClientRect();
  11794   if (Math.abs(e.clientX - rect.left) > 12) return;
  11795   e.preventDefault();
  11796   startModResize(e.clientX);
  11797 });
  11798 peopleDrawerEl?.addEventListener("mousedown", (e) => {
  11799   if (e.button !== 0 || isMobileSwipeMode() || peopleDrawerEl.classList.contains("hidden")) return;
  11800   const rect = peopleDrawerEl.getBoundingClientRect();
  11801   if (Math.abs(e.clientX - rect.left) > 12) return;
  11802   e.preventDefault();
  11803   startPeopleResize(e.clientX);
  11804 });
  11805 mobileNavEl?.addEventListener("click", (e) => {
  11806   const btn = e.target.closest("[data-mobilescreen]");
  11807   if (!btn) return;
  11808   const id = String(btn.getAttribute("data-mobilescreen") || "").trim();
  11809   if (!id) return;
  11810   if (id === "more") {
  11811     renderMobileMoreList();
  11812     setMobileMoreOpen(true);
  11813     return;
  11814   }
  11815   const layout = loadMobileLayout();
  11816   layout.active = id;
  11817   saveMobileLayout(layout);
  11818   setMobileScreen(id);
  11819   renderMobileNav();
  11820 });
  11821 
  11822 function renderMobileMoreList() {
  11823   if (!(mobileMoreListEl instanceof HTMLElement)) return;
  11824   const q = String(mobileMoreSearchEl?.value || "").trim().toLowerCase();
  11825   const { core, plugins } = availableMobileScreens();
  11826 
  11827   const filter = (item) => {
  11828     if (!q) return true;
  11829     return String(item.title || "").toLowerCase().includes(q) || String(item.id || "").toLowerCase().includes(q);
  11830   };
  11831 
  11832   const section = (title, items) => {
  11833     const wrap = document.createElement("div");
  11834     const head = document.createElement("div");
  11835     head.className = "muted small";
  11836     head.textContent = title;
  11837     head.style.margin = "6px 0 6px 2px";
  11838     wrap.appendChild(head);
  11839     const list = document.createElement("div");
  11840     list.style.display = "flex";
  11841     list.style.flexDirection = "column";
  11842     list.style.gap = "10px";
  11843     for (const it of items.filter(filter)) {
  11844       const row = document.createElement("button");
  11845       row.type = "button";
  11846       row.className = "mobileMoreItem";
  11847       row.innerHTML = `<span>${escapeHtml(it.title || it.id)}</span><span class="muted small">${escapeHtml(it.core ? "core" : "plugin")}</span>`;
  11848       row.onclick = () => {
  11849         const layout = loadMobileLayout();
  11850         layout.active = it.id;
  11851         saveMobileLayout(layout);
  11852         setMobileScreen(it.id);
  11853         renderMobileNav();
  11854         setMobileMoreOpen(false);
  11855       };
  11856       list.appendChild(row);
  11857     }
  11858     wrap.appendChild(list);
  11859     return wrap;
  11860   };
  11861 
  11862   mobileMoreListEl.innerHTML = "";
  11863   mobileMoreListEl.appendChild(section("Core", core));
  11864   if (plugins.length) mobileMoreListEl.appendChild(section("Plugins", plugins));
  11865 }
  11866 
  11867 mobileMoreSearchEl?.addEventListener("input", () => {
  11868   if (!mobileMoreOpen) return;
  11869   renderMobileMoreList();
  11870 });
  11871 
  11872 mobileMoreCloseBtn?.addEventListener("click", () => setMobileMoreOpen(false));
  11873 mobileMoreSheetEl?.addEventListener("click", (e) => {
  11874   const target = e.target;
  11875   if (!target) return;
  11876   if (target.closest?.("[data-mobilemoreclose]")) setMobileMoreOpen(false);
  11877 });
  11878 
  11879 walkieRecordBtn?.addEventListener("pointerdown", (e) => {
  11880   e.preventDefault();
  11881   startWalkieRecording();
  11882 });
  11883 walkieRecordBtn?.addEventListener("pointerup", (e) => {
  11884   e.preventDefault();
  11885   stopWalkieRecording();
  11886 });
  11887 walkieRecordBtn?.addEventListener("pointerleave", () => stopWalkieRecording());
  11888 walkieRecordBtn?.addEventListener("mousedown", (e) => {
  11889   e.preventDefault();
  11890   startWalkieRecording();
  11891 });
  11892 walkieRecordBtn?.addEventListener("mouseup", (e) => {
  11893   e.preventDefault();
  11894   stopWalkieRecording();
  11895 });
  11896 walkieRecordBtn?.addEventListener(
  11897   "touchstart",
  11898   (e) => {
  11899     e.preventDefault();
  11900     startWalkieRecording();
  11901   },
  11902   { passive: false }
  11903 );
  11904 walkieRecordBtn?.addEventListener(
  11905   "touchend",
  11906   (e) => {
  11907     e.preventDefault();
  11908     stopWalkieRecording();
  11909   },
  11910   { passive: false }
  11911 );
  11912 
  11913 window.addEventListener("keydown", (e) => {
  11914   if (!shouldHandleWalkieHotkey(e)) return;
  11915   if (!canWalkieTalkNow()) return;
  11916   e.preventDefault();
  11917   startWalkieRecording();
  11918 });
  11919 window.addEventListener("keyup", (e) => {
  11920   if (!shouldHandleWalkieHotkey(e)) return;
  11921   if (!canWalkieTalkNow()) return;
  11922   e.preventDefault();
  11923   stopWalkieRecording();
  11924 });
  11925 window.addEventListener("pointerup", () => stopWalkieRecording());
  11926 window.addEventListener("mouseup", () => stopWalkieRecording());
  11927 window.addEventListener("mousemove", (e) => {
  11928   if (chatResizeDragging) {
  11929     const next = chatResizeStartWidth + (e.clientX - chatResizeStartX);
  11930     applyChatWidth(next, false);
  11931     return;
  11932   }
  11933   if (sidebarResizeDragging) {
  11934     const next = sidebarResizeStartWidth + (e.clientX - sidebarResizeStartX);
  11935     applySidebarWidth(next, false);
  11936     return;
  11937   }
  11938   if (modResizeDragging) {
  11939     const next = modResizeStartWidth - (e.clientX - modResizeStartX);
  11940     applyModWidth(next, false);
  11941     return;
  11942   }
  11943   if (peopleResizeDragging) {
  11944     const next = peopleResizeStartWidth - (e.clientX - peopleResizeStartX);
  11945     applyPeopleWidth(next, false);
  11946   }
  11947 });
  11948 window.addEventListener("mouseup", () => {
  11949   if (chatResizeDragging && chatPanelEl) {
  11950     applyChatWidth(chatPanelEl.getBoundingClientRect().width || readStoredChatWidth());
  11951   }
  11952   if (sidebarResizeDragging && sidebarPanelEl) {
  11953     applySidebarWidth(sidebarPanelEl.getBoundingClientRect().width || readStoredSidebarWidth());
  11954   }
  11955   if (modResizeDragging && modPanelEl) {
  11956     applyModWidth(modPanelEl.getBoundingClientRect().width || readStoredModWidth());
  11957   }
  11958   if (peopleResizeDragging && peopleDrawerEl) {
  11959     applyPeopleWidth(peopleDrawerEl.getBoundingClientRect().width || readStoredPeopleWidth());
  11960   }
  11961   stopAnyPanelResize();
  11962 });
  11963 
  11964 appRoot?.addEventListener(
  11965   "touchstart",
  11966   (e) => {
  11967     if (!isMobileSwipeMode()) return;
  11968     if (!e.touches || e.touches.length !== 1) return;
  11969     const t = e.touches[0];
  11970     touchStartX = t.clientX;
  11971     touchStartY = t.clientY;
  11972     touchTracking = true;
  11973   },
  11974   { passive: true }
  11975 );
  11976 
  11977 appRoot?.addEventListener(
  11978   "touchend",
  11979   (e) => {
  11980     if (!isMobileSwipeMode() || !touchTracking) return;
  11981     touchTracking = false;
  11982     if (!e.changedTouches || e.changedTouches.length !== 1) return;
  11983     const t = e.changedTouches[0];
  11984     const dx = t.clientX - touchStartX;
  11985     const dy = t.clientY - touchStartY;
  11986     if (Math.abs(dx) < 60) return;
  11987     if (Math.abs(dx) < Math.abs(dy) * 1.2) return;
  11988     if (dx < 0) shiftMobilePanel(1);
  11989     else shiftMobilePanel(-1);
  11990   },
  11991   { passive: true }
  11992 );
  11993 
  11994 window.addEventListener("resize", applyMobileMode);
  11995 applyMobileMode();
  11996 
  11997 // Initialize experimental rack layout (safe no-op when disabled).
  11998 initRackLayout();
  11999 
  12000 window.addEventListener("focus", () => {
  12001   windowFocused = true;
  12002   updateNotifUi();
  12003 });
  12004 window.addEventListener("blur", () => {
  12005   windowFocused = false;
  12006   stopAnyPanelResize();
  12007 });
  12008 document.addEventListener("visibilitychange", () => updateNotifUi());
  12009 
  12010 enableNotifsBtn?.addEventListener("click", async () => {
  12011   if (!notifSupported()) return;
  12012   try {
  12013     const res = await Notification.requestPermission();
  12014     if (res === "granted") toast("Notifications", "Enabled.");
  12015   } catch {
  12016     // ignore
  12017   }
  12018   updateNotifUi();
  12019 });
  12020 
  12021 updateNotifUi();