/**
* @name Braid Layout
* @description Vastly simplified two-edge layout: folds the server rail into the sidebar, docks the full voice controls bottom-left, tucks header extras into a kebab menu, merges message runs into cards, and calms the chrome. One-key toggle (Ctrl+Shift+B) between Braid and the classic layout. Suspends itself while Mod Mode edits the layout. Pairs with the Braid / Braid Light themes, and respects every other theme: cosmetic shape rules use :where() so any [data-theme] override wins.
* @author Amnibro
* @version 1.9
*/
class BraidLayout {
static _DENSITIES = [
{ id: 'compact', label: 'Compact', hint: 'Tighter than stock, more messages on screen', tick: '3px' },
{ id: 'cozy', label: 'Cozy', hint: 'The shipped Braid spacing', tick: '5px' },
{ id: 'spacious', label: 'Spacious', hint: 'Roomier than stock, more air around each run', tick: '7px' },
];
start() {
this._stopping = false;
this._permListeners = []; // survive disengage; removed only in stop()
this._engaged = false;
this._suspended = false;
this._transitioning = false;
HavenApi.DOM.addStyle('BraidPillCSS', BraidLayout._PILL_CSS);
this._buildReturnPill();
// One shortcut, both directions: the way back must never be buried.
// Ctrl+Alt+B is the browser-safe twin (Chrome eats Ctrl+Shift+B on web).
const kd = (e) => {
if (e.ctrlKey && (e.shiftKey || e.altKey) && (e.key === 'B' || e.key === 'b')) {
e.preventDefault();
this._toggleLayout();
}
};
document.addEventListener('keydown', kd, true);
this._permListeners.push([document, 'keydown', kd, true]);
const ownerChanged = (event) => {
if (event.detail?.owner || this._stopping || this._transitioning
|| document.documentElement.hasAttribute('data-haven-layout-editing')) return;
if (HavenApi.Data.load('BraidLayout', 'layoutOn', '1') === '0') return;
if (this._engaged && this._suspended) this._resume();
else if (!this._engaged) this._engage();
};
document.addEventListener('haven:layout-owner-change', ownerChanged);
this._permListeners.push([document, 'haven:layout-owner-change', ownerChanged]);
const editingChanged = (event) => {
if (this._stopping) return;
if (event.detail?.active === true) {
if (this._engaged && !this._suspended) this._suspend();
return;
}
if (HavenApi.Data.load('BraidLayout', 'layoutOn', '1') === '0') return;
if (event.detail?.owner && event.detail.owner !== 'BraidLayout') return;
if (this._engaged) this._resume();
else this._engage();
};
document.addEventListener('haven:layout-editing', editingChanged);
this._permListeners.push([document, 'haven:layout-editing', editingChanged]);
const fxChanged = (event) => {
if (typeof event.detail?.braid !== 'boolean') return;
if (event.detail.braid) this._engage();
else this._disengage();
};
document.addEventListener('haven:layout-effect', fxChanged);
this._permListeners.push([document, 'haven:layout-effect', fxChanged]);
const layoutOn = HavenApi.Data.load('BraidLayout', 'layoutOn', '1') !== '0';
if (layoutOn && !document.documentElement.hasAttribute('data-haven-layout-editing')) this._engage();
else if (layoutOn) console.log('[BraidLayout] Waiting for Mod Mode to finish');
else console.log('[BraidLayout] Started dormant: classic layout (pill or Ctrl+Shift+B to re-engage)');
}
_toggleLayout() {
this._engaged ? this._disengage() : this._engage();
}
// Everything visual lives between _engage and _disengage, so "back to
// normal" is one click without touching the plugin toggle in Settings.
_engage() {
if (this._engaged || document.documentElement.hasAttribute('data-haven-layout-editing')) return;
if (HavenApi.Layout && !HavenApi.Layout.acquire('BraidLayout')) {
HavenApi.UI.showToast('Another structural layout plugin is active', 'warning');
return;
}
this._hidden = new Map(); // el -> { display, hadHidden }
this._lsPrev = new Map(); // localStorage key -> previous value (null = absent)
this._listeners = []; // [target, type, fn, opts]
this._collapsedAdded = []; // elements we added a class to
this._moved = []; // [el, origParent, origNextSibling] for relocations
this._suspended = false;
this._engaged = true;
try {
HavenApi.Data.save('BraidLayout', 'layoutOn', '1');
HavenApi.DOM.addStyle('BraidLayoutCSS', BraidLayout._LAYOUT_CSS);
HavenApi.DOM.addStyle('BraidShapeCSS', BraidLayout._SHAPE_CSS);
HavenApi.DOM.addStyle('BraidFormCSS', BraidLayout._FORM_CSS);
HavenApi.DOM.addStyle('BraidMotionCSS', BraidLayout._MOTION_CSS);
document.documentElement.setAttribute('data-braid-layout', '1');
document.documentElement.setAttribute('data-braid-form', '1');
this._applyDensity();
this._paintOwn();
this._themeBottomIcons();
this._applyTextScales();
// Channel switches get the hex loader moment
this._listen(document, 'click', (e) => {
if (e.target.closest && e.target.closest('.channel-item')) this._showHexLoader();
}, true);
this._collapseJoinCreate();
this._setPeopleOpen(false);
this._buildMoreMenu();
this._applyLayout();
let scheduled = false;
let applying = false;
// childList-only observer: _applyLayout mutates style/attributes and its
// one-time builds are idempotent, so the observer can't feed back on itself.
this._obs = new MutationObserver(() => {
if (applying || scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
if (applying) return;
applying = true;
try { this._applyLayout(); }
finally { applying = false; }
});
});
this._obs.observe(document.getElementById('app-body') || document.body, { childList: true, subtree: true });
console.log('[BraidLayout] Engaged');
document.dispatchEvent(new CustomEvent('haven:braid-layout', { detail: { on: true } }));
} catch (error) {
try { this._disengage(false); }
catch (cleanupError) { console.error('[BraidLayout] Rollback error:', cleanupError); }
throw error;
}
}
stop() {
this._stopping = true;
try {
this._disengage(false);
} finally {
for (const [t, type, fn, opts] of this._permListeners || []) t.removeEventListener(type, fn, opts);
this._permListeners = [];
document.getElementById('braid-return-pill')?.remove();
document.getElementById('braid-mod-done')?.remove();
HavenApi.DOM.removeStyle('BraidPillCSS');
console.log('[BraidLayout] Stopped');
}
}
// persist=false is the plugin-loader disable path: tearing the layer down
// must not overwrite the user's Braid/classic preference.
_disengage(persist = true) {
if (!this._engaged) return;
this._engaged = false;
this._transitioning = true;
try {
if (this._obs) { this._obs.disconnect(); this._obs = null; }
document.getElementById('braid-mod-done')?.remove();
this._unfoldVoiceDock();
this._restoreBottomIcons();
document.getElementById('braid-text-sliders')?.remove();
document.getElementById('braid-density-card')?.remove();
document.getElementById('braid-hex-overlay')?.remove();
document.documentElement.removeAttribute('data-braid-density');
document.documentElement.style.removeProperty('--braid-chat-scale');
document.documentElement.style.removeProperty('--braid-ui-scale');
// Unfold the server rail back to its own column
const bar = document.getElementById('server-bar');
const strip = document.getElementById('braid-server-strip');
if (bar && strip) {
while (strip.firstChild) bar.appendChild(strip.firstChild);
delete bar.dataset.braidFolded;
bar.removeAttribute('aria-hidden');
}
strip?.remove();
document.querySelector('.braid-more-wrap')?.remove();
document.getElementById('braid-more-menu')?.remove();
document.getElementById('braid-theme-btn')?.remove();
document.getElementById('braid-classic-btn')?.remove();
document.getElementById('braid-apps-drawer')?.remove();
// Restore everything we hid
for (const [el, prev] of this._hidden) {
el.style.display = prev.display;
if (!prev.hadHidden) el.removeAttribute('hidden');
}
this._hidden.clear();
// Restore the right sidebar to interactive state
const right = document.getElementById('right-sidebar');
if (right) { right.style.display = ''; right.style.width = ''; right.style.opacity = ''; right.style.pointerEvents = ''; }
// Undo collapse classes we added (leave ones the user already had)
for (const [el, cls] of this._collapsedAdded) el.classList.remove(cls);
this._collapsedAdded = [];
// Restore localStorage keys we introduced
for (const [key, prev] of this._lsPrev) {
try { prev === null ? localStorage.removeItem(key) : localStorage.setItem(key, prev); } catch { /* storage blocked: nothing was saved to restore */ }
}
this._lsPrev.clear();
for (const [t, type, fn, opts] of this._listeners) t.removeEventListener(type, fn, opts);
this._listeners = [];
document.documentElement.classList.remove('braid-people-open', 'braid-sound-open', 'braid-status-open');
document.documentElement.removeAttribute('data-braid-layout');
document.documentElement.removeAttribute('data-braid-form');
document.dispatchEvent(new CustomEvent('haven:braid-layout', { detail: { on: false } }));
document.querySelectorAll('[data-braid-run]').forEach((el) => el.removeAttribute('data-braid-run'));
HavenApi.DOM.removeStyle('BraidLayoutCSS');
HavenApi.DOM.removeStyle('BraidMotionCSS');
HavenApi.DOM.removeStyle('BraidShapeCSS');
HavenApi.DOM.removeStyle('BraidFormCSS');
HavenApi.DOM.removeStyle('BraidFormOwn');
HavenApi.DOM.removeStyle('BraidDensityCSS');
this._suspended = false;
if (persist) HavenApi.Data.save('BraidLayout', 'layoutOn', '0');
console.log('[BraidLayout] Disengaged: classic layout');
} finally {
HavenApi.Layout?.release('BraidLayout');
this._transitioning = false;
}
}
// The classic-mode switch lives INSIDE the stock bottom-left icon bar,
// right next to the theme button, a peer of the other settings, never
// floating over them. Falls back to a fixed pill only if the bar is gone.
_buildReturnPill() {
if (document.getElementById('braid-return-pill')) return;
const pill = document.createElement('button');
pill.type = 'button';
pill.id = 'braid-return-pill';
pill.title = 'Braid layout (Ctrl+Shift+B)';
pill.setAttribute('aria-label', 'Switch to Braid layout');
pill.innerHTML =
'';
pill.addEventListener('click', () => this._engage());
const bar = document.querySelector('.sidebar-bottom-bar');
if (bar) {
pill.className = 'sidebar-bottom-btn braid-return-btn';
const themeBtn = document.getElementById('theme-popup-toggle');
themeBtn && themeBtn.parentElement === bar ? bar.insertBefore(pill, themeBtn.nextSibling) : bar.prepend(pill);
} else {
pill.className = 'braid-return-float';
document.body.appendChild(pill);
}
}
// While Mod Mode edits the layout Braid steps aside. This is the one
// unmissable way back out of the editor.
_showModDone() {
if (document.getElementById('braid-mod-done')) return;
const done = document.createElement('button');
done.type = 'button';
done.id = 'braid-mod-done';
done.innerHTML = '✓ Done editing layout';
done.addEventListener('click', () => {
const mm = window.app && window.app.modMode;
if (mm) mm.toggle();
else document.getElementById('mod-mode-settings-toggle')?.click();
});
document.body.appendChild(done);
}
_listen(target, type, fn, opts) {
target.addEventListener(type, fn, opts);
this._listeners.push([target, type, fn, opts]);
}
_setLS(key, value) {
try {
if (!this._lsPrev.has(key)) this._lsPrev.set(key, localStorage.getItem(key));
localStorage.setItem(key, value);
} catch { /* storage blocked (private mode): the layout still applies, it is just not remembered */ }
}
_hide(el) {
if (!el) return;
// Record the display exactly as found. A banner the user had already
// closed is hidden with display:none, and treating that as "visible"
// brought every dismissed banner back when the layout was switched off
// (#5671).
if (!this._hidden.has(el)) this._hidden.set(el, { display: el.style.display, hadHidden: el.hasAttribute('hidden') });
if (el.style.display !== 'none') el.style.display = 'none';
if (!el.hasAttribute('hidden')) el.setAttribute('hidden', '');
}
_addClass(el, cls) {
if (!el || el.classList.contains(cls)) return;
el.classList.add(cls);
this._collapsedAdded.push([el, cls]);
}
_foldServersIntoSidebar() {
const bar = document.getElementById('server-bar');
const sidebar = document.querySelector('.sidebar');
if (!bar || !sidebar || bar.dataset.braidFolded === '1') return;
let strip = sidebar.querySelector('.braid-server-strip');
if (!strip) {
strip = document.createElement('div');
strip.className = 'braid-server-strip';
strip.id = 'braid-server-strip';
const header = sidebar.querySelector('.sidebar-header');
if (header) sidebar.insertBefore(strip, header);
else sidebar.prepend(strip);
}
// Mod Mode's drag handles stay OUT of the strip: folding them in
// hides them from the editor's own cleanup and they leak, one per
// edit session. Anything mod-owned rides in the (hidden) bar.
strip.querySelectorAll('.mod-panel-handle').forEach((h) => bar.appendChild(h));
[...bar.children].forEach((el) => {
if (!el.classList.contains('mod-panel-handle')) strip.appendChild(el);
});
bar.dataset.braidFolded = '1';
bar.setAttribute('aria-hidden', 'true');
}
// ── Voice dock ───────────────────────────────────────────
// Stock keeps every in-call control (camera, screen share, soundboard,
// listen-together, the settings panel with the stream quality pickers)
// inside the right sidebar, which Braid hides. Relocate the real
// elements (all voice JS is getElementById-based, and their CSS is
// ancestor-free) into a dock above the bottom-left Menu, so a call has
// its full controls without opening the People panel. Original DOM
// positions are recorded and restored LIFO on suspend/disengage.
_foldVoiceDock() {
if (this._suspended || document.getElementById('braid-voice-dock')) return;
const bottom = document.querySelector('.sidebar-bottom');
const panel = document.getElementById('voice-panel');
if (!bottom || !panel) return;
const dock = document.createElement('div');
dock.id = 'braid-voice-dock';
bottom.insertBefore(dock, bottom.firstChild);
const adopt = (el, parent, before) => {
if (!el) return;
this._moved.push([el, el.parentNode, el.nextSibling]);
parent.insertBefore(el, before || null);
};
adopt(document.getElementById('voice-settings-panel'), dock);
adopt(panel, dock);
// Both mute/deafen pairs (header pair is default, bottom-bar pair is
// the haven_sidebar_voice_controls opt-in; stock shows one at a time)
const anchor = panel.firstChild;
adopt(document.getElementById('voice-mute-btn-header'), panel, anchor);
adopt(document.getElementById('voice-deafen-btn-header'), panel, anchor);
adopt(document.getElementById('voice-mute-btn'), panel, anchor);
adopt(document.getElementById('voice-deafen-btn'), panel, anchor);
const people = document.createElement('button');
people.type = 'button';
people.id = 'braid-voice-people-btn';
people.className = 'voice-panel-btn';
people.title = 'People & voice panel';
people.innerHTML =
'';
people.addEventListener('click', () => this._setPeopleOpen(!document.documentElement.classList.contains('braid-people-open')));
panel.insertBefore(people, document.getElementById('voice-settings-toggle'));
}
_unfoldVoiceDock() {
document.getElementById('braid-voice-people-btn')?.remove();
for (const [el, parent, next] of [...(this._moved || [])].reverse()) {
try { parent.insertBefore(el, next && next.parentNode === parent ? next : null); } catch { /* the old spot is gone: the next render rebuilds it */ }
}
this._moved = [];
document.getElementById('braid-voice-dock')?.remove();
}
_collapseJoinCreate() {
if (localStorage.getItem('haven_join_collapsed') === null) this._setLS('haven_join_collapsed', '1');
if (localStorage.getItem('haven_create_collapsed') === null) this._setLS('haven_create_collapsed', '1');
// Only the stock `.collapsed` class may drive visibility here. A second,
// Braid-owned class used to hide the body as well, and because nothing ever
// removed it, the stock toggle could flip `.collapsed` all it liked and the
// section stayed shut: Join a Channel and Create Channel were unreachable
// for as long as the layout was on.
document.querySelectorAll('#join-section-body, #create-section-body').forEach((el) => this._addClass(el, 'collapsed'));
document.querySelectorAll('#join-section-arrow, #create-section-arrow').forEach((el) => this._addClass(el, 'collapsed'));
}
_hideEdgeChrome() {
// Banners are inline-hidden (their features live in the kebab menu).
// Soundboard and status bar are hidden by CSS only, so the kebab
// toggles can bring them back via the braid-sound-open /
// braid-status-open classes.
['desktop-app-banner', 'android-beta-banner', 'update-banner', 'sidebar-toggle-btn'].forEach((id) => {
this._hide(document.getElementById(id));
});
document.querySelectorAll('.sidebar-collapse-btn').forEach((el) => this._hide(el));
if (!document.documentElement.classList.contains('braid-people-open')) {
const right = document.getElementById('right-sidebar');
if (right) {
this._addClass(right, 'collapsed');
if (right.style.display !== 'none') right.style.display = 'none';
}
}
this._setLS('haven_hide_desktop_banner', '1');
this._setLS('haven_hide_android_banner', '1');
this._setLS('haven_members_collapsed', '1');
}
_setPeopleOpen(open) {
document.documentElement.classList.toggle('braid-people-open', !!open);
const right = document.getElementById('right-sidebar');
if (!right) return;
if (open) {
right.classList.remove('collapsed');
right.style.display = '';
right.style.width = '';
right.style.opacity = '';
right.style.pointerEvents = '';
} else {
this._addClass(right, 'collapsed');
if (right.style.display !== 'none') right.style.display = 'none';
}
}
_buildMoreMenu() {
if (document.querySelector('.braid-more-wrap')) return;
// The hamburger lives bottom-left, where the icon bar it absorbed
// used to be; the popups it opens (themes, activities) anchor down
// there, so the menu and its children share a corner.
const bar = document.querySelector('.sidebar-bottom-bar');
const header = document.querySelector('.channel-header');
const host = bar || header;
if (!host) return;
const wrap = document.createElement('div');
wrap.className = 'braid-more-wrap';
wrap.innerHTML =
'';
host.prepend(wrap);
// Themes and the layout switch keep first-class seats next to the Menu,
// always one click, never buried.
if (bar && !document.getElementById('braid-theme-btn')) {
const mk = (id, title, paths, onClick) => {
const b = document.createElement('button');
b.type = 'button';
b.id = id;
b.className = 'braid-bar-btn';
b.title = title;
b.setAttribute('aria-label', title);
b.innerHTML = ``;
b.addEventListener('click', onClick);
bar.appendChild(b);
};
mk('braid-theme-btn', 'Themes',
'',
() => document.getElementById('theme-popup-toggle')?.click());
mk('braid-classic-btn', 'Classic layout (Ctrl+Shift+B)',
'',
() => this._disengage());
}
// The menu lives on
: the blurred header is a containing block
// (backdrop-filter) with overflow:hidden, which would trap and clip
// even a position:fixed dropdown rendered inside it.
const menu = document.createElement('div');
menu.className = 'braid-more-menu';
menu.id = 'braid-more-menu';
menu.setAttribute('role', 'menu');
document.body.appendChild(menu);
const btn = wrap.querySelector('#braid-more-btn');
// One hamburger absorbs the header extras AND the old bottom-left
// icon bar: every Haven feature keeps a door, just behind one
// animated menu instead of chrome on two edges.
let itemIndex = 0;
const mi = (paths) =>
``;
const addLabel = (text) => {
const d = document.createElement('div');
d.className = 'braid-menu-label';
d.textContent = text;
d.style.setProperty('--i', itemIndex++);
menu.appendChild(d);
};
// icon + short label; muted hints are reserved for shortcuts only;
// the menu reads as a glanceable list, not a paragraph.
const addItem = (icon, label, onClick, muted) => {
const b = document.createElement('button');
b.type = 'button';
b.innerHTML = `${mi(icon)}${label}${muted ? `${muted}` : ''}`;
b.style.setProperty('--i', itemIndex++);
b.addEventListener('click', () => { closeMenu(); onClick(); });
menu.appendChild(b);
};
const addProxy = (id, icon, label, muted) => {
const src = document.getElementById(id);
if (src) addItem(icon, label, () => src.click(), muted);
};
const toggleHtmlClass = (cls) => document.documentElement.classList.toggle(cls);
const closeMenu = () => {
menu.classList.remove('open');
wrap.classList.remove('open');
btn.setAttribute('aria-expanded', 'false');
};
const I = {
search: '',
pin: '',
media: '',
copy: '',
code: '',
lock: '',
people: '',
sound: '',
pulse: '',
grid: '',
classic: '',
palette: '',
game: '',
gear: '',
heart: '',
desktop: '',
phone: '',
rows: '',
};
addLabel('Channel');
addProxy('search-toggle-btn', I.search, 'Search');
addProxy('pinned-toggle-btn', I.pin, 'Pinned');
addProxy('gallery-toggle-btn', I.media, 'Files & media');
addProxy('copy-code-btn', I.copy, 'Copy code');
addProxy('channel-code-settings-btn', I.code, 'Code settings');
// Not `e2e-menu-btn`: that only opens a dropdown living inside the header
// wrapper, which is display:none outside a DM and hidden by this layout
// inside one, so the entry did nothing at all. Proxy the three actions
// instead; each opens its own modal. They are marked so the open handler
// can show them only where encryption applies.
['e2e-verify-btn::Verify encryption', 'e2e-recover-btn::Recover keys', 'e2e-reset-btn::Reset keys'].forEach((spec) => {
const [id, label] = spec.split('::');
const src = document.getElementById(id);
if (!src) return;
addItem(I.lock, label, () => src.click());
menu.lastElementChild.dataset.braidE2e = '1';
});
addLabel('View');
addItem(I.people, 'People & voice', () => this._setPeopleOpen(!document.documentElement.classList.contains('braid-people-open')));
addItem(I.sound, 'Soundboard', () => toggleHtmlClass('braid-sound-open'));
addItem(I.pulse, 'Status bar', () => toggleHtmlClass('braid-status-open'));
// Stays open on click: cycling Compact → Cozy → Spacious is something
// you want to see happen behind the menu, not one level per reopen.
const densityItem = document.createElement('button');
densityItem.type = 'button';
densityItem.id = 'braid-density-item';
densityItem.innerHTML = `${mi(I.rows)}Braid spacing`;
densityItem.style.setProperty('--i', itemIndex++);
densityItem.addEventListener('click', (e) => { e.stopPropagation(); this._cycleDensity(); });
menu.appendChild(densityItem);
addItem(I.grid, 'Edit layout', () => {
const mm = window.app && window.app.modMode;
if (mm) mm.toggle();
else document.getElementById('mod-mode-settings-toggle')?.click();
});
addItem(I.classic, 'Classic layout', () => this._disengage(), 'Ctrl⇧B');
addLabel('App');
addProxy('theme-popup-toggle', I.palette, 'Themes');
addProxy('activities-btn', I.game, 'Activities');
addProxy('sidebar-members-btn', I.people, 'All members');
addItem(I.gear, 'Settings', () => {
document.getElementById('open-settings-btn')?.click();
document.getElementById('mobile-settings-btn')?.click();
});
addProxy('donors-btn', I.heart, 'Support Haven');
const desktopBanner = document.getElementById('desktop-app-banner');
if (desktopBanner) addItem(I.desktop, 'Desktop app', () => (desktopBanner.querySelector('a') || desktopBanner).click());
const androidBanner = document.getElementById('android-beta-banner');
if (androidBanner) addItem(I.phone, 'Android app', () => androidBanner.click());
const peopleHdr = document.getElementById('mobile-users-btn');
if (peopleHdr) {
this._listen(peopleHdr, 'click', (e) => {
e.preventDefault();
e.stopPropagation();
this._setPeopleOpen(!document.documentElement.classList.contains('braid-people-open'));
}, true);
}
btn.addEventListener('click', (e) => {
e.stopPropagation();
const opening = !menu.classList.contains('open');
// The menu is built once, so anything channel-dependent has to be
// resolved here rather than at build time.
if (opening) {
const wrapper = document.getElementById('e2e-menu-wrapper');
const e2eOn = !!wrapper && getComputedStyle(wrapper).display !== 'none';
menu.querySelectorAll('[data-braid-e2e]').forEach((el) => { el.style.display = e2eOn ? '' : 'none'; });
}
menu.classList.toggle('open', opening);
wrap.classList.toggle('open', opening);
btn.setAttribute('aria-expanded', opening ? 'true' : 'false');
});
this._listen(document, 'click', (e) => {
if (!wrap.contains(e.target) && !menu.contains(e.target)) closeMenu();
});
}
_quietChips() {
const vc = document.querySelector('.voice-controls');
if (!vc) return;
vc.querySelectorAll('button, .pill, .chip, span, div').forEach((el) => {
const t = (el.textContent || '').toLowerCase();
if (t.includes('get the desktop') || t.includes('android app')) this._hide(el);
});
}
_applyLayout() {
if (this._suspended) return;
this._foldServersIntoSidebar();
this._foldVoiceDock();
this._hideEdgeChrome();
this._quietChips();
this._markRuns();
this._themeBottomIcons();
this._themeSettingsNav();
this._injectTextSliders();
this._injectDensityCard();
}
// ── Density (Settings → Layout & Density card) ───────────
// Three levels either side of the shipped spacing, so the cards can be
// roomier or tighter without losing the rounded run/channel shapes.
_densityValue() {
const v = HavenApi.Data.load('BraidLayout', 'density', 'cozy');
return BraidLayout._DENSITIES.some((d) => d.id === v) ? v : 'cozy';
}
_applyDensity() {
const v = this._densityValue();
document.documentElement.setAttribute('data-braid-density', v);
document.querySelectorAll('#braid-density-card .braid-density-btn').forEach((b) => {
b.classList.toggle('active', b.dataset.braidDensity === v);
b.setAttribute('aria-pressed', String(b.dataset.braidDensity === v));
});
const tag = document.querySelector('#braid-density-item .braid-mi-tag');
if (tag) tag.textContent = BraidLayout._DENSITIES.find((d) => d.id === v).label;
}
_setDensity(v) {
HavenApi.Data.save('BraidLayout', 'density', v);
this._applyDensity();
}
_cycleDensity() {
const ids = BraidLayout._DENSITIES.map((d) => d.id);
this._setDensity(ids[(ids.indexOf(this._densityValue()) + 1) % ids.length]);
}
_injectDensityCard() {
if (document.getElementById('braid-density-card')) return;
const anchor = document.getElementById('section-zoom');
if (!anchor) return;
const card = document.createElement('div');
card.className = 'settings-section';
card.id = 'braid-density-card';
card.innerHTML = `
🪢 Braid spacing
How much room the message runs and channel rows take. The rounded cards stay the same at every level.