commit c7ebc642485be6b6df55d252529cd7270f947d2b
parent 0c8eaff808437374fb56fc7e4236fa300b3539a6
Author: SageAzakaela <106701693+SageAzakaela@users.noreply.github.com>
Date: Mon, 23 Feb 2026 15:31:53 -0700
better workspace UX, fixed required refresh by adding in a watchdog awoooo
Diffstat:
| M | public/app.js | | | 1659 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------- |
| M | public/index.html | | | 119 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------ |
| M | public/styles.css | | | 178 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- |
3 files changed, 1586 insertions(+), 370 deletions(-)
diff --git a/public/app.js b/public/app.js
@@ -32,6 +32,24 @@ const toggleRightRackEl = document.getElementById("toggleRightRack");
const layoutPresetEl = document.getElementById("layoutPreset");
const uiScaleEl = document.getElementById("uiScale");
const deviceLayoutEl = document.getElementById("deviceLayout");
+const appearancePresetEl = document.getElementById("appearancePreset");
+const appearanceApplyPresetBtn = document.getElementById("appearanceApplyPreset");
+const appearanceResetPreviewBtn = document.getElementById("appearanceResetPreview");
+const appearanceBgEl = document.getElementById("appearanceBg");
+const appearancePanelEl = document.getElementById("appearancePanel");
+const appearanceTextEl = document.getElementById("appearanceText");
+const appearanceAccentEl = document.getElementById("appearanceAccent");
+const appearanceAccent2El = document.getElementById("appearanceAccent2");
+const appearanceGoodEl = document.getElementById("appearanceGood");
+const appearanceBadEl = document.getElementById("appearanceBad");
+const appearanceMutedPctEl = document.getElementById("appearanceMutedPct");
+const appearanceLinePctEl = document.getElementById("appearanceLinePct");
+const appearancePanel2PctEl = document.getElementById("appearancePanel2Pct");
+const appearanceFontBodyEl = document.getElementById("appearanceFontBody");
+const appearanceFontMonoEl = document.getElementById("appearanceFontMono");
+const appearanceSaveBtn = document.getElementById("appearanceSave");
+const appearanceClearBtn = document.getElementById("appearanceClear");
+const appearanceStatusEl = document.getElementById("appearanceStatus");
const stayConnectedEl = document.getElementById("stayConnected");
const enableHintsEl = document.getElementById("enableHints");
const chatEnterModeEl = document.getElementById("chatEnterMode");
@@ -214,6 +232,7 @@ const FORCE_RACK_MODE = true;
// Display prefs (device layout + text scale)
const UI_SCALE_KEY = "bzl_uiScale"; // "auto" | "xs" | "sm" | "md" | "lg"
const DEVICE_LAYOUT_KEY = "bzl_deviceLayout"; // "auto" | "widescreen" | "fourThree" | "threeTwo" | "ultrawide" | "portrait"
+const USER_APPEARANCE_KEY = "bzl_userAppearance_v1";
/** @type {Map<string, any>} */
const posts = new Map();
@@ -409,6 +428,8 @@ 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";
+const WORKSPACE_INFINITE_MODE = true;
+const RIGHT_RACK_FIXED_PANEL_ID = "people";
/**
* @typedef {{
@@ -425,6 +446,7 @@ let rackLayoutState = {
presetId: "onboardingDefault",
docked: { bottom: [] },
racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
+ panelSizes: {},
};
let rackLayoutEnabled = false;
let rightRackEl = null;
@@ -432,6 +454,14 @@ let mainRack = null;
let mainSideRack = null;
const WORKSPACE_ACTIVE_PRIMARY_KEY = "bzl_workspace_activePrimary";
+function workspaceInfiniteMode() {
+ return Boolean(rackLayoutEnabled && WORKSPACE_INFINITE_MODE);
+}
+
+function isRightRackFixedPanel(panelId) {
+ return String(panelId || "").trim() === RIGHT_RACK_FIXED_PANEL_ID;
+}
+
function readBoolPref(key, fallback = false) {
try {
const raw = localStorage.getItem(key);
@@ -710,6 +740,7 @@ function setSideCollapsed(collapsed, opts) {
if (persist) writeBoolPref(RACK_SIDE_COLLAPSED_KEY, Boolean(collapsed));
if (updateControls && toggleSideRackEl) toggleSideRackEl.checked = !Boolean(collapsed);
updateSideRackEmptyState();
+ updateWorkspaceMinView();
}
function setRightCollapsed(collapsed, opts) {
@@ -720,6 +751,7 @@ function setRightCollapsed(collapsed, opts) {
appRoot.classList.toggle("rightCollapsed", Boolean(collapsed));
if (persist) writeBoolPref(RACK_RIGHT_COLLAPSED_KEY, Boolean(collapsed));
if (updateControls && toggleRightRackEl) toggleRightRackEl.checked = !Boolean(collapsed);
+ updateWorkspaceMinView();
}
function updateSideRackEmptyState() {
@@ -746,6 +778,173 @@ function registerCorePanel(def) {
panelRegistry.set(id, { id, title, icon, source: "core", role, defaultRack, element });
}
+function normalizeWorkspacePanelSize(size) {
+ const raw = String(size || "").trim().toLowerCase();
+ if (raw === "skinny" || raw === "full") return raw;
+ return "half";
+}
+
+function panelAllowsSkinnyWorkspaceSize(panelId) {
+ const id = String(panelId || "").trim();
+ if (!id) return false;
+ if (id === "moderation") return false;
+ return true;
+}
+
+function panelWorkspaceSize(panelId) {
+ const id = String(panelId || "").trim();
+ if (!id) return "half";
+ const sizes = rackLayoutState?.panelSizes && typeof rackLayoutState.panelSizes === "object" ? rackLayoutState.panelSizes : {};
+ const normalized = normalizeWorkspacePanelSize(sizes[id] || "half");
+ if (normalized === "skinny" && !panelAllowsSkinnyWorkspaceSize(id)) return "half";
+ return normalized;
+}
+
+function workspaceHalfPanelWidthPx() {
+ return Math.min(680, Math.max(340, Math.round(window.innerWidth * 0.74)));
+}
+
+function workspaceSkinnyPanelWidthPx() {
+ return Math.min(360, Math.max(220, Math.round(window.innerWidth * 0.42)));
+}
+
+function workspaceSidePanelsEnabled() {
+ if (!appRoot) return false;
+ return !(appRoot.classList.contains("sideCollapsed") && appRoot.classList.contains("rightCollapsed"));
+}
+
+function ensureWorkspaceMinSpacer() {
+ const workspace = ensureWorkspaceStripRack();
+ if (!(workspace instanceof HTMLElement)) return null;
+ let spacer = workspace.querySelector?.(":scope > .workspaceMinSpacer");
+ if (!(spacer instanceof HTMLElement)) {
+ spacer = document.createElement("div");
+ spacer.className = "workspaceMinSpacer";
+ workspace.appendChild(spacer);
+ }
+ if (workspace.lastElementChild !== spacer) workspace.appendChild(spacer);
+ return spacer;
+}
+
+function panelWorkspaceWidthPx(panelId) {
+ const size = panelWorkspaceSize(panelId);
+ const half = workspaceHalfPanelWidthPx();
+ if (size === "full") return half * 2;
+ if (size === "skinny") return workspaceSkinnyPanelWidthPx();
+ return half;
+}
+
+function updateWorkspaceMinView() {
+ if (!workspaceInfiniteMode()) return;
+ const workspace = ensureWorkspaceStripRack();
+ if (!(workspace instanceof HTMLElement)) return;
+ const spacer = ensureWorkspaceMinSpacer();
+ if (!(spacer instanceof HTMLElement)) return;
+ const panelIds = Array.from(workspace.querySelectorAll(":scope > .rackPanel:not(.hidden)"))
+ .map((el) => String(el?.dataset?.panelId || "").trim())
+ .filter(Boolean);
+ const currentWidth = panelIds.reduce((sum, id) => sum + panelWorkspaceWidthPx(id), 0);
+ const half = workspaceHalfPanelWidthPx();
+ const minRequired = workspaceSidePanelsEnabled() ? half * 2 + workspaceSkinnyPanelWidthPx() : half * 2;
+ const deficit = Math.max(0, Math.round(minRequired - currentWidth));
+ spacer.style.width = `${deficit}px`;
+}
+
+function installWorkspaceScrollSnapStep() {
+ const workspace = ensureWorkspaceStripRack();
+ if (!(workspace instanceof HTMLElement)) return;
+ if (workspace.dataset.snapStepInstalled === "1") return;
+ workspace.dataset.snapStepInstalled = "1";
+ let snapTimer = null;
+ workspace.addEventListener("scroll", () => {
+ if (!workspaceInfiniteMode()) return;
+ if (appRoot?.classList.contains("rackIsDragging")) return;
+ if (snapTimer) clearTimeout(snapTimer);
+ snapTimer = setTimeout(() => {
+ const step = workspaceHalfPanelWidthPx();
+ if (!step) return;
+ const current = workspace.scrollLeft;
+ const maxScroll = Math.max(0, workspace.scrollWidth - workspace.clientWidth);
+ let target = Math.round(current / step) * step;
+ target = Math.max(0, Math.min(maxScroll, target));
+ const nearRightEdge = maxScroll > 0 && current >= maxScroll - Math.max(28, Math.round(step * 0.45));
+ if (nearRightEdge) target = maxScroll;
+ if (Math.abs(target - current) < 4) return;
+ workspace.scrollTo({ left: target, behavior: "smooth" });
+ }, 90);
+ });
+}
+
+function refreshPanelSizeButtons(panelId) {
+ const id = String(panelId || "").trim();
+ if (!id) return;
+ const size = panelWorkspaceSize(id);
+ const allowSkinny = panelAllowsSkinnyWorkspaceSize(id);
+ const root = getPanelElement(id);
+ if (!(root instanceof HTMLElement)) return;
+ for (const btn of root.querySelectorAll(`[data-panelsize][data-panelid="${cssEscape(id)}"]`)) {
+ const btnSize = normalizeWorkspacePanelSize(btn.getAttribute("data-panelsize") || "");
+ if (btnSize === "skinny") btn.disabled = !allowSkinny;
+ btn.classList.toggle("isActive", btnSize === size);
+ }
+}
+
+function applyPanelWorkspaceSize(panelEl) {
+ const el = panelEl instanceof HTMLElement ? panelEl : null;
+ if (!el) return;
+ const panelId = String(el.dataset.panelId || "").trim();
+ const inWorkspace = el.parentElement && String(el.parentElement.id || "") === "mainWorkspaceRack";
+ el.classList.toggle("workspaceSizeSkinny", Boolean(inWorkspace && panelWorkspaceSize(panelId) === "skinny"));
+ el.classList.toggle("workspaceSizeHalf", Boolean(inWorkspace && panelWorkspaceSize(panelId) === "half"));
+ el.classList.toggle("workspaceSizeFull", Boolean(inWorkspace && panelWorkspaceSize(panelId) === "full"));
+ refreshPanelSizeButtons(panelId);
+}
+
+function applyAllWorkspacePanelSizes() {
+ const workspace = ensureWorkspaceStripRack();
+ if (!(workspace instanceof HTMLElement)) return;
+ for (const panel of workspace.querySelectorAll(":scope > .rackPanel")) applyPanelWorkspaceSize(panel);
+ updateWorkspaceMinView();
+}
+
+function setPanelWorkspaceSize(panelId, size) {
+ const id = String(panelId || "").trim();
+ if (!id) return;
+ let next = normalizeWorkspacePanelSize(size);
+ if (next === "skinny" && !panelAllowsSkinnyWorkspaceSize(id)) next = "half";
+ if (!rackLayoutState.panelSizes || typeof rackLayoutState.panelSizes !== "object") rackLayoutState.panelSizes = {};
+ rackLayoutState.panelSizes[id] = next;
+ saveRackLayoutState();
+ const panelEl = getPanelElement(id);
+ if (panelEl) applyPanelWorkspaceSize(panelEl);
+}
+
+function panelCanUseWorkspaceSizes(panelId) {
+ const id = String(panelId || "").trim();
+ if (!id) return false;
+ if (id.startsWith("chat:")) return true;
+ return true;
+}
+
+function installPanelSizeButtons(headerEl, panelId) {
+ if (!(headerEl instanceof HTMLElement)) return;
+ const id = String(panelId || "").trim();
+ if (!id) return;
+ if (!panelCanUseWorkspaceSizes(id)) return;
+ const row = headerEl.querySelector(".row") || headerEl;
+ if (row.querySelector(`[data-panelsizerow="${cssEscape(id)}"]`)) return;
+ const wrap = document.createElement("div");
+ wrap.className = "panelSizeControls";
+ wrap.setAttribute("data-panelsizerow", id);
+ wrap.innerHTML = `
+ <button type="button" class="ghost smallBtn panelSizeBtn" data-panelsize="skinny" data-panelid="${escapeHtml(id)}" title="Skinny width">S</button>
+ <button type="button" class="ghost smallBtn panelSizeBtn" data-panelsize="half" data-panelid="${escapeHtml(id)}" title="Half width">H</button>
+ <button type="button" class="ghost smallBtn panelSizeBtn" data-panelsize="full" data-panelid="${escapeHtml(id)}" title="Full width">F</button>
+ `;
+ row.appendChild(wrap);
+ refreshPanelSizeButtons(id);
+}
+
function togglePanelSkinny(panelId) {
if (!rackLayoutEnabled) return;
const id = String(panelId || "").trim();
@@ -792,7 +991,7 @@ function togglePanelSkinny(panelId) {
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: "onboarding", title: "Onboarding", icon: "🧭", role: "primary", defaultRack: "main", element: onboardingPanelEl });
-registerCorePanel({ id: "people", title: "People", icon: "👥", role: "aux", defaultRack: "right", element: peopleDrawerEl });
+registerCorePanel({ id: "people", title: "Members list", 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 });
@@ -973,158 +1172,158 @@ 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.
+ // In workspace-infinite mode, these arrays are merged into one ordered strip.
onboardingDefault: {
presetId: "onboardingDefault",
label: "Onboarding (Default)",
group: "user",
- workspaceLeftOrder: ["onboarding"],
- workspaceRightOrder: ["hives"],
- sideOrder: ["chat", "profile", "composer"],
+ workspaceLeftOrder: ["onboarding", "hives"],
+ workspaceRightOrder: ["chat", "people"],
+ sideOrder: ["composer", "profile"],
sideCollapsed: false,
- rightOrder: ["people"],
+ rightOrder: ["pluginRack"],
dockBottom: ["pluginRack", "maps", "library-browser", "library-shelf", "library-reader"],
},
social: {
presetId: "social",
label: "Default (Social)",
group: "user",
- workspaceLeftOrder: ["hives"],
- workspaceRightOrder: ["chat"],
- sideOrder: ["profile", "composer"],
+ workspaceLeftOrder: ["hives", "chat"],
+ workspaceRightOrder: ["people", "profile"],
+ sideOrder: ["composer", "pluginRack"],
sideCollapsed: true,
- rightOrder: ["people"],
- dockBottom: ["pluginRack", "maps", "library-browser", "library-shelf", "library-reader"],
+ rightOrder: ["onboarding"],
+ dockBottom: ["maps", "library-browser", "library-shelf", "library-reader"],
},
chatFocus: {
presetId: "chatFocus",
label: "Chat Focus",
group: "user",
- workspaceLeftOrder: ["chat"],
- workspaceRightOrder: [],
+ workspaceLeftOrder: ["chat", "hives"],
+ workspaceRightOrder: ["people", "profile"],
expandedPrimary: "chat",
- sideOrder: ["profile"],
+ sideOrder: ["composer"],
sideCollapsed: true,
- rightOrder: ["people"],
- dockBottom: ["pluginRack", "hives", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
+ rightOrder: ["pluginRack"],
+ dockBottom: ["onboarding", "maps", "library-browser", "library-shelf", "library-reader"],
},
browse: {
presetId: "browse",
label: "Browse",
group: "user",
- workspaceLeftOrder: ["hives"],
- workspaceRightOrder: [],
+ workspaceLeftOrder: ["hives", "chat"],
+ workspaceRightOrder: ["profile", "people"],
expandedPrimary: "hives",
- sideOrder: ["chat"],
+ sideOrder: ["composer", "pluginRack"],
sideCollapsed: true,
- rightOrder: ["profile"],
- dockBottom: ["pluginRack", "people", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
+ rightOrder: ["onboarding"],
+ dockBottom: ["maps", "library-browser", "library-shelf", "library-reader"],
},
creator: {
presetId: "creator",
label: "Creator",
group: "user",
- workspaceLeftOrder: ["hives"],
- workspaceRightOrder: ["composer"],
+ workspaceLeftOrder: ["hives", "composer"],
+ workspaceRightOrder: ["chat", "people"],
composerOpen: true,
- sideOrder: ["people"],
+ sideOrder: ["profile", "pluginRack"],
sideCollapsed: true,
- rightOrder: ["profile"],
- dockBottom: ["pluginRack", "chat", "maps", "library-browser", "library-shelf", "library-reader"],
+ rightOrder: ["onboarding"],
+ dockBottom: ["maps", "library-browser", "library-shelf", "library-reader"],
},
mapsSession: {
presetId: "mapsSession",
label: "Maps Session",
group: "user",
- workspaceLeftOrder: ["maps"], // if installed
- workspaceRightOrder: ["chat"],
- sideOrder: ["hives"],
+ workspaceLeftOrder: ["maps", "chat"], // if installed
+ workspaceRightOrder: ["hives", "people"],
+ sideOrder: ["profile", "composer"],
sideCollapsed: true,
- rightOrder: ["people"],
- dockBottom: ["pluginRack", "profile", "composer", "library-browser", "library-shelf", "library-reader"],
+ rightOrder: ["pluginRack"],
+ dockBottom: ["onboarding", "library-browser", "library-shelf", "library-reader"],
},
quiet: {
presetId: "quiet",
label: "Quiet (No People)",
group: "user",
- workspaceLeftOrder: ["hives"],
- workspaceRightOrder: ["profile"],
- sideOrder: ["composer"],
+ workspaceLeftOrder: ["hives", "profile"],
+ workspaceRightOrder: ["composer", "library-reader"],
+ sideOrder: [],
sideCollapsed: true,
rightOrder: [],
rightCollapsed: true,
- dockBottom: ["pluginRack", "chat", "people", "maps", "library-browser", "library-shelf", "library-reader"],
+ dockBottom: ["pluginRack", "chat", "people", "maps", "library-browser", "library-shelf", "onboarding"],
},
readingNook: {
presetId: "readingNook",
label: "Reading Nook",
group: "user",
- workspaceLeftOrder: ["library-reader"],
- workspaceRightOrder: ["library-shelf"],
- sideOrder: ["profile"],
+ workspaceLeftOrder: ["library-reader", "library-shelf"],
+ workspaceRightOrder: ["library-browser", "chat"],
+ sideOrder: ["profile", "people"],
sideCollapsed: true,
- rightOrder: ["people"],
- dockBottom: ["pluginRack", "hives", "chat", "composer", "maps", "library-browser"],
+ rightOrder: ["pluginRack"],
+ dockBottom: ["hives", "composer", "maps", "onboarding"],
},
libraryCurator: {
presetId: "libraryCurator",
label: "Library Curator",
group: "user",
- workspaceLeftOrder: ["library-browser"],
- workspaceRightOrder: ["library-shelf"],
- sideOrder: ["profile"],
+ workspaceLeftOrder: ["library-browser", "library-shelf"],
+ workspaceRightOrder: ["library-reader", "hives"],
+ sideOrder: ["chat", "profile"],
sideCollapsed: true,
- rightOrder: ["people"],
- dockBottom: ["pluginRack", "hives", "chat", "composer", "maps", "library-reader"],
+ rightOrder: ["pluginRack"],
+ dockBottom: ["people", "composer", "maps", "onboarding"],
},
ops: {
presetId: "ops",
label: "Ops",
group: "mod",
modOnly: true,
- workspaceLeftOrder: ["moderation"],
- workspaceRightOrder: ["chat"],
- sideOrder: ["hives"],
+ workspaceLeftOrder: ["moderation", "chat"],
+ workspaceRightOrder: ["hives", "people"],
+ sideOrder: ["profile", "composer"],
sideCollapsed: true,
- rightOrder: ["people"],
- dockBottom: ["pluginRack", "profile", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
+ rightOrder: ["pluginRack"],
+ dockBottom: ["onboarding", "maps", "library-browser", "library-shelf", "library-reader"],
},
reportsFocus: {
presetId: "reportsFocus",
label: "Reports Focus",
group: "mod",
modOnly: true,
- workspaceLeftOrder: ["moderation"],
- workspaceRightOrder: [],
+ workspaceLeftOrder: ["moderation", "chat"],
+ workspaceRightOrder: ["hives", "people"],
expandedPrimary: "moderation",
- sideOrder: ["people"],
+ sideOrder: ["profile"],
sideCollapsed: true,
- rightOrder: ["chat"],
- dockBottom: ["pluginRack", "hives", "profile", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
+ rightOrder: ["pluginRack"],
+ dockBottom: ["onboarding", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
},
communityWatch: {
presetId: "communityWatch",
label: "Community Watch",
group: "mod",
modOnly: true,
- workspaceLeftOrder: ["hives"],
- workspaceRightOrder: ["moderation"],
- sideOrder: ["chat"],
+ workspaceLeftOrder: ["hives", "moderation"],
+ workspaceRightOrder: ["chat", "people"],
+ sideOrder: ["profile", "composer"],
sideCollapsed: true,
- rightOrder: ["people"],
- dockBottom: ["pluginRack", "profile", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
+ rightOrder: ["pluginRack"],
+ dockBottom: ["onboarding", "maps", "library-browser", "library-shelf", "library-reader"],
},
serverAdmin: {
presetId: "serverAdmin",
label: "Server Admin",
group: "mod",
modOnly: true,
- workspaceLeftOrder: ["moderation"],
- workspaceRightOrder: ["hives"],
- sideOrder: ["chat"],
+ workspaceLeftOrder: ["moderation", "hives"],
+ workspaceRightOrder: ["chat", "people"],
+ sideOrder: ["composer", "profile"],
sideCollapsed: true,
- rightOrder: ["people"],
- dockBottom: ["pluginRack", "profile", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
+ rightOrder: ["pluginRack"],
+ dockBottom: ["onboarding", "maps", "library-browser", "library-shelf", "library-reader"],
},
};
@@ -1220,6 +1419,7 @@ function loadRackLayoutState() {
racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
pluginRackWidgets: [],
lastRackByPanelId: {},
+ panelSizes: {},
};
const parsed = JSON.parse(raw);
if (!parsed || parsed.version !== 2)
@@ -1230,6 +1430,7 @@ function loadRackLayoutState() {
racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
pluginRackWidgets: [],
lastRackByPanelId: {},
+ panelSizes: {},
};
const bottom = Array.isArray(parsed?.docked?.bottom) ? parsed.docked.bottom.map((x) => String(x || "")).filter(Boolean) : [];
const pluginRackWidgets = Array.isArray(parsed?.pluginRackWidgets)
@@ -1248,7 +1449,16 @@ function loadRackLayoutState() {
if (!id || !rackId) continue;
lastRackByPanelId[id] = rackId;
}
- return { version: 2, presetId, docked: { bottom }, racks: { workspaceLeft, workspaceRight, side, right }, pluginRackWidgets, lastRackByPanelId };
+ const panelSizesRaw = parsed?.panelSizes && typeof parsed.panelSizes === "object" ? parsed.panelSizes : {};
+ const panelSizes = {};
+ for (const [k, v] of Object.entries(panelSizesRaw)) {
+ const id = String(k || "").trim();
+ const size = String(v || "").trim().toLowerCase();
+ if (!id) continue;
+ if (size !== "skinny" && size !== "half" && size !== "full") continue;
+ panelSizes[id] = size;
+ }
+ return { version: 2, presetId, docked: { bottom }, racks: { workspaceLeft, workspaceRight, side, right }, pluginRackWidgets, lastRackByPanelId, panelSizes };
} catch {
return {
version: 2,
@@ -1257,6 +1467,7 @@ function loadRackLayoutState() {
racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
pluginRackWidgets: [],
lastRackByPanelId: {},
+ panelSizes: {},
};
}
}
@@ -1272,6 +1483,21 @@ function saveRackLayoutState() {
function ensureWorkspaceSlots() {
const workspace = mainWorkspaceRackEl || document.getElementById("mainWorkspaceRack");
if (!workspace) return { left: null, right: null };
+ if (workspaceInfiniteMode()) {
+ const leftSlot = workspace.querySelector?.("#workspaceLeftSlot");
+ const rightSlot = workspace.querySelector?.("#workspaceRightSlot");
+ for (const slot of [leftSlot, rightSlot]) {
+ if (!(slot instanceof HTMLElement)) continue;
+ const kids = Array.from(slot.querySelectorAll(":scope > .rackPanel"));
+ for (const kid of kids) workspace.appendChild(kid);
+ try {
+ slot.remove();
+ } catch {
+ // ignore
+ }
+ }
+ return { left: workspace, right: workspace };
+ }
let left = workspace.querySelector?.("#workspaceLeftSlot");
let right = workspace.querySelector?.("#workspaceRightSlot");
@@ -1295,6 +1521,12 @@ function ensureWorkspaceSlots() {
return { left, right };
}
+function ensureWorkspaceStripRack() {
+ if (!workspaceInfiniteMode()) return null;
+ const workspace = ensureMainRack();
+ return workspace instanceof HTMLElement ? workspace : null;
+}
+
function panelTitle(panelId) {
const entry = panelRegistry.get(panelId);
if (entry?.title) return entry.title;
@@ -1370,7 +1602,8 @@ function rackIdForPanelElement(panelEl) {
if (!el) return "";
const parent = el.parentElement;
const id = parent && typeof parent.id === "string" ? parent.id : "";
- if (id === "workspaceLeftSlot" || id === "workspaceRightSlot" || id === "mainSideRack" || id === "rightRack") return id;
+ if (id === "mainWorkspaceRack" || id === "workspaceLeftSlot" || id === "workspaceRightSlot" || id === "mainSideRack" || id === "rightRack")
+ return id;
return "";
}
@@ -1399,6 +1632,7 @@ function rememberPanelLastRack(panelId, rackId) {
function dockPanel(panelId) {
const id = String(panelId || "").trim();
if (!id) return;
+ if (rackLayoutEnabled && isRightRackFixedPanel(id)) return;
// Docking a hosted widget should implicitly un-host it.
removePanelFromPluginRack(id);
const el = getPanelElement(id);
@@ -1417,13 +1651,46 @@ function undockPanel(panelId) {
applyDockState();
}
-function restorePanelFromHotbar(panelId) {
+function restorePanelFromHotbar(panelId, opts) {
const id = String(panelId || "").trim();
if (!id) return;
if (!rackLayoutEnabled) return;
+ const options = opts && typeof opts === "object" ? opts : {};
+ const userAdded = options.userAdded === true;
const panelEl = getPanelElement(id);
if (!panelEl) return;
+ if (workspaceInfiniteMode() && isRightRackFixedPanel(id)) {
+ const rightRack = ensureRightRack();
+ if (!(rightRack instanceof HTMLElement)) return;
+ undockPanel(id);
+ rightRack.appendChild(panelEl);
+ rememberPanelLastRack(id, rightRack.id);
+ setRightCollapsed(false);
+ saveRackLayoutState();
+ syncRackStateFromDom();
+ enforceWorkspaceRules();
+ return;
+ }
+ if (workspaceInfiniteMode()) {
+ const workspace = ensureWorkspaceStripRack();
+ if (!(workspace instanceof HTMLElement)) return;
+ undockPanel(id);
+ const spacer = ensureWorkspaceMinSpacer();
+ if (spacer instanceof HTMLElement && spacer.parentElement === workspace) workspace.insertBefore(panelEl, spacer);
+ else workspace.appendChild(panelEl);
+ rememberPanelLastRack(id, workspace.id);
+ applyPanelWorkspaceSize(panelEl);
+ saveRackLayoutState();
+ syncRackStateFromDom();
+ enforceWorkspaceRules();
+ if (userAdded) {
+ requestAnimationFrame(() => {
+ focusWorkspaceArrival(panelEl);
+ });
+ }
+ return;
+ }
// Decide where to restore the panel.
const lastRackId =
@@ -1489,6 +1756,11 @@ function restorePanelFromHotbar(panelId) {
syncRackStateFromDom();
enforceWorkspaceRules();
+ if (userAdded) {
+ requestAnimationFrame(() => {
+ focusWorkspaceArrival(panelEl);
+ });
+ }
}
function showHotbar(show) {
@@ -1514,13 +1786,23 @@ function renderHotbar() {
const orbsHtml = items
.map(
(id) => `
- <button type="button" class="dockOrb" data-undock="${escapeHtml(id)}" title="Restore ${escapeHtml(panelTitle(id))}">
+ <button type="button" class="dockOrb" data-undock="${escapeHtml(id)}" title="Click to restore ${escapeHtml(panelTitle(id))}. Drag to place in a rack.">
<span class="dockOrbIcon" aria-hidden="true">${escapeHtml(panelIcon(id))}</span>
<span>${escapeHtml(panelTitle(id))}</span>
</button>
`
)
.join("");
+ const hintHtml = items.length
+ ? `
+ <div class="hotbarHint" aria-hidden="true">
+ <span class="hotbarHintGrip">≡</span>
+ <span>Drag to place</span>
+ <span class="hotbarHintSep">•</span>
+ <span>Click to open</span>
+ </div>
+ `
+ : "";
const plusHtml = includePlus
? `
@@ -1531,7 +1813,7 @@ function renderHotbar() {
`
: "";
- dockHotbarEl.innerHTML = `${orbsHtml}${plusHtml}`;
+ dockHotbarEl.innerHTML = `${hintHtml}${orbsHtml}${plusHtml}`;
dockHotbarEl.classList.remove("hidden");
requestAnimationFrame(() => showHotbar(true));
}
@@ -1563,6 +1845,7 @@ function workspaceAddCandidates() {
return Array.from(panelRegistry.keys())
.filter((id) => Boolean(getPanelElement(id)))
.filter((id) => !id.startsWith("chat:post:"))
+ .filter((id) => !isRightRackFixedPanel(id))
.filter((id) => id !== "profile")
.filter((id) => !(id === "moderation" && !canModerate))
.map((id) => ({
@@ -1574,10 +1857,16 @@ function workspaceAddCandidates() {
.sort((a, b) => a.title.localeCompare(b.title));
}
-function restorePanelToWorkspaceSlot(panelId, slotId) {
+function restorePanelToWorkspaceSlot(panelId, slotId, opts) {
const id = String(panelId || "").trim();
const slot = String(slotId || "").trim();
if (!id || !slot) return;
+ const options = opts && typeof opts === "object" ? opts : {};
+ const userAdded = options.userAdded === true;
+ if (workspaceInfiniteMode()) {
+ restorePanelFromHotbar(id, { userAdded });
+ return;
+ }
const target = slot === "workspaceRightSlot" ? ensureWorkspaceRightRack() : ensureWorkspaceLeftRack();
if (!(target instanceof HTMLElement)) return;
const panelEl = getPanelElement(id);
@@ -1594,6 +1883,11 @@ function restorePanelToWorkspaceSlot(panelId, slotId) {
applyDockState();
syncRackStateFromDom();
enforceWorkspaceRules();
+ if (userAdded) {
+ requestAnimationFrame(() => {
+ focusWorkspaceArrival(panelEl);
+ });
+ }
}
function openWorkspaceAddMenu(anchorEl, slotId) {
@@ -1620,7 +1914,7 @@ function openWorkspaceAddMenu(anchorEl, slotId) {
const id = String(btn.getAttribute("data-workspaceaddpanel") || "").trim();
const slotIdNext = String(btn.getAttribute("data-workspaceaddslot") || "").trim();
if (!id || !slotIdNext) return;
- restorePanelToWorkspaceSlot(id, slotIdNext);
+ restorePanelToWorkspaceSlot(id, slotIdNext, { userAdded: true });
closeWorkspaceAddMenu();
});
document.body.appendChild(menu);
@@ -1680,6 +1974,10 @@ function applyDockState() {
for (const [id, p] of panelRegistry.entries()) {
const el = p?.element;
if (!(el instanceof HTMLElement)) continue;
+ if (rackLayoutEnabled && isRightRackFixedPanel(id)) {
+ el.classList.remove("hidden");
+ continue;
+ }
if (id === "moderation" && !canModerate) {
el.classList.add("hidden");
continue;
@@ -1691,10 +1989,12 @@ function applyDockState() {
updateSideRackEmptyState();
updateSkinnyChatPanels();
renderWorkspaceSlotAffordances();
+ applyAllWorkspacePanelSizes();
}
function renderWorkspaceSlotAffordances() {
if (!rackLayoutEnabled) return;
+ if (workspaceInfiniteMode()) return;
const left = ensureWorkspaceLeftRack();
const right = ensureWorkspaceRightRack();
for (const slot of [left, right]) {
@@ -1726,6 +2026,36 @@ function readRackOrder(rackEl) {
function applyRackStateToDom() {
if (!rackLayoutEnabled) return;
+ if (workspaceInfiniteMode()) {
+ ensurePluginRackPanel();
+ const workspace = ensureWorkspaceStripRack();
+ const rightRack = ensureRightRack();
+ if (!(workspace instanceof HTMLElement)) return;
+ const combinedOrder = [
+ ...(Array.isArray(rackLayoutState?.racks?.workspaceLeft) ? rackLayoutState.racks.workspaceLeft : []),
+ ...(Array.isArray(rackLayoutState?.racks?.workspaceRight) ? rackLayoutState.racks.workspaceRight : []),
+ ...(Array.isArray(rackLayoutState?.racks?.side) ? rackLayoutState.racks.side : []),
+ ...(Array.isArray(rackLayoutState?.racks?.right) ? rackLayoutState.racks.right : []),
+ ];
+ for (const panelId of combinedOrder) {
+ if (isRightRackFixedPanel(panelId)) continue;
+ const el = getPanelElement(panelId);
+ if (!el) continue;
+ if (isDocked(panelId)) continue;
+ workspace.appendChild(el);
+ applyPanelWorkspaceSize(el);
+ }
+ const rightPanel = getPanelElement(RIGHT_RACK_FIXED_PANEL_ID);
+ if (
+ rightRack instanceof HTMLElement &&
+ rightPanel instanceof HTMLElement &&
+ !isDocked(RIGHT_RACK_FIXED_PANEL_ID) &&
+ rightPanel.parentElement !== rightRack
+ ) {
+ rightRack.appendChild(rightPanel);
+ }
+ return;
+ }
// Ensure core "virtual" panels exist before we try to place them.
ensurePluginRackPanel();
const left = ensureWorkspaceLeftRack();
@@ -1789,6 +2119,43 @@ function writeWorkspaceActivePrimary(panelId) {
function enforceWorkspaceRules() {
if (!rackLayoutEnabled) return;
+ if (workspaceInfiniteMode()) {
+ const workspace = ensureWorkspaceStripRack();
+ const rightRack = ensureRightRack();
+ if (!(workspace instanceof HTMLElement)) return;
+ if (!(rightRack instanceof HTMLElement)) return;
+ const candidatePanels = Array.from(
+ appRoot.querySelectorAll(
+ "#mainWorkspaceRack > .rackPanel, #workspaceLeftSlot > .rackPanel, #workspaceRightSlot > .rackPanel, #mainSideRack > .rackPanel, #rightRack > .rackPanel"
+ )
+ );
+ for (const panel of candidatePanels) {
+ if (!(panel instanceof HTMLElement)) continue;
+ const panelId = String(panel.dataset.panelId || "").trim();
+ if (!panelId) continue;
+ if (isRightRackFixedPanel(panelId)) {
+ if (rightRack instanceof HTMLElement && panel.parentElement !== rightRack) rightRack.appendChild(panel);
+ panel.classList.remove("hidden");
+ continue;
+ }
+ if (isDocked(panelId)) continue;
+ if (panel.parentElement !== workspace) workspace.appendChild(panel);
+ applyPanelWorkspaceSize(panel);
+ }
+ if (rackLayoutState?.docked?.bottom?.includes?.(RIGHT_RACK_FIXED_PANEL_ID)) {
+ rackLayoutState.docked.bottom = rackLayoutState.docked.bottom.filter((x) => x !== RIGHT_RACK_FIXED_PANEL_ID);
+ }
+ const peoplePanel = getPanelElement(RIGHT_RACK_FIXED_PANEL_ID);
+ if (rightRack instanceof HTMLElement && peoplePanel instanceof HTMLElement && peoplePanel.parentElement !== rightRack) {
+ rightRack.appendChild(peoplePanel);
+ }
+ if (appRoot) {
+ appRoot.classList.remove("workspaceExpandedLeft", "workspaceExpandedRight", "workspaceSingleLeft", "workspaceSingleRight");
+ }
+ syncRackStateFromDom();
+ updateWorkspaceMinView();
+ return;
+ }
const left = ensureWorkspaceLeftRack();
const rightWorkspace = ensureWorkspaceRightRack();
const side = ensureMainSideRack();
@@ -1931,6 +2298,22 @@ function installWorkspaceInteractions() {
function syncRackStateFromDom() {
if (!rackLayoutEnabled) return;
+ if (workspaceInfiniteMode()) {
+ const workspace = ensureWorkspaceStripRack();
+ const right = ensureRightRack();
+ if (!(workspace instanceof HTMLElement)) return;
+ const workspaceIds = readRackOrder(workspace).filter((id) => !isRightRackFixedPanel(id));
+ const rightIds = right instanceof HTMLElement ? readRackOrder(right).filter((id) => isRightRackFixedPanel(id)) : [];
+ rackLayoutState.racks = {
+ workspaceLeft: workspaceIds,
+ workspaceRight: [],
+ side: [],
+ right: rightIds,
+ };
+ rackLayoutState.pluginRackWidgets = readPluginRackWidgetsOrder();
+ saveRackLayoutState();
+ return;
+ }
const left = ensureWorkspaceLeftRack();
const rightWorkspace = ensureWorkspaceRightRack();
const side = ensureMainSideRack();
@@ -2010,11 +2393,13 @@ function ensureMainSideRack() {
}
function ensureWorkspaceLeftRack() {
+ if (workspaceInfiniteMode()) return ensureWorkspaceStripRack();
const { left } = ensureWorkspaceSlots();
return left instanceof HTMLElement ? left : null;
}
function ensureWorkspaceRightRack() {
+ if (workspaceInfiniteMode()) return ensureWorkspaceStripRack();
const { right } = ensureWorkspaceSlots();
return right instanceof HTMLElement ? right : null;
}
@@ -2022,12 +2407,14 @@ function ensureWorkspaceRightRack() {
function enableRackLayoutDom() {
if (!appRoot) return;
appRoot.classList.add("rackMode");
+ appRoot.classList.toggle("workspaceInfinite", workspaceInfiniteMode());
const rack = ensureRightRack();
if (!rack) return;
const main = ensureMainRack();
const left = ensureWorkspaceLeftRack();
const rightWorkspace = ensureWorkspaceRightRack();
const side = ensureMainSideRack();
+ const workspaceStrip = ensureWorkspaceStripRack();
const mark = (el, panelId) => {
if (!el) return;
@@ -2039,8 +2426,9 @@ function enableRackLayoutDom() {
// (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 (workspaceInfiniteMode()) {
+ if (workspaceStrip && chatPanelEl.parentElement !== workspaceStrip) workspaceStrip.appendChild(chatPanelEl);
+ } else if (rightWorkspace && chatPanelEl.parentElement !== rightWorkspace) rightWorkspace.appendChild(chatPanelEl);
}
if (peopleDrawerEl) {
mark(peopleDrawerEl, "people");
@@ -2048,29 +2436,39 @@ function enableRackLayoutDom() {
}
if (modPanelEl) {
mark(modPanelEl, "moderation");
- if (modPanelEl.parentElement !== rack) rack.appendChild(modPanelEl);
+ if (workspaceInfiniteMode()) {
+ if (workspaceStrip && modPanelEl.parentElement !== workspaceStrip) workspaceStrip.appendChild(modPanelEl);
+ } else 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 (onboardingPanelEl) {
mark(onboardingPanelEl, "onboarding");
- if (left && onboardingPanelEl.parentElement !== left) left.appendChild(onboardingPanelEl);
+ if (workspaceInfiniteMode()) {
+ if (workspaceStrip && onboardingPanelEl.parentElement !== workspaceStrip) workspaceStrip.appendChild(onboardingPanelEl);
+ } else if (left && onboardingPanelEl.parentElement !== left) left.appendChild(onboardingPanelEl);
onboardingPanelEl.classList.remove("hidden");
}
if (hivesPanelEl) {
mark(hivesPanelEl, "hives");
- if (left && hivesPanelEl.parentElement !== left) left.appendChild(hivesPanelEl);
+ if (workspaceInfiniteMode()) {
+ if (workspaceStrip && hivesPanelEl.parentElement !== workspaceStrip) workspaceStrip.appendChild(hivesPanelEl);
+ } else if (left && hivesPanelEl.parentElement !== left) left.appendChild(hivesPanelEl);
}
if (profileViewPanel) {
mark(profileViewPanel, "profile");
- if (side && profileViewPanel.parentElement !== side) side.appendChild(profileViewPanel);
+ if (workspaceInfiniteMode()) {
+ if (workspaceStrip && profileViewPanel.parentElement !== workspaceStrip) workspaceStrip.appendChild(profileViewPanel);
+ } else 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);
+ if (workspaceInfiniteMode()) {
+ if (workspaceStrip && pollinatePanel.parentElement !== workspaceStrip) workspaceStrip.appendChild(pollinatePanel);
+ } else if (side && pollinatePanel.parentElement !== side) side.appendChild(pollinatePanel);
}
}
@@ -2088,11 +2486,15 @@ function enableRackLayoutDom() {
// Profile panel no longer "replaces" the feed in rack mode, so the back button is confusing.
profileBackBtn?.classList.add("hidden");
+ applyAllWorkspacePanelSizes();
+ installWorkspaceScrollSnapStep();
+ updateWorkspaceMinView();
}
function disableRackLayoutDom() {
if (!appRoot) return;
appRoot.classList.remove("rackMode");
+ appRoot.classList.remove("workspaceInfinite");
// No attempt to move elements back (yet). Disable is meant for page reload use.
}
@@ -2105,10 +2507,8 @@ function applyPreset(presetId) {
return;
}
- // Presets are hard-applied: clear any hosted widgets so placement remains deterministic.
+ // Keep hosted widgets alive across preset switches so panel runtime state is preserved.
closePluginRackAddMenu();
- for (const id of readPluginRackWidgetsOrder()) removePanelFromPluginRack(id);
- rackLayoutState.pluginRackWidgets = [];
rackLayoutState.presetId = def.presetId || key;
@@ -2134,13 +2534,65 @@ function applyPreset(presetId) {
const rightRack = ensureRightRack();
if (!leftRack || !rightWorkspaceRack || !sideRack || !rightRack) return;
+ if (workspaceInfiniteMode()) {
+ const workspace = ensureWorkspaceStripRack();
+ if (!(workspace instanceof HTMLElement)) return;
+ const order = [...workspaceLeftOrder, ...workspaceRightOrder, ...sideOrder, ...rightOrder].filter((id) => !isRightRackFixedPanel(id));
+ const placed = new Set(order);
+ const presetDocked = new Set(
+ (Array.isArray(def.dockBottom) ? def.dockBottom.map((x) => String(x || "")).filter(Boolean) : []).filter((id) => !isRightRackFixedPanel(id))
+ );
+ const affectedByPreset = new Set([...placed, ...presetDocked]);
+ const docked = new Set(Array.isArray(rackLayoutState?.docked?.bottom) ? rackLayoutState.docked.bottom : []);
+ for (const id of affectedByPreset) docked.delete(id);
+ for (const id of presetDocked) docked.add(id);
+ for (const id of placed) docked.delete(id);
+ // Core presets should not unexpectedly surface plugin panels unless explicitly placed.
+ for (const [panelId] of panelRegistry.entries()) {
+ if (!panelIsPluginOwned(panelId)) continue;
+ if (placed.has(panelId) || presetDocked.has(panelId)) continue;
+ docked.add(panelId);
+ }
+ if (!canModerate) docked.add("moderation");
+ rackLayoutState.docked.bottom = Array.from(docked);
+ saveRackLayoutState();
+ applyDockState();
+ for (const panelId of order) {
+ if (docked.has(panelId)) continue;
+ const el = getPanelElement(panelId);
+ if (!el) continue;
+ workspace.appendChild(el);
+ applyPanelWorkspaceSize(el);
+ }
+ syncRackStateFromDom();
+ enforceWorkspaceRules();
+ updateLayoutPresetOptions();
+ requestAnimationFrame(() => {
+ try {
+ workspace.scrollTo({ left: 0, behavior: "auto" });
+ } catch {
+ // ignore
+ }
+ });
+ 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) : []);
+ const presetDocked = new Set(
+ (Array.isArray(def.dockBottom) ? def.dockBottom.map((x) => String(x || "")).filter(Boolean) : []).filter((id) => !isRightRackFixedPanel(id))
+ );
+ const affectedByPreset = new Set([...placed, ...presetDocked]);
+ // Apply preset intent only to panels explicitly named by the preset.
+ // Dynamic panels (for example chat instances) keep their current dock/placement state.
+ const docked = new Set(Array.isArray(rackLayoutState?.docked?.bottom) ? rackLayoutState.docked.bottom : []);
+ for (const id of affectedByPreset) docked.delete(id);
+ for (const id of presetDocked) docked.add(id);
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);
+ // Keep plugin-owned panels out of core layouts unless preset explicitly names them.
+ for (const [panelId] of panelRegistry.entries()) {
+ if (!panelIsPluginOwned(panelId)) continue;
+ if (placed.has(panelId) || presetDocked.has(panelId)) continue;
+ docked.add(panelId);
}
// Moderation panel should not be forced visible for non-mods.
@@ -2157,41 +2609,31 @@ function applyPreset(presetId) {
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);
+ const el = 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);
+ const el = 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);
+ const el = 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);
+ const el = getPanelElement(panelId);
if (el) rightRack.appendChild(el);
}
}
@@ -2216,30 +2658,7 @@ function installPanelMinimizeButtons() {
row.appendChild(drag);
}
- if (panelIsSkinnyCapable(panelId) && !headerEl.querySelector(`[data-skinny="${panelId}"]`)) {
- const skinny = document.createElement("button");
- skinny.type = "button";
- skinny.className = "ghost smallBtn";
- skinny.textContent = "↔";
- skinny.title = "Toggle skinny/full";
- skinny.setAttribute("data-skinny", panelId);
- skinny.onclick = () => togglePanelSkinny(panelId);
- row.appendChild(skinny);
- }
- if (!panelIsSkinnyCapable(panelId)) {
- headerEl.querySelector(`[data-skinny="${cssEscape(panelId)}"]`)?.remove();
- }
-
- 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);
- }
+ installPanelSizeButtons(headerEl, panelId);
if (!headerEl.querySelector(`[data-minimize="${panelId}"]`)) {
const btn = document.createElement("button");
@@ -2255,7 +2674,6 @@ function installPanelMinimizeButtons() {
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");
@@ -2302,25 +2720,12 @@ function ensurePluginPanelShell(panelId, title, icon, defaultRack, role) {
</div>
<div class="panelBody" data-pluginmount="1"></div>
`;
-
+ installPanelSizeButtons(shell.querySelector(".panelHeader"), panelId);
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);
+ applyPanelWorkspaceSize(shell);
return shell;
}
@@ -2346,8 +2751,11 @@ function ensureChatPostPanelInstance(postId, opts) {
</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-skinny="${escapeHtml(panelId)}" title="Toggle skinny/full">↔</button>
- <button type="button" class="ghost smallBtn" data-expand="${escapeHtml(panelId)}" title="Expand workspace">□</button>
+ <div class="panelSizeControls" data-panelsizerow="${escapeHtml(panelId)}">
+ <button type="button" class="ghost smallBtn panelSizeBtn" data-panelsize="skinny" data-panelid="${escapeHtml(panelId)}" title="Skinny width">S</button>
+ <button type="button" class="ghost smallBtn panelSizeBtn" data-panelsize="half" data-panelid="${escapeHtml(panelId)}" title="Half width">H</button>
+ <button type="button" class="ghost smallBtn panelSizeBtn" data-panelsize="full" data-panelid="${escapeHtml(panelId)}" title="Full width">F</button>
+ </div>
<button type="button" class="ghost smallBtn" data-minimize="${escapeHtml(panelId)}" title="Minimize to hotbar">-</button>
</div>
</div>
@@ -2390,8 +2798,7 @@ function ensureChatPostPanelInstance(postId, opts) {
const modToggleEl = shell.querySelector(".chatInstModToggleInput");
shell.querySelector(`[data-minimize="${cssEscape(panelId)}"]`)?.addEventListener("click", () => dockPanel(panelId));
- shell.querySelector(`[data-expand="${cssEscape(panelId)}"]`)?.addEventListener("click", () => togglePrimaryExpand(panelId));
- shell.querySelector(`[data-skinny="${cssEscape(panelId)}"]`)?.addEventListener("click", () => togglePanelSkinny(panelId));
+ refreshPanelSizeButtons(panelId);
if (formEl && editorEl) {
formEl.addEventListener("submit", (e) => {
@@ -2473,6 +2880,7 @@ function ensureChatPostPanelInstance(postId, opts) {
syncRackStateFromDom();
enforceWorkspaceRules();
}
+ applyPanelWorkspaceSize(shell);
renderChatPostPanelInstance(panelId, true);
return panelId;
@@ -2801,13 +3209,16 @@ function applyPluginPresetHint(panelDef) {
function enableRackDnD() {
if (!rackLayoutEnabled) return;
+ const pluginWidgets = ensurePluginRackWidgetsRack();
+ const workspaceRack = ensureWorkspaceStripRack();
const right = ensureRightRack();
const left = ensureWorkspaceLeftRack();
const rightWorkspace = ensureWorkspaceRightRack();
const side = ensureMainSideRack();
- if (!right || !left || !rightWorkspace || !side) return;
- const pluginWidgets = ensurePluginRackWidgetsRack();
- const racks = [left, rightWorkspace, side, right, pluginWidgets].filter((x) => x instanceof HTMLElement);
+ if (!workspaceInfiniteMode() && (!right || !left || !rightWorkspace || !side)) return;
+ const racks = workspaceInfiniteMode()
+ ? [workspaceRack, pluginWidgets].filter((x) => x instanceof HTMLElement)
+ : [left, rightWorkspace, side, right, pluginWidgets].filter((x) => x instanceof HTMLElement);
// Guard against double-install if initRackLayout is called more than once.
if (appRoot?.dataset?.rackDnd === "1") return;
@@ -2902,6 +3313,10 @@ function enableRackDnD() {
e.preventDefault();
const targetRack = placeholderEl?.parentElement || activeRack;
if (targetRack && placeholderEl && placeholderEl.parentElement === targetRack) {
+ if (workspaceInfiniteMode()) {
+ targetRack.insertBefore(draggingEl, placeholderEl);
+ if (targetRack.id === "pluginRackWidgetsRack") draggingEl.classList.add("pluginRackWidget");
+ } else {
const isWorkspaceSlot = targetRack.id === "workspaceLeftSlot" || targetRack.id === "workspaceRightSlot";
const isRightRackSlot = targetRack.id === "rightRack";
const isSideRackSlot = targetRack.id === "mainSideRack";
@@ -2946,11 +3361,15 @@ function enableRackDnD() {
targetRack.insertBefore(draggingEl, placeholderEl);
}
if (isPluginRackWidgets) draggingEl.classList.add("pluginRackWidget");
+ }
}
const shouldDock = Boolean(dockHotbarEl && e.clientY > window.innerHeight - 90);
const dockId = draggingPanelId;
+ const droppedEl = draggingEl;
+ const droppedRackId = String(targetRack?.id || "");
cleanup();
if (shouldDock && dockId) dockPanel(dockId);
+ if (!shouldDock && droppedEl instanceof HTMLElement) applyPanelWorkspaceSize(droppedEl);
syncRackStateFromDom();
enforceWorkspaceRules();
};
@@ -3118,6 +3537,18 @@ function initRackLayout() {
applyPreset("onboardingDefault");
}
installPanelMinimizeButtons();
+ if (appRoot && appRoot.dataset.panelSizeControls !== "1") {
+ appRoot.dataset.panelSizeControls = "1";
+ appRoot.addEventListener("click", (e) => {
+ const btn = e.target?.closest?.("[data-panelsize][data-panelid]");
+ if (!(btn instanceof HTMLElement)) return;
+ const panelId = String(btn.getAttribute("data-panelid") || "").trim();
+ const size = String(btn.getAttribute("data-panelsize") || "").trim().toLowerCase();
+ if (!panelId) return;
+ setPanelWorkspaceSize(panelId, size);
+ applyAllWorkspacePanelSizes();
+ });
+ }
enableRackDnD();
installWorkspaceInteractions();
enforceWorkspaceRules();
@@ -3127,9 +3558,15 @@ function initRackLayout() {
if (dockHotbarEl) {
dockHotbarEl.onmouseenter = null;
dockHotbarEl.onmouseleave = null;
- // Docked items must be restored via drag-and-drop (click does nothing), but the "+" orb is clickable.
+ // Click restores a panel to workspace; drag still supports precise placement.
dockHotbarEl.onclick = (e) => {
if (dockHotbarEl.dataset.dragging === "1") return;
+ const restoreBtn = e.target.closest?.("[data-undock]");
+ if (restoreBtn) {
+ const id = String(restoreBtn.getAttribute("data-undock") || "").trim();
+ if (id) restorePanelFromHotbar(id, { userAdded: true });
+ return;
+ }
const plus = e.target.closest?.("[data-hotbarplus]");
if (!plus) return;
if (hotbarPlusMenuEl) closeHotbarPlusMenu();
@@ -3184,6 +3621,11 @@ function initRackLayout() {
const resolveOrbDropRack = (panelId, rackEl) => {
const id = String(panelId || "").trim();
if (!id) return rackEl;
+ if (workspaceInfiniteMode()) {
+ if (isRightRackFixedPanel(id)) return ensureRightRack() || rackEl;
+ if (rackEl && rackEl.id === "pluginRackWidgetsRack" && panelIsHostableInPluginRack(id)) return rackEl;
+ return ensureWorkspaceStripRack() || rackEl;
+ }
if (rackEl && rackEl.id === "pluginRackWidgetsRack") {
if (panelIsHostableInPluginRack(id)) return rackEl;
const left = ensureWorkspaceLeftRack();
@@ -3226,6 +3668,11 @@ function initRackLayout() {
};
const orbRacks = () => {
+ if (workspaceInfiniteMode()) {
+ const workspaceRack = ensureWorkspaceStripRack();
+ const pluginWidgetsRack = ensurePluginRackWidgetsRack();
+ return [workspaceRack, pluginWidgetsRack].filter((x) => x instanceof HTMLElement);
+ }
const leftRack = ensureWorkspaceLeftRack();
const rightWorkspaceRack = ensureWorkspaceRightRack();
const sideRack = ensureMainSideRack();
@@ -3255,21 +3702,22 @@ function initRackLayout() {
if (rack.id === "rightRack") setRightCollapsed(false);
undockPanel(id);
-
- 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 (!workspaceInfiniteMode()) {
+ 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 (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);
+ }
}
}
@@ -3281,11 +3729,17 @@ function initRackLayout() {
if (insertBefore) rack.insertBefore(panelEl, insertBefore);
else rack.appendChild(panelEl);
}
+ applyPanelWorkspaceSize(panelEl);
if (rack.id === "pluginRackWidgetsRack") panelEl.classList.add("pluginRackWidget");
rememberPanelLastRack(id, rack.id);
saveRackLayoutState();
syncRackStateFromDom();
enforceWorkspaceRules();
+ if (isWorkspaceRackId(rack.id)) {
+ requestAnimationFrame(() => {
+ focusWorkspaceArrival(panelEl);
+ });
+ }
};
dockHotbarEl.addEventListener("pointerdown", (e) => {
@@ -3426,6 +3880,7 @@ function writeChatEnterModePref(mode) {
}
let instanceBranding = { title: "Bzl", subtitle: "Ephemeral hives + chat", allowMemberPermanentPosts: false, appearance: {} };
+let userAppearanceOverride = null;
let onboardingState = {
enabled: true,
rulesVersion: 1,
@@ -3461,8 +3916,8 @@ const THEME_PRESETS = [
fontMono: "mono",
mutedPct: 65,
linePct: 10,
- panel2Pct: 2
- }
+ panel2Pct: 2,
+ },
},
{
id: "midnight_cyan",
@@ -3474,13 +3929,13 @@ const THEME_PRESETS = [
accent: "#2bf5d6",
accent2: "#4aa0ff",
good: "#2bf5d6",
- bad: "#ff4d8a",
- fontBody: "system",
+ bad: "#ff5c8a",
+ fontBody: "clean",
fontMono: "mono",
mutedPct: 64,
linePct: 10,
- panel2Pct: 2
- }
+ panel2Pct: 2,
+ },
},
{
id: "warm_amber",
@@ -3490,33 +3945,15 @@ const THEME_PRESETS = [
panel: "#17100e",
text: "#fff2ea",
accent: "#ffb020",
- accent2: "#ff3ea5",
- good: "#3ddc97",
- bad: "#ff4d8a",
+ accent2: "#ff6b3d",
+ good: "#56dba3",
+ bad: "#ff5b6a",
fontBody: "serif",
fontMono: "mono",
mutedPct: 66,
linePct: 11,
- panel2Pct: 3
- }
- },
- {
- id: "slate_violet",
- name: "Slate Violet",
- appearance: {
- bg: "#080a10",
- panel: "#101522",
- text: "#eef0ff",
- accent: "#9b8cff",
- accent2: "#ff3ea5",
- good: "#3ddc97",
- bad: "#ff4d8a",
- fontBody: "system",
- fontMono: "mono",
- mutedPct: 62,
- linePct: 9,
- panel2Pct: 2
- }
+ panel2Pct: 3,
+ },
},
{
id: "terminal_green",
@@ -3533,8 +3970,8 @@ const THEME_PRESETS = [
fontMono: "mono",
mutedPct: 58,
linePct: 12,
- panel2Pct: 2
- }
+ panel2Pct: 2,
+ },
},
{
id: "high_contrast",
@@ -3547,33 +3984,214 @@ const THEME_PRESETS = [
accent2: "#00d3ff",
good: "#00ff85",
bad: "#ff2d55",
- fontBody: "system",
+ fontBody: "condensed",
fontMono: "mono",
mutedPct: 70,
linePct: 16,
- panel2Pct: 3
- }
+ panel2Pct: 3,
+ },
},
{
- id: "lavender_mist",
- name: "Lavender Mist",
+ id: "paper_ink",
+ name: "Paper Ink",
appearance: {
- bg: "#070611",
- panel: "#120c1b",
- text: "#f7f3ff",
- accent: "#c9a3ff",
- accent2: "#ff79c6",
- good: "#3ddc97",
- bad: "#ff4d8a",
- fontBody: "system",
+ bg: "#f2ecdf",
+ panel: "#e7decd",
+ text: "#2e251d",
+ accent: "#1d5eff",
+ accent2: "#b44f2b",
+ good: "#2f9f63",
+ bad: "#c84545",
+ fontBody: "slab",
fontMono: "mono",
- mutedPct: 68,
+ mutedPct: 46,
+ linePct: 14,
+ panel2Pct: 5,
+ },
+ },
+ {
+ id: "sunset_neon",
+ name: "Sunset Neon",
+ appearance: {
+ bg: "#13070f",
+ panel: "#1f0c18",
+ text: "#ffeaf7",
+ accent: "#ff7a00",
+ accent2: "#ff2ea8",
+ good: "#2be7b0",
+ bad: "#ff4f70",
+ fontBody: "rounded",
+ fontMono: "mono",
+ mutedPct: 62,
+ linePct: 11,
+ panel2Pct: 4,
+ },
+ },
+ {
+ id: "ocean_depth",
+ name: "Ocean Depth",
+ appearance: {
+ bg: "#031119",
+ panel: "#082130",
+ text: "#d7f2ff",
+ accent: "#28c7ff",
+ accent2: "#3d78ff",
+ good: "#28e6a1",
+ bad: "#ff5e84",
+ fontBody: "humanist",
+ fontMono: "clean",
+ mutedPct: 61,
linePct: 10,
- panel2Pct: 3
- }
- }
+ panel2Pct: 3,
+ },
+ },
+ {
+ id: "retro_arcade",
+ name: "Retro Arcade",
+ appearance: {
+ bg: "#09040f",
+ panel: "#13081c",
+ text: "#f7dcff",
+ accent: "#2dff89",
+ accent2: "#ffea00",
+ good: "#32ff9f",
+ bad: "#ff4f8b",
+ fontBody: "condensed",
+ fontMono: "mono",
+ mutedPct: 60,
+ linePct: 13,
+ panel2Pct: 3,
+ },
+ },
+ {
+ id: "forest_moss",
+ name: "Forest Moss",
+ appearance: {
+ bg: "#08120b",
+ panel: "#102016",
+ text: "#e8f7ea",
+ accent: "#80c96a",
+ accent2: "#2fae95",
+ good: "#5fd48b",
+ bad: "#e6626f",
+ fontBody: "serif",
+ fontMono: "humanist",
+ mutedPct: 60,
+ linePct: 9,
+ panel2Pct: 4,
+ },
+ },
+ {
+ id: "noir_redline",
+ name: "Noir Redline",
+ appearance: {
+ bg: "#0a090a",
+ panel: "#151114",
+ text: "#f8f4f6",
+ accent: "#ff3d52",
+ accent2: "#9aa0a6",
+ good: "#4dd79c",
+ bad: "#ff3d52",
+ fontBody: "clean",
+ fontMono: "mono",
+ mutedPct: 67,
+ linePct: 14,
+ panel2Pct: 3,
+ },
+ },
+ {
+ id: "mist_ui",
+ name: "Mist UI",
+ appearance: {
+ bg: "#f3f7fb",
+ panel: "#e6edf5",
+ text: "#1d2833",
+ accent: "#2e76ff",
+ accent2: "#34b3ff",
+ good: "#21a56d",
+ bad: "#cf4d66",
+ fontBody: "clean",
+ fontMono: "system",
+ mutedPct: 48,
+ linePct: 12,
+ panel2Pct: 6,
+ },
+ },
+ {
+ id: "calculator_lcd",
+ name: "Calculator LCD",
+ appearance: {
+ bg: "#0a110e",
+ panel: "#121c18",
+ text: "#d8ffe8",
+ accent: "#98ff9a",
+ accent2: "#62d2a2",
+ good: "#98ff9a",
+ bad: "#ff6b7a",
+ fontBody: "lcd",
+ fontMono: "lcd",
+ mutedPct: 55,
+ linePct: 12,
+ panel2Pct: 3,
+ },
+ },
+ {
+ id: "digital_radio",
+ name: "Digital Radio",
+ appearance: {
+ bg: "#041018",
+ panel: "#0a1c26",
+ text: "#dff8ff",
+ accent: "#38e8ff",
+ accent2: "#6bd0ff",
+ good: "#43ffd0",
+ bad: "#ff6d95",
+ fontBody: "lcd",
+ fontMono: "lcd",
+ mutedPct: 57,
+ linePct: 11,
+ panel2Pct: 3,
+ },
+ },
];
+const THEME_PRESET_GROUP_ORDER = ["Dark", "Light", "Retro", "High-Contrast", "Reading"];
+const THEME_PRESET_GROUP_BY_ID = {
+ bzl_original: "Dark",
+ midnight_cyan: "Dark",
+ ocean_depth: "Dark",
+ noir_redline: "Dark",
+ forest_moss: "Dark",
+ sunset_neon: "Dark",
+ mist_ui: "Light",
+ paper_ink: "Reading",
+ terminal_green: "Retro",
+ retro_arcade: "Retro",
+ calculator_lcd: "Retro",
+ digital_radio: "Retro",
+ warm_amber: "Reading",
+ high_contrast: "High-Contrast",
+};
+
+function groupedThemePresetOptionsHtml() {
+ const groups = new Map(THEME_PRESET_GROUP_ORDER.map((label) => [label, []]));
+ groups.set("Other", []);
+ for (const preset of THEME_PRESETS) {
+ const id = String(preset?.id || "").trim();
+ if (!id) continue;
+ const group = THEME_PRESET_GROUP_BY_ID[id] || "Other";
+ if (!groups.has(group)) groups.set(group, []);
+ groups.get(group).push(preset);
+ }
+ return [...groups.entries()]
+ .map(([groupLabel, presets]) => {
+ if (!Array.isArray(presets) || !presets.length) return "";
+ const opts = presets.map((p) => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)}</option>`).join("");
+ return `<optgroup label="${escapeHtml(groupLabel)}">${opts}</optgroup>`;
+ })
+ .join("");
+}
+
const SFX = {
open: "/assets/sfx/Select_B7.wav",
post: "/assets/sfx/Select_B7.wav",
@@ -3625,8 +4243,14 @@ function normalizeInstanceBranding(raw) {
const accent2 = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.accent2 || "")) ? String(appearanceRaw.accent2).toLowerCase() : "#b84bff";
const good = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.good || "")) ? String(appearanceRaw.good).toLowerCase() : "#3ddc97";
const bad = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.bad || "")) ? String(appearanceRaw.bad).toLowerCase() : "#ff4d8a";
- const fontBody = ["system", "serif", "mono"].includes(String(appearanceRaw.fontBody || "")) ? String(appearanceRaw.fontBody) : "system";
- const fontMono = ["mono", "system"].includes(String(appearanceRaw.fontMono || "")) ? String(appearanceRaw.fontMono) : "mono";
+ const fontBody = ["system", "serif", "mono", "humanist", "rounded", "condensed", "slab", "clean", "lcd"].includes(
+ String(appearanceRaw.fontBody || "")
+ )
+ ? String(appearanceRaw.fontBody)
+ : "system";
+ const fontMono = ["mono", "system", "humanist", "rounded", "clean", "lcd"].includes(String(appearanceRaw.fontMono || ""))
+ ? String(appearanceRaw.fontMono)
+ : "mono";
const clampPct = (n, fallback) => {
const v = Math.floor(Number(n));
if (!Number.isFinite(v)) return fallback;
@@ -3708,14 +4332,173 @@ function normalizeOnboardingState(raw) {
};
}
+function loadUserAppearanceOverride() {
+ try {
+ const raw = localStorage.getItem(USER_APPEARANCE_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw);
+ if (!parsed || typeof parsed !== "object") return null;
+ return normalizeInstanceBranding({ appearance: parsed }).appearance;
+ } catch {
+ return null;
+ }
+}
+
+function saveUserAppearanceOverride(appearance) {
+ try {
+ localStorage.setItem(USER_APPEARANCE_KEY, JSON.stringify(normalizeInstanceBranding({ appearance }).appearance));
+ } catch {
+ // ignore
+ }
+}
+
+function clearUserAppearanceOverride() {
+ try {
+ localStorage.removeItem(USER_APPEARANCE_KEY);
+ } catch {
+ // ignore
+ }
+}
+
+function effectiveAppearanceForUi() {
+ const base = normalizeInstanceBranding(instanceBranding).appearance || {};
+ if (!userAppearanceOverride || typeof userAppearanceOverride !== "object") return base;
+ return normalizeInstanceBranding({ appearance: { ...base, ...userAppearanceOverride } }).appearance;
+}
+
+function setAppearanceStatus(msg) {
+ if (!(appearanceStatusEl instanceof HTMLElement)) return;
+ appearanceStatusEl.textContent = String(msg || "");
+}
+
+function syncAppearanceControlsFromCurrent() {
+ const a = effectiveAppearanceForUi();
+ if (appearanceBgEl) appearanceBgEl.value = a.bg || "#060611";
+ if (appearancePanelEl) appearancePanelEl.value = a.panel || "#0c0c18";
+ if (appearanceTextEl) appearanceTextEl.value = a.text || "#f6f0ff";
+ if (appearanceAccentEl) appearanceAccentEl.value = a.accent || "#ff3ea5";
+ if (appearanceAccent2El) appearanceAccent2El.value = a.accent2 || "#b84bff";
+ if (appearanceGoodEl) appearanceGoodEl.value = a.good || "#3ddc97";
+ if (appearanceBadEl) appearanceBadEl.value = a.bad || "#ff4d8a";
+ if (appearanceMutedPctEl) appearanceMutedPctEl.value = String(Number(a.mutedPct ?? 65));
+ if (appearanceLinePctEl) appearanceLinePctEl.value = String(Number(a.linePct ?? 10));
+ if (appearancePanel2PctEl) appearancePanel2PctEl.value = String(Number(a.panel2Pct ?? 2));
+ if (appearanceFontBodyEl) appearanceFontBodyEl.value = a.fontBody || "system";
+ if (appearanceFontMonoEl) appearanceFontMonoEl.value = a.fontMono || "mono";
+}
+
+function readAppearanceFromControls() {
+ return normalizeInstanceBranding({
+ appearance: {
+ bg: String(appearanceBgEl?.value || "").trim(),
+ panel: String(appearancePanelEl?.value || "").trim(),
+ text: String(appearanceTextEl?.value || "").trim(),
+ accent: String(appearanceAccentEl?.value || "").trim(),
+ accent2: String(appearanceAccent2El?.value || "").trim(),
+ good: String(appearanceGoodEl?.value || "").trim(),
+ bad: String(appearanceBadEl?.value || "").trim(),
+ fontBody: String(appearanceFontBodyEl?.value || "system").trim(),
+ fontMono: String(appearanceFontMonoEl?.value || "mono").trim(),
+ mutedPct: String(appearanceMutedPctEl?.value || "").trim(),
+ linePct: String(appearanceLinePctEl?.value || "").trim(),
+ panel2Pct: String(appearancePanel2PctEl?.value || "").trim(),
+ },
+ }).appearance;
+}
+
+function initAppearanceControls() {
+ if (!(appearancePresetEl instanceof HTMLSelectElement)) return;
+ if (appearancePresetEl.dataset.ready === "1") return;
+ appearancePresetEl.dataset.ready = "1";
+ appearancePresetEl.innerHTML = `<option value="">(choose...)</option>${groupedThemePresetOptionsHtml()}`;
+ userAppearanceOverride = loadUserAppearanceOverride();
+ syncAppearanceControlsFromCurrent();
+ applyInstanceAppearance();
+
+ appearanceApplyPresetBtn?.addEventListener("click", () => {
+ const id = String(appearancePresetEl.value || "").trim();
+ const preset = THEME_PRESETS.find((p) => p.id === id) || null;
+ if (!preset) return;
+ const a = normalizeInstanceBranding({ appearance: preset.appearance || {} }).appearance;
+ userAppearanceOverride = a;
+ syncAppearanceControlsFromCurrent();
+ applyInstanceAppearance(a);
+ setAppearanceStatus(`Preset "${preset.name}" applied (preview).`);
+ });
+
+ appearanceResetPreviewBtn?.addEventListener("click", () => {
+ userAppearanceOverride = loadUserAppearanceOverride();
+ syncAppearanceControlsFromCurrent();
+ applyInstanceAppearance();
+ setAppearanceStatus("Reset to current saved look.");
+ });
+
+ appearanceSaveBtn?.addEventListener("click", () => {
+ const a = readAppearanceFromControls();
+ userAppearanceOverride = a;
+ saveUserAppearanceOverride(a);
+ applyInstanceAppearance();
+ setAppearanceStatus("Saved personal look.");
+ });
+
+ appearanceClearBtn?.addEventListener("click", () => {
+ userAppearanceOverride = null;
+ clearUserAppearanceOverride();
+ syncAppearanceControlsFromCurrent();
+ applyInstanceAppearance();
+ setAppearanceStatus("Using server default look.");
+ });
+
+ const previewInputs = [
+ appearanceBgEl,
+ appearancePanelEl,
+ appearanceTextEl,
+ appearanceAccentEl,
+ appearanceAccent2El,
+ appearanceGoodEl,
+ appearanceBadEl,
+ appearanceMutedPctEl,
+ appearanceLinePctEl,
+ appearancePanel2PctEl,
+ appearanceFontBodyEl,
+ appearanceFontMonoEl,
+ ];
+ for (const input of previewInputs) {
+ input?.addEventListener("input", () => {
+ const a = readAppearanceFromControls();
+ userAppearanceOverride = a;
+ applyInstanceAppearance(a);
+ setAppearanceStatus("Previewing changes. Click Save look to keep.");
+ });
+ input?.addEventListener("change", () => {
+ const a = readAppearanceFromControls();
+ userAppearanceOverride = a;
+ applyInstanceAppearance(a);
+ setAppearanceStatus("Previewing changes. Click Save look to keep.");
+ });
+ }
+}
+
function applyInstanceAppearance(appearanceOverride = null) {
- const b = normalizeInstanceBranding(appearanceOverride ? { ...instanceBranding, appearance: appearanceOverride } : instanceBranding);
+ const override =
+ appearanceOverride && typeof appearanceOverride === "object"
+ ? appearanceOverride
+ : userAppearanceOverride && typeof userAppearanceOverride === "object"
+ ? userAppearanceOverride
+ : null;
+ const b = normalizeInstanceBranding(override ? { ...instanceBranding, appearance: { ...(instanceBranding?.appearance || {}), ...override } } : instanceBranding);
const a = b.appearance || {};
const fontStacks = {
system:
'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"',
serif: 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif',
mono: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
+ humanist: '"Trebuchet MS", "Segoe UI", Tahoma, Verdana, sans-serif',
+ rounded: '"Avenir Next Rounded", "Arial Rounded MT Bold", "Nunito", "Quicksand", "Trebuchet MS", sans-serif',
+ condensed: '"Roboto Condensed", "Arial Narrow", "Liberation Sans Narrow", "Helvetica Neue Condensed", sans-serif',
+ slab: '"Rockwell", "Roboto Slab", "Bitter", "Courier New", serif',
+ clean: '"Inter", "Public Sans", "Noto Sans", "Segoe UI", sans-serif',
+ lcd: '"Orbitron", "Eurostile", "Bank Gothic", "OCR A Std", "Consolas", "Courier New", monospace',
};
const fontBodyStack = fontStacks[a.fontBody] || fontStacks.system;
const fontMonoStack = fontStacks[a.fontMono] || fontStacks.mono;
@@ -3731,6 +4514,7 @@ function applyInstanceAppearance(appearanceOverride = null) {
document.documentElement.style.setProperty("--muted-pct", String(Number(a.mutedPct ?? 65)));
document.documentElement.style.setProperty("--line-pct", String(Number(a.linePct ?? 10)));
document.documentElement.style.setProperty("--panel2-pct", String(Number(a.panel2Pct ?? 2)));
+ if (appearancePresetEl instanceof HTMLSelectElement) syncAppearanceControlsFromCurrent();
}
function renderInstanceBranding() {
@@ -4276,15 +5060,15 @@ function setPeopleOpen(open) {
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.
+ // In rack mode, Members list is anchored to the right rail.
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";
+ togglePeopleBtn.textContent = peopleOpen ? "Hide members" : "Members";
+ togglePeopleBtn.title = peopleOpen ? "Hide members list" : "Show members list";
}
}
if (peopleOpen && ws.readyState === WebSocket.OPEN) {
@@ -4926,7 +5710,7 @@ function availableMobileScreens() {
if (Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.enabled)) out.push({ id: "onboarding", title: "Onboarding", core: true });
out.push({ id: "hives", title: "Hives", core: true });
out.push({ id: "chat", title: "Chat", core: true });
- out.push({ id: "people", title: "People", core: true });
+ out.push({ id: "people", title: "Members list", core: true });
out.push({ id: "profile", title: "Profile", core: true });
if (canModerate) out.push({ id: "moderation", title: "Moderation", core: true });
@@ -6871,7 +7655,8 @@ function renderOnboardingPanel() {
</div>`;
if (onboardingPanelAcceptBtn instanceof HTMLButtonElement) {
- onboardingPanelAcceptBtn.classList.toggle("hidden", !onboardingRequiresAcceptance());
+ const showAccept = onboardingRequiresAcceptance() && onboardingViewerTab === "rules";
+ onboardingPanelAcceptBtn.classList.toggle("hidden", !showAccept);
onboardingPanelAcceptBtn.disabled = !loggedInUser || !needs;
onboardingPanelAcceptBtn.textContent = needs ? "Accept and continue" : "Accepted";
}
@@ -6921,7 +7706,8 @@ function renderOnboardingCard() {
}
`;
if (onboardingAcceptBtn instanceof HTMLButtonElement) {
- onboardingAcceptBtn.classList.toggle("hidden", !onboardingRequiresAcceptance());
+ const showAccept = onboardingRequiresAcceptance() && onboardingViewerTab === "rules";
+ onboardingAcceptBtn.classList.toggle("hidden", !showAccept);
onboardingAcceptBtn.disabled = !loggedInUser || !needs;
onboardingAcceptBtn.textContent = needs ? "Accept and continue" : "Accepted";
}
@@ -7015,6 +7801,37 @@ function shouldAutoShowGuidedTour(user = loggedInUser) {
return !pref.completed && !pref.dontShow;
}
+function guidedTourViewportAnchor(targetEl) {
+ const target = targetEl instanceof HTMLElement ? targetEl : null;
+ if (!(target instanceof HTMLElement)) return null;
+ const panel = target.closest?.(".rackPanel");
+ if (panel instanceof HTMLElement) {
+ const rackId = rackIdForPanelElement(panel);
+ if (isWorkspaceRackId(rackId)) return panel;
+ }
+ return target;
+}
+
+function focusGuidedTourTarget(targetEl) {
+ const target = targetEl instanceof HTMLElement ? targetEl : null;
+ if (!(target instanceof HTMLElement)) return;
+ const anchor = guidedTourViewportAnchor(target);
+ if (!(anchor instanceof HTMLElement)) return;
+ try {
+ const panel = anchor.closest?.(".rackPanel");
+ if (panel instanceof HTMLElement && anchor === panel && isWorkspaceRackId(rackIdForPanelElement(panel))) {
+ followWorkspacePanel(panel);
+ } else {
+ anchor.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
+ }
+ } catch {
+ // ignore
+ }
+ requestAnimationFrame(() => updateGuidedTourSpotlight());
+ setTimeout(() => updateGuidedTourSpotlight(), 140);
+ setTimeout(() => updateGuidedTourSpotlight(), 320);
+}
+
function revealAuthPanelForGuests() {
if (loggedInUser) return;
if (guestAuthPanelRevealed) return;
@@ -7379,12 +8196,7 @@ function renderGuidedTourStep() {
if (target instanceof HTMLElement) {
guidedTourTargetEl = target;
guidedTourTargetEl.classList.add("tourTargetPulse");
- try {
- guidedTourTargetEl.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
- } catch {
- // ignore
- }
- updateGuidedTourSpotlight();
+ focusGuidedTourTarget(guidedTourTargetEl);
}
if (guidedTourPrevBtn) guidedTourPrevBtn.disabled = guidedTourState.index <= 0;
@@ -7698,9 +8510,7 @@ function renderModPanel() {
if (modTab === "server") {
const isOwner = isOwnerRole(loggedInRole) || isAdminRole(loggedInRole);
- const canEditAppearance = isStaffRole(loggedInRole);
const b = normalizeInstanceBranding(instanceBranding);
- const a = b.appearance || {};
const loading = Boolean(serverInfoStatus.loading);
const err = String(serverInfoStatus.error || "");
const info = serverInfo && typeof serverInfo === "object" ? serverInfo : null;
@@ -7717,20 +8527,6 @@ function renderModPanel() {
? `<span class="muted">Updated: ${escapeHtml(updatedAt)}</span>`
: `<span class="muted">Not loaded yet.</span>`;
- const fontBodyOptions = [
- { value: "system", label: "System (sans)" },
- { value: "serif", label: "Serif" },
- { value: "mono", label: "Monospace" },
- ]
- .map((o) => `<option value="${o.value}" ${a.fontBody === o.value ? "selected" : ""}>${escapeHtml(o.label)}</option>`)
- .join("");
- const fontMonoOptions = [
- { value: "mono", label: "Monospace" },
- { value: "system", label: "System" },
- ]
- .map((o) => `<option value="${o.value}" ${a.fontMono === o.value ? "selected" : ""}>${escapeHtml(o.label)}</option>`)
- .join("");
-
const instanceOwnerControls = `<label>
<span>Title</span>
<input data-instance-title maxlength="32" value="${escapeHtml(b.title)}" />
@@ -7744,104 +8540,18 @@ function renderModPanel() {
<span>Allow members to create permanent hives</span>
</label>`;
- const themePresetRow = `
- <div class="row" style="gap:10px">
- <label style="flex:1">
- <span>Theme preset</span>
- <select data-theme-preset>
- <option value="">(choose...)</option>
- ${THEME_PRESETS.map((p) => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)}</option>`).join("")}
- </select>
- </label>
- <div class="row" style="align-items:flex-end">
- <button type="button" class="ghost" data-theme-reset="1">Reset</button>
- </div>
- </div>
- `;
-
- const appearanceControls = `
- ${themePresetRow}
- <div class="row" style="gap:10px">
- <label style="flex:1">
- <span>Background</span>
- <input data-instance-bg type="color" value="${escapeHtml(a.bg || "#060611")}" />
- </label>
- <label style="flex:1">
- <span>Panel</span>
- <input data-instance-panel type="color" value="${escapeHtml(a.panel || "#0c0c18")}" />
- </label>
- </div>
- <div class="row" style="gap:10px">
- <label style="flex:1">
- <span>Text</span>
- <input data-instance-text type="color" value="${escapeHtml(a.text || "#f6f0ff")}" />
- </label>
- <label style="flex:1">
- <span>Success / Danger</span>
- <div class="row" style="gap:10px">
- <input data-instance-good type="color" value="${escapeHtml(a.good || "#3ddc97")}" />
- <input data-instance-bad type="color" value="${escapeHtml(a.bad || "#ff4d8a")}" />
- </div>
- </label>
- </div>
- <div class="row" style="gap:10px">
- <label style="flex:1">
- <span>Accent</span>
- <input data-instance-accent type="color" value="${escapeHtml(a.accent || "#ff3ea5")}" />
- </label>
- <label style="flex:1">
- <span>Accent 2</span>
- <input data-instance-accent2 type="color" value="${escapeHtml(a.accent2 || "#b84bff")}" />
- </label>
- </div>
- <div class="row" style="gap:10px">
- <label style="flex:1">
- <span>Muted %</span>
- <input data-instance-mutedpct type="number" min="0" max="100" value="${escapeHtml(String(a.mutedPct ?? 65))}" />
- </label>
- <label style="flex:1">
- <span>Divider %</span>
- <input data-instance-linepct type="number" min="0" max="100" value="${escapeHtml(String(a.linePct ?? 10))}" />
- </label>
- <label style="flex:1">
- <span>Panel tint %</span>
- <input data-instance-panel2pct type="number" min="0" max="100" value="${escapeHtml(String(a.panel2Pct ?? 2))}" />
- </label>
- </div>
- <div class="row" style="gap:10px">
- <label style="flex:1">
- <span>Body font</span>
- <select data-instance-fontbody>${fontBodyOptions}</select>
- </label>
- <label style="flex:1">
- <span>Mono font</span>
- <select data-instance-fontmono>${fontMonoOptions}</select>
- </label>
- </div>
- `;
-
const instanceControls = isOwner
? `${instanceOwnerControls}
- ${appearanceControls}
+ <div class="small muted">Look & feel moved to View → Advanced display. Users can now set personal themes there.</div>
<div class="row" style="gap:8px">
<button type="button" class="primary" data-instance-save="1">Save</button>
<button type="button" class="ghost" data-server-refresh="1">Refresh server</button>
</div>`
- : canEditAppearance
- ? `<div class="small muted">Owner-only: title/subtitle and permanent-hive setting.</div>
- <div class="small">Title: <b>${escapeHtml(b.title)}</b></div>
- <div class="small">Subtitle: <b>${escapeHtml(b.subtitle)}</b></div>
- <div class="small">Members can create permanent hives: <b>${b.allowMemberPermanentPosts ? "yes" : "no"}</b></div>
- <div class="panelDivider"></div>
- ${appearanceControls}
- <div class="row" style="gap:8px">
- <button type="button" class="primary" data-instance-saveappearance="1">Save theme</button>
- <button type="button" class="ghost" data-server-refresh="1">Refresh server</button>
- </div>`
- : `<div class="small muted">Only moderators can edit appearance. Only the owner can edit core instance settings.</div>
+ : `<div class="small muted">Only the owner/admin can edit core instance settings.</div>
<div class="small">Title: <b>${escapeHtml(b.title)}</b></div>
<div class="small">Subtitle: <b>${escapeHtml(b.subtitle)}</b></div>
<div class="small">Members can create permanent hives: <b>${b.allowMemberPermanentPosts ? "yes" : "no"}</b></div>
+ <div class="small muted" style="margin-top:6px;">Look & feel moved to View → Advanced display.</div>
<div class="row" style="gap:8px; margin-top:8px">
<button type="button" class="ghost" data-server-refresh="1">Refresh server</button>
</div>`;
@@ -9331,6 +10041,18 @@ function isTextEntryFocused() {
return Boolean(el.isContentEditable);
}
+function isMapSurfaceActiveForHotkey() {
+ if (isMapChatActive()) return true;
+ const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ if (active?.closest?.('[data-panel-id="maps"]')) return true;
+ if (isMobileScreenMode() && appRoot) {
+ const mobile = String(appRoot.getAttribute("data-mobile-screen") || "").trim();
+ if (mobile === "maps") return true;
+ if (mobile === "host" && mobileHostPanelId === "maps") return true;
+ }
+ return false;
+}
+
function shouldSubmitChatOnEnter(evt) {
if (!evt || evt.key !== "Enter") return false;
const mode = readChatEnterModePref();
@@ -9355,6 +10077,115 @@ function cycleLayoutPresetBy(step) {
}
let hotkeyPanelContext = "";
+let hoveredWorkspacePanelId = "";
+let hoveredHotbarPanelId = "";
+
+function isWorkspaceRackId(rackId) {
+ const id = String(rackId || "").trim();
+ return id === "mainWorkspaceRack" || id === "workspaceLeftSlot" || id === "workspaceRightSlot";
+}
+
+function hoveredWorkspacePanelElement() {
+ const id = String(hoveredWorkspacePanelId || "").trim();
+ if (!id) return null;
+ const el = getPanelElement(id);
+ if (!(el instanceof HTMLElement)) return null;
+ if (el.classList.contains("hidden")) return null;
+ const parentId = String(el.parentElement?.id || "").trim();
+ if (!isWorkspaceRackId(parentId)) return null;
+ return el;
+}
+
+function followWorkspacePanel(panelEl) {
+ const panel = panelEl instanceof HTMLElement ? panelEl : null;
+ if (!(panel instanceof HTMLElement)) return;
+ const workspace = panel.closest?.("#mainWorkspaceRack");
+ if (!(workspace instanceof HTMLElement)) return;
+ const left = panel.offsetLeft;
+ const width = panel.offsetWidth || 0;
+ const target = Math.max(0, Math.round(left - (workspace.clientWidth - width) / 2));
+ const max = Math.max(0, workspace.scrollWidth - workspace.clientWidth);
+ workspace.scrollTo({ left: Math.min(max, target), behavior: "smooth" });
+}
+
+function glowWorkspacePanel(panelEl) {
+ const panel = panelEl instanceof HTMLElement ? panelEl : null;
+ if (!(panel instanceof HTMLElement)) return;
+ panel.classList.remove("workspaceArrivalGlow");
+ // Restart animation on repeated arrivals.
+ void panel.offsetWidth;
+ panel.classList.add("workspaceArrivalGlow");
+ const token = String(Date.now());
+ panel.dataset.workspaceArrivalToken = token;
+ setTimeout(() => {
+ if (panel.dataset.workspaceArrivalToken !== token) return;
+ panel.classList.remove("workspaceArrivalGlow");
+ }, 900);
+}
+
+function focusWorkspaceArrival(panelEl) {
+ const panel = panelEl instanceof HTMLElement ? panelEl : null;
+ if (!(panel instanceof HTMLElement)) return;
+ const rackId = rackIdForPanelElement(panel);
+ if (!isWorkspaceRackId(rackId)) return;
+ followWorkspacePanel(panel);
+ glowWorkspacePanel(panel);
+}
+
+function cycleHoveredWorkspacePanelSize() {
+ if (!rackLayoutEnabled) return false;
+ const panelEl = hoveredWorkspacePanelElement();
+ if (!(panelEl instanceof HTMLElement)) return false;
+ const panelId = String(panelEl.dataset.panelId || "").trim();
+ if (!panelId || isRightRackFixedPanel(panelId)) return false;
+ const order = ["skinny", "half", "full"];
+ const current = panelWorkspaceSize(panelId);
+ const idx = order.indexOf(current);
+ const next = order[(idx + 1 + order.length) % order.length];
+ setPanelWorkspaceSize(panelId, next);
+ applyAllWorkspacePanelSizes();
+ followWorkspacePanel(panelEl);
+ return true;
+}
+
+function moveHoveredWorkspacePanelHorizontal(step) {
+ if (!rackLayoutEnabled) return false;
+ const panelEl = hoveredWorkspacePanelElement();
+ if (!(panelEl instanceof HTMLElement)) return false;
+ const parent = panelEl.parentElement;
+ if (!(parent instanceof HTMLElement)) return false;
+ const peers = Array.from(parent.querySelectorAll(":scope > .rackPanel:not(.hidden)"));
+ const idx = peers.indexOf(panelEl);
+ if (idx < 0) return false;
+ const nextIdx = idx + (step < 0 ? -1 : 1);
+ if (nextIdx < 0 || nextIdx >= peers.length) return false;
+ const other = peers[nextIdx];
+ if (!(other instanceof HTMLElement)) return false;
+ if (step < 0) parent.insertBefore(panelEl, other);
+ else parent.insertBefore(other, panelEl);
+ syncRackStateFromDom();
+ enforceWorkspaceRules();
+ followWorkspacePanel(panelEl);
+ return true;
+}
+
+function dockHoveredWorkspacePanelToHotbar() {
+ if (!rackLayoutEnabled) return false;
+ const panelEl = hoveredWorkspacePanelElement();
+ if (!(panelEl instanceof HTMLElement)) return false;
+ const panelId = String(panelEl.dataset.panelId || "").trim();
+ if (!panelId || isRightRackFixedPanel(panelId)) return false;
+ dockPanel(panelId);
+ return true;
+}
+
+function restoreHoveredHotbarPanelToWorkspace() {
+ if (!rackLayoutEnabled) return false;
+ const panelId = String(hoveredHotbarPanelId || "").trim();
+ if (!panelId) return false;
+ restorePanelFromHotbar(panelId, { userAdded: true });
+ return true;
+}
function updateHotkeyPanelContextFromTarget(target) {
const el = target instanceof HTMLElement ? target : null;
if (!el) return;
@@ -10965,6 +11796,30 @@ window.addEventListener("keydown", (e) => {
}
if (e.altKey || e.ctrlKey || e.metaKey) return;
if (isTextEntryFocused()) return;
+ if (e.key === "ArrowUp") {
+ if (restoreHoveredHotbarPanelToWorkspace() || cycleHoveredWorkspacePanelSize()) {
+ e.preventDefault();
+ return;
+ }
+ }
+ if (e.key === "ArrowLeft") {
+ if (moveHoveredWorkspacePanelHorizontal(-1)) {
+ e.preventDefault();
+ return;
+ }
+ }
+ if (e.key === "ArrowRight") {
+ if (moveHoveredWorkspacePanelHorizontal(1)) {
+ e.preventDefault();
+ return;
+ }
+ }
+ if (e.key === "ArrowDown") {
+ if (dockHoveredWorkspacePanelToHotbar()) {
+ e.preventDefault();
+ return;
+ }
+ }
const ctx = activePanelContextForHotkeys();
const plus = e.key === "=" || e.code === "NumpadAdd";
const minus = e.key === "-" || e.code === "NumpadSubtract";
@@ -10986,6 +11841,13 @@ window.addEventListener("keydown", (e) => {
if (e.key === "]") {
e.preventDefault();
cycleLayoutPresetBy(1);
+ return;
+ }
+ if ((e.key === "r" || e.key === "R") && rackLayoutEnabled) {
+ if (isMapSurfaceActiveForHotkey()) return;
+ e.preventDefault();
+ const collapsed = Boolean(appRoot?.classList.contains("rightCollapsed"));
+ setRightCollapsed(!collapsed);
}
});
@@ -10997,6 +11859,28 @@ window.addEventListener(
true
);
+window.addEventListener(
+ "pointermove",
+ (e) => {
+ const target = e.target instanceof HTMLElement ? e.target : null;
+ const panel = target?.closest?.(".rackPanel[data-panel-id]");
+ if (panel instanceof HTMLElement) {
+ const panelId = String(panel.dataset.panelId || "").trim();
+ const rackId = String(panel.parentElement?.id || "").trim();
+ hoveredWorkspacePanelId = panelId && isWorkspaceRackId(rackId) ? panelId : "";
+ } else {
+ hoveredWorkspacePanelId = "";
+ }
+ const orb = target?.closest?.("[data-undock]");
+ hoveredHotbarPanelId = orb instanceof HTMLElement ? String(orb.getAttribute("data-undock") || "").trim() : "";
+ },
+ true
+);
+window.addEventListener("blur", () => {
+ hoveredWorkspacePanelId = "";
+ hoveredHotbarPanelId = "";
+});
+
window.addEventListener("click", (e) => {
if (!openPostMenuId) return;
const esc = cssEscape(openPostMenuId);
@@ -11425,29 +12309,18 @@ modBodyEl?.addEventListener("click", (e) => {
const title = String(modBodyEl.querySelector("input[data-instance-title]")?.value || "").replace(/\s+/g, " ").trim().slice(0, 32);
const subtitle = String(modBodyEl.querySelector("input[data-instance-subtitle]")?.value || "").replace(/\s+/g, " ").trim().slice(0, 80);
const allowMemberPermanentPosts = Boolean(modBodyEl.querySelector("input[data-instance-allowpermanent]")?.checked);
- const bg = String(modBodyEl.querySelector("input[data-instance-bg]")?.value || "").trim();
- const panel = String(modBodyEl.querySelector("input[data-instance-panel]")?.value || "").trim();
- const text = String(modBodyEl.querySelector("input[data-instance-text]")?.value || "").trim();
- const good = String(modBodyEl.querySelector("input[data-instance-good]")?.value || "").trim();
- const bad = String(modBodyEl.querySelector("input[data-instance-bad]")?.value || "").trim();
- const accent = String(modBodyEl.querySelector("input[data-instance-accent]")?.value || "").trim();
- const accent2 = String(modBodyEl.querySelector("input[data-instance-accent2]")?.value || "").trim();
- const fontBody = String(modBodyEl.querySelector("select[data-instance-fontbody]")?.value || "").trim();
- const fontMono = String(modBodyEl.querySelector("select[data-instance-fontmono]")?.value || "").trim();
- const mutedPct = String(modBodyEl.querySelector("input[data-instance-mutedpct]")?.value || "").trim();
- const linePct = String(modBodyEl.querySelector("input[data-instance-linepct]")?.value || "").trim();
- const panel2Pct = String(modBodyEl.querySelector("input[data-instance-panel2pct]")?.value || "").trim();
if (!title) {
toast("Instance", "Title is required.");
return;
}
+ const appearance = normalizeInstanceBranding(instanceBranding).appearance || {};
ws.send(
JSON.stringify({
type: "instanceSetBranding",
title,
subtitle,
allowMemberPermanentPosts,
- appearance: { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct }
+ appearance
})
);
toast("Instance", "Saving...");
@@ -12078,6 +12951,13 @@ let ws = null;
let wsKeepaliveTimer = null;
let wsReconnectTimer = null;
let wsReconnectAttempt = 0;
+let lastForegroundResyncAt = 0;
+let wsStaleWatchdogTimer = null;
+let wsLastInboundAt = 0;
+let wsLastStaleReconnectAt = 0;
+const WS_STALE_CHECK_MS = 15_000;
+const WS_STALE_INBOUND_MS = 180_000;
+const WS_STALE_RECONNECT_COOLDOWN_MS = 90_000;
function clearWsKeepalive() {
if (!wsKeepaliveTimer) return;
@@ -12099,6 +12979,47 @@ function clearWsReconnect() {
wsReconnectTimer = null;
}
+function clearWsStaleWatchdog() {
+ if (!wsStaleWatchdogTimer) return;
+ try {
+ clearInterval(wsStaleWatchdogTimer);
+ } catch {
+ // ignore
+ }
+ wsStaleWatchdogTimer = null;
+}
+
+function noteWsInbound() {
+ wsLastInboundAt = Date.now();
+}
+
+function startWsStaleWatchdog(sock) {
+ clearWsStaleWatchdog();
+ if (!readStayConnectedPref()) return;
+ if (!sock || sock.readyState !== WebSocket.OPEN) return;
+ noteWsInbound();
+ wsStaleWatchdogTimer = setInterval(() => {
+ if (!sock || sock !== ws) return;
+ if (sock.readyState !== WebSocket.OPEN) return;
+ const now = Date.now();
+ const idleMs = now - wsLastInboundAt;
+ if (idleMs < WS_STALE_INBOUND_MS) return;
+ if (now - wsLastStaleReconnectAt < WS_STALE_RECONNECT_COOLDOWN_MS) return;
+ wsLastStaleReconnectAt = now;
+ setConn("connecting");
+ try {
+ sock.close(4001, "stale-inbound-timeout");
+ } catch {
+ // ignore
+ }
+ setTimeout(() => {
+ if (!readStayConnectedPref()) return;
+ if (ws && ws !== sock && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
+ connectWs();
+ }, 900);
+ }, WS_STALE_CHECK_MS);
+}
+
function startWsKeepalive(sock) {
clearWsKeepalive();
if (!readStayConnectedPref()) return;
@@ -12131,6 +13052,7 @@ function scheduleWsReconnect() {
function connectWs() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
clearWsKeepalive();
+ clearWsStaleWatchdog();
setConn("connecting");
const sock = new WebSocket(wsUrl());
ws = sock;
@@ -12138,10 +13060,12 @@ function connectWs() {
sock.addEventListener("open", () => {
if (sock !== ws) return;
+ noteWsInbound();
setConn("open");
wsReconnectAttempt = 0;
clearWsReconnect();
startWsKeepalive(sock);
+ startWsStaleWatchdog(sock);
const token = getSessionToken();
if (token) {
try {
@@ -12150,6 +13074,10 @@ function connectWs() {
// ignore
}
}
+ setTimeout(() => {
+ if (sock !== ws) return;
+ requestForegroundResync("ws-open");
+ }, 120);
});
sock.addEventListener("close", () => {
@@ -12157,18 +13085,43 @@ function connectWs() {
leaveActiveStream(false);
setConn("closed");
clearWsKeepalive();
+ clearWsStaleWatchdog();
scheduleWsReconnect();
});
sock.addEventListener("error", () => {
if (sock !== ws) return;
setConn("closed");
+ clearWsStaleWatchdog();
});
sock.addEventListener("message", onWsMessage);
}
+function requestForegroundResync(reason = "") {
+ const now = Date.now();
+ if (now - lastForegroundResyncAt < 1400) return;
+ lastForegroundResyncAt = now;
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
+ try {
+ ws.send(JSON.stringify({ type: "peopleList" }));
+ ws.send(JSON.stringify({ type: "dmList" }));
+ if (activeDmThreadId) {
+ ws.send(JSON.stringify({ type: "dmHistory", threadId: activeDmThreadId }));
+ } else if (activeChatPostId) {
+ ws.send(JSON.stringify({ type: "getChat", postId: activeChatPostId }));
+ }
+ if (activeMapsRoomId) {
+ ws.send(JSON.stringify({ type: "plugin:maps:chatHistoryReq", mapId: activeMapsRoomId }));
+ }
+ if (reason === "visibility") ws.send(JSON.stringify({ type: "onboardingGet" }));
+ } catch {
+ // ignore
+ }
+}
+
function onWsMessage(evt) {
+ noteWsInbound();
let msg;
try {
msg = JSON.parse(evt.data);
@@ -13067,6 +14020,7 @@ connectWs();
renderLanHint();
writeHintsEnabledPref(readHintsEnabledPref());
initDisplayPrefsUi();
+initAppearanceControls();
if (stayConnectedEl) {
stayConnectedEl.checked = readStayConnectedPref();
stayConnectedEl.addEventListener("change", () => {
@@ -13075,9 +14029,11 @@ if (stayConnectedEl) {
if (on) {
if (!ws || ws.readyState === WebSocket.CLOSED) connectWs();
startWsKeepalive(ws);
+ if (ws && ws.readyState === WebSocket.OPEN) startWsStaleWatchdog(ws);
} else {
clearWsReconnect();
clearWsKeepalive();
+ clearWsStaleWatchdog();
}
});
}
@@ -13712,12 +14668,25 @@ initRackLayout();
window.addEventListener("focus", () => {
windowFocused = true;
updateNotifUi();
+ if (readStayConnectedPref() && (!ws || ws.readyState === WebSocket.CLOSED)) {
+ connectWs();
+ return;
+ }
+ requestForegroundResync("focus");
});
window.addEventListener("blur", () => {
windowFocused = false;
stopAnyPanelResize();
});
-document.addEventListener("visibilitychange", () => updateNotifUi());
+document.addEventListener("visibilitychange", () => {
+ updateNotifUi();
+ if (document.hidden) return;
+ if (readStayConnectedPref() && (!ws || ws.readyState === WebSocket.CLOSED)) {
+ connectWs();
+ return;
+ }
+ requestForegroundResync("visibility");
+});
enableNotifsBtn?.addEventListener("click", async () => {
if (!notifSupported()) return;
diff --git a/public/index.html b/public/index.html
@@ -9,8 +9,8 @@
<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>
- <button id="showRightRack" class="ghost smallBtn rightRackToggle hidden" type="button" title="Show right rack">Right</button>
+ <button id="togglePeople" class="ghost smallBtn peopleToggle" type="button" title="Show members list">Members</button>
+ <button id="showRightRack" class="ghost smallBtn rightRackToggle hidden" type="button" title="Show members list">Members</button>
<aside class="sidebar">
<div class="sidebarScroll">
<div class="brand">
@@ -28,7 +28,7 @@
<section id="viewPanel" class="panel">
<div class="panelTitle">View</div>
- <div class="uiHint">Use layout presets for quick panel setups. Shortcuts: <b>[</b>/<b>]</b> cycle presets, <b>-</b>/<b>=</b> cycle hives/chats in the active panel, <b>?</b> opens shortcut help.</div>
+ <div class="uiHint">Use layout presets for quick panel setups. Shortcuts: <b>[</b>/<b>]</b> cycle presets, <b>-</b>/<b>=</b> cycle hives/chats in the active panel, <b>R</b> toggles Members list, <b>?</b> opens shortcut help.</div>
<label class="checkRow" style="margin-top:8px;">
<span>Rack layout (experimental)</span>
<input id="toggleRackLayout" type="checkbox" />
@@ -51,7 +51,7 @@
<input id="toggleSideRack" type="checkbox" checked />
</label>
<label class="checkRow" style="margin-top:8px;">
- <span>Right rack</span>
+ <span>Members list</span>
<input id="toggleRightRack" type="checkbox" checked />
</label>
<label class="checkRow" style="margin-top:8px;">
@@ -91,17 +91,100 @@
<option value="lg">Large</option>
</select>
</label>
- <label style="margin-top:10px;">
- <span>Device layout</span>
- <select id="deviceLayout">
- <option value="auto" selected>Auto</option>
- <option value="widescreen">16:9 / 16:10</option>
- <option value="fourThree">4:3</option>
- <option value="threeTwo">3:2</option>
- <option value="ultrawide">Ultrawide</option>
- <option value="portrait">Portrait</option>
- </select>
- </label>
+ <div class="appearanceWorkbench">
+ <div class="appearanceWorkbenchHeader">
+ <div class="small">Look & feel</div>
+ <div class="small muted">Personal (this browser)</div>
+ </div>
+ <div class="appearanceWorkbenchRow">
+ <label class="grow">
+ <span>Theme preset</span>
+ <select id="appearancePreset">
+ <option value="">(choose...)</option>
+ </select>
+ </label>
+ <div class="row" style="align-items:flex-end;gap:8px;">
+ <button id="appearanceApplyPreset" class="ghost smallBtn" type="button">Apply</button>
+ <button id="appearanceResetPreview" class="ghost smallBtn" type="button">Reset</button>
+ </div>
+ </div>
+ <div class="appearanceColorGrid">
+ <label>
+ <span>Background</span>
+ <input id="appearanceBg" type="color" />
+ </label>
+ <label>
+ <span>Panel</span>
+ <input id="appearancePanel" type="color" />
+ </label>
+ <label>
+ <span>Text</span>
+ <input id="appearanceText" type="color" />
+ </label>
+ <label>
+ <span>Accent</span>
+ <input id="appearanceAccent" type="color" />
+ </label>
+ <label>
+ <span>Accent 2</span>
+ <input id="appearanceAccent2" type="color" />
+ </label>
+ <label>
+ <span>Success</span>
+ <input id="appearanceGood" type="color" />
+ </label>
+ <label>
+ <span>Danger</span>
+ <input id="appearanceBad" type="color" />
+ </label>
+ </div>
+ <div class="appearanceWorkbenchRow">
+ <label>
+ <span>Muted %</span>
+ <input id="appearanceMutedPct" type="number" min="0" max="100" />
+ </label>
+ <label>
+ <span>Divider %</span>
+ <input id="appearanceLinePct" type="number" min="0" max="100" />
+ </label>
+ <label>
+ <span>Panel tint %</span>
+ <input id="appearancePanel2Pct" type="number" min="0" max="100" />
+ </label>
+ </div>
+ <div class="appearanceWorkbenchRow">
+ <label class="grow">
+ <span>Body font</span>
+ <select id="appearanceFontBody">
+ <option value="system">System (sans)</option>
+ <option value="clean">Clean sans</option>
+ <option value="humanist">Humanist</option>
+ <option value="rounded">Rounded</option>
+ <option value="condensed">Condensed</option>
+ <option value="serif">Serif</option>
+ <option value="slab">Slab serif</option>
+ <option value="mono">Monospace</option>
+ <option value="lcd">LCD / Digital</option>
+ </select>
+ </label>
+ <label class="grow">
+ <span>Mono font</span>
+ <select id="appearanceFontMono">
+ <option value="mono">Monospace</option>
+ <option value="system">System</option>
+ <option value="clean">Clean sans</option>
+ <option value="humanist">Humanist</option>
+ <option value="rounded">Rounded</option>
+ <option value="lcd">LCD / Digital</option>
+ </select>
+ </label>
+ </div>
+ <div class="appearanceWorkbenchActions">
+ <button id="appearanceSave" class="primary smallBtn" type="button">Save look</button>
+ <button id="appearanceClear" class="ghost smallBtn" type="button">Use server default</button>
+ </div>
+ <div id="appearanceStatus" class="small muted"></div>
+ </div>
</div>
</details>
</section>
@@ -498,7 +581,7 @@
<aside id="peopleDrawer" class="peopleDrawer hidden">
<div id="peopleResizeHandle" class="peopleResizeHandle" title="Drag to resize people panel" aria-hidden="true"></div>
<div class="panelHeader">
- <div class="panelTitle">People</div>
+ <div class="panelTitle">Members list</div>
<button id="closePeople" class="ghost smallBtn" type="button">Close</button>
</div>
<div class="peopleTabs">
@@ -521,7 +604,7 @@
<button type="button" data-mobilescreen="account">Account</button>
<button type="button" data-mobilescreen="hives">Hives</button>
<button type="button" data-mobilescreen="chat">Chat</button>
- <button id="mobileFourthBtn" type="button" data-mobilescreen="people">People</button>
+ <button id="mobileFourthBtn" type="button" data-mobilescreen="people">Members</button>
<button type="button" data-mobilescreen="profile">Profile</button>
<button type="button" data-mobilescreen="more">More</button>
</div>
@@ -678,6 +761,8 @@
<div class="modalBody shortcutHelpBody">
<div><span class="tag">[ / ]</span> Cycle layout presets</div>
<div><span class="tag">- / =</span> Cycle hives or chats in active panel</div>
+ <div><span class="tag">Arrows</span> Hover panel: Up size, Left/Right move, Down dock. Hover hotbar: Up restore.</div>
+ <div><span class="tag">R</span> Toggle Members list rail</div>
<div><span class="tag">?</span> Open this shortcut help</div>
<div><span class="tag">`</span> Hold for walkie talkie (when enabled)</div>
<div><span class="tag">Esc</span> Close menus/modals</div>
diff --git a/public/styles.css b/public/styles.css
@@ -587,8 +587,8 @@ body {
pointer-events: none;
z-index: 60;
display: flex;
- gap: 10px;
- padding: 8px 10px;
+ gap: 12px;
+ padding: 10px 12px;
max-width: calc(100vw - 24px);
overflow-x: auto;
overscroll-behavior: contain;
@@ -617,17 +617,19 @@ body {
.dockOrb {
display: inline-flex;
align-items: center;
- gap: 8px;
+ gap: 9px;
flex: 0 0 auto;
max-width: 220px;
- padding: 7px 12px;
+ min-height: 38px;
+ padding: 8px 13px;
border-radius: 999px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--text) 6%, transparent);
color: var(--text);
- cursor: pointer;
+ cursor: grab;
user-select: none;
- font-size: 12px;
+ font-size: 13px;
+ font-weight: 600;
white-space: nowrap;
}
@@ -636,9 +638,13 @@ body {
border-color: color-mix(in srgb, var(--accent) 25%, transparent);
}
+.dockOrb:active {
+ cursor: grabbing;
+}
+
.dockOrb .dockOrbIcon {
- width: 18px;
- height: 18px;
+ width: 22px;
+ height: 22px;
border-radius: 6px;
display: inline-flex;
align-items: center;
@@ -646,6 +652,30 @@ body {
background: color-mix(in srgb, var(--accent) 22%, transparent);
}
+.hotbarHint {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ flex: 0 0 auto;
+ min-height: 38px;
+ padding: 8px 12px;
+ border-radius: 999px;
+ border: 1px dashed color-mix(in srgb, var(--text) 22%, transparent);
+ background: color-mix(in srgb, var(--text) 4%, transparent);
+ color: color-mix(in srgb, var(--text) 82%, transparent);
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.hotbarHintGrip {
+ font-weight: 700;
+ opacity: 0.9;
+}
+
+.hotbarHintSep {
+ opacity: 0.66;
+}
+
.hotbarAddMenu {
position: fixed;
z-index: 90;
@@ -751,6 +781,48 @@ a.poweredByTile:hover {
line-height: 1.1;
}
+.appearanceWorkbench {
+ margin-top: 12px;
+ padding: 10px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: color-mix(in srgb, var(--panel2) 80%, transparent);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.appearanceWorkbenchHeader {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.appearanceWorkbenchRow {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.appearanceColorGrid {
+ display: grid;
+ gap: 10px;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.appearanceWorkbenchActions {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+@media (max-width: 980px) {
+ .appearanceColorGrid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
.main {
grid-area: main;
display: flex;
@@ -778,6 +850,84 @@ a.poweredByTile:hover {
overflow: hidden;
}
+.app.rackMode.workspaceInfinite .workspaceRack {
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+ gap: var(--app-gap);
+ overflow-x: auto;
+ overflow-y: hidden;
+ scroll-snap-type: x mandatory;
+ scroll-behavior: smooth;
+ padding-bottom: 2px;
+}
+
+.app.rackMode.workspaceInfinite .workspaceRack > .rackPanel {
+ flex: 0 0 min(640px, 74vw);
+ min-width: 280px;
+ max-width: min(96vw, 1120px);
+ min-height: 0;
+ scroll-snap-align: start;
+}
+
+.app.rackMode.workspaceInfinite .workspaceRack > .rackPanel.workspaceSizeSkinny {
+ flex-basis: min(360px, 42vw);
+}
+
+.app.rackMode.workspaceInfinite .workspaceRack > .rackPanel.workspaceSizeHalf {
+ flex-basis: min(680px, 74vw);
+}
+
+.app.rackMode.workspaceInfinite .workspaceRack > .rackPanel.workspaceSizeFull {
+ flex-basis: min(1040px, 92vw);
+}
+
+.app.rackMode .workspaceRack > .rackPanel.workspaceArrivalGlow {
+ animation: workspaceArrivalGlow 780ms ease-out;
+}
+
+.app.rackMode.workspaceInfinite .workspaceRack > .workspaceMinSpacer {
+ flex: 0 0 auto;
+ width: 0;
+ min-width: 0;
+ pointer-events: none;
+}
+
+.app.rackMode.workspaceInfinite .workspaceSlot {
+ display: none !important;
+}
+
+.app.rackMode.workspaceInfinite .sideRack {
+ display: none !important;
+}
+
+.app.rackMode.workspaceInfinite .rightRack {
+ display: flex;
+}
+
+.panelSizeControls {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ padding: 1px;
+ border-radius: 999px;
+ border: 1px solid color-mix(in srgb, var(--text) 12%, transparent);
+ background: color-mix(in srgb, var(--text) 5%, transparent);
+}
+
+.panelSizeBtn {
+ min-width: 24px;
+ padding: 4px 7px;
+ font-size: 11px;
+ line-height: 1;
+}
+
+.panelSizeBtn.isActive {
+ border-color: color-mix(in srgb, var(--accent) 30%, transparent);
+ background: color-mix(in srgb, var(--accent) 22%, transparent);
+ color: var(--text);
+}
+
/* Workspace 4x2 grid (rack mode) */
.app.rackMode .workspaceRack {
display: grid;
@@ -2294,6 +2444,18 @@ button:disabled {
}
}
+@keyframes workspaceArrivalGlow {
+ 0% {
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--text) 12%, transparent), 0 0 0 rgba(255, 62, 165, 0);
+ }
+ 35% {
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 48%, transparent), 0 18px 52px color-mix(in srgb, var(--accent) 22%, transparent);
+ }
+ 100% {
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--text) 12%, transparent), 0 0 0 rgba(255, 62, 165, 0);
+ }
+}
+
.badgeDot {
width: 10px;
height: 10px;