bzl

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

commit 388e5800550e0f8b463063e276b658f46d02e32e
parent 76ad80434cb3afe49f972507d574e2aa6c155b26
Author: SageAzakaela <106701693+SageAzakaela@users.noreply.github.com>
Date:   Tue, 17 Feb 2026 17:47:14 -0700

UI OVERHAUL OF THE DECADE - PART 1 - RISE OF THE PANELS

It's not done, but I really outdid myself here. IT's poetry honestly.

Diffstat:
MCLEAN_INSTALL/plugins_dev/maps/client.js | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
MCLEAN_INSTALL/plugins_dev/maps/plugin.json | 4++--
MREADME.md | 2++
Mdocs/PLUGINS.md | 34++++++++++++++++++++++++++++++++++
Adocs/SERVER_UPDATE.md | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/UI_RACK_LAYOUT.md | 383+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mplugins_dev/maps/client.js | 435++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mplugins_dev/maps/plugin.json | 4++--
Mplugins_dev/maps/server.js | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mpublic/app.js | 1814++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpublic/index.html | 47++++++++++++++++++++++++++++++++++++++++-------
Mpublic/styles.css | 366+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 3287 insertions(+), 92 deletions(-)

diff --git a/CLEAN_INSTALL/plugins_dev/maps/client.js b/CLEAN_INSTALL/plugins_dev/maps/client.js @@ -5,16 +5,49 @@ const ws = window.__bzlWs; if (!ws) return; - const mainPanel = document.querySelector(".main .panelFill"); + const appRootRef = document.querySelector(".app"); + const inRackMode = (() => { + try { + // Rack mode reloads the page; this flag is available before the DOM gets the .rackMode class. + if (localStorage.getItem("bzl_rackLayout_enabled") === "1") return true; + } catch { + // ignore + } + return Boolean(appRootRef?.classList.contains("rackMode")); + })(); + + // In rack mode, Maps should render into its own dockable panel (not inside Hives). + if (inRackMode && ctx?.ui?.registerPanel) { + try { + ctx.ui.registerPanel({ + id: "maps", + title: "Maps", + icon: "🗺️", + defaultRack: "main", + role: "primary", + render() { + // no-op: this plugin mounts into the panel shell below + } + }); + } catch { + // ignore + } + } + + let mainPanel = document.querySelector(".main .panelFill"); + if (inRackMode) { + const shell = document.querySelector('.panel.pluginPanel[data-panel-id="maps"]'); + if (shell instanceof HTMLElement) mainPanel = shell; + } const panelHeader = mainPanel ? mainPanel.querySelector(".panelHeader") : null; const panelTitle = panelHeader ? panelHeader.querySelector(".panelTitle") : null; const filters = panelHeader ? panelHeader.querySelector(".filters") : null; - const hiveTabs = document.getElementById("hiveTabs"); - const feed = document.getElementById("feed"); - const pollinatePanel = document.getElementById("pollinatePanel"); - const chatPanel = document.querySelector(".chat"); - const chatResizeHandle = document.getElementById("chatResizeHandle"); - const appRoot = document.querySelector(".app"); + const hiveTabs = inRackMode ? null : document.getElementById("hiveTabs"); + const feed = inRackMode ? null : document.getElementById("feed"); + const pollinatePanel = inRackMode ? null : document.getElementById("pollinatePanel"); + const chatPanel = inRackMode ? null : document.querySelector(".chat"); + const chatResizeHandle = inRackMode ? null : document.getElementById("chatResizeHandle"); + const appRoot = inRackMode ? null : appRootRef; if (!mainPanel || !panelHeader || !panelTitle) return; @@ -108,17 +141,23 @@ `; document.head.appendChild(style); - const mapsBtn = document.createElement("button"); - mapsBtn.type = "button"; - mapsBtn.className = "ghost smallBtn mapsTabBtn"; - mapsBtn.textContent = "Maps"; - panelTitle.insertAdjacentElement("afterend", mapsBtn); + const mapsBtn = inRackMode + ? null + : (() => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "ghost smallBtn mapsTabBtn"; + btn.textContent = "Maps"; + panelTitle.insertAdjacentElement("afterend", btn); + return btn; + })(); const mapsPanel = document.createElement("div"); - mapsPanel.className = "mapsPanel hidden"; - mainPanel.appendChild(mapsPanel); + mapsPanel.className = inRackMode ? "mapsPanel" : "mapsPanel hidden"; + const mount = inRackMode ? mainPanel.querySelector("[data-pluginmount]") : null; + (mount || mainPanel).appendChild(mapsPanel); - let mode = "hives"; // "hives" | "maps" | "map" + let mode = inRackMode ? "maps" : "hives"; // "hives" | "maps" | "map" let maps = []; let activeMap = null; let users = new Map(); // username -> {x,y,color,image} @@ -558,8 +597,10 @@ function enterMaps() { mode = "maps"; - mapsBtn.classList.add("primary"); - mapsBtn.classList.remove("ghost"); + if (mapsBtn) { + mapsBtn.classList.add("primary"); + mapsBtn.classList.remove("ghost"); + } setHidden(filters, true); setHidden(hiveTabs, true); setHidden(feed, true); @@ -574,8 +615,10 @@ function exitMapsToHives() { mode = "hives"; - mapsBtn.classList.add("ghost"); - mapsBtn.classList.remove("primary"); + if (mapsBtn) { + mapsBtn.classList.add("ghost"); + mapsBtn.classList.remove("primary"); + } setHidden(filters, false); setHidden(hiveTabs, false); setHidden(feed, false); @@ -2980,10 +3023,12 @@ renderMapsList(); } - mapsBtn.addEventListener("click", () => { - if (mode === "hives") enterMaps(); - else exitMapsToHives(); - }); + if (mapsBtn) { + mapsBtn.addEventListener("click", () => { + if (mode === "hives") enterMaps(); + else exitMapsToHives(); + }); + } mapsPanel.addEventListener("click", (e) => { const enter = e.target.closest("[data-mapenter]"); @@ -3500,9 +3545,14 @@ } }); - // Initial list request (in case the Maps view is opened immediately). - // The Maps panel triggers another list() on open. - ctx.send("list", {}); + if (inRackMode) { + // In rack mode, Maps is its own panel: start in the list view immediately. + enterMaps(); + } else { + // Initial list request (in case the Maps view is opened immediately). + // The Maps panel triggers another list() on open. + ctx.send("list", {}); + } }); })(); diff --git a/CLEAN_INSTALL/plugins_dev/maps/plugin.json b/CLEAN_INSTALL/plugins_dev/maps/plugin.json @@ -1,8 +1,8 @@ { "id": "maps", "name": "Maps", - "version": "0.2.6", - "description": "Adds spatial chat rooms with map camera, collisions/masks/exits, and TTRPG tooling.", + "version": "0.3.9", + "description": "Adds spatial chat rooms with map camera, collisions/masks/exits, fog + fall-through zones, and TTRPG tooling.", "entryClient": "client.js", "entryServer": "server.js", "permissions": ["ui", "ws"] diff --git a/README.md b/README.md @@ -20,10 +20,12 @@ Media uploads: - Full feature rundown (video script aid): `docs/FUNCTIONALITY_REFERENCE.md` - Plugins (MVP): `docs/PLUGINS.md` +- UI rack layout (draft): `docs/UI_RACK_LAYOUT.md` - Directory plugins (draft): `docs/DIRECTORY_SPEC.md` - Moderation spec: `docs/MODERATION_MVP_SPEC.md` - Self-hosted installer plan: `docs/SELF_HOSTED_INSTALLER_PLAN.md` - Issue tracker guide: `docs/ISSUE_TRACKER.md` +- Updating a live server (git + docker): `docs/SERVER_UPDATE.md` ## Run locally diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md @@ -75,6 +75,12 @@ After extraction, the server installs it into `data/plugins/<id>/`. If your plugin has `entryClient`, it is loaded from: - `/plugins/<id>/<entryClient>` +### UI panels (draft) + +Some plugins are primarily UI. We’re planning a UI “rack layout” where plugins (and core features) register **dockable panels** instead of directly manipulating core DOM. + +See: `docs/UI_RACK_LAYOUT.md` (draft). + Client plugins can register via: ```js window.BzlPluginHost.register("polls", (ctx) => { @@ -89,9 +95,37 @@ window.BzlPluginHost.register("polls", (ctx) => { - `ctx.id` - `ctx.toast(title, body)` - `ctx.getUser()` / `ctx.getRole()` +- `ctx.ui.registerPanel(panelDef)` (experimental; only active when the core rack layout is enabled) - `ctx.send(eventName, payload)` -> sends `{ type: "plugin:<id>:<eventName>", ...payload }` - `ctx.devLog(level, message, data)` -> writes to the in-app dev log (Moderation -> Log -> Server dev log) +### `ctx.ui.registerPanel(panelDef)` (experimental) + +This registers a dockable UI panel for your plugin under the rack layout system. + +Notes: +- This is currently **experimental** and only works when the user has enabled the rack layout UI. +- If rack layout is disabled, your plugin should still work using the existing DOM integration patterns. + +Example: +```js +window.BzlPluginHost.register("hello", (ctx) => { + ctx.ui?.registerPanel?.({ + id: "hello", + title: "Hello", + icon: "👋", + defaultRack: "right", // or "main" + role: "aux", // "primary" | "aux" | "transient" | "utility" + presetHints: { + discordLike: { place: "docked.bottom" } + }, + render(mount, api) { + mount.innerHTML = "<div class='small'>Hello from a plugin panel.</div>"; + } + }); +}); +``` + ## Server plugin API If your plugin has `entryServer`, it must export a function: diff --git a/docs/SERVER_UPDATE.md b/docs/SERVER_UPDATE.md @@ -0,0 +1,122 @@ +# Updating a Live Bzl Server (Git + Docker) + +This doc is for a typical droplet setup where you: +- have the `bzlapp/Bzl` repo checked out on the server (example: `~/Bzl`) +- run Bzl via Docker / Docker Compose using that checkout (builds the image from the local Dockerfile) + +If you’re not sure which setup you have, run `docker ps` and see whether Bzl is running as a container. + +--- + +## The “golden” update flow (Compose + local build) + +From the droplet host (not inside the container): + +```bash +cd ~/Bzl + +# 1) make sure you're on the right branch and clean +git status +git checkout main + +# 2) pull latest code +git fetch origin +git pull --ff-only origin main + +# 3) rebuild + recreate container(s) +docker compose up -d --build +``` + +That’s it. + +### Why this order matters +- If you rebuild before pulling, you rebuild the *old* code. +- A `git pull` does **not** update a running container unless you rebuild/recreate it (or you’re bind-mounting the code into the container). + +--- + +## Restart only (no code update) + +```bash +docker compose restart +``` + +Or, if you run a single container named `bzl`: + +```bash +docker restart bzl +``` + +--- + +## Common git mistake (your screenshot) + +If you run: + +```bash +git pull main +``` + +Git interprets `main` as a *remote name* (not a branch), and you’ll get: +`fatal: 'main' does not appear to be a git repository` + +Use this instead: + +```bash +git pull --ff-only origin main +``` + +Quick sanity checks: + +```bash +git remote -v +git branch --show-current +git log -1 --oneline +``` + +--- + +## If you have local changes on the droplet + +If `git status` shows modified files, decide whether you want to keep them. + +To throw away local changes and match GitHub exactly: + +```bash +git fetch origin +git reset --hard origin/main +``` + +Then rebuild: + +```bash +docker compose up -d --build +``` + +--- + +## Confirm you’re actually updated + +After updating/restarting: + +```bash +docker ps +docker logs --tail 80 bzl +``` + +In the UI, the Moderation → Server panel should reflect the latest version string. + +If the UI still looks unchanged, hard-refresh your browser: +- Chrome/Edge: `Ctrl+Shift+R` + +--- + +## Next step (recommended): in-app core updates + +For “Clean Install” desktop setups, the Launcher UI can already do opt-in updates via GitHub Releases. + +For live servers, we can add a similar admin-only “Update core” flow (pull + rebuild + restart) later, but it needs extra safety: +- confirmation prompts + backup +- clear logs of what changed +- no “surprise” updates + diff --git a/docs/UI_RACK_LAYOUT.md b/docs/UI_RACK_LAYOUT.md @@ -0,0 +1,383 @@ +# UI Rack Layout (draft) + +This doc describes a planned UI architecture change: turning the Bzl desktop layout into a **rack of dockable panels**. + +The goal is to make core views (Hives, Chat, People, Moderation, Maps, Library, etc.) and **plugins** behave the same way: +- They are panels you can move between columns (“racks”). +- They can be minimized into edge docks. +- They can be restored from a bottom hotbar of small “orbs”. + +This is a **design + migration doc**, not a completed implementation yet. + +## Why (problem statement) + +Today, plugins often: +- Directly query and manipulate core DOM nodes (`.main`, `.panelHeader`, etc.). +- Hide/show core panels with custom CSS. +- Invent their own navigation inside existing panes. + +That works for MVP, but it creates friction: +- Plugins can fight with the core layout. +- Core layout changes become breaking changes for plugins. +- “Maps inside Hives” (or any plugin inside a core panel) makes it hard to treat the plugin as a first-class app surface. + +The rack layout solves this by giving every surface a consistent home: **a panel container managed by core**. + +## Terms + +### Surface +A UI “screen” that renders content. Examples: Hives feed, a chat thread, the People list, the Maps room UI, a plugin directory. + +### Panel roles +Panels are dockable, but not all panels should behave the same way. We classify panels by *role*: + +- **Primary**: the main workspace surfaces that want the most space (e.g. Hives, Chat, Maps, Directory). +- **Auxiliary**: useful alongside a primary surface, usually in a side stack (e.g. People, Moderation, Library). +- **Transient**: task flows that shouldn’t permanently occupy workspace real estate (e.g. Profile, New Hive composer, setup wizards). +- **Utility**: small tools that are frequently docked/minimized (logs, debug). + +Rack mode should feel great out of the box by using these roles to drive defaults and constraints. + +### Display modes +Each panel should support different display modes (even if the first implementation only ships a subset): + +- `full`: normal panel (default when active) +- `collapsed`: header-only (no body rendered) +- `overlay`: rendered as an overlay/drawer on top of a rack (ideal for transient panels) + +The rack system uses these modes to prevent “everything is a full-height panel” layouts. + +### Panel +A container for one surface, with standard chrome: +- title / icon +- drag handle +- close / minimize / restore +- optional “pin” behavior + +Panels are the atomic units of layout. + +### Rack +A column that holds an ordered list of panels. + +Initial scope (MVP) assumes a few racks: +- left rack (current sidebar) +- main rack (current main content) +- right rack (current “People” / “Moderation” area) + +Future scope could allow splits inside a rack (stacking panels vertically), but this doc does not require it. + +### Dock +An edge storage area for minimized panels: +- left dock +- right dock +- bottom hotbar (primary “closed panel” surface) + +### Orb +The minimized representation of a panel in the bottom hotbar (small pill/circle with label + optional badge). + +Orbs can be: +- clicked to restore the panel +- dragged into a rack to restore at a specific position + +## UX goals + +- Panels are **movable** between racks by drag-and-drop. +- Panels are **minimizable** into a dock (no content rendered; just an orb). +- The bottom hotbar **auto-hides** but appears on hover/near-bottom cursor. +- The layout is **per-user** and persisted (initially `localStorage`). +- Plugins can register panels without DOM poking. + +## Non-goals (for the first version) + +- Perfect desktop-window-manager behavior (snapping, tearing out into new windows). +- Nested splits, arbitrary grids, or tiling layouts. +- Multi-user shared layouts. +- Heavy animation polish (we can add later). + +## Current layout (reference) + +The current app is a CSS grid with: +- left sidebar +- main area +- optional right moderation panel +- resizers between these major columns + +The rack model can be implemented *on top of* the current grid by treating each grid area as one rack. + +## Proposed layout state model + +Persist as JSON in `localStorage` (per user) with a schema like: + +```json +{ + "version": 1, + "presetId": "discordLike", + "racks": { + "left": ["instance", "hives"], + "main": ["chat"], + "right": ["people", "moderation"] + }, + "docked": { + "bottom": ["maps", "library"], + "left": [], + "right": [] + }, + "sizes": { + "leftWidth": 340, + "rightWidth": 360 + } +} +``` + +Notes: +- Panel ids are stable strings (core + plugins share the namespace). +- A panel must exist in **exactly one** place: a rack or a dock. +- Unknown panels should be ignored safely (e.g. plugin removed). +- Missing panels can be auto-inserted into default racks (migration). +- `presetId` is informational (what preset the current layout originated from). + +## Panel identity and ownership + +Panel ids are global (e.g. `"maps"`, `"library"`, `"people"`). + +Every panel has: +- `id` (string) +- `title` (string) +- `icon` (optional) +- `source`: + - `"core"` (built-in) + - `"plugin:<pluginId>"` (plugin-owned) +- `role` (string): `primary` | `aux` | `transient` | `utility` +- `defaultRack` (string): `left` | `main` | `right` + +## Proposed plugin UI API (draft) + +Today, a client plugin registers: + +```js +window.BzlPluginHost.register("maps", (ctx) => { /* ... */ }); +``` + +The rack layout introduces a panel-oriented extension: + +```js +ctx.ui.registerPanel({ + id: "maps", + title: "Maps", + icon: "🗺️", // optional (or use a CSS class / svg id) + defaultRack: "right", // "left" | "main" | "right" + role: "primary", // "primary" | "aux" | "transient" | "utility" (optional) + orderHint: 50, // optional: helps default ordering + render(mount, api) { + // mount is a DOM element owned by the panel container + // render into mount; return optional cleanup() + } +}); +``` + +Design constraints: +- Plugins should not need to query core DOM nodes to mount UI. +- Core can add consistent chrome (title bar, minimize button, etc.) without plugin changes. +- Core controls visibility and lifecycle: `render()` when shown, `cleanup()` when closed/uninstalled. + +### Panel `render()` contract (draft) + +- Called when the panel becomes visible (restored into a rack). +- Receives: + - `mount`: a container element unique to that panel instance. + - `api`: helpers (toast, ws send, user/role, persistent panel storage). +- Should return: + - `() => void` cleanup function (remove event listeners, timers). + +### Minimal helper APIs a panel needs + +Suggested `api` surface (exact shape TBD): +- `api.toast(title, body)` +- `api.send(eventName, payload)` (same as current `ctx.send`) +- `api.getUser()` / `api.getRole()` +- `api.storage.get(key)` / `api.storage.set(key, value)` (panel-scoped persistence) +- `api.requestAttention({ badge, pulse })` (optional: hotbar badge) + +## Migration plan for existing plugins + +### Phase 0 (now) +Plugins can manipulate the DOM directly. This is allowed but fragile. + +### Phase 1 (introduce panel containers) +Core exposes `ctx.ui.registerPanel()` and provides: +- a stable mount node for panel content +- a stable way to show/hide panels + +During this phase: +- existing plugins can continue working +- updated plugins can opt into panels + +### Phase 2 (deprecate DOM poking) +Update docs and templates: +- “Do not query core DOM nodes” becomes the recommended path. +- `ctx.ui.registerPanel()` becomes the default expectation for UI plugins. + +## Core surfaces as panels + +Core should register its own panels using the same mechanism (internal): +- `hives` (feed + filters + compose) +- `chat` (thread + composer) +- `people` +- `moderation` +- `instance` (settings/plugins) + +This enables the “everything is a panel” mental model for users. + +## Separating Maps from Hives + +Under the rack model, Maps becomes a standalone panel: +- Panel id: `maps` +- Default rack: `right` (or `main`, depending on preference) +- Behavior: + - Opening a map room switches the **Maps panel’s internal mode** (list vs room) without hijacking the Hives panel. + +This avoids “Maps inside Hives”, which is hard to reason about and makes plugin upgrades brittle. + +## Bottom hotbar (“orbs”) + +Behavior: +- Any panel can be minimized → moved to `docked.bottom`. +- Docked panels show as orbs with: + - title (short) + - optional icon + - optional badge (count, dot) +- Click orb → restore to its last rack (or default rack). +- Drag orb → hover racks to preview drop targets → drop to restore. + +Persistence: +- dock state is per-user (localStorage) and survives reload. + +## Preset layouts (MVP) + +We should ship a few **layout presets** so users can switch modes without hand-arranging panels every time. + +### Default presets (proposed) + +Notes / constraints: +- Full-height columns only (no half-height / vertical splits). +- The workspace can show up to 2 primary slots at once (left + right). +- The right rack is a **single** skinny-capable panel (toggleable). +- Skinny-capable panels: `people`, `profile`, `composer`, `hives` (list), `chat`. +- Moderation panels only exist for moderators (mod-only presets live in their own section). + +#### Non-mod presets + +1. **Default (Social)** + - Workspace: `hives` | `chat` + - Right rack (skinny): `people` + - Side (collapsed): `profile`, `composer` + - Docked bottom: `maps`, `library` (if installed) + +2. **Chat Focus** + - Workspace: `chat` (expanded) + - Right rack (skinny): `people` + - Side (collapsed): `profile` + - Docked bottom: `hives`, `composer`, `maps`, `library` (if installed) + +3. **Browse** + - Workspace: `hives` (expanded) + - Right rack (skinny): `profile` (inspector) + - Side (collapsed): `chat` + - Docked bottom: `people`, `composer`, `maps`, `library` (if installed) + +4. **Creator** + - Workspace: `hives` | `composer` + - Right rack (skinny): `profile` + - Side (collapsed): `people` + - Docked bottom: `chat`, `maps`, `library` (if installed) + +5. **Maps Session** + - Workspace: `maps` | `chat` + - Right rack (skinny): `people` + - Side (collapsed): `hives` (list) + - Docked bottom: `profile`, `composer`, `library` (if installed) + +6. **Quiet (No People)** + - Workspace: `hives` | `profile` + - Right rack (skinny): (empty / off) + - Side (collapsed): `composer` + - Docked bottom: `chat`, `people`, `maps`, `library` (if installed) + +#### Mod-only presets + +7. **Ops** + - Workspace: `moderation` | `chat` + - Right rack (skinny): `people` + - Side (collapsed): `hives` (list) + - Docked bottom: `profile`, `composer`, `maps`, `library` (if installed) + +8. **Reports Focus** + - Workspace: `moderation` (expanded) + - Right rack (skinny): `chat` + - Side (collapsed): `people` + - Docked bottom: `hives`, `profile`, `composer`, `maps`, `library` (if installed) + +9. **Community Watch** + - Workspace: `hives` | `moderation` + - Right rack (skinny): `people` + - Side (collapsed): `chat` + - Docked bottom: `profile`, `composer`, `maps`, `library` (if installed) + +10. **Server Admin** + - Workspace: `moderation` | `hives` + - Right rack (skinny): `people` + - Side (collapsed): `chat` + - Docked bottom: `profile`, `composer`, `maps`, `library` (if installed) + +These are intentionally opinionated. Users can always customize after selecting a preset. + +### How presets apply + +Selecting a preset: +- **Hard apply**: replaces the current rack/dock assignments with the preset template (exact placement). +- Any panel not explicitly placed by the preset starts in the hotbar. +- Moderation panels are omitted for non-moderators; mod-only presets should either be hidden or gracefully degrade (e.g. replace `moderation` with `people`). +- Optionally preserves column widths (or resets them; TBD). +- Saves the result as the user’s current layout. + +### Plugins extending presets (draft) + +Plugins may register a panel and optionally provide **preset hints**, e.g.: +- preferred default rack for each preset +- whether the panel should start docked for a preset + +Example shape (not final): +```js +ctx.ui.registerPanel({ + id: "maps", + title: "Maps", + defaultRack: "right", + presetHints: { + discordLike: { place: "docked.bottom" }, + browsing: { place: "right" }, + moderation: { place: "docked.bottom" }, + chat: { place: "docked.bottom" } + } +}); +``` + +Core should treat these hints as *suggestions*: +- If a preset doesn’t know a plugin panel, it can still place it using `defaultRack` (or dock it). +- If a plugin isn’t installed, the preset remains valid. + +## Notes for plugin authors + +When the rack layout exists: +- Prefer `ctx.ui.registerPanel()` for all UI. +- Avoid selecting core elements like: + - `.main`, `.panelFill`, `.panelHeader`, `#feed`, etc. +- Treat your plugin UI as “just a panel”: + - it should render into its mount + - it should not assume what other panels exist or where they are + +## Open questions + +- Should panels be “single-instance” only, or can a plugin open multiple panels (e.g. multiple map rooms)? +- Should panel layout persist per-device or per-user (server-side)? +- How do we expose keyboard shortcuts for focusing panels? +- What is the best “default” panel set for first-time users? diff --git a/plugins_dev/maps/client.js b/plugins_dev/maps/client.js @@ -13,16 +13,50 @@ } }; - const mainPanel = document.querySelector(".main .panelFill"); + const appRootRef = document.querySelector(".app"); + const inRackMode = (() => { + try { + // Rack mode reloads the page; this flag is available before the DOM gets the .rackMode class. + if (localStorage.getItem("bzl_rackLayout_enabled") === "1") return true; + } catch { + // ignore + } + return Boolean(appRootRef?.classList.contains("rackMode")); + })(); + + // In rack mode, Maps should render into its own dockable panel (not inside the Hives panel). + if (inRackMode && ctx?.ui?.registerPanel) { + try { + ctx.ui.registerPanel({ + id: "maps", + title: "Maps", + icon: "🗺️", + defaultRack: "main", + role: "primary", + render() { + // no-op: this plugin uses DOM mounting below, into the panel shell's mount node + }, + }); + } catch { + // ignore + } + } + + let mainPanel = document.querySelector(".main .panelFill"); + if (inRackMode) { + const shell = document.querySelector('.panel.pluginPanel[data-panel-id="maps"]'); + if (shell instanceof HTMLElement) mainPanel = shell; + } + const panelHeader = mainPanel ? mainPanel.querySelector(".panelHeader") : null; const panelTitle = panelHeader ? panelHeader.querySelector(".panelTitle") : null; const filters = panelHeader ? panelHeader.querySelector(".filters") : null; - const hiveTabs = document.getElementById("hiveTabs"); - const feed = document.getElementById("feed"); - const pollinatePanel = document.getElementById("pollinatePanel"); - const chatPanel = document.querySelector(".chat"); - const chatResizeHandle = document.getElementById("chatResizeHandle"); - const appRoot = document.querySelector(".app"); + const hiveTabs = inRackMode ? null : document.getElementById("hiveTabs"); + const feed = inRackMode ? null : document.getElementById("feed"); + const pollinatePanel = inRackMode ? null : document.getElementById("pollinatePanel"); + const chatPanel = inRackMode ? null : document.querySelector(".chat"); + const chatResizeHandle = inRackMode ? null : document.getElementById("chatResizeHandle"); + const appRoot = inRackMode ? null : appRootRef; if (!mainPanel || !panelHeader || !panelTitle) return; @@ -117,17 +151,23 @@ `; document.head.appendChild(style); - const mapsBtn = document.createElement("button"); - mapsBtn.type = "button"; - mapsBtn.className = "ghost smallBtn mapsTabBtn"; - mapsBtn.textContent = "Maps"; - panelTitle.insertAdjacentElement("afterend", mapsBtn); + const mapsBtn = inRackMode + ? null + : (() => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "ghost smallBtn mapsTabBtn"; + btn.textContent = "Maps"; + panelTitle.insertAdjacentElement("afterend", btn); + return btn; + })(); const mapsPanel = document.createElement("div"); - mapsPanel.className = "mapsPanel hidden"; - mainPanel.appendChild(mapsPanel); + mapsPanel.className = inRackMode ? "mapsPanel" : "mapsPanel hidden"; + const mount = inRackMode ? mainPanel.querySelector("[data-pluginmount]") : null; + (mount || mainPanel).appendChild(mapsPanel); - let mode = "hives"; // "hives" | "maps" | "map" + let mode = inRackMode ? "maps" : "hives"; // "hives" | "maps" | "map" let maps = []; let activeMap = null; let users = new Map(); // username -> {x,y,color,image} @@ -148,7 +188,7 @@ let lastEditModeLogged = false; let lastPolyUiLogAt = 0; let editMode = false; - let editKind = "collision"; // "collision" | "mask" | "exit" | "hidden" | "occluder" + let editKind = "collision"; // "collision" | "mask" | "exit" | "hidden" | "fall" | "occluder" let editTool = "draw"; // "draw" | "select" | "move" | "vertex" let selectedPolyKind = ""; let selectedPolyIndex = -1; @@ -162,6 +202,15 @@ let exitTargetMapId = ""; let exitTargetExitName = ""; let exitDraftName = ""; + // Fog metadata ("hiddenMasks") + let fogDraftMode = "auto"; // "auto" | "manual" + let fogDraftName = ""; + // Fall-through metadata + let fallDraftDirection = "down"; // "down" | "up" | "left" | "right" + let fallDraftOffset = 0.02; // normalized units (0..1) + let fallDraftName = ""; + // Fog reveal toggle (per-map, local-only) + let revealFog = false; let draftPoly = []; // points [{x,y}] in normalized let lastTransform = null; // {srcX,srcY,zoom,worldW,worldH,viewW,viewH} let selfInvisible = false; @@ -215,6 +264,31 @@ return `bzl_maps_dockCollapsed_${safe}`; } + function fogRevealKey(mapId) { + const id = String(mapId || "") + .trim() + .toLowerCase(); + const safe = id && /^[a-z0-9][a-z0-9_-]{0,40}$/.test(id) ? id : "default"; + return `bzl_maps_revealFog_${safe}`; + } + + function getFogReveal(mapId) { + try { + return localStorage.getItem(fogRevealKey(mapId)) === "1"; + } catch { + return false; + } + } + + function setFogReveal(mapId, on) { + revealFog = Boolean(on); + try { + localStorage.setItem(fogRevealKey(mapId), revealFog ? "1" : "0"); + } catch { + // ignore + } + } + function readDockCollapsed(mapId) { try { return localStorage.getItem(dockCollapsedKey(mapId)) === "1"; @@ -569,8 +643,10 @@ function enterMaps() { mode = "maps"; - mapsBtn.classList.add("primary"); - mapsBtn.classList.remove("ghost"); + if (mapsBtn) { + mapsBtn.classList.add("primary"); + mapsBtn.classList.remove("ghost"); + } setHidden(filters, true); setHidden(hiveTabs, true); setHidden(feed, true); @@ -585,8 +661,10 @@ function exitMapsToHives() { mode = "hives"; - mapsBtn.classList.add("ghost"); - mapsBtn.classList.remove("primary"); + if (mapsBtn) { + mapsBtn.classList.add("ghost"); + mapsBtn.classList.remove("primary"); + } setHidden(filters, false); setHidden(hiveTabs, false); setHidden(feed, false); @@ -751,9 +829,12 @@ const polysCount = (Array.isArray(activeMap.collisions) ? activeMap.collisions.length : 0) + (Array.isArray(activeMap.masks) ? activeMap.masks.length : 0) + - (Array.isArray(activeMap.exits) ? activeMap.exits.length : 0); + (Array.isArray(activeMap.exits) ? activeMap.exits.length : 0) + + (Array.isArray(activeMap.hiddenMasks) ? activeMap.hiddenMasks.length : 0) + + (Array.isArray(activeMap.fallThroughs) ? activeMap.fallThroughs.length : 0); const walkiesEnabled = Boolean(activeMap.walkiesEnabled); const ttrpgEnabled = Boolean(activeMap.ttrpgEnabled); + const fogCount = Array.isArray(activeMap.hiddenMasks) ? activeMap.hiddenMasks.length : 0; const shortcutHintHtml = ` <div class="mapHint"> Shortcuts:<br/> @@ -863,6 +944,20 @@ <div class="mapHint"> Exits: <b>${escapeHtml(Array.isArray(activeMap.exits) ? activeMap.exits.length : 0)}</b> </div> + ${ + fogCount + ? ` + <label class="checkRow" style="margin-top:10px;"> + <span>Reveal fog</span> + <input id="mapsFogRevealToggle" type="checkbox" ${revealFog ? "checked" : ""} /> + </label> + <div class="small muted" style="margin-top:6px; line-height:1.15rem;"> + Fog zones: <b>${escapeHtml(String(fogCount))}</b><br/> + Auto fog reveals when you stand inside it. + </div> + ` + : "" + } ${shortcutHintHtml} ${settingsHtml} </div> @@ -888,6 +983,13 @@ loadBackground(activeMap.backgroundUrl || ""); startLoop(); + const fogRevealToggle = document.getElementById("mapsFogRevealToggle"); + if (fogRevealToggle && fogCount) { + fogRevealToggle.onchange = () => { + setFogReveal(activeMap.id, Boolean(fogRevealToggle.checked)); + }; + } + const invToggle = document.getElementById("mapsInvisibleToggle"); if (invToggle && showSettings) { invToggle.onchange = () => { @@ -1725,6 +1827,16 @@ if (action === "toMap" && !toMapId) return false; const targetExit = action === "toMap" ? String(exitTargetExitName || "").trim().slice(0, 40) : ""; list.push({ ...poly, name, action, toMapId, targetExit }); + } else if (editKind === "hidden") { + const mode = fogDraftMode === "manual" ? "manual" : "auto"; + const name = String(fogDraftName || "").trim().slice(0, 40); + list.push({ ...poly, mode, name }); + } else if (editKind === "fall") { + const dir = String(fallDraftDirection || "").trim().toLowerCase(); + const direction = dir === "up" || dir === "left" || dir === "right" ? dir : "down"; + const offset = Math.max(0.002, Math.min(0.08, Number(fallDraftOffset || 0.02) || 0.02)); + const name = String(fallDraftName || "").trim().slice(0, 40); + list.push({ ...poly, direction, offset, name }); } else { list.push(poly); } @@ -1765,6 +1877,10 @@ if (ensure && !Array.isArray(map.hiddenMasks)) map.hiddenMasks = []; return Array.isArray(map.hiddenMasks) ? map.hiddenMasks : []; } + if (k === "fall") { + if (ensure && !Array.isArray(map.fallThroughs)) map.fallThroughs = []; + return Array.isArray(map.fallThroughs) ? map.fallThroughs : []; + } if (k === "occluder") { if (ensure && !Array.isArray(map.occluders)) map.occluders = []; return Array.isArray(map.occluders) ? map.occluders : []; @@ -1776,7 +1892,8 @@ if (kind === "collision") return "Collisions"; if (kind === "mask") return "Y-sort masks"; if (kind === "exit") return "Exits"; - if (kind === "hidden") return "Hidden masks"; + if (kind === "hidden") return "Fog zones"; + if (kind === "fall") return "Fall-through zones"; if (kind === "occluder") return "Occluders"; return String(kind || ""); } @@ -1886,13 +2003,67 @@ const inspectorBody = (() => { const pts = Array.isArray(selected?.points) ? selected.points.length : 0; if (editKind !== "exit") { - return selOk - ? ` - <div class="small muted">Selected</div> - <div style="margin-top:6px;"><b>${escapeHtml(kindLabel(editKind))}</b></div> - <div class="small muted" style="margin-top:6px;">${pts} points</div> - ` - : `<div class="small muted">Select a polygon to edit, or draw a new one.</div>`; + const header = selOk ? "Selected" : "New polygon defaults"; + const fogModel = + editKind === "hidden" && selected + ? { mode: String(selected.mode || "auto") === "manual" ? "manual" : "auto", name: String(selected.name || "").trim().slice(0, 40) } + : { mode: fogDraftMode === "manual" ? "manual" : "auto", name: String(fogDraftName || "").trim().slice(0, 40) }; + const fallModel = + editKind === "fall" && selected + ? { + direction: ["up", "down", "left", "right"].includes(String(selected.direction || "")) ? String(selected.direction || "") : "down", + offset: Math.max(0.002, Math.min(0.08, Number(selected.offset || 0.02) || 0.02)), + name: String(selected.name || "").trim().slice(0, 40), + } + : { + direction: ["up", "down", "left", "right"].includes(String(fallDraftDirection || "")) ? String(fallDraftDirection || "") : "down", + offset: Math.max(0.002, Math.min(0.08, Number(fallDraftOffset || 0.02) || 0.02)), + name: String(fallDraftName || "").trim().slice(0, 40), + }; + + const metaControls = + editKind === "hidden" + ? ` + <label style="margin-top:10px;"> + <div class="small muted">Reveal mode</div> + <select id="mapsFogMode"> + <option value="auto" ${fogModel.mode === "auto" ? "selected" : ""}>Auto (reveal when inside)</option> + <option value="manual" ${fogModel.mode === "manual" ? "selected" : ""}>Manual (toggle “Reveal fog”)</option> + </select> + </label> + <label style="margin-top:10px;"> + <div class="small muted">Label (optional)</div> + <input id="mapsFogName" type="text" maxlength="40" placeholder="Example: Secret room" value="${escapeHtml(fogModel.name)}" /> + </label> + ` + : editKind === "fall" + ? ` + <label style="margin-top:10px;"> + <div class="small muted">Direction</div> + <select id="mapsFallDirection"> + <option value="down" ${fallModel.direction === "down" ? "selected" : ""}>Down</option> + <option value="up" ${fallModel.direction === "up" ? "selected" : ""}>Up</option> + <option value="left" ${fallModel.direction === "left" ? "selected" : ""}>Left</option> + <option value="right" ${fallModel.direction === "right" ? "selected" : ""}>Right</option> + </select> + </label> + <label style="margin-top:10px;"> + <div class="small muted">Nudge distance</div> + <input id="mapsFallOffset" type="number" min="0.002" max="0.08" step="0.002" value="${escapeHtml(fallModel.offset.toFixed(3))}" /> + </label> + <label style="margin-top:10px;"> + <div class="small muted">Label (optional)</div> + <input id="mapsFallName" type="text" maxlength="40" placeholder="Example: Cliff edge" value="${escapeHtml(fallModel.name)}" /> + </label> + ` + : ""; + + return ` + <div class="small muted">${header}</div> + <div style="margin-top:6px;"><b>${escapeHtml(kindLabel(editKind))}</b></div> + ${selOk ? `<div class="small muted" style="margin-top:6px;">${pts} points</div>` : `<div class="small muted" style="margin-top:6px;">Draw a polygon, then Close polygon.</div>`} + ${metaControls} + `; } const header = selOk ? "Selected exit" : "New exit defaults"; @@ -1937,7 +2108,8 @@ ${kindBtn("collision", "Collisions")} ${kindBtn("mask", "Y-sort")} ${kindBtn("exit", "Exits")} - ${kindBtn("hidden", "Hidden (soon)", true)} + ${kindBtn("hidden", "Fog")} + ${kindBtn("fall", "Fall-through")} ${kindBtn("occluder", "Occluders (soon)", true)} <div class="small muted" style="margin-left:auto;">${escapeHtml(String(list.length))} in ${escapeHtml(kindLabel(editKind))}</div> </div> @@ -2114,6 +2286,7 @@ const masks = Array.isArray(activeMap.masks) ? activeMap.masks : []; const exits = Array.isArray(activeMap.exits) ? activeMap.exits : []; const hiddenMasks = Array.isArray(activeMap.hiddenMasks) ? activeMap.hiddenMasks : []; + const fallThroughs = Array.isArray(activeMap.fallThroughs) ? activeMap.fallThroughs : []; const occluders = Array.isArray(activeMap.occluders) ? activeMap.occluders : []; devLog("info", "maps:saveAll", { mapId: activeMap.id, @@ -2121,9 +2294,10 @@ masks: masks.length, exits: exits.length, hiddenMasks: hiddenMasks.length, + fallThroughs: fallThroughs.length, occluders: occluders.length, }); - ctx.send("updateMap", { id: activeMap.id, collisions, masks, exits, hiddenMasks, occluders }); + ctx.send("updateMap", { id: activeMap.id, collisions, masks, exits, hiddenMasks, fallThroughs, occluders }); setStatus("Saved."); }; } @@ -2217,6 +2391,13 @@ const exitToMapWrap = document.getElementById("mapsExitToMapWrap"); const exitToMapEl = document.getElementById("mapsExitToMap"); const exitTargetExitEl = document.getElementById("mapsExitTargetExit"); + // Fog meta fields (selected fog OR draft defaults) + const fogModeEl = document.getElementById("mapsFogMode"); + const fogNameEl = document.getElementById("mapsFogName"); + // Fall-through meta fields (selected fall OR draft defaults) + const fallDirEl = document.getElementById("mapsFallDirection"); + const fallOffsetEl = document.getElementById("mapsFallOffset"); + const fallNameEl = document.getElementById("mapsFallName"); const applyExitModel = (patch) => { if (editKind !== "exit") return; @@ -2232,6 +2413,43 @@ } }; + const applyFogModel = (patch) => { + if (editKind !== "hidden") return; + const list = polysForKind(activeMap, "hidden", true); + const isSel = selectedPolyKind === "hidden" && selectedPolyIndex >= 0 && selectedPolyIndex < list.length; + if (isSel) { + const next = { ...list[selectedPolyIndex], ...patch }; + next.mode = String(next.mode || "auto") === "manual" ? "manual" : "auto"; + next.name = typeof next.name === "string" ? next.name.trim().slice(0, 40) : ""; + list[selectedPolyIndex] = next; + } else { + if (Object.prototype.hasOwnProperty.call(patch, "mode")) fogDraftMode = String(patch.mode || "") === "manual" ? "manual" : "auto"; + if (Object.prototype.hasOwnProperty.call(patch, "name")) fogDraftName = typeof patch.name === "string" ? patch.name.trim().slice(0, 40) : ""; + } + }; + + const applyFallModel = (patch) => { + if (editKind !== "fall") return; + const list = polysForKind(activeMap, "fall", true); + const isSel = selectedPolyKind === "fall" && selectedPolyIndex >= 0 && selectedPolyIndex < list.length; + const normalizeDir = (d) => { + const dir = String(d || "").trim().toLowerCase(); + return dir === "up" || dir === "left" || dir === "right" ? dir : "down"; + }; + const normalizeOffset = (n) => Math.max(0.002, Math.min(0.08, Number(n || 0.02) || 0.02)); + if (isSel) { + const next = { ...list[selectedPolyIndex], ...patch }; + next.direction = normalizeDir(next.direction); + next.offset = normalizeOffset(next.offset); + next.name = typeof next.name === "string" ? next.name.trim().slice(0, 40) : ""; + list[selectedPolyIndex] = next; + } else { + if (Object.prototype.hasOwnProperty.call(patch, "direction")) fallDraftDirection = normalizeDir(patch.direction); + if (Object.prototype.hasOwnProperty.call(patch, "offset")) fallDraftOffset = normalizeOffset(patch.offset); + if (Object.prototype.hasOwnProperty.call(patch, "name")) fallDraftName = typeof patch.name === "string" ? patch.name.trim().slice(0, 40) : ""; + } + }; + const syncExitVis = () => { const behavior = exitBehaviorEl ? String(exitBehaviorEl.value || "toMaps") : "toMaps"; if (exitToMapWrap) exitToMapWrap.classList.toggle("hidden", behavior !== "toMap"); @@ -2273,6 +2491,33 @@ }; } syncExitVis(); + + if (fogModeEl) { + fogModeEl.onchange = () => { + applyFogModel({ mode: String(fogModeEl.value || "auto") === "manual" ? "manual" : "auto" }); + }; + } + if (fogNameEl) { + fogNameEl.oninput = () => { + applyFogModel({ name: String(fogNameEl.value || "").slice(0, 40) }); + }; + } + + if (fallDirEl) { + fallDirEl.onchange = () => { + applyFallModel({ direction: String(fallDirEl.value || "down") }); + }; + } + if (fallOffsetEl) { + const onOffset = () => applyFallModel({ offset: Number(fallOffsetEl.value || 0.02) || 0.02 }); + fallOffsetEl.oninput = onOffset; + fallOffsetEl.onchange = onOffset; + } + if (fallNameEl) { + fallNameEl.oninput = () => { + applyFallModel({ name: String(fallNameEl.value || "").slice(0, 40) }); + }; + } } function pointInPoly(pt, poly) { @@ -2363,8 +2608,52 @@ if (moved) { const speedNx = speedPxPerSec / Math.max(1, dims.w); const speedNy = speedPxPerSec / Math.max(1, dims.h); - const nextX = Math.max(0, Math.min(1, controlPos.x + dx * speedNx * dt)); - const nextY = Math.max(0, Math.min(1, controlPos.y + dy * speedNy * dt)); + let nextX = Math.max(0, Math.min(1, controlPos.x + dx * speedNx * dt)); + let nextY = Math.max(0, Math.min(1, controlPos.y + dy * speedNy * dt)); + + // Fall-through zones: if you enter one, teleport to the far side based on direction. + const fallThroughs = Array.isArray(activeMap.fallThroughs) ? activeMap.fallThroughs : []; + if (fallThroughs.length) { + const prevPt = { x: controlPos.x, y: controlPos.y }; + const entered = (poly) => !pointInPoly(prevPt, poly) && pointInPoly({ x: nextX, y: nextY }, poly); + for (const poly of fallThroughs) { + if (!poly || !Array.isArray(poly.points) || poly.points.length < 3) continue; + if (!entered(poly)) continue; + const pts = poly.points; + let minX = 1, + maxX = 0, + minY = 1, + maxY = 0; + for (const p of pts) { + const x = Number(p?.x); + const y = Number(p?.y); + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + } + const dirRaw = String(poly.direction || "").trim().toLowerCase(); + const direction = dirRaw === "up" || dirRaw === "left" || dirRaw === "right" ? dirRaw : "down"; + const off = Math.max(0.002, Math.min(0.08, Number(poly.offset || 0.02) || 0.02)); + if (direction === "up" || direction === "down") { + const clampedX = Math.max(minX + 1e-4, Math.min(maxX - 1e-4, nextX)); + nextX = Math.max(0, Math.min(1, clampedX)); + nextY = direction === "down" ? Math.max(0, Math.min(1, maxY + off)) : Math.max(0, Math.min(1, minY - off)); + for (let i = 0; i < 8 && pointInPoly({ x: nextX, y: nextY }, poly); i++) { + nextY = direction === "down" ? Math.max(0, Math.min(1, nextY + off)) : Math.max(0, Math.min(1, nextY - off)); + } + } else { + const clampedY = Math.max(minY + 1e-4, Math.min(maxY - 1e-4, nextY)); + nextY = Math.max(0, Math.min(1, clampedY)); + nextX = direction === "right" ? Math.max(0, Math.min(1, maxX + off)) : Math.max(0, Math.min(1, minX - off)); + for (let i = 0; i < 8 && pointInPoly({ x: nextX, y: nextY }, poly); i++) { + nextX = direction === "right" ? Math.max(0, Math.min(1, nextX + off)) : Math.max(0, Math.min(1, nextX - off)); + } + } + break; + } + } const collisions = Array.isArray(activeMap.collisions) ? activeMap.collisions : []; const tryPtX = { x: nextX, y: controlPos.y }; const tryPtY = { x: controlPos.x, y: nextY }; @@ -2843,6 +3132,39 @@ } } + // Fog zones: draw dark overlays over polygons, unless revealed. + if (!editMode && !revealFog) { + const fogs = Array.isArray(activeMap.hiddenMasks) ? activeMap.hiddenMasks : []; + if (fogs.length) { + const possessed = getPossessedTokenForMe(); + const myPos = possessed + ? { x: Math.max(0, Math.min(1, Number(possessed.x || 0.5))), y: Math.max(0, Math.min(1, Number(possessed.y || 0.5))) } + : { x: localPos.x, y: localPos.y }; + g.save(); + g.globalAlpha = 1; + for (const poly of fogs) { + const pts = Array.isArray(poly?.points) ? poly.points : []; + if (pts.length < 3) continue; + const mode = String(poly?.mode || "auto") === "manual" ? "manual" : "auto"; + if (mode === "auto" && pointInPoly(myPos, poly)) continue; + g.beginPath(); + const first = pts[0]; + g.moveTo((Number(first.x) * worldW - srcX) * zoom, (Number(first.y) * worldH - srcY) * zoom); + for (let i = 1; i < pts.length; i++) { + const p = pts[i]; + g.lineTo((Number(p.x) * worldW - srcX) * zoom, (Number(p.y) * worldH - srcY) * zoom); + } + g.closePath(); + g.fillStyle = "rgba(5,4,10,0.78)"; + g.strokeStyle = "rgba(180,120,255,0.22)"; + g.lineWidth = 1.2; + g.fill(); + g.stroke(); + } + g.restore(); + } + } + // Edit overlays if (editMode) { drawPolysOverlay(g, activeMap, worldW, worldH, srcX, srcY, zoom); @@ -2905,7 +3227,9 @@ for (const p of exits) drawPoly(p, "rgba(255,215,90,0.90)", "rgba(255,215,90,0.10)", false, selected === p); const hidden = Array.isArray(map.hiddenMasks) ? map.hiddenMasks : []; const occ = Array.isArray(map.occluders) ? map.occluders : []; + const fall = Array.isArray(map.fallThroughs) ? map.fallThroughs : []; for (const p of hidden) drawPoly(p, "rgba(180,120,255,0.80)", "rgba(180,120,255,0.08)", false, selected === p); + for (const p of fall) drawPoly(p, "rgba(255,140,80,0.80)", "rgba(255,140,80,0.08)", false, selected === p); for (const p of occ) drawPoly(p, "rgba(120,255,180,0.80)", "rgba(120,255,180,0.08)", false, selected === p); if (selected) { @@ -2918,7 +3242,9 @@ ? "rgba(255,215,90,0.98)" : editKind === "hidden" ? "rgba(180,120,255,0.98)" - : "rgba(120,255,180,0.98)"; + : editKind === "fall" + ? "rgba(255,140,80,0.98)" + : "rgba(120,255,180,0.98)"; const fill = editKind === "collision" ? "rgba(255,70,70,0.16)" @@ -2928,7 +3254,9 @@ ? "rgba(255,215,90,0.14)" : editKind === "hidden" ? "rgba(180,120,255,0.12)" - : "rgba(120,255,180,0.12)"; + : editKind === "fall" + ? "rgba(255,140,80,0.12)" + : "rgba(120,255,180,0.12)"; drawPoly(selected, stroke, fill, true, true); } @@ -2943,7 +3271,9 @@ ? "rgba(255,215,90,0.98)" : editKind === "hidden" ? "rgba(180,120,255,0.98)" - : "rgba(120,255,180,0.98)"; + : editKind === "fall" + ? "rgba(255,140,80,0.98)" + : "rgba(120,255,180,0.98)"; const fill = editKind === "collision" ? "rgba(255,70,70,0.10)" @@ -2953,7 +3283,9 @@ ? "rgba(255,215,90,0.10)" : editKind === "hidden" ? "rgba(180,120,255,0.10)" - : "rgba(120,255,180,0.10)"; + : editKind === "fall" + ? "rgba(255,140,80,0.10)" + : "rgba(120,255,180,0.10)"; drawPoly(poly, stroke, fill, true, false); } } @@ -3054,6 +3386,7 @@ ttrpgDockCollapsed = readDockCollapsed(mapId); ttrpgTool = "select"; cameraPos = null; + revealFog = getFogReveal(mapId); // Seed a known-good local position (will be replaced once we get roomState). localPos = { x: 0.5, y: 0.5 }; exitInside.clear(); @@ -3071,6 +3404,7 @@ masks: [], exits: [], hiddenMasks: [], + fallThroughs: [], occluders: [], ttrpgEnabled: false, sprites: [], @@ -3099,10 +3433,12 @@ renderMapsList(); } - mapsBtn.addEventListener("click", () => { - if (mode === "hives") enterMaps(); - else exitMapsToHives(); - }); + if (mapsBtn) { + mapsBtn.addEventListener("click", () => { + if (mode === "hives") enterMaps(); + else exitMapsToHives(); + }); + } mapsPanel.addEventListener("click", (e) => { const enter = e.target.closest("[data-mapenter]"); @@ -3203,8 +3539,9 @@ const masks = Array.isArray(activeMap.masks) ? activeMap.masks : []; const exits = Array.isArray(activeMap.exits) ? activeMap.exits : []; const hiddenMasks = Array.isArray(activeMap.hiddenMasks) ? activeMap.hiddenMasks : []; + const fallThroughs = Array.isArray(activeMap.fallThroughs) ? activeMap.fallThroughs : []; const occluders = Array.isArray(activeMap.occluders) ? activeMap.occluders : []; - ctx.send("updateMap", { id: activeMap.id, collisions, masks, exits, hiddenMasks, occluders }); + ctx.send("updateMap", { id: activeMap.id, collisions, masks, exits, hiddenMasks, fallThroughs, occluders }); const se = document.getElementById("mapsPolyStatus"); if (se) se.textContent = "Saved."; return; @@ -3383,12 +3720,14 @@ exits: Array.isArray(msg.map.exits) ? msg.map.exits : [], hiddenMasks: Array.isArray(msg.map.hiddenMasks) ? msg.map.hiddenMasks : [], occluders: Array.isArray(msg.map.occluders) ? msg.map.occluders : [], + fallThroughs: Array.isArray(msg.map.fallThroughs) ? msg.map.fallThroughs : [], ttrpgEnabled: Boolean(msg.map.ttrpgEnabled), sprites: Array.isArray(msg.map.sprites) ? msg.map.sprites : [], props: Array.isArray(msg.map.props) ? msg.map.props : [], walkiesEnabled: Boolean(msg.map.walkiesEnabled) }; ttrpgDockCollapsed = readDockCollapsed(activeMap.id); + revealFog = getFogReveal(activeMap.id); if (pendingSpawn && pendingSpawn.mapId === activeMap.id && pendingSpawn.exitName) { const exits = Array.isArray(activeMap.exits) ? activeMap.exits : []; const want = String(pendingSpawn.exitName || "").trim().toLowerCase(); @@ -3424,6 +3763,7 @@ if (Object.prototype.hasOwnProperty.call(patch, "exits")) activeMap.exits = Array.isArray(patch.exits) ? patch.exits : []; if (Object.prototype.hasOwnProperty.call(patch, "hiddenMasks")) activeMap.hiddenMasks = Array.isArray(patch.hiddenMasks) ? patch.hiddenMasks : []; if (Object.prototype.hasOwnProperty.call(patch, "occluders")) activeMap.occluders = Array.isArray(patch.occluders) ? patch.occluders : []; + if (Object.prototype.hasOwnProperty.call(patch, "fallThroughs")) activeMap.fallThroughs = Array.isArray(patch.fallThroughs) ? patch.fallThroughs : []; renderMapView(); return; } @@ -3642,9 +3982,14 @@ } }); - // Initial list request (in case the Maps view is opened immediately). - // The Maps panel triggers another list() on open. - ctx.send("list", {}); + if (inRackMode) { + // In rack mode, Maps is its own panel: start in the list view immediately. + enterMaps(); + } else { + // Initial list request (in case the Maps view is opened immediately). + // The Maps panel triggers another list() on open. + ctx.send("list", {}); + } }); })(); diff --git a/plugins_dev/maps/plugin.json b/plugins_dev/maps/plugin.json @@ -1,8 +1,8 @@ { "id": "maps", "name": "Maps", - "version": "0.3.7", - "description": "Adds spatial chat rooms with map camera, collisions/masks/exits, and TTRPG tooling.", + "version": "0.3.9", + "description": "Adds spatial chat rooms with map camera, collisions/masks/exits, fog + fall-through zones, and TTRPG tooling.", "entryClient": "client.js", "entryServer": "server.js", "permissions": ["ui", "ws"] diff --git a/plugins_dev/maps/server.js b/plugins_dev/maps/server.js @@ -108,6 +108,60 @@ module.exports = function init(api) { return out; } + function normalizeFogList(list) { + const input = Array.isArray(list) ? list : []; + const out = []; + const maxPolys = 80; + const maxPoints = 60; + for (const raw of input.slice(0, maxPolys)) { + const points = Array.isArray(raw?.points) ? raw.points : []; + if (points.length < 3) continue; + const normPoints = []; + for (const p of points.slice(0, maxPoints)) { + const x = Number(p?.x); + const y = Number(p?.y); + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + normPoints.push({ x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }); + } + if (normPoints.length < 3) continue; + const modeRaw = + typeof raw?.mode === "string" + ? raw.mode.trim().toLowerCase() + : typeof raw?.reveal === "string" + ? raw.reveal.trim().toLowerCase() + : ""; + const mode = modeRaw === "manual" ? "manual" : "auto"; + const name = typeof raw?.name === "string" ? raw.name.trim().slice(0, 40) : ""; + out.push({ points: normPoints, mode, name }); + } + return out; + } + + function normalizeFallList(list) { + const input = Array.isArray(list) ? list : []; + const out = []; + const maxPolys = 60; + const maxPoints = 60; + for (const raw of input.slice(0, maxPolys)) { + const points = Array.isArray(raw?.points) ? raw.points : []; + if (points.length < 3) continue; + const normPoints = []; + for (const p of points.slice(0, maxPoints)) { + const x = Number(p?.x); + const y = Number(p?.y); + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + normPoints.push({ x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }); + } + if (normPoints.length < 3) continue; + const dirRaw = typeof raw?.direction === "string" ? raw.direction.trim().toLowerCase() : ""; + const direction = dirRaw === "up" || dirRaw === "left" || dirRaw === "right" ? dirRaw : "down"; + const offset = clampFloat(raw?.offset, 0.002, 0.08, 0.02); + const name = typeof raw?.name === "string" ? raw.name.trim().slice(0, 40) : ""; + out.push({ points: normPoints, direction, offset, name }); + } + return out; + } + function normalizeExitList(list) { const input = Array.isArray(list) ? list : []; const out = []; @@ -382,8 +436,9 @@ module.exports = function init(api) { const collisions = normalizePolyList(m?.collisions); const masks = normalizePolyList(m?.masks); const exits = normalizeExitList(m?.exits); - const hiddenMasks = normalizePolyList(m?.hiddenMasks); + const hiddenMasks = normalizeFogList(m?.hiddenMasks); const occluders = normalizePolyList(m?.occluders); + const fallThroughs = normalizeFallList(m?.fallThroughs); const ttrpgEnabled = Boolean(m?.ttrpgEnabled); const sprites = normalizeSpriteList(m?.sprites); const spriteIds = new Set(sprites.map((s) => s.id)); @@ -402,6 +457,7 @@ module.exports = function init(api) { exits, hiddenMasks, occluders, + fallThroughs, ttrpgEnabled, sprites, props, @@ -475,6 +531,7 @@ module.exports = function init(api) { exits: [], hiddenMasks: [], occluders: [], + fallThroughs: [], ttrpgEnabled: false, sprites: [], props: [], @@ -528,13 +585,17 @@ module.exports = function init(api) { patch.exits = next.exits; } if (msg && Object.prototype.hasOwnProperty.call(msg, "hiddenMasks")) { - next.hiddenMasks = normalizePolyList(msg.hiddenMasks); + next.hiddenMasks = normalizeFogList(msg.hiddenMasks); patch.hiddenMasks = next.hiddenMasks; } if (msg && Object.prototype.hasOwnProperty.call(msg, "occluders")) { next.occluders = normalizePolyList(msg.occluders); patch.occluders = next.occluders; } + if (msg && Object.prototype.hasOwnProperty.call(msg, "fallThroughs")) { + next.fallThroughs = normalizeFallList(msg.fallThroughs); + patch.fallThroughs = next.fallThroughs; + } if (msg && Object.prototype.hasOwnProperty.call(msg, "ttrpgEnabled")) { next.ttrpgEnabled = Boolean(msg.ttrpgEnabled); } @@ -871,6 +932,7 @@ module.exports = function init(api) { exits: Array.isArray(map.exits) ? map.exits : [], hiddenMasks: Array.isArray(map.hiddenMasks) ? map.hiddenMasks : [], occluders: Array.isArray(map.occluders) ? map.occluders : [], + fallThroughs: Array.isArray(map.fallThroughs) ? map.fallThroughs : [], ttrpgEnabled: Boolean(map.ttrpgEnabled), sprites: Array.isArray(map.sprites) ? map.sprites : [], props: Array.isArray(map.props) ? map.props : [], diff --git a/public/app.js b/public/app.js @@ -20,6 +20,14 @@ const mobileModBtn = document.getElementById("mobileModBtn"); const enableNotifsBtn = document.getElementById("enableNotifs"); const notifStatus = document.getElementById("notifStatus"); const toggleReactionsEl = document.getElementById("toggleReactions"); +const hivesViewModeEl = document.getElementById("hivesViewMode"); +const toggleRackLayoutEl = document.getElementById("toggleRackLayout"); +const toggleSideRackEl = document.getElementById("toggleSideRack"); +const toggleRightRackEl = document.getElementById("toggleRightRack"); +const layoutPresetEl = document.getElementById("layoutPreset"); +const dockHotbarEl = document.getElementById("dockHotbar"); +const showSideRackBtn = document.getElementById("showSideRack"); +const showRightRackBtn = document.getElementById("showRightRack"); const authHint = document.getElementById("authHint"); const userLabel = document.getElementById("userLabel"); @@ -61,6 +69,10 @@ const newPostForm = document.getElementById("newPostForm"); const pollinatePanel = document.getElementById("pollinatePanel"); const toggleComposerBtn = document.getElementById("toggleComposer"); const toggleComposerInlineBtn = document.getElementById("toggleComposerInline"); +const mainRackEl = document.getElementById("mainRack"); +const mainWorkspaceRackEl = document.getElementById("mainWorkspaceRack"); +const mainSideRackEl = document.getElementById("mainSideRack"); +const hivesPanelEl = document.getElementById("hivesPanel"); const postTitleInput = document.getElementById("postTitle"); const postImageInput = document.getElementById("postImage"); const postAudioInput = document.getElementById("postAudio"); @@ -218,8 +230,1605 @@ let customRoles = []; let plugins = []; const loadedPluginClientVersionById = new Map(); // pluginId -> version string let centerView = "hives"; +const HIVES_VIEW_MODE_KEY = "bzl_hivesViewMode"; +const HIVES_LIST_AUTO_THRESHOLD_PX = 520; +let lastHivesWidthPx = 0; +let hivesResizeObserver = null; + +// --- Rack layout (experimental) ------------------------------------------------ + +const RACK_LAYOUT_ENABLED_KEY = "bzl_rackLayout_enabled"; +const RACK_LAYOUT_STATE_KEY = "bzl_rackLayout_state_v2"; +const RACK_SIDE_COLLAPSED_KEY = "bzl_rackLayout_sideCollapsed"; +const RACK_RIGHT_COLLAPSED_KEY = "bzl_rackLayout_rightCollapsed"; +const WORKSPACE_EXPANDED_PRIMARY_KEY = "bzl_workspace_expandedPrimary"; +const WORKSPACE_EXPANDED_DISPLACED_KEY = "bzl_workspace_expandedDisplaced"; + +/** + * @typedef {{ + * version: 2, + * presetId: string, + * docked: { bottom: string[] }, + * racks?: { workspaceLeft?: string[], workspaceRight?: string[], side?: string[], right?: string[] }, + * }} RackLayoutState + */ + +/** @type {RackLayoutState} */ +let rackLayoutState = { + version: 2, + presetId: "discordLike", + docked: { bottom: [] }, + racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, +}; +let rackLayoutEnabled = false; +let rightRackEl = null; +let mainRack = null; +let mainSideRack = null; +const WORKSPACE_ACTIVE_PRIMARY_KEY = "bzl_workspace_activePrimary"; + +function readBoolPref(key, fallback = false) { + try { + const raw = localStorage.getItem(key); + if (raw == null) return fallback; + return raw === "1" || raw === "true"; + } catch { + return fallback; + } +} + +function writeBoolPref(key, value) { + try { + localStorage.setItem(key, value ? "1" : "0"); + } catch { + // ignore + } +} + +function readWorkspaceExpandedPrimary() { + return readStringPref(WORKSPACE_EXPANDED_PRIMARY_KEY, "").trim(); +} + +function writeWorkspaceExpandedPrimary(panelId) { + writeStringPref(WORKSPACE_EXPANDED_PRIMARY_KEY, String(panelId || "").trim()); +} + +function readWorkspaceExpandedDisplaced() { + return readStringPref(WORKSPACE_EXPANDED_DISPLACED_KEY, "").trim(); +} + +function writeWorkspaceExpandedDisplaced(panelId) { + writeStringPref(WORKSPACE_EXPANDED_DISPLACED_KEY, String(panelId || "").trim()); +} + +function clearWorkspaceExpandedState() { + writeWorkspaceExpandedPrimary(""); + writeWorkspaceExpandedDisplaced(""); +} + +function togglePrimaryExpand(panelId) { + if (!rackLayoutEnabled) return; + const id = String(panelId || "").trim(); + if (!id) return; + if (!panelCanExpand(id)) return; + + const current = readWorkspaceExpandedPrimary(); + const left = ensureWorkspaceLeftRack(); + const right = ensureWorkspaceRightRack(); + if (!left || !right) return; + + // If the panel isn't in a workspace slot, pull it into the workspace first. + const panelEl = getPanelElement(id); + if (panelEl) { + const inWorkspace = panelEl.parentElement === left || panelEl.parentElement === right; + if (!inWorkspace) { + const leftExisting = left.querySelector?.(":scope > .rackPanel:not(.hidden)"); + const rightExisting = right.querySelector?.(":scope > .rackPanel:not(.hidden)"); + const leftEmpty = !leftExisting; + const rightEmpty = !rightExisting; + // Prefer the right slot for "aux" expandables like Moderation/Composer. + const target = rightEmpty ? right : leftEmpty ? left : right; + const existing = target === left ? leftExisting : rightExisting; + if (existing instanceof HTMLElement && existing !== panelEl) { + const existingId = String(existing.dataset?.panelId || "").trim(); + if (existingId) dockPanel(existingId); + } + target.appendChild(panelEl); + syncRackStateFromDom(); + enforceWorkspaceRules(); + } + } + + const leftPanel = left.querySelector?.(":scope > .rackPanel"); + const rightPanel = right.querySelector?.(":scope > .rackPanel"); + const leftId = String(leftPanel?.dataset?.panelId || "").trim(); + const rightId = String(rightPanel?.dataset?.panelId || "").trim(); + + if (current && current === id) { + // Collapse: try to restore the displaced panel (if any) back into the now-visible other slot. + const displaced = readWorkspaceExpandedDisplaced(); + clearWorkspaceExpandedState(); + if (displaced && isDocked(displaced)) { + undockPanel(displaced); + const el = getPanelElement(displaced); + if (el) { + if (leftId === id && !rightId) right.appendChild(el); + else if (rightId === id && !leftId) left.appendChild(el); + } + } + enforceWorkspaceRules(); + return; + } + + // Expand: if the other slot is occupied, dock it so it stays accessible via hotbar. + writeWorkspaceExpandedPrimary(id); + let displaced = ""; + if (leftId === id && rightId) displaced = rightId; + if (rightId === id && leftId) displaced = leftId; + if (displaced && displaced !== id) { + writeWorkspaceExpandedDisplaced(displaced); + dockPanel(displaced); + } else { + writeWorkspaceExpandedDisplaced(""); + } + enforceWorkspaceRules(); +} + +function readStringPref(key, fallback = "") { + try { + const raw = localStorage.getItem(key); + if (raw == null) return fallback; + return String(raw); + } catch { + return fallback; + } +} + +function writeStringPref(key, value) { + try { + localStorage.setItem(key, String(value)); + } catch { + // ignore + } +} + +function resolveHivesViewMode() { + const pref = readStringPref(HIVES_VIEW_MODE_KEY, "list"); + const normalized = String(pref || "auto").toLowerCase(); + if (normalized === "list") return "list"; + if (normalized === "cards") return "cards"; + // auto (currently treated as list by default; we can reintroduce responsive modes later) + return "list"; +} + +function applyHivesViewMode() { + const mode = resolveHivesViewMode(); + const list = mode === "list"; + feedEl?.classList.toggle("hivesListView", list); + hivesPanelEl?.classList.toggle("hivesListView", list); +} + +function installHivesAutoViewMode() { + if (!hivesPanelEl) return; + if (typeof ResizeObserver === "undefined") { + window.addEventListener("resize", () => applyHivesViewMode()); + return; + } + if (hivesResizeObserver) return; + hivesResizeObserver = new ResizeObserver((entries) => { + const entry = entries && entries[0]; + const w = Number(entry?.contentRect?.width || 0); + if (!w) return; + const rounded = Math.round(w); + if (rounded === lastHivesWidthPx) return; + lastHivesWidthPx = rounded; + applyHivesViewMode(); + }); + try { + hivesResizeObserver.observe(hivesPanelEl); + } catch { + // ignore + } +} + +function setSideCollapsed(collapsed, opts) { + const options = opts && typeof opts === "object" ? opts : {}; + const persist = options.persist !== false; + const updateControls = options.updateControls !== false; + if (!appRoot) return; + appRoot.classList.toggle("sideCollapsed", Boolean(collapsed)); + if (persist) writeBoolPref(RACK_SIDE_COLLAPSED_KEY, Boolean(collapsed)); + if (updateControls && toggleSideRackEl) toggleSideRackEl.checked = !Boolean(collapsed); + updateSideRackEmptyState(); +} + +function setRightCollapsed(collapsed, opts) { + const options = opts && typeof opts === "object" ? opts : {}; + const persist = options.persist !== false; + const updateControls = options.updateControls !== false; + if (!appRoot) return; + appRoot.classList.toggle("rightCollapsed", Boolean(collapsed)); + if (persist) writeBoolPref(RACK_RIGHT_COLLAPSED_KEY, Boolean(collapsed)); + if (updateControls && toggleRightRackEl) toggleRightRackEl.checked = !Boolean(collapsed); +} + +function updateSideRackEmptyState() { + if (!appRoot) return; + const side = mainSideRackEl || mainSideRack || document.getElementById("mainSideRack"); + if (!(side instanceof HTMLElement)) return; + const hasVisible = Boolean(side.querySelector?.(".rackPanel:not(.hidden)")); + appRoot.classList.toggle("sideRackEmpty", !hasVisible); +} + +// Panel registry (skeleton): this will become the primary way core + plugins register UI panels. +// For now, it powers rack mode (docking + ordering + workspace rules) and plugin panel shells. +/** @type {Map<string, {id:string,title:string,icon?:string,source:string,role:string,defaultRack:string,element?:HTMLElement|null}>} */ +const panelRegistry = new Map(); + +function registerCorePanel(def) { + const id = String(def?.id || "").trim(); + if (!id) return; + const title = String(def?.title || id).trim(); + const icon = typeof def?.icon === "string" ? def.icon : ""; + const role = typeof def?.role === "string" ? def.role : "aux"; + const defaultRack = typeof def?.defaultRack === "string" ? def.defaultRack : "right"; + const element = def?.element instanceof HTMLElement ? def.element : null; + panelRegistry.set(id, { id, title, icon, source: "core", role, defaultRack, element }); +} + +registerCorePanel({ id: "chat", title: "Chat", icon: "💬", role: "primary", defaultRack: "main", element: chatPanelEl }); +registerCorePanel({ id: "hives", title: "Hives", icon: "🐝", role: "primary", defaultRack: "main", element: hivesPanelEl }); +registerCorePanel({ id: "people", title: "People", icon: "👥", role: "aux", defaultRack: "right", element: peopleDrawerEl }); +registerCorePanel({ id: "moderation", title: "Moderation", icon: "🛡️", role: "aux", defaultRack: "right", element: modPanelEl }); +registerCorePanel({ id: "profile", title: "Profile", icon: "👤", role: "transient", defaultRack: "main", element: profileViewPanel }); +registerCorePanel({ id: "composer", title: "New Hive", icon: "✍️", role: "aux", defaultRack: "main", element: pollinatePanel }); + +// Rack mode: Profile should behave like a normal dockable panel (not a flow that replaces Hives). +// Override the role after the initial core registration (Map#set will replace the previous entry). +panelRegistry.set("profile", { ...(panelRegistry.get("profile") || { id: "profile", source: "core" }), role: "aux" }); + +// Expose for quick inspection in the browser console while iterating. +window.__bzlPanels = { panelRegistry }; + +const PRESET_DEFS = { + // Presets are hard-applied (exact placement). Anything not explicitly placed starts in the hotbar. + // Workspace uses two full-height primary slots (left + right). No vertical splits. + social: { + presetId: "social", + label: "Default (Social)", + group: "user", + workspaceLeftOrder: ["hives"], + workspaceRightOrder: ["chat"], + sideOrder: ["profile", "composer"], + rightOrder: ["people"], + dockBottom: ["maps", "library"], + }, + chatFocus: { + presetId: "chatFocus", + label: "Chat Focus", + group: "user", + workspaceLeftOrder: ["chat"], + workspaceRightOrder: [], + expandedPrimary: "chat", + sideOrder: ["profile"], + rightOrder: ["people"], + dockBottom: ["hives", "composer", "maps", "library"], + }, + browse: { + presetId: "browse", + label: "Browse", + group: "user", + workspaceLeftOrder: ["hives"], + workspaceRightOrder: [], + expandedPrimary: "hives", + sideOrder: ["chat"], + rightOrder: ["profile"], + dockBottom: ["people", "composer", "maps", "library"], + }, + creator: { + presetId: "creator", + label: "Creator", + group: "user", + workspaceLeftOrder: ["hives"], + workspaceRightOrder: ["composer"], + composerOpen: true, + sideOrder: ["people"], + rightOrder: ["profile"], + dockBottom: ["chat", "maps", "library"], + }, + mapsSession: { + presetId: "mapsSession", + label: "Maps Session", + group: "user", + workspaceLeftOrder: ["maps"], // if installed + workspaceRightOrder: ["chat"], + sideOrder: ["hives"], + rightOrder: ["people"], + dockBottom: ["profile", "composer", "library"], + }, + quiet: { + presetId: "quiet", + label: "Quiet (No People)", + group: "user", + workspaceLeftOrder: ["hives"], + workspaceRightOrder: ["profile"], + sideOrder: ["composer"], + rightOrder: [], + rightCollapsed: true, + dockBottom: ["chat", "people", "maps", "library"], + }, + ops: { + presetId: "ops", + label: "Ops", + group: "mod", + modOnly: true, + workspaceLeftOrder: ["moderation"], + workspaceRightOrder: ["chat"], + sideOrder: ["hives"], + rightOrder: ["people"], + dockBottom: ["profile", "composer", "maps", "library"], + }, + reportsFocus: { + presetId: "reportsFocus", + label: "Reports Focus", + group: "mod", + modOnly: true, + workspaceLeftOrder: ["moderation"], + workspaceRightOrder: [], + expandedPrimary: "moderation", + sideOrder: ["people"], + rightOrder: ["chat"], + dockBottom: ["hives", "profile", "composer", "maps", "library"], + }, + communityWatch: { + presetId: "communityWatch", + label: "Community Watch", + group: "mod", + modOnly: true, + workspaceLeftOrder: ["hives"], + workspaceRightOrder: ["moderation"], + sideOrder: ["chat"], + rightOrder: ["people"], + dockBottom: ["profile", "composer", "maps", "library"], + }, + serverAdmin: { + presetId: "serverAdmin", + label: "Server Admin", + group: "mod", + modOnly: true, + workspaceLeftOrder: ["moderation"], + workspaceRightOrder: ["hives"], + sideOrder: ["chat"], + rightOrder: ["people"], + dockBottom: ["profile", "composer", "maps", "library"], + }, +}; + +const PRESET_ALIASES = { + // Back-compat for older preset ids. + discordLike: "social", + chat: "chatFocus", + browsing: "browse", + maps: "mapsSession", + focus: "quiet", + clean: "social", + moderation: "ops", +}; + +function resolvePresetKey(presetId) { + const raw = String(presetId || "").trim(); + const mapped = Object.prototype.hasOwnProperty.call(PRESET_ALIASES, raw) ? PRESET_ALIASES[raw] : raw; + return Object.prototype.hasOwnProperty.call(PRESET_DEFS, mapped) ? mapped : "social"; +} + +function updateLayoutPresetOptions() { + if (!layoutPresetEl) return; + const current = resolvePresetKey(rackLayoutState?.presetId || layoutPresetEl.value || "social"); + + const defs = Object.values(PRESET_DEFS).filter((d) => d && typeof d === "object"); + const userDefs = defs.filter((d) => d.group === "user"); + const modDefs = defs.filter((d) => d.group === "mod"); + + const makeOpt = (def) => { + const opt = document.createElement("option"); + opt.value = String(def.presetId || ""); + opt.textContent = String(def.label || def.presetId || "Preset"); + return opt; + }; + + layoutPresetEl.innerHTML = ""; + + const userGroup = document.createElement("optgroup"); + userGroup.label = "Presets"; + for (const def of userDefs) userGroup.appendChild(makeOpt(def)); + layoutPresetEl.appendChild(userGroup); + + if (canModerate) { + const modGroup = document.createElement("optgroup"); + modGroup.label = "Moderation (mods)"; + for (const def of modDefs) modGroup.appendChild(makeOpt(def)); + layoutPresetEl.appendChild(modGroup); + } + + const nextValue = canModerate ? current : (PRESET_DEFS[current]?.modOnly ? "social" : current); + layoutPresetEl.value = Object.prototype.hasOwnProperty.call(PRESET_DEFS, nextValue) ? nextValue : "social"; +} + +function readRackLayoutEnabled() { + try { + return localStorage.getItem(RACK_LAYOUT_ENABLED_KEY) === "1"; + } catch { + return false; + } +} + +function writeRackLayoutEnabled(enabled) { + rackLayoutEnabled = Boolean(enabled); + try { + localStorage.setItem(RACK_LAYOUT_ENABLED_KEY, rackLayoutEnabled ? "1" : "0"); + } catch { + // ignore + } +} + +/** @returns {RackLayoutState} */ +function loadRackLayoutState() { + try { + const raw = localStorage.getItem(RACK_LAYOUT_STATE_KEY); + if (!raw) + return { + version: 2, + presetId: "discordLike", + docked: { bottom: [] }, + racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, + }; + const parsed = JSON.parse(raw); + if (!parsed || parsed.version !== 2) + return { + version: 2, + presetId: "discordLike", + docked: { bottom: [] }, + racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, + }; + const bottom = Array.isArray(parsed?.docked?.bottom) ? parsed.docked.bottom.map((x) => String(x || "")).filter(Boolean) : []; + const presetId = typeof parsed?.presetId === "string" ? parsed.presetId : "discordLike"; + const workspaceLeft = Array.isArray(parsed?.racks?.workspaceLeft) ? parsed.racks.workspaceLeft.map((x) => String(x || "")).filter(Boolean) : []; + const workspaceRight = Array.isArray(parsed?.racks?.workspaceRight) ? parsed.racks.workspaceRight.map((x) => String(x || "")).filter(Boolean) : []; + const side = Array.isArray(parsed?.racks?.side) ? parsed.racks.side.map((x) => String(x || "")).filter(Boolean) : []; + const right = Array.isArray(parsed?.racks?.right) ? parsed.racks.right.map((x) => String(x || "")).filter(Boolean) : []; + return { version: 2, presetId, docked: { bottom }, racks: { workspaceLeft, workspaceRight, side, right } }; + } catch { + return { version: 2, presetId: "discordLike", docked: { bottom: [] }, racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] } }; + } +} + +function saveRackLayoutState() { + try { + localStorage.setItem(RACK_LAYOUT_STATE_KEY, JSON.stringify(rackLayoutState)); + } catch { + // ignore + } +} + +function ensureWorkspaceSlots() { + const workspace = mainWorkspaceRackEl || document.getElementById("mainWorkspaceRack"); + if (!workspace) return { left: null, right: null }; + + let left = workspace.querySelector?.("#workspaceLeftSlot"); + let right = workspace.querySelector?.("#workspaceRightSlot"); + + if (!left) { + left = document.createElement("div"); + left.id = "workspaceLeftSlot"; + left.className = "workspaceSlot workspaceSlotLeft"; + left.setAttribute("aria-label", "Workspace left"); + workspace.prepend(left); + } + if (!right) { + right = document.createElement("div"); + right.id = "workspaceRightSlot"; + right.className = "workspaceSlot workspaceSlotRight"; + right.setAttribute("aria-label", "Workspace right"); + const afterLeft = workspace.querySelector?.("#workspaceLeftSlot"); + if (afterLeft && afterLeft.nextSibling) workspace.insertBefore(right, afterLeft.nextSibling); + else workspace.appendChild(right); + } + return { left, right }; +} + +function panelTitle(panelId) { + const entry = panelRegistry.get(panelId); + if (entry?.title) return entry.title; + if (panelId === "maps") return "Maps"; + if (panelId === "library") return "Library"; + return String(panelId || ""); +} + +function panelIcon(panelId) { + const entry = panelRegistry.get(panelId); + if (entry?.icon) return entry.icon; + if (panelId === "maps") return "🗺️"; + if (panelId === "library") return "📚"; + return "•"; +} + +function panelRole(panelId) { + const entry = panelRegistry.get(panelId); + return typeof entry?.role === "string" ? entry.role : "aux"; +} + +function panelCanExpand(panelId) { + const id = String(panelId || "").trim(); + if (!id) return false; + if (panelRole(id) === "primary") return true; + // Allow a few core panels to take over the workspace even though they aren't "primary" by default. + return id === "moderation" || id === "composer"; +} + +function isDocked(panelId) { + return rackLayoutState.docked.bottom.includes(panelId); +} + +function getPanelElement(panelId) { + const id = String(panelId || "").trim(); + if (!id) return null; + const entry = panelRegistry.get(id); + const el = entry?.element; + return el instanceof HTMLElement ? el : null; +} + +function dockPanel(panelId) { + const id = String(panelId || "").trim(); + if (!id) return; + if (!isDocked(id)) rackLayoutState.docked.bottom.push(id); + saveRackLayoutState(); + applyDockState(); +} + +function undockPanel(panelId) { + const id = String(panelId || "").trim(); + if (!id) return; + rackLayoutState.docked.bottom = rackLayoutState.docked.bottom.filter((x) => x !== id); + saveRackLayoutState(); + applyDockState(); +} + +function showHotbar(show) { + if (!dockHotbarEl) return; + if (!show && dockHotbarEl.dataset.lockVisible === "1") return; + dockHotbarEl.classList.toggle("hidden", !show); + dockHotbarEl.classList.toggle("show", Boolean(show)); +} + +function renderHotbar() { + if (!dockHotbarEl) return; + const items = rackLayoutState.docked.bottom.slice().filter((id) => getPanelElement(id)); + if (!items.length) { + dockHotbarEl.classList.add("hidden"); + dockHotbarEl.classList.remove("show"); + dockHotbarEl.innerHTML = ""; + return; + } + dockHotbarEl.innerHTML = items + .map( + (id) => ` + <button type="button" class="dockOrb" data-undock="${escapeHtml(id)}" title="Restore ${escapeHtml(panelTitle(id))}"> + <span class="dockOrbIcon" aria-hidden="true">${escapeHtml(panelIcon(id))}</span> + <span>${escapeHtml(panelTitle(id))}</span> + </button> + ` + ) + .join(""); + dockHotbarEl.classList.remove("hidden"); + requestAnimationFrame(() => showHotbar(true)); +} + +function applyDockState() { + // For the first implementation phase, we support docking any registered panel that has a DOM element. + for (const [id, p] of panelRegistry.entries()) { + const el = p?.element; + if (!(el instanceof HTMLElement)) continue; + if (id === "moderation" && !canModerate) { + el.classList.add("hidden"); + continue; + } + el.classList.toggle("hidden", isDocked(id)); + } + + renderHotbar(); + updateSideRackEmptyState(); +} + +function readRackOrder(rackEl) { + if (!(rackEl instanceof HTMLElement)) return []; + return Array.from(rackEl.querySelectorAll(".rackPanel")) + .filter((el) => el instanceof HTMLElement && !el.classList.contains("hidden")) + .map((el) => String(el?.dataset?.panelId || "").trim()) + .filter(Boolean); +} + +function applyRackStateToDom() { + if (!rackLayoutEnabled) return; + const left = ensureWorkspaceLeftRack(); + const rightWorkspace = ensureWorkspaceRightRack(); + const side = ensureMainSideRack(); + const right = ensureRightRack(); + if (!left || !rightWorkspace || !side || !right) return; + const leftOrder = Array.isArray(rackLayoutState?.racks?.workspaceLeft) ? rackLayoutState.racks.workspaceLeft : []; + const rightOrderW = Array.isArray(rackLayoutState?.racks?.workspaceRight) ? rackLayoutState.racks.workspaceRight : []; + const sideOrder = Array.isArray(rackLayoutState?.racks?.side) ? rackLayoutState.racks.side : []; + const rightOrder = Array.isArray(rackLayoutState?.racks?.right) ? rackLayoutState.racks.right : []; + + for (const panelId of leftOrder) { + const el = getPanelElement(panelId); + if (el) left.appendChild(el); + } + for (const panelId of rightOrderW) { + const el = getPanelElement(panelId); + if (el) rightWorkspace.appendChild(el); + } + for (const panelId of sideOrder) { + const el = getPanelElement(panelId); + if (el) side.appendChild(el); + } + for (const panelId of rightOrder) { + const el = getPanelElement(panelId); + if (el) right.appendChild(el); + } +} + +function readWorkspaceActivePrimary() { + try { + const raw = localStorage.getItem(WORKSPACE_ACTIVE_PRIMARY_KEY); + return raw ? String(raw) : ""; + } catch { + return ""; + } +} + +function writeWorkspaceActivePrimary(panelId) { + const id = String(panelId || "").trim(); + if (!id) return; + try { + localStorage.setItem(WORKSPACE_ACTIVE_PRIMARY_KEY, id); + } catch { + // ignore + } +} + +function enforceWorkspaceRules() { + if (!rackLayoutEnabled) return; + const left = ensureWorkspaceLeftRack(); + const rightWorkspace = ensureWorkspaceRightRack(); + const side = ensureMainSideRack(); + const rightRack = ensureRightRack(); + if (!left || !rightWorkspace || !side || !rightRack) return; + + // Primary panels: allow up to 2 visible (one per workspace slot). Enforce max 1 per slot. + const cleanupSlot = (slotEl) => { + const kids = Array.from(slotEl.querySelectorAll(":scope > .rackPanel")); + if (kids.length <= 1) return; + for (const extra of kids.slice(1)) side.appendChild(extra); + }; + cleanupSlot(left); + cleanupSlot(rightWorkspace); + + // Right rack is single-slot: keep at most one visible panel. + const rightKids = Array.from(rightRack.querySelectorAll(":scope > .rackPanel:not(.hidden)")); + if (rightKids.length > 1) { + for (const extra of rightKids.slice(1)) { + const id = String(extra?.dataset?.panelId || "").trim(); + if (id) dockPanel(id); + } + } + + // Panels that live in the workspace slots should be "full" by default (especially primaries). + for (const slot of [left, rightWorkspace]) { + const panel = slot.querySelector?.(":scope > .rackPanel"); + if (!(panel instanceof HTMLElement)) continue; + const id = String(panel.dataset.panelId || "").trim(); + if (!id) continue; + panel.classList.remove("panelCollapsed"); + panel.dataset.panelDisplay = "full"; + } + + // If only one workspace slot is occupied, allow it to expand to full width to avoid blank space. + // (We temporarily disable this during drag so the empty slot remains a visible drop target.) + const leftPanel = left.querySelector?.(":scope > .rackPanel"); + const rightPanel = rightWorkspace.querySelector?.(":scope > .rackPanel"); + const leftId = String(leftPanel?.dataset?.panelId || "").trim(); + const rightId = String(rightPanel?.dataset?.panelId || "").trim(); + + // Workspace expansion (explicit maximize for primaries). + const expandedId = readWorkspaceExpandedPrimary(); + const expandedInLeft = Boolean(expandedId && expandedId === leftId); + const expandedInRight = Boolean(expandedId && expandedId === rightId); + const expandedValid = expandedInLeft || expandedInRight; + if (appRoot) { + appRoot.classList.toggle("workspaceExpandedLeft", expandedInLeft); + appRoot.classList.toggle("workspaceExpandedRight", expandedInRight); + if (!expandedValid) appRoot.classList.remove("workspaceExpandedLeft", "workspaceExpandedRight"); + } + if (expandedId && !expandedValid) clearWorkspaceExpandedState(); + + // If expanded and the other slot is occupied, keep it accessible via hotbar. + if (expandedInLeft && rightId && rightId !== expandedId) { + if (!readWorkspaceExpandedDisplaced()) writeWorkspaceExpandedDisplaced(rightId); + dockPanel(rightId); + } + if (expandedInRight && leftId && leftId !== expandedId) { + if (!readWorkspaceExpandedDisplaced()) writeWorkspaceExpandedDisplaced(leftId); + dockPanel(leftId); + } + + // Auto-expand single-primary only when not explicitly expanded. + if (appRoot && !appRoot.classList.contains("rackIsDragging") && !expandedValid) { + const leftOnly = Boolean(leftPanel && !rightPanel); + const rightOnly = Boolean(!leftPanel && rightPanel); + appRoot.classList.toggle("workspaceSingleLeft", leftOnly); + appRoot.classList.toggle("workspaceSingleRight", rightOnly); + } else if (appRoot) { + appRoot.classList.remove("workspaceSingleLeft", "workspaceSingleRight"); + } + + // If a primary ends up outside the workspace slots, dock it (no half-width primaries). + const primariesToDock = []; + for (const el of Array.from(appRoot.querySelectorAll(".rackPanel"))) { + const id = String(el?.dataset?.panelId || "").trim(); + if (!id) continue; + if (panelRole(id) !== "primary") continue; + if (el.parentElement === left || el.parentElement === rightWorkspace) continue; + primariesToDock.push(id); + } + for (const id of primariesToDock) dockPanel(id); + + // Transient panels should live in the side column and be collapsed by default. + for (const el of Array.from(appRoot.querySelectorAll("#mainWorkspaceRack .rackPanel, #mainSideRack .rackPanel"))) { + const id = String(el?.dataset?.panelId || "").trim(); + if (!id) continue; + if (panelRole(id) !== "transient") continue; + if (el.parentElement !== side) side.appendChild(el); + el.classList.add("panelCollapsed"); + el.dataset.panelDisplay = "collapsed"; + } + + syncRackStateFromDom(); +} + +function installWorkspaceInteractions() { + if (!rackLayoutEnabled) return; + if (!appRoot) return; + if (appRoot.dataset.workspaceClicks === "1") return; + appRoot.dataset.workspaceClicks = "1"; + + appRoot.addEventListener("click", (e) => { + if (!rackLayoutEnabled) return; + const target = e.target; + const interactive = target?.closest?.("button,a,input,select,textarea,label"); + if (interactive) return; + const panel = target?.closest?.(".rackPanel"); + if (!panel) return; + if (!(panel instanceof HTMLElement)) return; + if (!panel.closest?.("#mainRack")) return; + const panelId = String(panel.dataset.panelId || "").trim(); + if (!panelId) return; + if (panelRole(panelId) !== "primary") return; + writeWorkspaceActivePrimary(panelId); + enforceWorkspaceRules(); + }); +} + +function syncRackStateFromDom() { + if (!rackLayoutEnabled) return; + const left = ensureWorkspaceLeftRack(); + const rightWorkspace = ensureWorkspaceRightRack(); + const side = ensureMainSideRack(); + const right = ensureRightRack(); + if (!left || !rightWorkspace || !side || !right) return; + rackLayoutState.racks = { + workspaceLeft: readRackOrder(left), + workspaceRight: readRackOrder(rightWorkspace), + side: readRackOrder(side), + right: readRackOrder(right), + }; + saveRackLayoutState(); +} + +function ensureRightRack() { + if (!appRoot) return null; + if (rightRackEl && rightRackEl.isConnected) return rightRackEl; + const el = document.createElement("aside"); + el.id = "rightRack"; + el.className = "rightRack"; + appRoot.appendChild(el); + rightRackEl = el; + return el; +} + +function ensureMainRack() { + // In rack mode, "main rack" is the workspace column inside #mainRack. + if (mainRack && mainRack.isConnected) return mainRack; + if (mainWorkspaceRackEl) { + mainRack = mainWorkspaceRackEl; + return mainRack; + } + + const wrapper = mainRackEl || document.querySelector("#mainRack") || document.querySelector("main.main"); + if (!wrapper) return null; + + let workspace = wrapper.querySelector?.("#mainWorkspaceRack"); + let side = wrapper.querySelector?.("#mainSideRack"); + if (!workspace) { + const w = document.createElement("div"); + w.id = "mainWorkspaceRack"; + w.className = "workspaceRack"; + w.setAttribute("aria-label", "Workspace"); + wrapper.appendChild(w); + workspace = w; + } + if (!side) { + const s = document.createElement("div"); + s.id = "mainSideRack"; + s.className = "sideRack"; + s.setAttribute("aria-label", "Side panels"); + wrapper.appendChild(s); + side = s; + } + mainSideRack = side; + mainRack = workspace; + return mainRack; +} + +function ensureMainSideRack() { + if (mainSideRack && mainSideRack.isConnected) return mainSideRack; + if (mainSideRackEl) { + mainSideRack = mainSideRackEl; + return mainSideRack; + } + // Ensure the workspace rack exists too (creates both columns if missing). + ensureMainRack(); + return mainSideRack instanceof HTMLElement ? mainSideRack : null; +} + +function ensureWorkspaceLeftRack() { + const { left } = ensureWorkspaceSlots(); + return left instanceof HTMLElement ? left : null; +} + +function ensureWorkspaceRightRack() { + const { right } = ensureWorkspaceSlots(); + return right instanceof HTMLElement ? right : null; +} + +function enableRackLayoutDom() { + if (!appRoot) return; + appRoot.classList.add("rackMode"); + const rack = ensureRightRack(); + if (!rack) return; + const main = ensureMainRack(); + const left = ensureWorkspaceLeftRack(); + const rightWorkspace = ensureWorkspaceRightRack(); + const side = ensureMainSideRack(); + + const mark = (el, panelId) => { + if (!el) return; + el.classList.add("rackPanel"); + el.dataset.panelId = panelId; + }; + + // Move right-side panels into the rack so they become stackable. + // (This is a stepping stone toward full dockable panels.) + if (chatPanelEl) { + mark(chatPanelEl, "chat"); + // Chat is a workspace primary in rack mode by default; enforceWorkspaceRules will manage if moved. + if (rightWorkspace && chatPanelEl.parentElement !== rightWorkspace) rightWorkspace.appendChild(chatPanelEl); + } + if (peopleDrawerEl) { + mark(peopleDrawerEl, "people"); + if (peopleDrawerEl.parentElement !== rack) rack.appendChild(peopleDrawerEl); + } + if (modPanelEl) { + mark(modPanelEl, "moderation"); + if (modPanelEl.parentElement !== rack) rack.appendChild(modPanelEl); + } + + // Mark center panels as rack panels too (they already live in mainRack in normal DOM). + if (main) { + if (hivesPanelEl) { + mark(hivesPanelEl, "hives"); + if (left && hivesPanelEl.parentElement !== left) left.appendChild(hivesPanelEl); + } + if (profileViewPanel) { + mark(profileViewPanel, "profile"); + if (side && profileViewPanel.parentElement !== side) side.appendChild(profileViewPanel); + // In rack mode, profile is its own panel; don't keep it hidden behind the legacy center-view toggle. + profileViewPanel.classList.remove("hidden"); + } + if (pollinatePanel) { + mark(pollinatePanel, "composer"); + if (side && pollinatePanel.parentElement !== side) side.appendChild(pollinatePanel); + } + } + + // Hide old resizers in rack mode (we'll replace with rack-aware resizing later). + chatResizeHandle?.classList.add("hidden"); + peopleResizeHandle?.classList.add("hidden"); + + // People drawer chrome: hide the close button (panel is now a rack item). + closePeopleBtn?.classList.add("hidden"); + // People drawer toggle button is obsolete in rack mode. + togglePeopleBtn?.classList.add("hidden"); + // Ensure people panel isn't hidden by legacy state. + peopleDrawerEl?.classList.remove("hidden"); + peopleOpen = true; + + // Profile panel no longer "replaces" the feed in rack mode, so the back button is confusing. + profileBackBtn?.classList.add("hidden"); +} + +function disableRackLayoutDom() { + if (!appRoot) return; + appRoot.classList.remove("rackMode"); + // No attempt to move elements back (yet). Disable is meant for page reload use. +} + +function applyPreset(presetId) { + const key = resolvePresetKey(presetId); + const def = PRESET_DEFS[key]; + if (!def) return; + if (def.modOnly && !canModerate) { + applyPreset("social"); + return; + } + + rackLayoutState.presetId = def.presetId || key; + + const workspaceLeftOrder = Array.isArray(def.workspaceLeftOrder) ? def.workspaceLeftOrder.map((x) => String(x || "")).filter(Boolean) : []; + const workspaceRightOrder = Array.isArray(def.workspaceRightOrder) ? def.workspaceRightOrder.map((x) => String(x || "")).filter(Boolean) : []; + const sideOrder = Array.isArray(def.sideOrder) ? def.sideOrder.map((x) => String(x || "")).filter(Boolean) : []; + const rightOrderRaw = Array.isArray(def.rightOrder) ? def.rightOrder.map((x) => String(x || "")).filter(Boolean) : []; + // Right rack is a single skinny-capable panel. + const rightOrder = rightOrderRaw.length ? [rightOrderRaw[0]] : []; + + // Applying a preset should be deterministic even after the user has rearranged panels. + clearWorkspaceExpandedState(); + const expandedPrimary = typeof def.expandedPrimary === "string" ? def.expandedPrimary.trim() : ""; + if (expandedPrimary) writeWorkspaceExpandedPrimary(expandedPrimary); + + if (typeof def.composerOpen === "boolean") setComposerOpen(def.composerOpen); + setSideCollapsed(Boolean(def.sideCollapsed), { persist: true }); + setRightCollapsed(Boolean(def.rightCollapsed), { persist: true }); + + const leftRack = ensureWorkspaceLeftRack(); + const rightWorkspaceRack = ensureWorkspaceRightRack(); + const sideRack = ensureMainSideRack(); + const rightRack = ensureRightRack(); + if (!leftRack || !rightWorkspaceRack || !sideRack || !rightRack) return; + + const placed = new Set([...workspaceLeftOrder, ...workspaceRightOrder, ...sideOrder, ...rightOrder]); + const docked = new Set(Array.isArray(def.dockBottom) ? def.dockBottom.map((x) => String(x || "")).filter(Boolean) : []); + for (const id of placed) docked.delete(id); + + // Default: anything not explicitly placed by the preset goes to the hotbar. + for (const id of Array.from(panelRegistry.keys())) { + if (!placed.has(id)) docked.add(id); + } + + // Moderation panel should not be forced visible for non-mods. + if (!canModerate) { + docked.add("moderation"); + // Also ensure moderation isn't placed anywhere. + workspaceLeftOrder.splice(0, workspaceLeftOrder.length, ...workspaceLeftOrder.filter((x) => x !== "moderation")); + workspaceRightOrder.splice(0, workspaceRightOrder.length, ...workspaceRightOrder.filter((x) => x !== "moderation")); + sideOrder.splice(0, sideOrder.length, ...sideOrder.filter((x) => x !== "moderation")); + } + + rackLayoutState.docked.bottom = Array.from(docked); + + saveRackLayoutState(); + applyDockState(); + + // Detach all known panels before re-placing, so we don't end up with "stale" panels sticking in old racks. + const elsById = new Map(); + for (const id of Array.from(panelRegistry.keys())) { + const el = getPanelElement(id); + if (el) elsById.set(id, el); + } + for (const el of elsById.values()) { + if (el.parentElement) el.parentElement.removeChild(el); + } + + if (leftRack) { + for (const panelId of workspaceLeftOrder) { + if (docked.has(panelId)) continue; + const el = elsById.get(panelId) || getPanelElement(panelId); + if (el) leftRack.appendChild(el); + } + } + if (rightWorkspaceRack) { + for (const panelId of workspaceRightOrder) { + if (docked.has(panelId)) continue; + const el = elsById.get(panelId) || getPanelElement(panelId); + if (el) rightWorkspaceRack.appendChild(el); + } + } + if (sideRack) { + for (const panelId of sideOrder) { + if (docked.has(panelId)) continue; + const el = elsById.get(panelId) || getPanelElement(panelId); + if (el) sideRack.appendChild(el); + } + } + if (rightRack) { + for (const panelId of rightOrder) { + if (docked.has(panelId)) continue; + const el = elsById.get(panelId) || getPanelElement(panelId); + if (el) rightRack.appendChild(el); + } + } + + syncRackStateFromDom(); + enforceWorkspaceRules(); + updateLayoutPresetOptions(); +} + +function installPanelMinimizeButtons() { + const addMinBtn = (headerEl, panelId) => { + if (!headerEl) return; + const row = headerEl.querySelector(".row") || headerEl.querySelector(".filters") || headerEl; + + if (!headerEl.querySelector(`[data-rackdrag="${panelId}"]`)) { + const drag = document.createElement("button"); + drag.type = "button"; + drag.className = "ghost smallBtn rackDragHandle"; + drag.textContent = "☰"; + drag.title = "Drag to reorder"; + drag.setAttribute("data-rackdrag", panelId); + row.appendChild(drag); + } + + if (panelCanExpand(panelId) && !headerEl.querySelector(`[data-expand="${panelId}"]`)) { + const expand = document.createElement("button"); + expand.type = "button"; + expand.className = "ghost smallBtn"; + expand.textContent = "[]"; + expand.title = "Expand workspace"; + expand.setAttribute("data-expand", panelId); + expand.onclick = () => togglePrimaryExpand(panelId); + row.appendChild(expand); + } + + if (!headerEl.querySelector(`[data-minimize="${panelId}"]`)) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "ghost smallBtn"; + btn.textContent = "—"; + btn.title = "Minimize to hotbar"; + btn.setAttribute("data-minimize", panelId); + btn.onclick = () => dockPanel(panelId); + row.appendChild(btn); + } + }; + + addMinBtn(chatHeaderEl, "chat"); + addMinBtn(modPanelEl?.querySelector(".panelHeader"), "moderation"); + addMinBtn(peopleDrawerEl?.querySelector(".panelHeader"), "people"); + addMinBtn(hivesPanelEl?.querySelector(".panelHeader"), "hives"); + addMinBtn(profileViewPanel?.querySelector(".panelHeader"), "profile"); + addMinBtn(pollinatePanel?.querySelector(".panelHeader"), "composer"); +} + +function ensurePluginPanelShell(panelId, title, icon, defaultRack, role) { + const wantsMain = String(defaultRack || "").toLowerCase() === "main"; + const isPrimary = String(role || "").toLowerCase() === "primary"; + let preferred = null; + if (wantsMain && isPrimary) { + // Primary panels should live inside a workspace slot, not as loose items in the workspace grid. + const left = ensureWorkspaceLeftRack(); + const right = ensureWorkspaceRightRack(); + const side = ensureMainSideRack(); + const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel").length === 0 : false; + const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel").length === 0 : false; + preferred = leftEmpty ? left : rightEmpty ? right : side; + } else if (wantsMain) { + preferred = ensureMainSideRack(); + } else { + preferred = ensureRightRack(); + } + const rack = preferred || ensureRightRack() || ensureMainSideRack() || ensureWorkspaceLeftRack() || ensureWorkspaceRightRack() || ensureMainRack(); + if (!rack) return null; + + const existing = document.querySelector?.(`.panel.pluginPanel[data-panel-id="${CSS.escape(panelId)}"]`); + if (existing instanceof HTMLElement) { + if (existing.parentElement !== rack) rack.appendChild(existing); + return existing; + } + + const shell = document.createElement("section"); + shell.className = "panel panelFill pluginPanel rackPanel"; + shell.dataset.panelId = panelId; + shell.innerHTML = ` + <div class="panelHeader"> + <div class="panelTitle">${escapeHtml(title || panelId)}</div> + <div class="row"> + <button type="button" class="ghost smallBtn rackDragHandle" data-rackdrag="${escapeHtml(panelId)}" title="Drag to reorder">☰</button> + <button type="button" class="ghost smallBtn" data-minimize="${escapeHtml(panelId)}" title="Minimize to hotbar">—</button> + </div> + </div> + <div class="panelBody" data-pluginmount="1"></div> + `; + + const minBtn = shell.querySelector(`[data-minimize="${panelId}"]`); + if (isPrimary || panelCanExpand(panelId)) { + const headerRow = shell.querySelector(".panelHeader .row"); + if (headerRow && !headerRow.querySelector(`[data-expand="${panelId}"]`)) { + const expand = document.createElement("button"); + expand.type = "button"; + expand.className = "ghost smallBtn"; + expand.textContent = "[]"; + expand.title = "Expand workspace"; + expand.setAttribute("data-expand", panelId); + expand.addEventListener("click", () => togglePrimaryExpand(panelId)); + if (minBtn && minBtn.parentElement === headerRow) headerRow.insertBefore(expand, minBtn); + else headerRow.appendChild(expand); + } + } + if (minBtn) minBtn.addEventListener("click", () => dockPanel(panelId)); + + rack.appendChild(shell); + return shell; +} + +function applyPluginPresetHint(panelDef) { + if (!rackLayoutEnabled) return; + const id = String(panelDef?.id || "").trim(); + if (!id) return; + if (isDocked(id)) return; + const presetId = rackLayoutState?.presetId || ""; + const hint = panelDef?.presetHints && typeof panelDef.presetHints === "object" ? panelDef.presetHints[presetId] : null; + const place = hint && typeof hint === "object" ? String(hint.place || "") : ""; + if (place === "docked.bottom") { + dockPanel(id); + return; + } + if (place === "main" || place === "right") { + const rack = place === "main" ? ensureMainSideRack() : ensureRightRack(); + const el = getPanelElement(id); + if (rack && el) rack.appendChild(el); + } +} + +function enableRackDnD() { + if (!rackLayoutEnabled) return; + const right = ensureRightRack(); + const left = ensureWorkspaceLeftRack(); + const rightWorkspace = ensureWorkspaceRightRack(); + const side = ensureMainSideRack(); + if (!right || !left || !rightWorkspace || !side) return; + const racks = [left, rightWorkspace, side, right]; + + // Guard against double-install if initRackLayout is called more than once. + if (appRoot?.dataset?.rackDnd === "1") return; + if (appRoot) appRoot.dataset.rackDnd = "1"; + + let draggingEl = null; + let placeholderEl = null; + let pointerId = null; + let dragOffset = { x: 0, y: 0 }; + let draggingPanelId = ""; + let activeRack = null; + let originRack = null; + let originBefore = null; + + const cancelDrag = () => { + if (!draggingEl) return; + cleanup(); + enforceWorkspaceRules(); + }; + + const cleanup = () => { + if (appRoot) appRoot.classList.remove("rackIsDragging"); + if (draggingEl) { + draggingEl.classList.remove("rackDragging"); + draggingEl.style.position = ""; + draggingEl.style.left = ""; + draggingEl.style.top = ""; + draggingEl.style.width = ""; + draggingEl.style.zIndex = ""; + draggingEl.style.pointerEvents = ""; + } + if (dockHotbarEl) dockHotbarEl.classList.remove("dockTarget"); + if (placeholderEl && placeholderEl.parentElement) placeholderEl.parentElement.removeChild(placeholderEl); + draggingEl = null; + placeholderEl = null; + pointerId = null; + draggingPanelId = ""; + activeRack = null; + originRack = null; + originBefore = null; + }; + + const siblings = (rack) => Array.from(rack.querySelectorAll(".rackPanel")).filter((el) => el !== draggingEl && el !== placeholderEl); + + const insertPlaceholderAt = (rack, y) => { + const items = siblings(rack); + for (const el of items) { + const r = el.getBoundingClientRect(); + const mid = r.top + r.height / 2; + if (y < mid) { + rack.insertBefore(placeholderEl, el); + return; + } + } + rack.appendChild(placeholderEl); + }; + + const rackAtPoint = (x, y) => { + for (const r of racks) { + const rect = r.getBoundingClientRect(); + if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return r; + } + return null; + }; + + const onMove = (e) => { + if (!draggingEl || e.pointerId !== pointerId) return; + e.preventDefault(); + const x = e.clientX - dragOffset.x; + const y = e.clientY - dragOffset.y; + draggingEl.style.left = `${x}px`; + draggingEl.style.top = `${y}px`; + + const targetRack = rackAtPoint(e.clientX, e.clientY) || activeRack; + if (targetRack && placeholderEl && placeholderEl.parentElement !== targetRack) { + targetRack.appendChild(placeholderEl); + } + if (targetRack) { + activeRack = targetRack; + insertPlaceholderAt(targetRack, e.clientY); + } + + if (dockHotbarEl) { + const nearBottom = e.clientY > window.innerHeight - 90; + dockHotbarEl.classList.toggle("dockTarget", Boolean(nearBottom)); + if (nearBottom) showHotbar(true); + } + }; + + const onUp = (e) => { + if (!draggingEl || e.pointerId !== pointerId) return; + e.preventDefault(); + const targetRack = placeholderEl?.parentElement || activeRack; + if (targetRack && placeholderEl && placeholderEl.parentElement === targetRack) { + const isWorkspaceSlot = targetRack.id === "workspaceLeftSlot" || targetRack.id === "workspaceRightSlot"; + const isRightRackSlot = targetRack.id === "rightRack"; + const isPrimary = panelRole(draggingPanelId) === "primary"; + + // Primaries are only allowed in the workspace slots. Dropping elsewhere snaps them back. + if (isPrimary && !isWorkspaceSlot) { + if (originRack) { + if (originBefore && originBefore.parentElement === originRack) originRack.insertBefore(draggingEl, originBefore); + else originRack.appendChild(draggingEl); + } + cleanup(); + syncRackStateFromDom(); + enforceWorkspaceRules(); + return; + } + + if (isWorkspaceSlot || isRightRackSlot) { + const existing = Array.from(targetRack.querySelectorAll(":scope > .rackPanel")).find((x) => x !== draggingEl); + targetRack.insertBefore(draggingEl, placeholderEl); + // Swap if occupied: send the previous occupant back to the origin rack position. + if (existing && originRack) { + if (originBefore && originBefore.parentElement === originRack) originRack.insertBefore(existing, originBefore); + else originRack.appendChild(existing); + } + } else { + targetRack.insertBefore(draggingEl, placeholderEl); + } + } + const shouldDock = Boolean(dockHotbarEl && e.clientY > window.innerHeight - 90); + const dockId = draggingPanelId; + cleanup(); + if (shouldDock && dockId) dockPanel(dockId); + syncRackStateFromDom(); + enforceWorkspaceRules(); + }; + + // Use window-level listeners so cross-rack dragging stays responsive even when the cursor passes over gaps/resizers. + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + // Extra safety: pointer events can fail to deliver pointerup if the mouse is released outside the window. + window.addEventListener("blur", cancelDrag); + window.addEventListener("mouseup", cancelDrag); + window.addEventListener("touchend", cancelDrag, { passive: true }); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState !== "visible") cancelDrag(); + }); + window.addEventListener("keydown", (e) => { + if (e.key === "Escape") cancelDrag(); + }); + + const onDown = (e) => { + const btn = e.target.closest?.("[data-rackdrag]"); + if (!btn) return; + const el = btn.closest?.(".rackPanel"); + if (!(el instanceof HTMLElement)) return; + if (el.classList.contains("hidden")) return; + + e.preventDefault(); + // If a drag somehow got stuck, start clean. + cleanup(); + if (appRoot) appRoot.classList.add("rackIsDragging"); + draggingEl = el; + draggingPanelId = String(el.dataset.panelId || ""); + pointerId = e.pointerId; + draggingEl.setPointerCapture?.(pointerId); + + activeRack = el.parentElement; + originRack = activeRack; + originBefore = draggingEl.nextSibling; + const rect = draggingEl.getBoundingClientRect(); + dragOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + + placeholderEl = document.createElement("div"); + placeholderEl.className = "rackPlaceholder"; + placeholderEl.style.height = `${Math.max(40, Math.round(rect.height))}px`; + + (activeRack || main).insertBefore(placeholderEl, draggingEl.nextSibling); + + draggingEl.classList.add("rackDragging"); + draggingEl.style.position = "fixed"; + draggingEl.style.left = `${rect.left}px`; + draggingEl.style.top = `${rect.top}px`; + draggingEl.style.width = `${rect.width}px`; + draggingEl.style.zIndex = "80"; + draggingEl.style.pointerEvents = "none"; + }; + + // Delegate to the app root so panels can be dragged regardless of which rack they're currently in. + (appRoot || document).addEventListener("pointerdown", onDown); +} + +function initRackLayout() { + rackLayoutEnabled = readRackLayoutEnabled(); + let hadState = false; + try { + hadState = Boolean(localStorage.getItem(RACK_LAYOUT_STATE_KEY)); + } catch { + hadState = false; + } + rackLayoutState = loadRackLayoutState(); + // Normalize older preset ids in persisted state. + rackLayoutState.presetId = resolvePresetKey(rackLayoutState.presetId); + + if (toggleRackLayoutEl) { + toggleRackLayoutEl.checked = rackLayoutEnabled; + toggleRackLayoutEl.onchange = () => { + writeRackLayoutEnabled(Boolean(toggleRackLayoutEl.checked)); + // Reload is the simplest safe path while the feature is in flux. + location.reload(); + }; + } + + if (layoutPresetEl) { + updateLayoutPresetOptions(); + layoutPresetEl.value = resolvePresetKey(rackLayoutState.presetId || "social"); + layoutPresetEl.disabled = !rackLayoutEnabled; + layoutPresetEl.onchange = () => { + if (!rackLayoutEnabled) return; + const next = String(layoutPresetEl.value || "social"); + applyPreset(next); + }; + } + + if (!rackLayoutEnabled) { + disableRackLayoutDom(); + setSideCollapsed(false, { persist: false, updateControls: false }); + setRightCollapsed(false, { persist: false, updateControls: false }); + toggleSideRackEl && (toggleSideRackEl.disabled = true); + toggleRightRackEl && (toggleRightRackEl.disabled = true); + showSideRackBtn?.classList.add("hidden"); + showRightRackBtn?.classList.add("hidden"); + showHotbar(false); + return; + } + + enableRackLayoutDom(); + + // Side racks behave like summonable hotbars: hide/show without changing panel layout state. + toggleSideRackEl && (toggleSideRackEl.disabled = false); + toggleRightRackEl && (toggleRightRackEl.disabled = false); + + if (showSideRackBtn) { + showSideRackBtn.classList.remove("hidden"); + showSideRackBtn.onclick = () => setSideCollapsed(false); + } + if (showRightRackBtn) { + showRightRackBtn.classList.remove("hidden"); + showRightRackBtn.onclick = () => setRightCollapsed(false); + } + + if (toggleSideRackEl) { + toggleSideRackEl.onchange = () => { + if (!rackLayoutEnabled) return; + setSideCollapsed(!Boolean(toggleSideRackEl.checked)); + }; + } + if (toggleRightRackEl) { + toggleRightRackEl.onchange = () => { + if (!rackLayoutEnabled) return; + setRightCollapsed(!Boolean(toggleRightRackEl.checked)); + }; + } + + setSideCollapsed(readBoolPref(RACK_SIDE_COLLAPSED_KEY, false), { persist: false }); + setRightCollapsed(readBoolPref(RACK_RIGHT_COLLAPSED_KEY, false), { persist: false }); + + applyRackStateToDom(); + installPanelMinimizeButtons(); + enableRackDnD(); + installWorkspaceInteractions(); + enforceWorkspaceRules(); + renderProfilePanel(); + + // Hotbar interactions + if (dockHotbarEl) { + dockHotbarEl.onmouseenter = () => showHotbar(true); + dockHotbarEl.onmouseleave = () => showHotbar(false); + dockHotbarEl.onclick = (e) => { + if (dockHotbarEl.dataset.dragging === "1") return; + const btn = e.target.closest?.("[data-undock]"); + if (!btn) return; + const id = String(btn.getAttribute("data-undock") || ""); + if (!id) return; + undockPanel(id); + }; + } + + // Drag orbs back into the rack to restore (MVP: restore to end of rack). + if (dockHotbarEl) { + let orbDragId = ""; + let orbPointer = null; + let orbStart = null; + let orbMoved = false; + + const lockHotbarVisible = (lock) => { + dockHotbarEl.dataset.lockVisible = lock ? "1" : "0"; + dockHotbarEl.dataset.dragging = lock ? "1" : "0"; + // While dragging an orb, keep both workspace slots visible as drop targets. + if (appRoot) { + if (lock) { + appRoot.classList.add("rackIsDragging"); + appRoot.dataset.orbDragging = "1"; + } else if (appRoot.dataset.orbDragging === "1") { + delete appRoot.dataset.orbDragging; + appRoot.classList.remove("rackIsDragging"); + } + } + if (lock) showHotbar(true); + }; + + const resolveOrbDropRack = (panelId, rackEl) => { + const id = String(panelId || "").trim(); + if (!id) return rackEl; + if (panelRole(id) !== "primary") return rackEl; + const isWorkspaceSlot = rackEl && (rackEl.id === "workspaceLeftSlot" || rackEl.id === "workspaceRightSlot"); + if (isWorkspaceSlot) return rackEl; + const left = ensureWorkspaceLeftRack(); + const right = ensureWorkspaceRightRack(); + const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; + const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; + return leftEmpty ? left : rightEmpty ? right : left; + }; + + const dropOrbIntoRack = (panelId, targetRack) => { + const id = String(panelId || "").trim(); + if (!id) return; + const rack = resolveOrbDropRack(id, targetRack); + if (!(rack instanceof HTMLElement)) return; + undockPanel(id); + const panelEl = getPanelElement(id); + if (!panelEl) return; + + const isWorkspaceSlot = rack.id === "workspaceLeftSlot" || rack.id === "workspaceRightSlot"; + const isRightRackSlot = rack.id === "rightRack"; + if (isWorkspaceSlot) { + const existing = rack.querySelector?.(":scope > .rackPanel:not(.hidden)"); + if (existing instanceof HTMLElement && existing !== panelEl) { + const existingId = String(existing.dataset.panelId || "").trim(); + if (existingId) dockPanel(existingId); + } + } + if (isRightRackSlot) { + const existing = rack.querySelector?.(":scope > .rackPanel:not(.hidden)"); + if (existing instanceof HTMLElement && existing !== panelEl) { + const existingId = String(existing.dataset.panelId || "").trim(); + if (existingId) dockPanel(existingId); + } + } + + if (panelEl.parentElement !== rack) rack.appendChild(panelEl); + syncRackStateFromDom(); + enforceWorkspaceRules(); + }; + + dockHotbarEl.addEventListener("pointerdown", (e) => { + const orb = e.target.closest?.("[data-undock]"); + if (!orb) return; + orbDragId = String(orb.getAttribute("data-undock") || ""); + if (!orbDragId) return; + orbPointer = e.pointerId; + orbStart = { x: e.clientX, y: e.clientY }; + orbMoved = false; + orb.classList.add("dragging"); + orb.setPointerCapture?.(orbPointer); + lockHotbarVisible(true); + e.preventDefault(); + }); + window.addEventListener("pointermove", (e) => { + if (!orbDragId || e.pointerId !== orbPointer) return; + if (!orbStart) return; + const dx = Math.abs(e.clientX - orbStart.x); + const dy = Math.abs(e.clientY - orbStart.y); + if (dx + dy > 6) orbMoved = true; + }); + dockHotbarEl.addEventListener("pointerup", (e) => { + if (!orbDragId || e.pointerId !== orbPointer) return; + const orb = dockHotbarEl.querySelector(`[data-undock="${CSS.escape(orbDragId)}"]`); + if (orb) orb.classList.remove("dragging"); + const leftRack = ensureWorkspaceLeftRack(); + const rightWorkspaceRack = ensureWorkspaceRightRack(); + const sideRack = ensureMainSideRack(); + const rightRack = ensureRightRack(); + const racks = [leftRack, rightWorkspaceRack, sideRack, rightRack].filter((x) => x instanceof HTMLElement); + let targetRack = null; + for (const r of racks) { + const rect = r.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetRack = r; + break; + } + } + if (orbMoved && targetRack) dropOrbIntoRack(orbDragId, targetRack); + orbDragId = ""; + orbPointer = null; + orbStart = null; + orbMoved = false; + lockHotbarVisible(false); + }); + dockHotbarEl.addEventListener("pointercancel", () => { + orbDragId = ""; + orbPointer = null; + orbStart = null; + orbMoved = false; + lockHotbarVisible(false); + dockHotbarEl.querySelectorAll(".dockOrb.dragging").forEach((x) => x.classList.remove("dragging")); + }); + } + + // Reveal hotbar when cursor is near bottom if there are docked items. + window.addEventListener("mousemove", (e) => { + if (!dockHotbarEl) return; + if (!rackLayoutEnabled) return; + if (!rackLayoutState.docked.bottom.length) return; + const nearBottom = e.clientY > window.innerHeight - 80; + showHotbar(Boolean(nearBottom)); + }); + + // First enable: seed state from the selected preset so users immediately get a sensible layout. + if (!hadState) { + const preset = resolvePresetKey(rackLayoutState.presetId || (layoutPresetEl ? String(layoutPresetEl.value || "") : "") || "social"); + applyPreset(preset); + } + + applyDockState(); + enforceWorkspaceRules(); +} let activeProfileUsername = ""; let activeProfile = null; +let lastRequestedProfileUsername = ""; let isEditingProfile = false; let replyToMessage = null; let chatResizeDragging = false; @@ -828,14 +2437,24 @@ function getSidebarHidden() { } function setPeopleOpen(open) { - peopleOpen = Boolean(open); - if (!peopleDrawerEl || !togglePeopleBtn) return; - peopleDrawerEl.classList.toggle("hidden", !peopleOpen); - togglePeopleBtn.textContent = peopleOpen ? "Hide people" : "People"; - togglePeopleBtn.title = peopleOpen ? "Hide people" : "Show people"; + const inRackMode = Boolean(appRoot?.classList.contains("rackMode")); + peopleOpen = inRackMode ? true : Boolean(open); + if (!peopleDrawerEl) return; + // In rack mode, "People" is a normal dockable panel; don't hide it behind a special toggle. + peopleDrawerEl.classList.toggle("hidden", !peopleOpen && !inRackMode); + if (togglePeopleBtn) { + if (inRackMode) { + togglePeopleBtn.classList.add("hidden"); + } else { + togglePeopleBtn.classList.remove("hidden"); + togglePeopleBtn.textContent = peopleOpen ? "Hide people" : "People"; + togglePeopleBtn.title = peopleOpen ? "Hide people" : "Show people"; + } + } if (peopleOpen && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "peopleList" })); } + if (inRackMode) return; try { localStorage.setItem("bzl_peopleOpen", peopleOpen ? "1" : "0"); } catch { @@ -859,6 +2478,7 @@ function setComposerOpen(open) { toggleComposerBtn.title = composerOpen ? "Hide hive creator" : "Open hive creator"; } renderCenterPanels(); + updateSideRackEmptyState(); try { localStorage.setItem("bzl_composerOpen", composerOpen ? "1" : "0"); } catch { @@ -1272,6 +2892,18 @@ function renderProfileEditor() { } function renderCenterPanels() { + // In rack mode, panels are independent. Profile shouldn't "replace" the Hives panel. + if (rackLayoutEnabled) { + if (pollinatePanel) { + pollinatePanel.classList.remove("hidden"); + pollinatePanel.classList.toggle("panelCollapsed", !composerOpen); + pollinatePanel.dataset.panelDisplay = composerOpen ? "full" : "collapsed"; + } + renderProfilePanel(); + updateSideRackEmptyState(); + return; + } + const profileMode = centerView === "profile"; if (profileViewPanel) profileViewPanel.classList.toggle("hidden", !profileMode); if (feedEl?.closest("section")) feedEl.closest("section").classList.toggle("hidden", profileMode); @@ -1280,7 +2912,37 @@ function renderCenterPanels() { else pollinatePanel.classList.toggle("hidden", !composerOpen); } if (!profileMode) return; - const username = activeProfile?.username || activeProfileUsername || ""; + renderProfilePanel(); +} + +function renderProfilePanel() { + if (!profileViewPanel) return; + if (!activeProfileUsername && !activeProfile && loggedInUser) { + activeProfileUsername = String(loggedInUser || "").trim().toLowerCase(); + } + + const username = String(activeProfile?.username || activeProfileUsername || "") + .trim() + .toLowerCase(); + + if (username) { + // Ensure we always have *some* profile data to show immediately. + if (!activeProfile || String(activeProfile.username || "").toLowerCase() !== username) { + const basic = getProfile(username); + activeProfile = normalizeProfileData({ username, image: basic.image || "", color: basic.color || "" }); + } + + // Pull the full profile from the server (bio/links/song) once per username selection. + try { + if (ws?.readyState === WebSocket.OPEN && lastRequestedProfileUsername !== username) { + lastRequestedProfileUsername = username; + ws.send(JSON.stringify({ type: "getUserProfile", username })); + } + } catch { + // ignore + } + } + if (profileViewTitle) profileViewTitle.textContent = username ? `@${username}` : "Profile"; if (profileViewMeta) profileViewMeta.textContent = username === loggedInUser ? "Your profile" : "Community profile"; renderProfileCard(); @@ -1288,6 +2950,32 @@ function renderCenterPanels() { } function setCenterView(next, username = "") { + if (rackLayoutEnabled) { + // Keep the legacy centerView on "hives" in rack mode; just update profile context. + const wantsProfile = next === "profile"; + if (wantsProfile) { + activeProfileUsername = String(username || activeProfileUsername || "") + .trim() + .toLowerCase(); + isEditingProfile = false; + if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile"; + + // Make sure the profile panel is actually visible as its own panel. + undockPanel("profile"); + profileViewPanel.classList.remove("panelCollapsed"); + profileViewPanel.dataset.panelDisplay = "full"; + enforceWorkspaceRules(); + renderProfilePanel(); + } else { + activeProfileUsername = ""; + activeProfile = null; + isEditingProfile = false; + if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile"; + renderProfilePanel(); + } + return; + } + centerView = next === "profile" ? "profile" : "hives"; if (centerView === "hives") { activeProfileUsername = ""; @@ -1392,7 +3080,7 @@ window.bzlDevLog = sendDevLog; if (!window.BzlPluginHost) { const pluginInits = new Map(); window.BzlPluginHost = { - apiVersion: 1, + apiVersion: 2, register(pluginId, initFn) { const id = String(pluginId || "").trim().toLowerCase(); if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(id)) throw new Error("Invalid plugin id"); @@ -1405,6 +3093,84 @@ if (!window.BzlPluginHost) { toast, getUser: () => loggedInUser, getRole: () => loggedInRole, + ui: { + registerPanel(panelDef) { + const panelId = String(panelDef?.id || id).trim().toLowerCase(); + if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(panelId)) throw new Error("Invalid panel id"); + const title = typeof panelDef?.title === "string" ? panelDef.title.trim().slice(0, 40) : panelId; + const icon = typeof panelDef?.icon === "string" ? panelDef.icon.trim().slice(0, 10) : ""; + const defaultRack = + typeof panelDef?.defaultRack === "string" && /^(main|right)$/i.test(panelDef.defaultRack) + ? panelDef.defaultRack.toLowerCase() + : "right"; + const role = + typeof panelDef?.role === "string" && /^(primary|aux|transient|utility)$/i.test(panelDef.role) + ? panelDef.role.toLowerCase() + : "aux"; + const source = `plugin:${id}`; + + // Create a visible shell only when rack layout is enabled (for now). + // Otherwise, plugins should continue using their existing DOM hooks. + let element = null; + if (rackLayoutEnabled) { + const shell = ensurePluginPanelShell(panelId, title, icon, defaultRack, role); + element = shell; + const mount = shell ? shell.querySelector("[data-pluginmount]") : null; + if (mount) { + mount.innerHTML = ""; + const api = { + toast, + send: (eventName, payload) => { + const ev = String(eventName || "").trim(); + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false; + const wsRef = window.__bzlWs; + if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false; + const msg = payload && typeof payload === "object" ? payload : {}; + wsRef.send(JSON.stringify({ ...msg, type: `plugin:${id}:${ev}` })); + return true; + }, + getUser: () => loggedInUser, + getRole: () => loggedInRole, + storage: { + get(key) { + try { + return localStorage.getItem(`bzl_panel_${panelId}_${String(key || "")}`); + } catch { + return null; + } + }, + set(key, value) { + try { + localStorage.setItem(`bzl_panel_${panelId}_${String(key || "")}`, String(value ?? "")); + return true; + } catch { + return false; + } + }, + }, + }; + try { + const cleanup = typeof panelDef?.render === "function" ? panelDef.render(mount, api) : null; + if (typeof cleanup === "function") { + // Store cleanup on the shell so future hot-reload / uninstall can call it. + shell.__panelCleanup = cleanup; + } + } catch (e) { + console.warn(`Plugin ${id} panel render failed:`, e?.message || e); + mount.textContent = `Failed to render panel "${panelId}".`; + } + } + + enableRackDnD(); + } + + panelRegistry.set(panelId, { id: panelId, title, icon, source, role, defaultRack, element }); + applyPluginPresetHint(panelDef); + applyDockState(); + syncRackStateFromDom(); + return true; + }, + }, devLog: (level, message, data) => sendDevLog(level, `plugin:${id}`, message, data), send(eventName, payload) { const ev = String(eventName || "").trim(); @@ -2414,7 +4180,7 @@ function renderFeed() { `.trim(); const hasMenu = Boolean(menuItems); const kebabBtn = hasMenu - ? `<button type="button" class="ghost smallBtn kebabBtn" data-postmenu="${p.id}" aria-haspopup="menu" aria-expanded="false" title="More">⋯</button>` + ? `<button type="button" class="ghost smallBtn kebabBtn" data-postmenu="${p.id}" aria-haspopup="menu" aria-expanded="false" title="More">&#8942;</button>` : ""; const postMenu = hasMenu ? `<div class="postMenu hidden" role="menu" data-postmenu-panel="${p.id}">${menuItems}</div>` @@ -2427,6 +4193,10 @@ function renderFeed() { const buzzClass = buzzTimers.has(p.id) ? " isBuzz" : ""; const lockLine = p.locked ? `<div class="small muted">🔒 password protected</div>` : ""; const cardTint = p.author ? cardTintStylesFromHex(getProfile(p.author).color) : ""; + const contentHtml = typeof p.contentHtml === "string" && p.contentHtml.trim() ? p.contentHtml : ""; + const contentText = typeof p.content === "string" && p.content.trim() ? escapeHtml(p.content) : ""; + const content = contentHtml ? contentHtml : contentText ? `<div class="muted">${contentText}</div>` : ""; + const contentBlock = content ? `<div class="postContent">${content}</div>` : ""; return ` <article class="post${unreadClass}${newClass}${buzzClass}" data-id="${p.id}" ${cardTint}> @@ -2451,11 +4221,18 @@ function renderFeed() { </div> ${deletedLine} ${editedLine} + ${contentBlock} <div class="postMeta">${collectionTag}${tags ? ` ${tags}` : ""}</div> ${reactionsHtml} </article>`; }) .join(""); + + try { + feedEl.querySelectorAll?.(".postContent").forEach((el) => decorateYouTubeEmbedsInElement(el)); + } catch { + // ignore + } } function setAuthUi() { @@ -5741,6 +7518,8 @@ ws.addEventListener("message", (evt) => { renderCenterPanels(); } if (canModerate) requestModData(); + if (rackLayoutEnabled) applyDockState(); + updateLayoutPresetOptions(); return; } @@ -5765,6 +7544,8 @@ ws.addEventListener("message", (evt) => { renderLanHint(); renderPeoplePanel(); renderCenterPanels(); + if (rackLayoutEnabled) applyDockState(); + updateLayoutPresetOptions(); return; } @@ -5776,8 +7557,10 @@ ws.addEventListener("message", (evt) => { if (msg.prefs && typeof msg.prefs === "object") setUserPrefs(msg.prefs); setAuthUi(); renderLanHint(); + if (rackLayoutEnabled) applyDockState(); renderPeoplePanel(); if (canModerate) requestModData(); + updateLayoutPresetOptions(); return; } @@ -6157,6 +7940,18 @@ if (toggleReactionsEl) { }); } +if (hivesViewModeEl) { + const pref = readStringPref(HIVES_VIEW_MODE_KEY, "auto"); + hivesViewModeEl.value = pref === "cards" || pref === "list" ? pref : "auto"; + hivesViewModeEl.addEventListener("change", () => { + const next = String(hivesViewModeEl.value || "auto").toLowerCase(); + writeStringPref(HIVES_VIEW_MODE_KEY, next === "cards" || next === "list" ? next : "auto"); + applyHivesViewMode(); + }); +} +installHivesAutoViewMode(); +applyHivesViewMode(); + if (chatHeaderEl && appRoot) { chatHeaderEl.setAttribute("draggable", "true"); chatHeaderEl.title = "Drag left/right to dock chat"; @@ -6540,6 +8335,9 @@ appRoot?.addEventListener( window.addEventListener("resize", applyMobileMode); applyMobileMode(); +// Initialize experimental rack layout (safe no-op when disabled). +initRackLayout(); + window.addEventListener("focus", () => { windowFocused = true; updateNotifUi(); diff --git a/public/index.html b/public/index.html @@ -4,13 +4,14 @@ <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Bzl - Hives</title> - <link rel="stylesheet" href="/styles.css?v=83" /> + <link rel="stylesheet" href="/styles.css?v=88" /> </head> <body> - <div class="app"> - <button id="showSidebar" class="ghost smallBtn sidebarToggle hidden" type="button" title="Show sidebar">Show</button> - <button id="togglePeople" class="ghost smallBtn peopleToggle" type="button" title="Show people">People</button> - <aside class="sidebar"> + <div class="app"> + <button id="showSidebar" class="ghost smallBtn sidebarToggle hidden" type="button" title="Show sidebar">Show</button> + <button id="togglePeople" class="ghost smallBtn peopleToggle" type="button" title="Show people">People</button> + <button id="showRightRack" class="ghost smallBtn rightRackToggle hidden" type="button" title="Show right rack">Right</button> + <aside class="sidebar"> <div class="sidebarScroll"> <div class="brand"> <div id="instanceTitle" class="logo">Bzl</div> @@ -31,6 +32,31 @@ <span>Show reactions bar</span> <input id="toggleReactions" type="checkbox" /> </label> + <label class="checkRow" style="margin-top:8px;"> + <span>Rack layout (experimental)</span> + <input id="toggleRackLayout" type="checkbox" /> + </label> + <label class="checkRow" style="margin-top:8px;"> + <span>Side panels</span> + <input id="toggleSideRack" type="checkbox" checked /> + </label> + <label class="checkRow" style="margin-top:8px;"> + <span>Right rack</span> + <input id="toggleRightRack" type="checkbox" checked /> + </label> + <label style="margin-top:10px;"> + <span>Layout preset</span> + <select id="layoutPreset"> + <option value="discordLike">Discord-like</option> + <option value="chat">Chat</option> + <option value="browsing">Browsing</option> + <option value="maps">Maps</option> + <option value="moderation">Moderation</option> + <option value="focus">Focus</option> + <option value="clean">Clean</option> + <option value="ops">Ops</option> + </select> + </label> </section> <section class="panel"> @@ -104,7 +130,10 @@ <div id="sidebarResizeHandle" class="panelResizeHandle sidebarResizeHandle" title="Drag to resize sidebar" aria-hidden="true"></div> <main class="main"> - <section class="panel panelFill"> + <div id="mainRack" class="mainRack"> + <button id="showSideRack" class="ghost smallBtn sideRackToggle hidden" type="button" title="Show side panels">Side</button> + <div id="mainWorkspaceRack" class="workspaceRack" aria-label="Workspace"> + <section id="hivesPanel" class="panel panelFill"> <div class="panelHeader"> <div class="panelTitle">Hives</div> <div class="filters"> @@ -249,6 +278,9 @@ </div> </form> </section> + </div> + <div id="mainSideRack" class="sideRack" aria-label="Side panels"></div> + </div> </main> <div id="chatResizeHandle" class="panelResizeHandle chatResizeHandle" title="Drag to resize chat" aria-hidden="true"></div> @@ -466,6 +498,7 @@ </div> </div> - <script src="/app.js?v=91"></script> + <div id="dockHotbar" class="dockHotbar hidden" aria-label="Docked panels"></div> + <script src="/app.js?v=99"></script> </body> </html> diff --git a/public/styles.css b/public/styles.css @@ -131,6 +131,83 @@ body { position: relative; } +.app.rackMode { + grid-template-columns: minmax(240px, var(--sidebar-width)) 10px 1fr 10px minmax(320px, var(--people-width)); + grid-template-areas: "sidebar sidebarResize main mainResize rightRack"; +} + +.app.rackMode .peopleToggle { + display: none !important; +} + +.app.rackMode.rightCollapsed, +.app.rackMode.hasMod.rightCollapsed { + grid-template-columns: minmax(240px, var(--sidebar-width)) 10px 1fr; + grid-template-areas: "sidebar sidebarResize main"; +} + +.app.rackMode.rightCollapsed #rightRack { + display: none !important; +} + +.app.rackMode.rightCollapsed .mainResizeHandle { + display: none !important; +} + +.app.sideCollapsed #mainSideRack { + display: none !important; +} + +.app.sideRackEmpty #mainSideRack { + display: none !important; +} + +.sideRackToggle { + position: absolute; + top: 12px; + right: 12px; + z-index: 60; +} + +.app.rackMode.rightCollapsed .sideRackToggle { + right: 64px; +} + +.app.rackMode.sideCollapsed .sideRackToggle { + display: block !important; +} + +.app.rackMode:not(.sideCollapsed) .sideRackToggle { + display: none !important; +} + +.rightRackToggle { + position: absolute; + top: 12px; + right: 12px; + z-index: 70; +} + +.app.rackMode.rightCollapsed .rightRackToggle { + display: block !important; +} + +.app.rackMode:not(.rightCollapsed) .rightRackToggle { + display: none !important; +} + +.pluginPanel .panelBody { + display: flex; + flex-direction: column; + min-height: 0; +} + +.app.rackMode.hasMod { + /* In rack mode, mod is just another panel inside the right rack. */ + grid-template-columns: minmax(240px, var(--sidebar-width)) 10px 1fr 10px minmax(320px, var(--people-width)); + grid-template-areas: "sidebar sidebarResize main mainResize rightRack"; +} + .app.hasMod { grid-template-columns: minmax(240px, var(--sidebar-width)) 10px minmax(380px, var(--chat-width)) 10px 1fr 10px minmax(280px, var(--mod-width)); grid-template-areas: "sidebar sidebarResize chat chatResize main mainResize moderation"; @@ -146,6 +223,12 @@ body { grid-template-areas: "sidebar sidebarResize main chatResize chat mainResize moderation"; } +.app.rackMode.chatRight, +.app.rackMode.hasMod.chatRight { + grid-template-columns: minmax(240px, var(--sidebar-width)) 10px 1fr 10px minmax(320px, var(--people-width)); + grid-template-areas: "sidebar sidebarResize main mainResize rightRack"; +} + @media (max-width: 760px) { .app { grid-template-columns: 300px 1fr; @@ -171,6 +254,15 @@ body { .peopleResizeHandle { display: none; } + + .mainRack { + flex-direction: column; + } + .sideRack { + flex: 0 0 auto; + min-width: 0; + max-width: none; + } } @media (max-width: 760px) { @@ -200,6 +292,141 @@ body { overflow: hidden; } +.rightRack { + grid-area: rightRack; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + gap: 12px; + overflow: hidden; +} + +.rightRack .rackPanel { + min-height: 0; +} + +.panel.panelCollapsed { + overflow: hidden; +} + +.panel.panelCollapsed > :not(.panelHeader) { + display: none !important; +} + +.rackDragHandle { + cursor: grab; + user-select: none; +} + +.rackDragHandle:active { + cursor: grabbing; +} + +.rackDragging { + opacity: 0.86; + box-shadow: 0 26px 90px rgba(0, 0, 0, 0.65); +} + +.rackPlaceholder { + border: 1px dashed color-mix(in srgb, var(--accent) 40%, transparent); + border-radius: 18px; + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + +.app.rackMode .panelHeader { + /* Slightly stronger header affordance in rack mode */ + background: linear-gradient(180deg, color-mix(in srgb, var(--text) 5%, transparent), transparent); +} + +.app.rackMode .chat, +.app.rackMode .moderation, +.app.rackMode .peopleDrawer { + position: static; + grid-area: unset !important; + height: auto; + min-height: 0; +} + +.app.rackMode .chatResizeHandle, +.app.rackMode .peopleResizeHandle { + display: none !important; +} + +.app.rackMode .peopleDrawer { + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 18px; + background: linear-gradient(180deg, color-mix(in srgb, var(--panel) 96%, transparent), color-mix(in srgb, var(--panel) 90%, transparent)); + box-shadow: var(--shadow-panel); +} + +.dockHotbar { + position: fixed; + left: 50%; + bottom: 10px; + transform: translateX(-50%) translateY(28px); + opacity: 0; + pointer-events: none; + z-index: 60; + display: flex; + gap: 10px; + padding: 8px 10px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--text) 12%, transparent); + background: color-mix(in srgb, var(--panel) 92%, transparent); + box-shadow: 0 16px 50px rgba(0, 0, 0, 0.45); + transition: transform var(--dur-med) var(--ease-out), opacity var(--dur-med) var(--ease-out); +} + +.dockHotbar.show { + transform: translateX(-50%) translateY(0); + opacity: 1; + pointer-events: auto; +} + +.dockHotbar.dockTarget { + border-color: color-mix(in srgb, var(--accent) 45%, transparent); + box-shadow: 0 22px 70px rgba(0, 0, 0, 0.6), 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent); +} + +.dockOrb.dragging { + opacity: 0.6; +} + +.dockOrb { + display: inline-flex; + align-items: center; + gap: 8px; + max-width: 220px; + padding: 7px 12px; + border-radius: 999px; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--text) 6%, transparent); + color: var(--text); + cursor: pointer; + user-select: none; + font-size: 12px; + white-space: nowrap; +} + +.dockOrb:hover { + background: color-mix(in srgb, var(--accent) 14%, transparent); + border-color: color-mix(in srgb, var(--accent) 25%, transparent); +} + +.dockOrb .dockOrbIcon { + width: 18px; + height: 18px; + border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--accent) 22%, transparent); +} + .sidebarScroll { flex: 1; min-height: 0; @@ -272,6 +499,103 @@ body { overflow: hidden; } +.mainRack { + flex: 1; + min-height: 0; + display: flex; + flex-direction: row; + gap: 12px; + overflow: hidden; +} + +.workspaceRack { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + gap: 12px; + overflow: hidden; +} + +/* Workspace 4x2 grid (rack mode) */ +.app.rackMode .workspaceRack { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.workspaceSlot { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.workspaceSlot > .rackPanel { + flex: 1; + min-height: 0; +} + +.workspaceSlot > .rackPanel > .panelBody, +.workspaceSlot > .rackPanel > .panelFill, +.workspaceSlot > .rackPanel .panelBody { + min-height: 0; +} + +.workspaceSlotLeft { + grid-column: 1 / span 2; + grid-row: 1 / span 2; +} + +.workspaceSlotRight { + grid-column: 3 / span 2; + grid-row: 1 / span 2; +} + +.app.rackMode.workspaceExpandedLeft #workspaceLeftSlot, +.app.rackMode.workspaceExpandedRight #workspaceRightSlot { + grid-column: 1 / span 4; +} + +.app.rackMode.workspaceExpandedLeft #workspaceRightSlot, +.app.rackMode.workspaceExpandedRight #workspaceLeftSlot { + display: none; +} + +.app.rackMode:not(.rackIsDragging).workspaceSingleLeft #workspaceLeftSlot { + grid-column: 1 / span 4; +} + +.app.rackMode:not(.rackIsDragging).workspaceSingleLeft #workspaceRightSlot { + display: none; +} + +.app.rackMode:not(.rackIsDragging).workspaceSingleRight #workspaceRightSlot { + grid-column: 1 / span 4; +} + +.app.rackMode:not(.rackIsDragging).workspaceSingleRight #workspaceLeftSlot { + display: none; +} + +.app:not(.rackMode) .workspaceSlot { + display: none; +} + +.sideRack { + flex: 0 0 min(380px, 30vw); + min-width: 260px; + max-width: 420px; + min-height: 0; + display: flex; + flex-direction: column; + gap: 12px; + overflow: hidden; +} + .chat { grid-area: chat; display: flex; @@ -1364,6 +1688,48 @@ button:disabled { background var(--dur-med) var(--ease-out), box-shadow var(--dur-med) var(--ease-out); } +.feed.hivesListView .post { + padding: 10px; + gap: 6px; +} + +.feed.hivesListView .postTop { + align-items: center; +} + +.feed.hivesListView .postTitleRow { + gap: 0; + min-width: 0; +} + +.feed.hivesListView .postTitle { + margin-bottom: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.feed.hivesListView .postMeta, +.feed.hivesListView .reactionsRow, +.feed.hivesListView .boostControls, +.feed.hivesListView .countdown.boost { + display: none !important; +} + +.feed.hivesListView .postMeta { + display: flex !important; + margin-top: 6px; + flex-wrap: nowrap; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + gap: 6px; +} + +.feed.hivesListView .rightCol { + gap: 6px; +} + .post:hover { transform: translateY(-2px); border-color: rgba(255, 62, 165, 0.22);