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:
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">⋮</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);