CollapsibileUI (#1231)

* refactor:collapsibleUI

Add surfaceClass API / rewrite collapsible

* fix: satisfy collapsibleUI stylelint

* ok papi

* Update CollapsibleUI

* Updates

* bleh

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

---------

Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Benjii 2026-07-08 22:11:12 +01:00 • committed by GitHub
parent d3410ee8e7
commit 1a99148ee2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1325 additions and 0 deletions

210
src/api/SurfaceClasses.ts Normal file
View file

@ -0,0 +1,210 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Logger } from "@utils/Logger";
import { useForceUpdater } from "@utils/react";
import { useEffect } from "@webpack/common";
import type { CSSProperties, FocusEventHandler, MouseEventHandler, RefCallback } from "react";
/**
* Adds plugin-owned semantic data attributes and a limited set of props to
* stable Discord-owned layout surfaces, so plugins can style/track layout
* state without patching compiled Discord JSX directly.
*
* This is not a generic prop bus and not for plugin-local components. It
* intentionally does not expose className: injected props land before or
* after Discord's own props depending on the surface, so class merging
* cannot be guaranteed. Use data attributes as CSS hooks instead.
*/
export type SurfaceId =
| "base"
| "sidebar"
| "guildBar"
| "channelList"
| "membersList"
| "titleBar"
| "headerBar"
| "userArea";
export interface SurfaceProvidedProps {
ref?: RefCallback<HTMLElement>;
style?: CSSProperties;
[dataAttribute: `data-${string}`]: string | undefined;
onFocusCapture?: FocusEventHandler<HTMLElement>;
onBlurCapture?: FocusEventHandler<HTMLElement>;
onMouseDownCapture?: MouseEventHandler<HTMLElement>;
onMouseOverCapture?: MouseEventHandler<HTMLElement>;
onMouseOutCapture?: MouseEventHandler<HTMLElement>;
}
export type SurfacePropsProvider = () => SurfaceProvidedProps | undefined;
interface SurfaceInstance {
forceUpdate(callback?: () => void): void;
}
const propsProviders = new Map<SurfaceId, Set<SurfacePropsProvider>>();
const listeners = new Map<SurfaceId, Set<() => void>>();
const surfaceInstances = new Map<SurfaceId, WeakRef<SurfaceInstance>>();
const failedPropsProviders = new WeakSet<SurfacePropsProvider>();
const logger = new Logger("SurfaceClasses");
function getPropsProviderSet(surfaceId: SurfaceId) {
let set = propsProviders.get(surfaceId);
if (set == null) {
set = new Set();
propsProviders.set(surfaceId, set);
}
return set;
}
function getListenerSet(surfaceId: SurfaceId) {
let set = listeners.get(surfaceId);
if (set == null) {
set = new Set();
listeners.set(surfaceId, set);
}
return set;
}
function chainHandlers<E>(
first?: (event: E) => void,
second?: (event: E) => void
) {
if (!first) return second;
if (!second) return first;
return (event: E) => {
first(event);
second(event);
};
}
function chainRefs<T>(first?: RefCallback<T>, second?: RefCallback<T>) {
if (!first) return second;
if (!second) return first;
return (instance: T | null) => {
first(instance);
second(instance);
};
}
function mergeSurfaceProvidedProps(target: SurfaceProvidedProps, source: SurfaceProvidedProps) {
const ref = chainRefs(target.ref, source.ref);
if (ref) target.ref = ref;
const onFocusCapture = chainHandlers(target.onFocusCapture, source.onFocusCapture);
if (onFocusCapture) target.onFocusCapture = onFocusCapture;
const onBlurCapture = chainHandlers(target.onBlurCapture, source.onBlurCapture);
if (onBlurCapture) target.onBlurCapture = onBlurCapture;
const onMouseDownCapture = chainHandlers(target.onMouseDownCapture, source.onMouseDownCapture);
if (onMouseDownCapture) target.onMouseDownCapture = onMouseDownCapture;
const onMouseOverCapture = chainHandlers(target.onMouseOverCapture, source.onMouseOverCapture);
if (onMouseOverCapture) target.onMouseOverCapture = onMouseOverCapture;
const onMouseOutCapture = chainHandlers(target.onMouseOutCapture, source.onMouseOutCapture);
if (onMouseOutCapture) target.onMouseOutCapture = onMouseOutCapture;
if (source.style) {
target.style = { ...target.style, ...source.style };
}
// Copy only string data-* attributes so providers cannot smuggle in
// arbitrary props (className in particular is deliberately unsupported).
for (const [key, value] of Object.entries(source)) {
if (key.startsWith("data-") && typeof value === "string") {
target[key as `data-${string}`] = value;
}
}
return target;
}
function getSurfaceProps(surfaceId: SurfaceId) {
const props: SurfaceProvidedProps = {};
for (const provider of propsProviders.get(surfaceId) ?? []) {
let providedProps: SurfaceProvidedProps | undefined;
try {
providedProps = provider();
} catch (error) {
if (!failedPropsProviders.has(provider)) {
failedPropsProviders.add(provider);
logger.error(`Surface props provider failed for ${surfaceId}`, error);
}
continue;
}
if (providedProps) {
mergeSurfaceProvidedProps(props, providedProps);
}
}
return props;
}
function notifyOneSurface(surfaceId: SurfaceId) {
const surfaceInstance = surfaceInstances.get(surfaceId)?.deref();
if (surfaceInstance) {
surfaceInstance.forceUpdate();
} else {
surfaceInstances.delete(surfaceId);
}
for (const listener of listeners.get(surfaceId) ?? []) {
listener();
}
}
export function addSurfacePropsProvider(surfaceId: SurfaceId, provider: SurfacePropsProvider) {
getPropsProviderSet(surfaceId).add(provider);
notifyOneSurface(surfaceId);
return () => {
propsProviders.get(surfaceId)?.delete(provider);
notifyOneSurface(surfaceId);
};
}
/** Re-renders a surface after the state backing one of its providers changed. */
export function notifySurfaceClassesChanged(surfaceId: SurfaceId) {
notifyOneSurface(surfaceId);
}
/** @internal Injected by SurfaceClassesAPI patch (do NOT call directly) */
export function _getSurfaceProps(surfaceId: SurfaceId) {
return getSurfaceProps(surfaceId);
}
/** @internal Injected by SurfaceClassesAPI patch (do NOT call directly) */
export function _useSurfaceProps(surfaceId: SurfaceId) {
const forceUpdate = useForceUpdater();
useEffect(() => {
const listener = () => forceUpdate();
getListenerSet(surfaceId).add(listener);
return () => { listeners.get(surfaceId)?.delete(listener); };
}, [surfaceId]);
return getSurfaceProps(surfaceId);
}
/**
* @internal Injected by SurfaceClassesAPI patch (do NOT call directly).
* Only used for class component surfaces where hooks cannot be injected into render().
* Function component surfaces use _useSurfaceProps instead.
*/
export function _trackSurfaceInstance(surfaceId: SurfaceId, instance: SurfaceInstance) {
if (surfaceInstances.get(surfaceId)?.deref() !== instance) {
surfaceInstances.set(surfaceId, new WeakRef(instance));
}
}

View file

@ -40,6 +40,7 @@ import * as $ProfileSections from "./ProfileSections";
import * as $ServerList from "./ServerList";
import * as $Settings from "./Settings";
import * as $Styles from "./Styles";
import * as $SurfaceClasses from "./SurfaceClasses";
import * as $Themes from "./Themes";
import * as $UserSettings from "./UserSettings";
@ -98,6 +99,12 @@ export const Badges = $Badges;
*/
export const ServerList = $ServerList;
/**
* An API allowing plugins to add semantic data attributes and limited props
* to stable Discord layout surfaces without patching them directly.
*/
export const SurfaceClasses = $SurfaceClasses;
/**
* An API allowing you to add components as message accessories.
*/

View file

@ -0,0 +1,68 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { EquicordDevs } from "@utils/constants";
import definePlugin from "@utils/types";
export default definePlugin({
name: "SurfaceClassesAPI",
description: "API to add plugin-owned semantic data attributes and limited props to stable Discord layout surfaces.",
authors: [EquicordDevs.benjii],
patches: [
{
find: "AnnouncementsSpoilerIcon:",
replacement: [
{
match: /"data-fullscreen":\i.{0,150},\{isSidebarOpen:/,
replace: '...Vencord.Api.SurfaceClasses._useSurfaceProps("base"),$&'
},
{
match: /"data-collapsed":\i,.{0,130}themeOverride/,
replace: '...Vencord.Api.SurfaceClasses._useSurfaceProps("sidebar"),$&'
},
{
match: /\.CHANNEL_SIDEBAR_RESIZED,\{.{0,100}\i=\{/,
replace: '$&...Vencord.Api.SurfaceClasses._useSurfaceProps("channelList"),'
},
{
match: /ref:\i.{0,70}#{intl::vTl6Lk::raw}\),/,
replace: "...Vencord.Api.SurfaceClasses._useSurfaceProps('userArea'),$&"
}
]
},
{
find: "#{intl::GUILDS_BAR_A11Y_LABEL}",
replacement: [
{
match: /"aria-label":.{0,50}#{intl::GUILDS_BAR_A11Y_LABEL}\),children:/,
replace: '...Vencord.Api.SurfaceClasses._useSurfaceProps("guildBar"),$&'
},
]
},
{
find: "#{intl::MEMBERS_LIST_LANDMARK_LABEL}",
replacement: {
match: /"aria-labelledby":.{0,130}#{intl::MEMBERS_LIST_LANDMARK_LABEL}/,
replace: '...(Vencord.Api.SurfaceClasses._trackSurfaceInstance("membersList",this),Vencord.Api.SurfaceClasses._getSurfaceProps("membersList")),$&'
}
},
{
find: '?"refresh-title-bar-small":',
replacement: {
match: /"data-window-chrome":"true"/,
replace: '...Vencord.Api.SurfaceClasses._useSurfaceProps("headerBar"),$&'
}
},
{
find: ".Masks.HEADER_BAR_BADGE_TOP:",
replacement: {
match: /"aria-label":\i.{0,25}role:\i,ref:\i/,
replace: '...Vencord.Api.SurfaceClasses._useSurfaceProps("titleBar"),$&'
}
}
]
});

View file

@ -0,0 +1,542 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { ChannelToolbarButton } from "@api/HeaderBar";
import { addSurfacePropsProvider, notifySurfaceClassesChanged, type SurfaceId, type SurfaceProvidedProps } from "@api/SurfaceClasses";
import ErrorBoundary from "@components/ErrorBoundary";
import { EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import { classes } from "@utils/misc";
import definePlugin from "@utils/types";
import { Clickable, ContextMenuApi, Menu } from "@webpack/common";
import type { FocusEvent as ReactFocusEvent, MouseEvent as ReactMouseEvent, ReactNode, SVGProps } from "react";
import { collapseSettingKeys, PanelId, panelRegistry, setCollapseSettingChangeHandler, settings, setUserAreaDetachSettingChangeHandler, toolbarPanelOrder } from "./settings";
import managedStyle from "./style.css?managed";
const cl = classNameFactory("vc-collapsible-ui-");
const panelDependentSurfaces: Record<PanelId, SurfaceId[]> = {
guildBar: ["guildBar", "userArea"],
channelList: ["channelList", "base", "sidebar", "userArea"],
membersList: ["membersList"],
chatButtons: [],
titleBar: ["titleBar"],
headerBar: ["headerBar", "base"],
userArea: ["userArea", "sidebar"],
};
const DETACHED_USER_AREA_WIDTH = 312;
const DETACHED_USER_AREA_HEIGHT = 88;
const DETACHED_USER_AREA_MARGIN = 16;
const DETACHED_USER_AREA_DEFAULT_OFFSET_X = 24;
const DETACHED_USER_AREA_DEFAULT_OFFSET_Y = 88;
let providerUnsubs: Array<() => void> = [];
let channelListExpandedByInteraction = false;
let headerBarExpandedByInteraction = false;
let headerBarPointerTrackerEnabled = false;
let userAreaDragState: { offsetX: number; offsetY: number; width: number; height: number; } | undefined;
let detachedUserAreaDragPosition: { x: number; y: number; } | undefined;
let detachedUserAreaPositionChanged = false;
let detachedUserAreaAnimationFrame: number | undefined;
let channelListElement: HTMLElement | null = null;
let userAreaElement: HTMLElement | null = null;
const surfaceCssPxCache = new Map<string, number>();
function PanelsIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" {...props}>
<path fill="currentColor" d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v3H3V5Zm0 5h6v11H5a2 2 0 0 1-2-2V10Zm8 0h10v9a2 2 0 0 1-2 2H11V10Zm2-5h8v3h-8V5Z" />
</svg>
);
}
function isPanelCollapsed(panelId: PanelId) {
return settings.plain[panelRegistry[panelId].collapsedKey];
}
function usePanelCollapsed(panelId: PanelId) {
const key = panelRegistry[panelId].collapsedKey;
return settings.use([key])[key];
}
function notifyPanelSurfacesChanged(panelId: PanelId) {
for (const surfaceId of panelDependentSurfaces[panelId]) {
notifySurfaceClassesChanged(surfaceId);
}
}
function setHeaderBarExpandedByInteraction(expanded: boolean) {
if (headerBarExpandedByInteraction === expanded) return;
headerBarExpandedByInteraction = expanded;
notifySurfaceClassesChanged("base");
notifySurfaceClassesChanged("headerBar");
}
function setChannelListExpandedByInteraction(expanded: boolean) {
if (channelListExpandedByInteraction === expanded) return;
channelListExpandedByInteraction = expanded;
notifySurfaceClassesChanged("base");
notifySurfaceClassesChanged("sidebar");
}
function syncPanelCollapsedState(panelId: PanelId, collapsed: boolean) {
if (panelId === "channelList") {
channelListExpandedByInteraction = false;
}
if (panelId === "headerBar") {
setHeaderBarPointerTrackerEnabled(collapsed);
if (!collapsed) {
headerBarExpandedByInteraction = false;
}
}
notifyPanelSurfacesChanged(panelId);
}
function syncAllPanelCollapsedStates() {
for (const panelId of toolbarPanelOrder) {
syncPanelCollapsedState(panelId, isPanelCollapsed(panelId));
}
}
// Reads a plugin CSS length variable from a mounted surface so the managed
// stylesheet stays the single source of truth. Cached while the pointer
// tracker runs; cleared each time tracking starts so theme overrides of the
// variables are picked up without querying computed style per mouse move.
function readSurfacePx(property: string, fallback: number) {
const cached = surfaceCssPxCache.get(property);
if (cached != null) return cached;
const host = userAreaElement ?? channelListElement;
if (!host) return fallback;
const value = parseFloat(getComputedStyle(host).getPropertyValue(property));
if (!Number.isFinite(value)) return fallback;
surfaceCssPxCache.set(property, value);
return value;
}
// Electron drag regions do not provide stable hover events, so keep this as a
// coordinate-only tracker while headerbar collapse is enabled.
function handleHeaderBarPointerMove(event: MouseEvent) {
if (!isPanelCollapsed("headerBar")) {
setHeaderBarPointerTrackerEnabled(false);
setHeaderBarExpandedByInteraction(false);
return;
}
const interactionHeight = headerBarExpandedByInteraction
? readSurfacePx("--vc-cui-header-bar-height", 32)
: readSurfacePx("--vc-cui-collapsed-block-size", 8);
setHeaderBarExpandedByInteraction(event.clientY >= 0 && event.clientY <= interactionHeight);
}
function setHeaderBarPointerTrackerEnabled(enabled: boolean) {
if (headerBarPointerTrackerEnabled === enabled) return;
headerBarPointerTrackerEnabled = enabled;
if (enabled) {
surfaceCssPxCache.clear();
document.addEventListener("mousemove", handleHeaderBarPointerMove, true);
} else {
document.removeEventListener("mousemove", handleHeaderBarPointerMove, true);
}
}
function setPanelCollapsed(panelId: PanelId, collapsed: boolean) {
const key = panelRegistry[panelId].collapsedKey;
if (settings.plain[key] === collapsed) return;
settings.store[key] = collapsed;
}
function togglePanel(panelId: PanelId) {
setPanelCollapsed(panelId, !isPanelCollapsed(panelId));
}
function openToolbarMenu(event: ReactMouseEvent) {
ContextMenuApi.openContextMenu(event, () => <ToolbarMenu onClose={ContextMenuApi.closeContextMenu} />);
}
function containsRelatedTarget(event: ReactFocusEvent<HTMLElement> | ReactMouseEvent<HTMLElement>) {
const { currentTarget, relatedTarget } = event;
return relatedTarget instanceof Node && currentTarget.contains(relatedTarget);
}
function nodeWithin(element: HTMLElement | null, node: unknown) {
return node instanceof Node && !!element?.contains(node);
}
function eventWithin(element: HTMLElement | null, event: ReactFocusEvent<HTMLElement> | ReactMouseEvent<HTMLElement>) {
if (!element) return false;
if (nodeWithin(element, event.target)) return true;
if (event.nativeEvent.composedPath().some(node => nodeWithin(element, node))) return true;
if ("clientX" in event && "clientY" in event) {
const rect = element.getBoundingClientRect();
return event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom;
}
return false;
}
function handleSidebarEnter(event: ReactFocusEvent<HTMLElement> | ReactMouseEvent<HTMLElement>) {
if (eventWithin(userAreaElement, event)) {
setChannelListExpandedByInteraction(false);
return;
}
if (isPanelCollapsed("channelList") && eventWithin(channelListElement, event)) {
setChannelListExpandedByInteraction(true);
}
}
function handleSidebarLeave(event: ReactFocusEvent<HTMLElement> | ReactMouseEvent<HTMLElement>) {
if (nodeWithin(userAreaElement, event.relatedTarget)) {
setChannelListExpandedByInteraction(false);
return;
}
if (eventWithin(channelListElement, event) && !nodeWithin(channelListElement, event.relatedTarget)) {
setChannelListExpandedByInteraction(false);
return;
}
if (containsRelatedTarget(event)) return;
if (isPanelCollapsed("channelList")) setChannelListExpandedByInteraction(false);
}
function isInteractiveDragTarget(node: unknown) {
if (!(node instanceof HTMLElement)) return false;
if (node.isContentEditable) return true;
switch (node.tagName) {
case "A":
case "BUTTON":
case "INPUT":
case "SELECT":
case "TEXTAREA":
return true;
}
return node.getAttribute("role") === "button" || node.getAttribute("aria-haspopup") === "menu";
}
function shouldDetachUserArea() {
return settings.plain.detachUserArea && !isPanelCollapsed("userArea");
}
function setChannelListElement(element: HTMLElement | null) {
channelListElement = element;
}
function setUserAreaElement(element: HTMLElement | null) {
userAreaElement = element;
}
function syncUserAreaDetachState() {
notifySurfaceClassesChanged("sidebar");
notifySurfaceClassesChanged("userArea");
}
function clampUserAreaPosition(x: number, y: number, width = DETACHED_USER_AREA_WIDTH, height = DETACHED_USER_AREA_HEIGHT) {
const maxX = Math.max(DETACHED_USER_AREA_MARGIN, window.innerWidth - width - DETACHED_USER_AREA_MARGIN);
const maxY = Math.max(DETACHED_USER_AREA_MARGIN, window.innerHeight - height - DETACHED_USER_AREA_MARGIN);
return {
x: Math.min(Math.max(DETACHED_USER_AREA_MARGIN, x), maxX),
y: Math.min(Math.max(DETACHED_USER_AREA_MARGIN, y), maxY),
};
}
function getDetachedUserAreaPosition() {
if (detachedUserAreaDragPosition) return detachedUserAreaDragPosition;
const storedX = settings.plain.detachedUserAreaX;
const storedY = settings.plain.detachedUserAreaY;
const defaultPosition = clampUserAreaPosition(
window.innerWidth - DETACHED_USER_AREA_WIDTH - DETACHED_USER_AREA_DEFAULT_OFFSET_X,
window.innerHeight - DETACHED_USER_AREA_HEIGHT - DETACHED_USER_AREA_DEFAULT_OFFSET_Y
);
if (!Number.isFinite(storedX) || !Number.isFinite(storedY) || storedX < 0 || storedY < 0) return defaultPosition;
return clampUserAreaPosition(storedX, storedY);
}
function persistDetachedUserAreaPosition(x: number, y: number, width?: number, height?: number) {
const position = clampUserAreaPosition(x, y, width, height);
const roundedX = Math.round(position.x);
const roundedY = Math.round(position.y);
if (settings.plain.detachedUserAreaX === roundedX && settings.plain.detachedUserAreaY === roundedY) return;
settings.store.detachedUserAreaX = roundedX;
settings.store.detachedUserAreaY = roundedY;
}
function scheduleDetachedUserAreaUpdate() {
if (detachedUserAreaAnimationFrame != null) return;
detachedUserAreaAnimationFrame = requestAnimationFrame(() => {
detachedUserAreaAnimationFrame = undefined;
notifySurfaceClassesChanged("userArea");
});
}
function cancelDetachedUserAreaUpdate() {
if (detachedUserAreaAnimationFrame == null) return;
cancelAnimationFrame(detachedUserAreaAnimationFrame);
detachedUserAreaAnimationFrame = undefined;
}
function handleDetachedUserAreaMouseMove(event: MouseEvent) {
if (!userAreaDragState) return;
const position = clampUserAreaPosition(
event.clientX - userAreaDragState.offsetX,
event.clientY - userAreaDragState.offsetY,
userAreaDragState.width,
userAreaDragState.height
);
if (detachedUserAreaDragPosition?.x === position.x && detachedUserAreaDragPosition.y === position.y) return;
detachedUserAreaDragPosition = position;
detachedUserAreaPositionChanged = true;
scheduleDetachedUserAreaUpdate();
}
function stopDetachedUserAreaDrag() {
const dragState = userAreaDragState;
cancelDetachedUserAreaUpdate();
if (dragState && detachedUserAreaDragPosition && detachedUserAreaPositionChanged) {
persistDetachedUserAreaPosition(detachedUserAreaDragPosition.x, detachedUserAreaDragPosition.y, dragState.width, dragState.height);
}
userAreaDragState = undefined;
detachedUserAreaDragPosition = undefined;
detachedUserAreaPositionChanged = false;
document.removeEventListener("mousemove", handleDetachedUserAreaMouseMove, true);
document.removeEventListener("mouseup", stopDetachedUserAreaDrag, true);
}
function startDetachedUserAreaDrag(event: ReactMouseEvent<HTMLElement>) {
if (!shouldDetachUserArea() || event.button !== 0) return;
const path = event.nativeEvent.composedPath();
if (path.some(node => node !== event.currentTarget && isInteractiveDragTarget(node))) return;
const rect = event.currentTarget.getBoundingClientRect();
const height = Math.min(rect.height, DETACHED_USER_AREA_HEIGHT);
detachedUserAreaDragPosition = getDetachedUserAreaPosition();
detachedUserAreaPositionChanged = false;
userAreaDragState = {
offsetX: event.clientX - rect.left,
offsetY: event.clientY - rect.top,
width: rect.width,
height,
};
document.addEventListener("mousemove", handleDetachedUserAreaMouseMove, true);
document.addEventListener("mouseup", stopDetachedUserAreaDrag, true);
}
const ToolbarMenu = ErrorBoundary.wrap(({ onClose }: { onClose(): void; }) => {
const store = settings.use(collapseSettingKeys);
return (
<Menu.Menu navId="vc-collapsible-ui-toolbar-menu" onClose={onClose} aria-label="Collapsible UI">
{toolbarPanelOrder.map(panelId => {
const panel = panelRegistry[panelId];
const collapsed = store[panel.collapsedKey];
return (
<Menu.MenuCheckboxItem
key={panelId}
id={`vc-collapsible-ui-${panel.classId}`}
label={panel.label}
checked={!collapsed}
action={() => togglePanel(panelId)}
/>
);
})}
</Menu.Menu>
);
}, { noop: true });
const ToolbarButtons = ErrorBoundary.wrap(() => {
const store = settings.use(collapseSettingKeys);
const anyCollapsed = toolbarPanelOrder.some(panelId => store[panelRegistry[panelId].collapsedKey]);
return (
<ChannelToolbarButton
icon={PanelsIcon}
tooltip="Collapsible UI"
aria-label="Collapsible UI"
selected={anyCollapsed}
onClick={openToolbarMenu}
onContextMenu={openToolbarMenu}
/>
);
}, { noop: true });
const CollapsedMenuButton = ErrorBoundary.wrap(() => (
<Clickable
className={cl("restore-button")}
role="button"
tabIndex={0}
aria-label="Collapsible UI"
onClick={openToolbarMenu}
onContextMenu={openToolbarMenu}
>
<PanelsIcon width={18} height={18} />
</Clickable>
), { noop: true });
const ChatButtonsRow = ErrorBoundary.wrap(({ buttons }: { buttons: ReactNode[]; }) => {
const chatButtonsCollapsed = usePanelCollapsed("chatButtons");
if (buttons.length === 0) return <>{buttons}</>;
return (
<div className={classes(cl("chat-buttons"), chatButtonsCollapsed && cl("chat-buttons-collapsed"))}>
<div className={cl("chat-buttons-items")}>
{buttons}
</div>
<CollapsedMenuButton />
</div>
);
}, { noop: true });
export default definePlugin({
name: "CollapsibleUI",
description: "Native collapsible channel, member, chat button, and user area surfaces.",
tags: ["Appearance", "Customisation", "Chat", "Servers"],
dependencies: ["HeaderBarAPI", "ChatInputButtonAPI", "SurfaceClassesAPI"],
authors: [EquicordDevs.benjii],
searchTerms: ["ui", "sidebar", "collapsible"],
managedStyle,
settings,
headerBarButton: {
icon: PanelsIcon,
location: "channeltoolbar",
priority: 25,
render: () => <ToolbarButtons />,
},
chatBarButtonWrapper: {
wrapper: (buttons: ReactNode) => {
if (!Array.isArray(buttons) || buttons.length === 0) return buttons;
return <ChatButtonsRow buttons={buttons} />;
},
priority: 0,
},
start() {
const panelAttr = (classId: string, collapsed: boolean): SurfaceProvidedProps => ({
[`data-vc-collapsible-ui-${classId}`]: "",
[`data-vc-collapsible-ui-${classId}-${collapsed ? "collapsed" : "expanded"}`]: "",
} as SurfaceProvidedProps);
providerUnsubs = [
addSurfacePropsProvider("guildBar", () => panelAttr(panelRegistry.guildBar.classId, isPanelCollapsed("guildBar"))),
addSurfacePropsProvider("channelList", () => {
const attrs: SurfaceProvidedProps = panelAttr(panelRegistry.channelList.classId, isPanelCollapsed("channelList"));
attrs.ref = setChannelListElement;
return attrs;
}),
addSurfacePropsProvider("membersList", () => panelAttr(panelRegistry.membersList.classId, isPanelCollapsed("membersList"))),
addSurfacePropsProvider("titleBar", () => panelAttr(panelRegistry.titleBar.classId, isPanelCollapsed("titleBar"))),
addSurfacePropsProvider("headerBar", () => {
const collapsed = isPanelCollapsed("headerBar");
const attrs: SurfaceProvidedProps = panelAttr(panelRegistry.headerBar.classId, collapsed);
if (collapsed && headerBarExpandedByInteraction) {
attrs["data-vc-collapsible-ui-header-bar-interaction-expanded"] = "";
}
attrs.onFocusCapture = () => {
if (isPanelCollapsed("headerBar")) setHeaderBarExpandedByInteraction(true);
};
attrs.onBlurCapture = event => {
if (containsRelatedTarget(event)) return;
if (isPanelCollapsed("headerBar")) setHeaderBarExpandedByInteraction(false);
};
return attrs;
}),
addSurfacePropsProvider("userArea", () => {
const uaCollapsed = isPanelCollapsed("userArea");
const clCollapsed = isPanelCollapsed("channelList");
const gbCollapsed = isPanelCollapsed("guildBar");
const userAreaDetached = shouldDetachUserArea();
const attrs: SurfaceProvidedProps = panelAttr(panelRegistry.userArea.classId, uaCollapsed);
attrs.ref = setUserAreaElement;
if (userAreaDetached) {
const position = getDetachedUserAreaPosition();
attrs["data-vc-collapsible-ui-user-area-detached"] = "";
attrs.style = {
left: 0,
top: 0,
transform: `translate3d(${position.x}px, ${position.y}px, 0)`,
};
attrs.onMouseDownCapture = startDetachedUserAreaDrag;
}
if (!userAreaDetached && clCollapsed) {
attrs["data-vc-collapsible-ui-user-area-channel-list-collapsed"] = "";
}
if (!userAreaDetached && gbCollapsed) {
attrs["data-vc-collapsible-ui-user-area-guild-bar-collapsed"] = "";
}
return attrs;
}),
addSurfacePropsProvider("base", () => {
const channelListCollapsed = isPanelCollapsed("channelList");
const headerBarCollapsed = isPanelCollapsed("headerBar");
return {
"data-vc-collapsible-ui-base": "",
[`data-vc-collapsible-ui-base-channel-list-${channelListCollapsed ? "collapsed" : "expanded"}`]: "",
...(channelListCollapsed && channelListExpandedByInteraction ? { "data-vc-collapsible-ui-base-channel-list-interaction-expanded": "" } : {}),
...(headerBarCollapsed && !headerBarExpandedByInteraction ? { "data-vc-collapsible-ui-base-header-bar-collapsed": "" } : {}),
...(headerBarCollapsed && headerBarExpandedByInteraction ? { "data-vc-collapsible-ui-base-header-bar-expanded": "" } : {}),
} as SurfaceProvidedProps;
}),
addSurfacePropsProvider("sidebar", () => {
const collapsed = isPanelCollapsed("channelList");
const userAreaDetached = shouldDetachUserArea();
return {
"data-vc-collapsible-ui-sidebar": "",
[`data-vc-collapsible-ui-sidebar-channel-list-${collapsed ? "collapsed" : "expanded"}`]: "",
...(collapsed && channelListExpandedByInteraction ? { "data-vc-collapsible-ui-sidebar-channel-list-expanded": "" } : {}),
...(userAreaDetached ? { "data-vc-collapsible-ui-sidebar-user-area-detached": "" } : {}),
onFocusCapture: handleSidebarEnter,
onBlurCapture: handleSidebarLeave,
onMouseOverCapture: handleSidebarEnter,
onMouseOutCapture: handleSidebarLeave,
} as SurfaceProvidedProps;
}),
];
setCollapseSettingChangeHandler(syncPanelCollapsedState);
setUserAreaDetachSettingChangeHandler(syncUserAreaDetachState);
syncAllPanelCollapsedStates();
},
stop() {
setCollapseSettingChangeHandler(undefined);
setUserAreaDetachSettingChangeHandler(undefined);
stopDetachedUserAreaDrag();
providerUnsubs.forEach(unsub => unsub());
providerUnsubs = [];
setHeaderBarPointerTrackerEnabled(false);
channelListElement = null;
userAreaElement = null;
channelListExpandedByInteraction = false;
headerBarExpandedByInteraction = false;
surfaceCssPxCache.clear();
},
});

View file

@ -0,0 +1,149 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { OptionType } from "@utils/types";
export const panelRegistry = {
guildBar: {
id: "guildBar",
classId: "guild-bar",
label: "Guild Bar",
collapsedKey: "guildBarCollapsed",
},
channelList: {
id: "channelList",
classId: "channel-list",
label: "Channel List",
collapsedKey: "channelListCollapsed",
},
membersList: {
id: "membersList",
classId: "members-list",
label: "Members List",
collapsedKey: "membersListCollapsed",
},
chatButtons: {
id: "chatButtons",
classId: "chat-buttons",
label: "Message Buttons",
collapsedKey: "chatButtonsCollapsed",
},
titleBar: {
id: "titleBar",
classId: "title-bar",
label: "Title Bar",
collapsedKey: "titleBarCollapsed",
},
headerBar: {
id: "headerBar",
classId: "header-bar",
label: "Header Bar",
collapsedKey: "headerBarCollapsed",
},
userArea: {
id: "userArea",
classId: "user-area",
label: "User Area",
collapsedKey: "userAreaCollapsed",
},
} as const;
export type PanelId = keyof typeof panelRegistry;
export const toolbarPanelOrder = ["guildBar", "channelList", "membersList", "chatButtons", "titleBar", "headerBar", "userArea"] as const satisfies readonly PanelId[];
export const collapseSettingKeys = toolbarPanelOrder.map(panelId => panelRegistry[panelId].collapsedKey);
type CollapseSettingChangeHandler = (panelId: PanelId, collapsed: boolean) => void;
type UserAreaDetachSettingChangeHandler = () => void;
let collapseSettingChangeHandler: CollapseSettingChangeHandler | undefined;
let userAreaDetachSettingChangeHandler: UserAreaDetachSettingChangeHandler | undefined;
export function setCollapseSettingChangeHandler(handler: CollapseSettingChangeHandler | undefined) {
collapseSettingChangeHandler = handler;
}
export function setUserAreaDetachSettingChangeHandler(handler: UserAreaDetachSettingChangeHandler | undefined) {
userAreaDetachSettingChangeHandler = handler;
}
function onCollapseSettingChanged(panelId: PanelId) {
return (collapsed: boolean) => collapseSettingChangeHandler?.(panelId, collapsed);
}
export const settings = definePluginSettings({
detachUserArea: {
type: OptionType.BOOLEAN,
description: "Detach the user area so it can be moved freely when it is not collapsed.",
default: false,
onChange: () => userAreaDetachSettingChangeHandler?.(),
},
detachedUserAreaX: {
type: OptionType.NUMBER,
description: "Persist the detached user area x position.",
default: -1,
hidden: true,
onChange: () => userAreaDetachSettingChangeHandler?.(),
},
detachedUserAreaY: {
type: OptionType.NUMBER,
description: "Persist the detached user area y position.",
default: -1,
hidden: true,
onChange: () => userAreaDetachSettingChangeHandler?.(),
},
guildBarCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the guild bar as collapsed.",
default: false,
hidden: true,
onChange: onCollapseSettingChanged("guildBar"),
},
channelListCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the channel list as collapsed.",
default: false,
hidden: true,
onChange: onCollapseSettingChanged("channelList"),
},
membersListCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the members list as collapsed.",
default: false,
hidden: true,
onChange: onCollapseSettingChanged("membersList"),
},
chatButtonsCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the message button row as collapsed.",
default: false,
hidden: true,
onChange: onCollapseSettingChanged("chatButtons"),
},
titleBarCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the title bar as collapsed.",
default: false,
hidden: true,
onChange: onCollapseSettingChanged("titleBar"),
},
headerBarCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the header bar as collapsed.",
default: false,
hidden: true,
onChange: onCollapseSettingChanged("headerBar"),
},
userAreaCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the user area as collapsed.",
default: false,
hidden: true,
onChange: onCollapseSettingChanged("userArea"),
},
});

View file

@ -0,0 +1,349 @@
[data-vc-collapsible-ui-base],
[data-vc-collapsible-ui-guild-bar],
[data-vc-collapsible-ui-channel-list],
[data-vc-collapsible-ui-members-list],
[data-vc-collapsible-ui-title-bar],
[data-vc-collapsible-ui-header-bar],
[data-vc-collapsible-ui-user-area],
.vc-collapsible-ui-chat-buttons {
--vc-cui-peek-size: 12px;
--vc-cui-user-area-collapsed-width: 52px;
--vc-cui-header-bar-height: 32px;
--vc-cui-collapsed-block-size: 8px;
--vc-cui-guild-bar-expanded-width: 64px;
--vc-cui-channel-list-min-width: 264px;
--vc-cui-channel-list-max-width: 432px;
--vc-cui-members-list-expanded-width: 240px;
--vc-cui-title-bar-expanded-height: 46px;
--vc-cui-chat-buttons-offset: 24px;
--vc-cui-restore-button-size: 24px;
--vc-cui-fade-delay: 80ms;
--vc-cui-transition: 140ms ease;
}
[data-vc-collapsible-ui-base-channel-list-collapsed] {
/* stylelint-disable-next-line value-keyword-case */
grid-template-columns: [start] min-content [guildsEnd] min-content [sidebarEnd] var(--vc-cui-peek-size) [channelsEnd] 1fr [end] !important;
}
[data-vc-collapsible-ui-base-channel-list-collapsed][data-vc-collapsible-ui-base-channel-list-interaction-expanded] {
/* stylelint-disable-next-line value-keyword-case */
grid-template-columns: [start] min-content [guildsEnd] min-content [sidebarEnd] var(--custom-guild-sidebar-width, 312px) [channelsEnd] 1fr [end] !important;
}
[data-vc-collapsible-ui-guild-bar],
[data-vc-collapsible-ui-channel-list],
[data-vc-collapsible-ui-members-list],
[data-vc-collapsible-ui-title-bar],
[data-vc-collapsible-ui-header-bar],
[data-vc-collapsible-ui-user-area] {
overflow: hidden;
transition: opacity var(--vc-cui-transition);
}
[data-vc-collapsible-ui-sidebar] {
position: relative;
}
[data-vc-collapsible-ui-sidebar-user-area-detached] {
--custom-app-panels-height: 0px !important;
}
[data-vc-collapsible-ui-sidebar-channel-list-collapsed] {
z-index: 5;
overflow: visible !important;
}
[data-vc-collapsible-ui-guild-bar-collapsed],
[data-vc-collapsible-ui-channel-list-collapsed],
[data-vc-collapsible-ui-members-list-collapsed] {
width: var(--vc-cui-peek-size) !important;
min-width: var(--vc-cui-peek-size) !important;
max-width: var(--vc-cui-peek-size) !important;
flex: 0 0 var(--vc-cui-peek-size) !important;
opacity: 0.01;
/* Delay only the opacity fade; dimensions change immediately to avoid animated layout work. */
transition-delay: var(--vc-cui-fade-delay);
}
[data-vc-collapsible-ui-guild-bar-collapsed]:is(:hover, :focus-within) {
width: var(--vc-cui-guild-bar-expanded-width) !important;
min-width: var(--vc-cui-guild-bar-expanded-width) !important;
max-width: var(--vc-cui-guild-bar-expanded-width) !important;
flex-basis: var(--vc-cui-guild-bar-expanded-width) !important;
opacity: 1;
transition-delay: 0ms;
}
[data-vc-collapsible-ui-channel-list-expanded],
[data-vc-collapsible-ui-channel-list-collapsed]:is(:hover, :focus-within) {
width: var(--custom-guild-sidebar-width, 312px) !important;
min-width: var(--vc-cui-channel-list-min-width) !important;
max-width: var(--vc-cui-channel-list-max-width) !important;
flex-basis: var(--custom-guild-sidebar-width, 312px) !important;
opacity: 1;
transition-delay: 0ms;
}
[data-vc-collapsible-ui-sidebar-channel-list-expanded] {
width: var(--custom-guild-sidebar-width, 312px) !important;
min-width: var(--custom-guild-sidebar-width, 312px) !important;
max-width: var(--custom-guild-sidebar-width, 312px) !important;
}
[data-vc-collapsible-ui-sidebar-channel-list-expanded] [data-vc-collapsible-ui-channel-list-collapsed] {
width: var(--custom-guild-sidebar-width, 312px) !important;
min-width: var(--vc-cui-channel-list-min-width) !important;
max-width: var(--vc-cui-channel-list-max-width) !important;
flex-basis: var(--custom-guild-sidebar-width, 312px) !important;
opacity: 1;
}
[data-vc-collapsible-ui-members-list-collapsed] {
flex: 0 1 auto !important;
flex-basis: auto !important;
}
[data-vc-collapsible-ui-members-list-collapsed]:is(:hover, :focus-within) {
width: var(--vc-cui-members-list-expanded-width) !important;
min-width: var(--vc-cui-members-list-expanded-width) !important;
max-width: var(--vc-cui-members-list-expanded-width) !important;
flex-basis: auto !important;
opacity: 1;
}
[data-vc-collapsible-ui-title-bar-collapsed] {
height: var(--vc-cui-collapsed-block-size) !important;
min-height: var(--vc-cui-collapsed-block-size) !important;
max-height: var(--vc-cui-collapsed-block-size) !important;
padding-block: 0 !important;
color: transparent !important;
opacity: 0.01 !important;
}
[data-vc-collapsible-ui-title-bar-collapsed]:is(:hover, :focus-within) {
height: var(--vc-cui-title-bar-expanded-height) !important;
min-height: var(--vc-cui-title-bar-expanded-height) !important;
max-height: var(--vc-cui-title-bar-expanded-height) !important;
color: inherit !important;
opacity: 1 !important;
}
[data-vc-collapsible-ui-base-header-bar-collapsed] {
--custom-app-top-bar-height: var(--vc-cui-collapsed-block-size) !important;
}
[data-vc-collapsible-ui-base-header-bar-expanded] {
--custom-app-top-bar-height: var(--vc-cui-header-bar-height) !important;
}
[data-vc-collapsible-ui-header-bar-collapsed] {
position: relative;
z-index: 10;
height: var(--vc-cui-collapsed-block-size) !important;
min-height: var(--vc-cui-collapsed-block-size) !important;
max-height: var(--vc-cui-collapsed-block-size) !important;
opacity: 0.01;
-webkit-app-region: drag;
overflow: visible;
}
[data-vc-collapsible-ui-header-bar-expanded] {
opacity: 1;
}
[data-vc-collapsible-ui-header-bar-collapsed]::before {
position: absolute;
z-index: 2;
inset: 0;
height: var(--vc-cui-collapsed-block-size);
content: "";
pointer-events: auto;
-webkit-app-region: no-drag;
}
[data-vc-collapsible-ui-header-bar-collapsed] > * {
pointer-events: none;
}
[data-vc-collapsible-ui-user-area-collapsed] > * {
opacity: 0;
pointer-events: none;
}
[data-vc-collapsible-ui-header-bar-collapsed][data-vc-collapsible-ui-header-bar-interaction-expanded] {
height: var(--vc-cui-header-bar-height) !important;
min-height: var(--vc-cui-header-bar-height) !important;
max-height: var(--vc-cui-header-bar-height) !important;
opacity: 1;
overflow: visible;
}
[data-vc-collapsible-ui-header-bar-collapsed][data-vc-collapsible-ui-header-bar-interaction-expanded]::before {
display: none;
}
[data-vc-collapsible-ui-header-bar-collapsed][data-vc-collapsible-ui-header-bar-interaction-expanded] > * {
pointer-events: auto;
}
[data-vc-collapsible-ui-user-area] {
background: var(--background-secondary-alt) !important;
}
[data-vc-collapsible-ui-user-area-collapsed] {
height: var(--vc-cui-peek-size) !important;
min-height: var(--vc-cui-peek-size) !important;
max-height: var(--vc-cui-peek-size) !important;
padding-block: 0 !important;
border-top-width: 0 !important;
}
[data-vc-collapsible-ui-user-area-collapsed]:is(:hover, :focus-within) {
height: auto !important;
min-height: 0 !important;
max-height: none !important;
}
[data-vc-collapsible-ui-user-area-collapsed]:is(:hover, :focus-within) > * {
opacity: 1;
pointer-events: auto;
}
[data-vc-collapsible-ui-sidebar-channel-list-collapsed]:not([data-vc-collapsible-ui-sidebar-user-area-detached]) [data-vc-collapsible-ui-user-area] {
width: var(--vc-cui-user-area-collapsed-width) !important;
min-width: var(--vc-cui-user-area-collapsed-width) !important;
max-width: var(--vc-cui-user-area-collapsed-width) !important;
z-index: 4;
}
[data-vc-collapsible-ui-sidebar-channel-list-collapsed]:not([data-vc-collapsible-ui-sidebar-user-area-detached]) [data-vc-collapsible-ui-user-area-expanded] {
position: absolute !important;
right: auto !important;
bottom: 6px !important;
left: 6px !important;
height: var(--vc-cui-user-area-collapsed-width) !important;
min-height: var(--vc-cui-user-area-collapsed-width) !important;
max-height: var(--vc-cui-user-area-collapsed-width) !important;
flex: 0 0 auto !important;
align-self: flex-start;
overflow: hidden !important;
}
[data-vc-collapsible-ui-sidebar-channel-list-collapsed]:not([data-vc-collapsible-ui-sidebar-user-area-detached]) [data-vc-collapsible-ui-user-area-expanded] > * {
width: var(--custom-guild-sidebar-width, 312px);
min-width: var(--vc-cui-channel-list-min-width);
max-width: var(--vc-cui-channel-list-max-width);
opacity: 1;
pointer-events: auto;
}
[data-vc-collapsible-ui-sidebar-channel-list-collapsed]:not([data-vc-collapsible-ui-sidebar-user-area-detached]) [data-vc-collapsible-ui-user-area-expanded]:is(:hover, :focus-within) {
width: var(--custom-guild-sidebar-width, 312px) !important;
min-width: var(--vc-cui-channel-list-min-width) !important;
max-width: var(--vc-cui-channel-list-max-width) !important;
height: auto !important;
min-height: 0 !important;
max-height: none !important;
overflow: visible !important;
}
[data-vc-collapsible-ui-sidebar-channel-list-collapsed]:not([data-vc-collapsible-ui-sidebar-user-area-detached]) [data-vc-collapsible-ui-user-area-guild-bar-collapsed][data-vc-collapsible-ui-user-area-collapsed] {
width: var(--vc-cui-peek-size) !important;
min-width: var(--vc-cui-peek-size) !important;
max-width: var(--vc-cui-peek-size) !important;
}
[data-vc-collapsible-ui-sidebar-channel-list-expanded]:not([data-vc-collapsible-ui-sidebar-user-area-detached]) [data-vc-collapsible-ui-user-area] {
width: var(--custom-guild-sidebar-width, 312px) !important;
min-width: var(--vc-cui-channel-list-min-width) !important;
max-width: var(--vc-cui-channel-list-max-width) !important;
}
[data-vc-collapsible-ui-user-area-detached] {
position: fixed !important;
right: auto !important;
bottom: auto !important;
width: var(--custom-guild-sidebar-width, 312px) !important;
min-width: var(--custom-guild-sidebar-width, 312px) !important;
max-width: var(--custom-guild-sidebar-width, 312px) !important;
height: auto !important;
min-height: 0 !important;
max-height: none !important;
cursor: grab;
z-index: 2 !important;
}
[data-vc-collapsible-ui-user-area-detached]:active {
cursor: grabbing;
}
.vc-collapsible-ui-chat-buttons {
position: relative;
display: flex;
align-items: center;
gap: 4px;
min-inline-size: 0;
}
.vc-collapsible-ui-chat-buttons-items {
display: flex;
align-items: center;
min-inline-size: 0;
overflow: hidden;
transition:
transform var(--vc-cui-transition),
opacity var(--vc-cui-transition);
transform-origin: right center;
}
.vc-collapsible-ui-chat-buttons-collapsed .vc-collapsible-ui-chat-buttons-items {
position: absolute;
top: 50%;
right: var(--vc-cui-chat-buttons-offset);
width: max-content;
opacity: 0;
pointer-events: none;
transform: translateY(-50%) scaleX(0);
transition-delay: var(--vc-cui-fade-delay);
}
.vc-collapsible-ui-chat-buttons-collapsed:is(:hover, :focus-within) .vc-collapsible-ui-chat-buttons-items {
opacity: 1;
pointer-events: auto;
transform: translateY(-50%) scaleX(1);
transition-delay: 0ms;
}
.vc-collapsible-ui-restore-button {
display: flex;
align-items: center;
justify-content: center;
width: var(--vc-cui-restore-button-size);
height: var(--vc-cui-restore-button-size);
border-radius: var(--radius-sm);
color: var(--interactive-normal);
}
.vc-collapsible-ui-restore-button:hover {
color: var(--interactive-hover);
background: var(--background-modifier-hover);
}
.vc-collapsible-ui-chat-buttons:not(.vc-collapsible-ui-chat-buttons-collapsed) .vc-collapsible-ui-restore-button {
display: none;
}
@media (prefers-reduced-motion: reduce) {
[data-vc-collapsible-ui-guild-bar],
[data-vc-collapsible-ui-channel-list],
[data-vc-collapsible-ui-members-list],
[data-vc-collapsible-ui-title-bar],
[data-vc-collapsible-ui-header-bar],
[data-vc-collapsible-ui-user-area],
.vc-collapsible-ui-chat-buttons,
.vc-collapsible-ui-chat-buttons-items {
--vc-cui-transition: 0ms;
}
}