/** * @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.

${BraidLayout._DENSITIES.map((d) => ` `).join('')}
`; anchor.after(card); card.querySelectorAll('.braid-density-btn').forEach((b) => { b.addEventListener('click', () => this._setDensity(b.dataset.braidDensity)); }); // Styled from a stylesheet rather than inline attributes: an inline // background would outrank the .active rule and the selected level // would never light up. HavenApi.DOM.addStyle('BraidDensityCSS', ` #braid-density-card .braid-density-hint{font-size:.75rem;color:var(--text-muted);margin:.25rem 0 0} #braid-density-card .braid-density-row{display:flex;gap:.5rem;margin-top:.625rem} #braid-density-card .braid-density-btn{flex:1;display:flex;flex-direction:column;align-items:center;gap:.25rem;padding:.625rem .5rem;border-radius:.75rem;border:1px solid var(--braid-btn-line,var(--border));background:var(--braid-btn-bg,var(--bg-tertiary));color:var(--text-secondary);cursor:pointer;font:inherit} #braid-density-card .braid-density-btn:hover{background:var(--braid-btn-bg-hover,var(--bg-hover));color:var(--text-primary)} #braid-density-card .braid-density-btn.active{background:color-mix(in srgb,var(--accent) 16%,var(--bg-tertiary));border-color:var(--accent);color:var(--accent)} #braid-density-card .braid-density-ticks{display:flex;flex-direction:column;width:1.25rem} #braid-density-card .braid-density-ticks>i{height:2px;border-radius:1px;background:currentColor} #braid-density-card .braid-density-label{font-size:.75rem} ${BraidLayout._DENSITIES.map((d) => `#braid-density-card .braid-density-btn[data-braid-density="${d.id}"] .braid-density-ticks{gap:${d.tick}}`).join('\n')}`); this._applyDensity(); } // ── Text size sliders (Settings → Text Size card) ──────── _applyTextScales() { const chat = parseFloat(HavenApi.Data.load('BraidLayout', 'chatScale', '1')) || 1; const ui = parseFloat(HavenApi.Data.load('BraidLayout', 'uiScale', '1')) || 1; document.documentElement.style.setProperty('--braid-chat-scale', chat); document.documentElement.style.setProperty('--braid-ui-scale', ui); } _injectTextSliders() { if (document.getElementById('braid-text-sliders')) return; const anchor = document.getElementById('section-zoom'); if (!anchor) return; const card = document.createElement('div'); card.className = 'settings-section'; card.id = 'braid-text-sliders'; const row = (id, label, min, max) => `
${label}
`; card.innerHTML = `
🪢 Braid text size

Continuous sliders, layered on top of Zoom. Chat text and interface text scale independently.

${row('braid-chat-scale', 'Chat text', 80, 160)} ${row('braid-ui-scale', 'Interface text', 85, 140)}`; anchor.after(card); const wire = (id, key) => { const input = card.querySelector(`#${id}`); const val = card.querySelector(`#${id}-val`); const current = Math.round((parseFloat(HavenApi.Data.load('BraidLayout', key, '1')) || 1) * 100); input.value = current; val.textContent = `${current}%`; input.addEventListener('input', () => { const scale = parseInt(input.value, 10) / 100; val.textContent = `${input.value}%`; HavenApi.Data.save('BraidLayout', key, String(scale)); document.documentElement.style.setProperty(key === 'chatScale' ? '--braid-chat-scale' : '--braid-ui-scale', scale); }); }; wire('braid-chat-scale', 'chatScale'); wire('braid-ui-scale', 'uiScale'); } // ── Hex loader ─────────────────────────────────────────── _showHexLoader() { if (this._suspended || document.getElementById('braid-hex-overlay')) return; const main = document.querySelector('.main'); if (!main) return; const overlay = document.createElement('div'); overlay.id = 'braid-hex-overlay'; overlay.innerHTML = ''; main.appendChild(overlay); setTimeout(() => overlay.classList.add('braid-fade'), 520); setTimeout(() => overlay.remove(), 740); } // ── Mod Mode interop ───────────────────────────────────── // While the layout editor is active the Braid gates come off and the // server rail unfolds, so every draggable section and panel handle is // real and visible. On exit the layer re-applies, leaving whatever // section order the user just saved in Mod Mode alone. _suspend() { if (!this._engaged || this._suspended) return; this._transitioning = true; this._suspended = true; try { this._restoreNativeLayout(); HavenApi.Layout?.release('BraidLayout'); } catch (error) { this._suspended = false; throw error; } finally { this._transitioning = false; } } _restoreNativeLayout() { if (document.documentElement.hasAttribute('data-haven-layout-editing')) this._showModDone(); else document.getElementById('braid-mod-done')?.remove(); this._unfoldVoiceDock(); 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'); bar.style.display = ''; } const right = document.getElementById('right-sidebar'); if (right) right.style.display = ''; document.documentElement.removeAttribute('data-braid-layout'); document.documentElement.removeAttribute('data-braid-form'); } _resume() { if (!this._engaged || !this._suspended || document.documentElement.hasAttribute('data-haven-layout-editing')) return; this._transitioning = true; try { if (HavenApi.Layout && !HavenApi.Layout.acquire('BraidLayout')) return; this._suspended = false; document.getElementById('braid-mod-done')?.remove(); document.documentElement.setAttribute('data-braid-layout', '1'); document.documentElement.setAttribute('data-braid-form', '1'); this._applyDensity(); this._setPeopleOpen(document.documentElement.classList.contains('braid-people-open')); this._applyLayout(); } catch (error) { this._suspended = true; try { this._restoreNativeLayout(); } finally { HavenApi.Layout?.release('BraidLayout'); } throw error; } finally { this._transitioning = false; } } // ── Themed bottom-left icons ───────────────────────────── // The stock buttons are colored emoji, which fight every palette. // Swap them for line icons drawn with currentColor so they follow // the theme like the rest of the chrome; originals are stashed for // stop(). Buttons added later (voice mute/deafen appear on join) // are themed by the observer pass. _themeBottomIcons() { const svg = (paths) => ``; const icons = { 'theme-popup-toggle': svg(''), 'activities-btn': svg(''), 'sidebar-members-btn': svg(''), 'mobile-settings-btn': svg(''), 'donors-btn': svg(''), }; // voice mute/deafen deliberately absent: they live in the voice dock // now, where _syncMuteDeafenButtons rewrites textContent on every // state change; an injected SVG would be clobbered mid-call anyway. Object.entries(icons).forEach(([id, markup]) => { const btn = document.getElementById(id); if (!btn || btn.dataset.braidIcon === '1') return; btn.dataset.braidIcon = '1'; btn.dataset.braidOrig = btn.innerHTML; btn.innerHTML = markup; }); } _restoreBottomIcons() { document.querySelectorAll('[data-braid-icon="1"]').forEach((btn) => { btn.innerHTML = btn.dataset.braidOrig || btn.innerHTML; delete btn.dataset.braidIcon; delete btn.dataset.braidOrig; }); } // ── Themed settings-nav icons ──────────────────────────── // Same treatment as the old bottom-left buttons: the leading emoji on // every nav row (and the User/Admin tabs) becomes a currentColor line // glyph, so the rail follows the palette. Unmapped rows get the Haven // hexagon. Idempotent per element; originals restore on disable. _themeSettingsNav() { const svg = (paths, size = 15) => ``; const hex = ''; const map = { 'section-language': '', 'section-layout': '', 'section-zoom': '', 'section-reaction-size': '', 'section-role-display': '', 'section-toolbar-icons': '', 'section-image-display': '', 'section-embed-size': '', 'section-statusbar': '', 'section-chat-behavior': '', 'section-soundboard-mode': '', 'section-sounds': '', 'section-push': '', 'section-activity': '', 'section-score-badges': '', 'section-password': '', 'section-two-factor': '', 'section-recovery': '', 'section-account': '', 'section-plugins': '', 'section-debug': '', 'section-modmode': '', }; document.querySelectorAll('.settings-nav-item').forEach((item) => { if (item.dataset.braidIcon === '1') return; item.dataset.braidIcon = '1'; item.dataset.braidOrig = item.innerHTML; const label = item.querySelector('span'); const paths = map[item.dataset.target] || hex; if (label) { item.innerHTML = svg(paths); item.appendChild(label); } else { const text = item.textContent.replace(/^[^\p{L}\p{N}]+\s*/u, '').trim(); item.innerHTML = `${svg(paths)}${text}`; } }); document.querySelectorAll('.settings-tab').forEach((tab) => { if (tab.dataset.braidIcon === '1') return; tab.dataset.braidIcon = '1'; tab.dataset.braidOrig = tab.innerHTML; const label = tab.querySelector('span'); const paths = tab.dataset.tab === 'admin' ? '' : ''; if (label) { tab.innerHTML = svg(paths, 13); tab.appendChild(label); } }); } // Run position for the merged cards, desktop twin of Haven-Mobile's // braidForm(). This is deliberately NOT :has(+ .message-compact): // Chromium re-runs :has() invalidation on every sibling insert, which // made a 600-message channel load go quadratic (622ms vs 80ms). // Attribute marking here is O(n) per observer batch. // // Runs chain on AUTHOR, not on the app's compact grouping: consecutive // posts by the same user (same persona, no explicit break_chain, not a // system notice) merge into one card even when the app rendered them as // separate full .message elements. A continuing .message gets // data-braid-cont="1": its avatar/header hide and its timestamp // surfaces in the gutter on hover via data-time-short. _markRuns() { const runOf = (first, last) => (first ? (last ? 'solo' : 'start') : (last ? 'end' : 'mid')); const chainKey = (el) => { if (!el || (!el.classList.contains('message') && !el.classList.contains('message-compact'))) return null; if (el.classList.contains('system-message') || el.classList.contains('announcement')) return null; return `${el.dataset.userId || '?'}|${el.dataset.personaId || ''}`; }; const nodes = [...document.querySelectorAll('.messages > .message, .messages > .message-compact')]; for (let i = 0; i < nodes.length; i++) { const el = nodes[i]; const key = chainKey(el); const prevKey = i > 0 ? chainKey(nodes[i - 1]) : null; const nextEl = i + 1 < nodes.length ? nodes[i + 1] : null; const nextKey = chainKey(nextEl); const first = !key || key !== prevKey || el.dataset.breakChain === '1'; const last = !key || key !== nextKey || (nextEl && nextEl.dataset.breakChain === '1'); const v = runOf(first, last); if (el.getAttribute('data-braid-run') !== v) el.setAttribute('data-braid-run', v); const cont = !first && el.classList.contains('message') ? '1' : null; if (cont) { if (el.getAttribute('data-braid-cont') !== '1') el.setAttribute('data-braid-cont', '1'); } else if (el.hasAttribute('data-braid-cont')) el.removeAttribute('data-braid-cont'); } const famOf = (el) => { if (!el || !el.classList || !el.classList.contains('channel-item')) return null; if (el.classList.contains('dm-item')) return 'dm'; return el.classList.contains('sub-channel-item') ? `sub:${el.dataset.parentId || ''}` : 'main'; }; const joinsRun = (a, b) => { const fa = famOf(a), fb = famOf(b); if (!fa || !fb) return false; if (fa === 'dm' && fb === 'dm') return true; if (fb.slice(0, 4) !== 'sub:') return false; return fa === fb || fa === 'main'; }; document.querySelectorAll('.channel-item').forEach((el) => { const v = runOf(!joinsRun(el.previousElementSibling, el), !joinsRun(el, el.nextElementSibling)); if (el.getAttribute('data-braid-run') !== v) el.setAttribute('data-braid-run', v); }); document.querySelectorAll('.channel-item.sub-channel-item').forEach((el) => { const hash = el.querySelector('.channel-hash'); const flat = el.hasAttribute('data-sub-tag') && hash && hash.textContent.trim() === '↳'; if (flat) { if (el.getAttribute('data-braid-flat') !== '1') el.setAttribute('data-braid-flat', '1'); } else if (el.hasAttribute('data-braid-flat')) el.removeAttribute('data-braid-flat'); }); } // Own messages get an accent-tinted card, like mobile. _paintOwn() { let id = null; try { id = (JSON.parse(localStorage.getItem('haven_user') || 'null') || {}).id; } catch { /* corrupt or blocked storage: nothing to highlight */ } if (!id) { HavenApi.DOM.removeStyle('BraidFormOwn'); return; } const sel = `html[data-braid-form="1"] .message[data-user-id="${id}"]>.message-row>.message-body,` + `html[data-braid-form="1"] .message-compact[data-user-id="${id}"]>.message-body`; HavenApi.DOM.addStyle('BraidFormOwn', `${sel}{background:var(--braid-me);border-color:var(--braid-me-line)}` + sel.split(',').map((s) => s + ':hover').join(',') + `{background:color-mix(in srgb,var(--accent) 18%,var(--bg-secondary))}`); } } BraidLayout._LAYOUT_CSS = ` /* Button fills mix from text-primary, not a fixed surface: guaranteed contrast against the ground in light AND dark palettes alike. */ html[data-braid-layout="1"]{--sidebar-width:17.5rem;--braid-bar-h:3rem;--braid-btn-bg:color-mix(in srgb,var(--text-primary) 7%,var(--bg-secondary));--braid-btn-bg-hover:color-mix(in srgb,var(--text-primary) 13%,var(--bg-secondary));--braid-btn-line:color-mix(in srgb,var(--text-primary) 18%,var(--border));--braid-chan-pad-y:.5rem;--braid-chan-pad-x:.625rem;--braid-msg-pad-y:1.125rem;--braid-msg-pad-x:1.75rem} html[data-braid-density="spacious"][data-braid-layout="1"]{--sidebar-width:18.5rem;--braid-chan-pad-y:.6875rem;--braid-chan-pad-x:.75rem;--braid-msg-pad-y:1.5rem;--braid-msg-pad-x:2.25rem} html[data-braid-density="compact"][data-braid-layout="1"]{--sidebar-width:16.25rem;--braid-chan-pad-y:.3125rem;--braid-chan-pad-x:.5rem;--braid-msg-pad-y:.625rem;--braid-msg-pad-x:1rem} html[data-braid-layout="1"] .channel-topic-bar{background:transparent!important;border-bottom:0!important;padding:.125rem 1.75rem .375rem!important;font-size:.71875rem!important;min-height:0!important;line-height:1.4!important;color:var(--text-muted)!important} html[data-braid-layout="1"] .sidebar-section[data-mod-id="join"] .section-label, html[data-braid-layout="1"] .sidebar-section#admin-controls .section-label{padding:.3125rem .5rem!important} html[data-braid-layout="1"] .user-bar{padding:.5rem .625rem!important} html[data-braid-layout="1"] .sidebar-bottom-bar{padding:.375rem .5rem!important} html[data-braid-layout="1"] .message-input-area .icon-btn, html[data-braid-layout="1"] .message-input-area>button, html[data-braid-layout="1"] .message-input-container .icon-btn{width:2rem;height:2rem} html[data-braid-layout="1"] body, html[data-braid-layout="1"] #app{overflow:hidden} html[data-braid-layout="1"] #app-body{display:flex!important;flex-direction:row!important;min-height:0;height:100%} html[data-braid-layout="1"] .server-bar{display:none!important} /* width deliberately NOT !important: the stock resize handle writes an inline style.width (persisted as haven_sidebar_width) and must win */ html[data-braid-layout="1"] .sidebar{width:var(--sidebar-width);min-width:12.5rem;max-width:25rem;flex:0 0 auto!important;background:var(--bg-secondary)!important;border-right:1px solid var(--border)!important;display:flex!important;flex-direction:column!important;position:relative;z-index:5} html[data-braid-layout="1"] .braid-server-strip{display:flex;align-items:center;gap:.3125rem;padding:.625rem .625rem .5rem;overflow-x:auto;border-bottom:1px solid var(--border);flex-shrink:0;scrollbar-width:none} html[data-braid-layout="1"] .braid-server-strip::-webkit-scrollbar{display:none;width:0;height:0} html[data-braid-layout="1"] .braid-server-strip .server-icon{width:2.25rem!important;height:2.25rem!important;min-width:2.25rem;border-radius:.6875rem!important;flex-shrink:0;position:relative} /* #server-list is a plain block div: inside the horizontal strip its children would stack vertically (exactly what broke multi-server setups). Flex it inline so every icon rides the same row. */ html[data-braid-layout="1"] .braid-server-strip #server-list{display:flex;align-items:center;gap:.3125rem;min-width:0;flex-shrink:0} html[data-braid-layout="1"] .braid-server-strip .server-separator{width:1px;height:1.375rem;background:var(--border);border-radius:0;margin:0 .1875rem;flex-shrink:0} html[data-braid-layout="1"] .braid-server-strip .server-icon-img{border-radius:inherit} html[data-braid-layout="1"] .braid-server-strip .server-icon>img{width:100%;height:100%;object-fit:cover;border-radius:inherit;display:block} html[data-braid-layout="1"] .braid-server-strip .server-icon.home .server-icon-text, html[data-braid-layout="1"] .braid-server-strip .server-icon:hover .server-icon-text{color:var(--bg-primary)} html[data-braid-layout="1"] .braid-server-strip .server-icon.add-server:hover .server-icon-text{color:var(--accent)} html[data-braid-layout="1"] .braid-server-strip .server-status-dot{width:.625rem;height:.625rem;bottom:-2px;right:-2px;border-width:2px} html[data-braid-layout="1"] .braid-server-strip .server-icon.manage-servers, html[data-braid-layout="1"] .braid-server-strip .server-icon.sync-servers{background:var(--braid-btn-bg)} html[data-braid-layout="1"] .braid-server-strip .server-icon.add-server{border-color:var(--braid-btn-line)} html[data-braid-layout="1"] .braid-server-strip .server-icon.add-server, html[data-braid-layout="1"] .braid-server-strip .server-icon.manage-servers, html[data-braid-layout="1"] .braid-server-strip .server-icon.sync-servers{width:1.875rem!important;height:1.875rem!important;min-width:1.875rem;border-radius:.5625rem!important} html[data-braid-layout="1"] .braid-server-strip .server-icon.add-server{margin-left:auto} html[data-braid-layout="1"] .braid-server-strip .server-icon.add-server .server-icon-text{font-size:1rem} html[data-braid-layout="1"] .braid-server-strip .server-icon.manage-servers .server-icon-text, html[data-braid-layout="1"] .braid-server-strip .server-icon.sync-servers .server-icon-text{font-size:.875rem} html[data-braid-layout="1"] .sidebar-header{order:0;padding:.625rem .75rem!important;border-bottom:1px solid var(--border)!important;background:color-mix(in srgb,var(--bg-secondary) 92%,transparent)!important;backdrop-filter:saturate(180%) blur(14px);-webkit-backdrop-filter:saturate(180%) blur(14px)} html[data-braid-layout="1"] .brand{margin-bottom:.5rem!important;gap:.5rem!important} html[data-braid-layout="1"] .brand-text{font-size:.9375rem!important;font-weight:650!important;letter-spacing:-.03em!important;text-transform:none!important} html[data-braid-layout="1"] .user-bar{border-radius:.75rem!important;padding:.5rem .625rem!important;gap:.5rem!important;background:var(--braid-btn-bg)!important;border:1px solid var(--braid-btn-line)!important} html[data-braid-layout="1"] .sidebar-mod-container{order:1;flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;padding:2px 0 7px} html[data-braid-layout="1"] .sidebar-section[data-mod-id="join"], html[data-braid-layout="1"] .sidebar-section#admin-controls{border:0!important;padding:2px .625rem!important;margin:0!important} html:not([data-braid-layout="1"]) .braid-more-wrap, html:not([data-braid-layout="1"]) .braid-more-menu, html:not([data-braid-layout="1"]) .braid-bar-btn, html:not([data-braid-layout="1"]) .braid-server-strip{display:none!important} html[data-braid-layout="1"] .sidebar-section[data-mod-id="join"] .section-label, html[data-braid-layout="1"] .sidebar-section#admin-controls .section-label{font-size:.71875rem!important;letter-spacing:0!important;text-transform:none!important;font-weight:550!important;color:var(--text-muted)!important;margin:2px 0!important;padding:.4375rem .5rem;border-radius:.625rem} html[data-braid-layout="1"] .sidebar-section[data-mod-id="join"] .section-label:hover, html[data-braid-layout="1"] .sidebar-section#admin-controls .section-label:hover{background:var(--bg-hover);color:var(--text-primary)} html[data-braid-layout="1"] #join-section-body.collapsed, html[data-braid-layout="1"] #create-section-body.collapsed{display:none!important} html[data-braid-layout="1"] .sidebar-split{flex:1;min-height:0;display:flex;flex-direction:column;border:0!important} html[data-braid-layout="1"] .channel-section{flex:1;min-height:0;padding:2px .375rem .375rem!important;border:0!important} html[data-braid-layout="1"] .dm-section-pane{flex:0 0 auto;max-height:28%;padding:2px .375rem .375rem!important;border-top:1px solid var(--border)!important} html[data-braid-layout="1"] .dm-section-pane[style*="flex"]{max-height:none} html[data-braid-layout="1"] .section-label.channels-toggle, html[data-braid-layout="1"] .section-label.dm-section-label{font-size:.625rem!important;font-weight:650!important;letter-spacing:.12em!important;text-transform:uppercase!important;color:var(--text-muted)!important;margin:.5rem .5rem .25rem!important} html[data-braid-layout="1"] .channel-item{margin:1px .375rem!important;padding:var(--braid-chan-pad-y) var(--braid-chan-pad-x)!important;border-radius:.625rem!important} html[data-braid-layout="1"] .channel-item.active{background:var(--bg-active)!important} html[data-braid-layout="1"] .sidebar-bottom{order:4;border-top:1px solid var(--border)!important;background:var(--bg-secondary)!important;flex-shrink:0} html[data-braid-layout="1"] .sidebar-bottom-bar{padding:.5rem!important;gap:2px!important;display:flex;align-items:center} html[data-braid-layout="1"] .sidebar-bottom-btn{width:2.125rem;height:2.125rem;border-radius:.625rem!important;border:0!important;background:transparent!important;color:var(--text-muted)!important} html[data-braid-layout="1"] .sidebar-bottom-btn:hover{background:var(--bg-hover)!important;color:var(--text-primary)!important} html[data-braid-layout="1"] .theme-popup{position:fixed!important;left:1rem!important;bottom:4rem!important;top:auto!important;right:auto!important;width:min(18.75rem,calc(100vw - 2.5rem))!important;max-height:min(60vh,30rem)!important;overflow:auto!important;z-index:80!important;border-radius:1rem!important;border:1px solid var(--border)!important;box-shadow:0 16px 48px -12px rgba(0,0,0,.28),var(--braid-shadow,0 1px 2px rgba(0,0,0,.2))!important;background:var(--bg-card)!important;padding:.75rem!important} html[data-braid-layout="1"] .main{flex:1!important;min-width:0!important;display:flex!important;flex-direction:column!important;background:var(--bg-primary)!important;position:relative} html[data-braid-layout="1"] .channel-header{flex:0 0 var(--braid-bar-h)!important;min-height:var(--braid-bar-h)!important;max-height:var(--braid-bar-h)!important;padding:0 .75rem 0 1rem!important;gap:.375rem!important;overflow:hidden;display:flex;align-items:center!important;background:color-mix(in srgb,var(--bg-secondary) 90%,transparent)!important;backdrop-filter:saturate(180%) blur(16px)!important;-webkit-backdrop-filter:saturate(180%) blur(16px)!important;border-bottom:1px solid var(--border)!important} html[data-braid-layout="1"] #channel-header-name{font-size:.90625rem!important;font-weight:650!important;letter-spacing:-.02em!important;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:min(40vw,20rem)} html[data-braid-layout="1"] .header-actions-box{display:flex!important;align-items:center;gap:2px!important;padding:0!important;border:0!important;background:transparent!important} html[data-braid-layout="1"] .header-actions-box .channel-code-tag, html[data-braid-layout="1"] .header-actions-box #copy-code-btn, html[data-braid-layout="1"] .header-actions-box #channel-code-settings-btn, html[data-braid-layout="1"] .header-actions-box .header-actions-divider{display:none!important} html[data-braid-layout="1"] .header-actions-box .icon-btn{width:2.125rem;height:2.125rem;border-radius:.625rem;color:var(--text-muted)} html[data-braid-layout="1"] .header-actions-box .icon-btn:hover{background:var(--bg-hover);color:var(--text-primary)} html[data-braid-layout="1"] #desktop-app-banner, html[data-braid-layout="1"] #android-beta-banner, html[data-braid-layout="1"] #update-banner{display:none!important;visibility:hidden!important;pointer-events:none!important;width:0!important;height:0!important;overflow:hidden!important;margin:0!important;padding:0!important} html[data-braid-layout="1"] .voice-controls{display:flex;align-items:center;gap:.25rem;margin-left:auto;flex-shrink:0} html[data-braid-layout="1"] .voice-active-indicator, html[data-braid-layout="1"] .btn-voice{border-radius:999px!important;border:1px solid var(--border)!important;background:var(--bg-tertiary)!important;color:var(--text-secondary)!important;font-size:.75rem!important;font-weight:550!important;padding:.25rem .625rem!important;box-shadow:none!important} html[data-braid-layout="1"] .voice-active-indicator{background:color-mix(in srgb,var(--accent) 10%,var(--bg-tertiary))!important;border-color:color-mix(in srgb,var(--accent) 28%,var(--border))!important;color:var(--accent)!important} html[data-braid-layout="1"] .voice-controls button[style*="background"], html[data-braid-layout="1"] .voice-controls div[style*="background"], html[data-braid-layout="1"] .voice-controls span[style*="background"]{background:var(--bg-tertiary)!important;color:var(--text-secondary)!important;border:1px solid var(--border)!important;border-radius:999px!important;box-shadow:none!important} /* ── Voice dock ── the relocated stock controls, bottom-left. Camera, screen share, soundboard, listen-together, settings (stream quality lives in there), people, leave: every streaming option in reach. */ html[data-braid-layout="1"] #braid-voice-dock{flex-shrink:0;display:flex;flex-direction:column;min-width:0} html[data-braid-layout="1"] #braid-voice-dock .voice-panel{border-top:1px solid var(--border)!important;border-bottom:0!important;background:var(--bg-secondary)!important;padding:.4375rem .5rem!important;gap:.25rem!important;justify-content:flex-start!important;flex-wrap:wrap} html[data-braid-layout="1"] #braid-voice-dock .voice-panel-btn, html[data-braid-layout="1"] #braid-voice-dock .voice-header-btn{width:2rem;height:2rem;border-radius:.625rem!important;border:1px solid var(--braid-btn-line)!important;background:var(--braid-btn-bg)!important;color:var(--text-primary)!important;font-size:.875rem;display:inline-flex;align-items:center;justify-content:center;padding:0;line-height:1;cursor:pointer;transition:background .15s,color .15s,border-color .15s,transform .12s} html[data-braid-layout="1"] #braid-voice-dock .voice-panel-btn:hover, html[data-braid-layout="1"] #braid-voice-dock .voice-header-btn:hover{background:var(--braid-btn-bg-hover)!important;color:var(--text-primary)!important;border-color:color-mix(in srgb,var(--accent) 40%,var(--braid-btn-line))!important} html[data-braid-layout="1"] #braid-voice-dock .voice-panel-btn:active, html[data-braid-layout="1"] #braid-voice-dock .voice-header-btn:active{transform:scale(.92)} html[data-braid-layout="1"] #braid-voice-dock .voice-panel-btn.active, html[data-braid-layout="1"] #braid-voice-dock .voice-header-btn.active{background:color-mix(in srgb,var(--accent) 16%,var(--bg-tertiary))!important;border-color:var(--accent)!important;color:var(--accent)!important} html[data-braid-layout="1"] #braid-voice-dock .voice-panel-btn.sharing{background:color-mix(in srgb,var(--success) 18%,var(--bg-tertiary))!important;border-color:var(--success)!important;color:var(--success)!important} html[data-braid-layout="1"] #braid-voice-dock .voice-panel-btn.muted, html[data-braid-layout="1"] #braid-voice-dock .voice-header-btn.muted{background:color-mix(in srgb,var(--warning) 20%,var(--bg-tertiary))!important;border-color:var(--warning)!important;color:var(--warning)!important} html[data-braid-layout="1"] #braid-voice-dock .voice-panel-divider{height:1.375rem;margin:0 .25rem;background:var(--border)} html[data-braid-layout="1"] #braid-voice-dock .voice-panel-leave{margin-left:auto;width:2rem;height:2rem;font-size:.75rem;background:color-mix(in srgb,var(--danger) 14%,var(--bg-tertiary))!important;border-color:color-mix(in srgb,var(--danger) 45%,var(--border))!important;color:var(--danger)!important} html[data-braid-layout="1"] #braid-voice-dock .voice-panel-leave:hover{background:var(--danger)!important;color:#fff!important;border-color:var(--danger)!important} html[data-braid-layout="1"] #braid-voice-dock .voice-settings-panel{border-top:1px solid var(--border)!important;border-bottom:0!important;background:var(--bg-secondary)!important;max-height:min(46vh,21rem);overflow:auto;padding:.625rem .75rem!important;scrollbar-width:thin} html[data-braid-layout="1"] #braid-voice-dock .voice-settings-select{border-radius:.5625rem!important;border:1px solid var(--border)!important;background:var(--bg-input,var(--bg-tertiary))!important} html[data-braid-layout="1"] #braid-voice-dock .voice-settings-section-label{font-size:.625rem;font-weight:650;letter-spacing:.12em;text-transform:uppercase;color:var(--text-muted)} /* The in-call status bar rides between dock and Menu, same quiet chrome */ html[data-braid-layout="1"] .sidebar-bottom .voice-bar{border-top:1px solid var(--border)!important;background:var(--bg-secondary)!important;padding:.375rem .625rem!important;box-shadow:none!important} html[data-braid-layout="1"] .sidebar-bottom .voice-bar-channel{font-size:.71875rem;color:var(--text-muted)} html[data-braid-layout="1"] .sidebar-bottom .voice-bar-badge{border-radius:999px!important;font-size:.59375rem;border:1px solid var(--braid-btn-line);background:var(--braid-btn-bg)} html[data-braid-layout="1"] .sidebar-bottom .voice-bar-leave{border-radius:.625rem!important;box-shadow:none!important;background:var(--braid-btn-bg)!important;border:1px solid var(--braid-btn-line)!important;color:var(--text-primary)!important} html[data-braid-layout="1"] .sidebar-bottom .voice-bar-leave:hover{background:color-mix(in srgb,var(--danger) 18%,var(--bg-secondary))!important;border-color:var(--danger)!important;color:var(--danger)!important} html[data-braid-layout="1"] .message-area{flex:1;min-height:0;display:flex;flex-direction:column} html[data-braid-layout="1"] .messages{padding:var(--braid-msg-pad-y) var(--braid-msg-pad-x) .5rem!important;width:100%;box-sizing:border-box} html[data-braid-layout="1"] .message-input-area, html[data-braid-layout="1"] .message-input-container{padding:.5rem 1rem .75rem!important;width:100%;box-sizing:border-box;border-top:1px solid var(--border)!important;background:color-mix(in srgb,var(--bg-secondary) 94%,transparent)!important} html[data-braid-layout="1"] .right-sidebar, html[data-braid-layout="1"] .right-sidebar.collapsed, html[data-braid-layout="1"] #right-sidebar{display:none!important;width:0!important;min-width:0!important;max-width:0!important;border:0!important;opacity:0!important;pointer-events:none!important;overflow:hidden!important} html[data-braid-layout="1"].braid-people-open .right-sidebar, html[data-braid-layout="1"].braid-people-open #right-sidebar{display:flex!important;width:var(--right-width,16.25rem)!important;min-width:12.5rem!important;max-width:22.5rem!important;opacity:1!important;pointer-events:auto!important;overflow:hidden!important;background:var(--bg-secondary)!important;border-left:1px solid var(--border)!important;flex:0 0 auto!important} html[data-braid-layout="1"] .sidebar-collapse-btn, html[data-braid-layout="1"] #sidebar-toggle-btn, html[data-braid-layout="1"] .status-bar, html[data-braid-layout="1"] #status-bar, html[data-braid-layout="1"] .status-bar-toggle-tab, html[data-braid-layout="1"] #status-bar-toggle, html[data-braid-layout="1"] #soundboard-sidebar, html[data-braid-layout="1"] .soundboard-sidebar{display:none!important;visibility:hidden!important;pointer-events:none!important} html[data-braid-layout="1"].braid-sound-open #soundboard-sidebar, html[data-braid-layout="1"].braid-sound-open .soundboard-sidebar{display:flex!important;visibility:visible!important;pointer-events:auto!important} html[data-braid-layout="1"].braid-status-open .status-bar, html[data-braid-layout="1"].braid-status-open #status-bar{display:flex!important;visibility:visible!important;pointer-events:auto!important} html[data-braid-layout="1"] .sidebar-bottom-bar{display:flex!important;padding:.5rem .625rem!important;gap:.375rem!important} html[data-braid-layout="1"] .sidebar-bottom-bar>*:not(.braid-more-wrap):not(.braid-bar-btn){display:none!important} html[data-braid-layout="1"] .braid-bar-btn{flex:0 0 auto;width:2.5rem;height:2.5rem;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--braid-btn-line)!important;border-radius:.75rem;background:var(--braid-btn-bg);color:var(--text-secondary);cursor:pointer;padding:0;transition:background .15s,color .15s,border-color .15s,transform .12s} html[data-braid-layout="1"] .braid-bar-btn:hover{background:var(--braid-btn-bg-hover);color:var(--accent);border-color:color-mix(in srgb,var(--accent) 40%,var(--braid-btn-line))!important} html[data-braid-layout="1"] .braid-bar-btn:active{transform:scale(.93)} html[data-braid-layout="1"] .braid-more-wrap{position:relative;flex:1;display:flex} html[data-braid-layout="1"] .braid-more-btn{flex:1;display:flex;align-items:center;gap:.625rem;height:2.5rem;padding:0 .875rem;border:1px solid var(--braid-btn-line)!important;border-radius:.75rem;background:var(--braid-btn-bg);color:var(--text-primary);cursor:pointer;font-size:calc(.8125rem*var(--braid-ui-scale,1));font-weight:600;letter-spacing:-.01em;transition:background .15s,color .15s,border-color .15s} html[data-braid-layout="1"] .braid-more-btn:hover{background:var(--braid-btn-bg-hover);color:var(--text-primary);border-color:color-mix(in srgb,var(--accent) 40%,var(--braid-btn-line))!important} html[data-braid-layout="1"] .braid-ham-label{pointer-events:none} html[data-braid-layout="1"] .braid-ham{display:flex;flex-direction:column;justify-content:center;gap:.25rem;width:1.0625rem;height:1.0625rem} html[data-braid-layout="1"] .braid-ham-line{display:block;height:2px;width:100%;border-radius:2px;background:currentColor;transition:transform .28s cubic-bezier(.16,1,.3,1),opacity .18s ease;transform-origin:center} html[data-braid-layout="1"] .braid-more-wrap.open .braid-more-btn{color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,transparent)} html[data-braid-layout="1"] .braid-more-wrap.open .braid-ham-line:nth-child(1){transform:translateY(.375rem) rotate(45deg)} html[data-braid-layout="1"] .braid-more-wrap.open .braid-ham-line:nth-child(2){opacity:0;transform:scaleX(.2)} html[data-braid-layout="1"] .braid-more-wrap.open .braid-ham-line:nth-child(3){transform:translateY(-.375rem) rotate(-45deg)} html[data-braid-layout="1"] .braid-more-menu{display:none;position:fixed;left:1rem;bottom:3.875rem;top:auto;right:auto;min-width:15rem;max-height:min(72vh,34rem);overflow:auto;z-index:90;background:var(--bg-card);border:1px solid var(--border);border-radius:.875rem;box-shadow:0 16px 48px -12px rgba(0,0,0,.28),var(--braid-shadow,0 1px 2px rgba(0,0,0,.2));padding:.375rem;transform-origin:bottom left} html[data-braid-layout="1"] .braid-more-menu.open{display:block;animation:braid-menu-pop .22s cubic-bezier(.16,1,.3,1) both} @keyframes braid-menu-pop{from{opacity:0;transform:scale(.92) translateY(.375rem)}to{opacity:1;transform:none}} @keyframes braid-item-in{from{opacity:0;transform:translateY(.375rem)}to{opacity:1;transform:none}} html[data-braid-layout="1"] .braid-more-menu.open>button, html[data-braid-layout="1"] .braid-more-menu.open>.braid-menu-label{animation:braid-item-in .3s cubic-bezier(.16,1,.3,1) both;animation-delay:calc(var(--i,0)*18ms)} html[data-braid-layout="1"] .braid-menu-label{font-size:.59375rem;font-weight:650;letter-spacing:.13em;text-transform:uppercase;color:var(--text-muted);padding:.5rem .625rem .25rem} html[data-braid-layout="1"] .braid-more-menu button{display:flex;width:100%;align-items:center;gap:.625rem;border:0;background:transparent;padding:.5rem .625rem;border-radius:.625rem;font-size:calc(.8125rem*var(--braid-ui-scale,1));font-weight:550;color:var(--text-primary);cursor:pointer;text-align:left} html[data-braid-layout="1"] .braid-more-menu button:hover{background:var(--bg-hover)} html[data-braid-layout="1"] .braid-mi{display:flex;flex:0 0 auto;opacity:.7} html[data-braid-layout="1"] .braid-more-menu button:hover .braid-mi{opacity:1;color:var(--accent)} html[data-braid-layout="1"] .braid-more-menu .muted{color:var(--text-muted);font-size:.71875rem;font-weight:450;margin-left:auto} @media (prefers-reduced-motion: reduce){ html[data-braid-layout="1"] .braid-more-menu.open,html[data-braid-layout="1"] .braid-more-menu.open>button,html[data-braid-layout="1"] .braid-more-menu.open>.braid-menu-label{animation:none!important} html[data-braid-layout="1"] .braid-ham-line{transition:none!important} } html[data-braid-layout="1"] .welcome-content{text-align:center;max-width:36ch;padding:2rem 1.25rem;margin:auto} html[data-braid-layout="1"] .welcome-content h2{font-size:1.625rem;font-weight:680;letter-spacing:-.035em;margin:0 0 .625rem} html[data-braid-layout="1"] .welcome-content p{color:var(--text-muted);font-size:.9375rem;line-height:1.5} @media (max-width:53.75rem){ html[data-braid-layout="1"] .messages{padding:.875rem .75rem!important;max-width:none} html[data-braid-layout="1"] .server-bar{display:none!important} html[data-braid-layout="1"].braid-people-open .right-sidebar{position:fixed;right:0;top:0;bottom:0;z-index:40;max-width:86vw!important} } `; BraidLayout._FORM_CSS = ` html[data-braid-form="1"]{ --braid-r:.875rem; --braid-bub:color-mix(in srgb,var(--text-primary) 5%,var(--bg-secondary)); --braid-line:var(--border); --braid-seam:color-mix(in srgb,var(--border) 55%,var(--braid-bub)); --braid-me:color-mix(in srgb,var(--accent) 8%,var(--bg-secondary)); --braid-me-line:color-mix(in srgb,var(--accent) 26%,var(--border)); /* Spacing scale. Every gap in a run card and a channel row reads one of these, so a density level only has to restate the tokens. The corner radii are deliberately NOT in here, the cards stay equally rounded at all three levels. --braid-gutter must stay derived: a continuation indents by exactly the leader's inset + avatar + row gap, so shrinking the avatar without recomputing it drifts the whole run left. */ --braid-inset:.625rem; --braid-avatar:2.25rem; --braid-row-gap:.75rem; --braid-gutter:calc(var(--braid-inset) + var(--braid-avatar) + var(--braid-row-gap)); --braid-body-pad-y:.4375rem; --braid-body-pad-x:.9375rem; --braid-cap-pad:.6875rem; --braid-run-gap:.625rem; --braid-chan-run:.25rem; } html[data-braid-density="spacious"][data-braid-form="1"]{ --braid-inset:.875rem; --braid-avatar:2.5rem; --braid-row-gap:.9375rem; --braid-body-pad-y:.625rem; --braid-body-pad-x:1.125rem; --braid-cap-pad:.9375rem; --braid-run-gap:1rem; --braid-chan-run:.4375rem; } html[data-braid-density="compact"][data-braid-form="1"]{ --braid-inset:.5rem; --braid-avatar:1.75rem; --braid-row-gap:.5rem; --braid-body-pad-y:.25rem; --braid-body-pad-x:.75rem; --braid-cap-pad:.4375rem; --braid-run-gap:.3125rem; --braid-chan-run:.125rem; } html[data-braid-form="1"] .message, html[data-braid-form="1"] .message-compact{background:transparent!important;border:0!important;border-radius:0!important;box-shadow:none!important;margin:0!important} html[data-braid-form="1"] .messages{gap:0!important} html[data-braid-form="1"] .message{padding:0 1.125rem 0 var(--braid-inset)!important} html[data-braid-form="1"] .message-compact{padding:0 1.125rem 0 var(--braid-gutter)!important} html[data-braid-form="1"] .message-row{padding:0!important;gap:var(--braid-row-gap)!important;align-items:flex-start} html[data-braid-form="1"] .message-avatar, html[data-braid-form="1"] .message-avatar-img{width:var(--braid-avatar)!important;height:var(--braid-avatar)!important;min-width:var(--braid-avatar)!important;box-sizing:border-box!important;border:0!important;margin:0!important} html[data-braid-form="1"] .message:hover, html[data-braid-form="1"] .message-compact:hover{background:transparent!important} /* No internal horizontal borders inside a run: every body keeps only its side rails; the run's top and bottom edges are drawn by start/end alone. (The old base rule left border-bottom on every body, which stacked a solid line under the dotted seam and read as a full-width divider.) */ html[data-braid-form="1"] .message>.message-row>.message-body, html[data-braid-form="1"] .message-compact>.message-body{position:relative;flex:1 1 auto;min-width:0;background:var(--braid-bub);border:1px solid var(--braid-line);border-top:0;border-bottom:0;border-radius:0;padding:var(--braid-body-pad-y) var(--braid-body-pad-x)} html[data-braid-form="1"] .message[data-braid-run="start"]>.message-row>.message-body, html[data-braid-form="1"] .message[data-braid-run="solo"]>.message-row>.message-body{border-top:1px solid var(--braid-line);border-top-left-radius:var(--braid-r);border-top-right-radius:var(--braid-r);padding-top:var(--braid-cap-pad);margin-top:var(--braid-run-gap)} html[data-braid-form="1"] .message[data-braid-run="end"]>.message-row>.message-body, html[data-braid-form="1"] .message[data-braid-run="solo"]>.message-row>.message-body, html[data-braid-form="1"] .message-compact[data-braid-run="end"]>.message-body{border-bottom:1px solid var(--braid-line);border-bottom-left-radius:var(--braid-r);border-bottom-right-radius:var(--braid-r);padding-bottom:var(--braid-cap-pad);margin-bottom:var(--braid-run-gap)} /* the only separator inside a run: a small centered dotted seam */ html[data-braid-form="1"] .message-compact>.message-body::before, html[data-braid-form="1"] .message[data-braid-cont="1"]>.message-row>.message-body::before{content:'';position:absolute;left:50%;transform:translateX(-50%);width:min(7rem,45%);top:0;border-top:1px dotted color-mix(in srgb,var(--braid-seam) 75%,transparent);pointer-events:none} /* a .message continuing another author-run: no repeated avatar/header; the gutter keeps its width, and the post's own time shows there on hover */ html[data-braid-form="1"] .message[data-braid-cont="1"]{position:relative} html[data-braid-form="1"] .message[data-braid-cont="1"] .message-avatar, html[data-braid-form="1"] .message[data-braid-cont="1"] .message-avatar-img{visibility:hidden} html[data-braid-form="1"] .message[data-braid-cont="1"]>.message-row>.message-body>.message-header{display:none} html[data-braid-form="1"] .message[data-braid-cont="1"]:hover::before{content:attr(data-time-short);position:absolute;left:var(--braid-inset);top:.5rem;width:var(--braid-avatar);text-align:center;font-size:.5625rem;line-height:1.2;color:var(--text-muted);pointer-events:none} html[data-braid-form="1"] .message>.message-row>.message-body:hover, html[data-braid-form="1"] .message-compact>.message-body:hover{background:color-mix(in srgb,var(--text-primary) 9%,var(--bg-secondary))} html[data-braid-form="1"] .message-user-sep{border-top:0!important;padding-top:0!important} html[data-braid-form="1"] .message.system-message>.message-row>.message-body, html[data-braid-form="1"] .message.announcement>.message-row>.message-body{background:transparent;border:0;border-radius:0} html[data-braid-form="1"] .channel-item{position:relative;margin:0 .5rem!important;border:1px solid var(--braid-line)!important;border-top:0!important;border-radius:0!important;background:var(--braid-bub)} html[data-braid-form="1"] .channel-item[data-braid-run="start"], html[data-braid-form="1"] .channel-item[data-braid-run="solo"]{border-top:1px solid var(--braid-line)!important;border-top-left-radius:.75rem!important;border-top-right-radius:.75rem!important;margin-top:var(--braid-chan-run)!important} html[data-braid-form="1"] .channel-item[data-braid-run="end"], html[data-braid-form="1"] .channel-item[data-braid-run="solo"]{border-bottom-left-radius:.75rem!important;border-bottom-right-radius:.75rem!important;margin-bottom:var(--braid-chan-run)!important} html[data-braid-form="1"] .channel-item[data-braid-run="mid"]::before, html[data-braid-form="1"] .channel-item[data-braid-run="end"]::before{content:'';position:absolute;left:.75rem;right:.75rem;top:0;border-top:1px dashed var(--braid-seam);pointer-events:none} html[data-braid-form="1"] .channel-item[data-braid-flat="1"] .channel-hash{visibility:hidden;position:relative} html[data-braid-form="1"] .channel-item[data-braid-flat="1"] .channel-hash::after{content:'#';visibility:visible;position:absolute;left:0;top:0} html[data-braid-form="1"] .channel-item:hover{background:color-mix(in srgb,var(--text-primary) 10%,var(--bg-secondary))} html[data-braid-form="1"] .channel-item.active{background:color-mix(in srgb,var(--accent) 16%,var(--bg-secondary));border-color:var(--accent)!important} html[data-braid-form="1"] .channel-item.active::before, html[data-braid-form="1"] .channel-item.active + .channel-item::before{display:none!important} html[data-braid-form="1"] :where(.reaction), html[data-braid-form="1"] :where(.reaction-add), html[data-braid-form="1"] :where(.message-reactions>*){border-radius:999px} html[data-braid-form="1"] .message-input-area textarea, html[data-braid-form="1"] .message-input-container textarea{background:var(--bg-primary)!important} `; BraidLayout._SHAPE_CSS = ` html[data-braid-layout="1"]{--radius:.875rem;--radius-sm:.75rem} html[data-braid-layout="1"] ::-webkit-scrollbar{width:.5rem;height:.5rem} html[data-braid-layout="1"] ::-webkit-scrollbar-thumb{background:var(--border-light);border-radius:999px;border:2px solid transparent;background-clip:padding-box} html[data-braid-layout="1"] :focus-visible{outline:2px solid color-mix(in srgb,var(--accent) 55%,transparent);outline-offset:2px;border-radius:.5rem} html[data-braid-layout="1"] :where(.icon-btn){border-radius:.625rem} html[data-braid-layout="1"] :where(.message){border-radius:.875rem} html[data-braid-layout="1"] .message:hover{background:color-mix(in srgb,var(--bg-hover) 80%,transparent)!important;box-shadow:none!important} html[data-braid-layout="1"] .message-row{gap:.875rem!important;padding:.5rem .75rem!important} html[data-braid-layout="1"] .message-avatar, html[data-braid-layout="1"] .message-avatar-img{width:2.375rem!important;height:2.375rem!important;border-radius:.75rem!important;border:0!important;box-shadow:none!important} html[data-braid-layout="1"] .message-author, html[data-braid-layout="1"] .message-username{font-weight:650;letter-spacing:-.01em} html[data-braid-layout="1"] .message-text, html[data-braid-layout="1"] .message-content{line-height:1.58!important;letter-spacing:-.01em} html[data-braid-layout="1"] :where(.btn-send), html[data-braid-layout="1"] :where(.thread-send-btn){border-radius:.875rem;box-shadow:none} html[data-braid-layout="1"] :where(.input-row input){border-radius:.625rem} html[data-braid-layout="1"] .input-row input:focus{border-color:color-mix(in srgb,var(--accent) 45%,var(--border));box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 12%,transparent)} html[data-braid-layout="1"] :where(.btn-sm), html[data-braid-layout="1"] :where(.btn-secondary), html[data-braid-layout="1"] :where(.btn.secondary), html[data-braid-layout="1"] :where(.btn-primary), html[data-braid-layout="1"] :where(.btn-accent), html[data-braid-layout="1"] :where(.profile-dm-btn), html[data-braid-layout="1"] :where(.btn-admin-save){border-radius:.75rem;box-shadow:none} html[data-braid-layout="1"] :where(.modal), html[data-braid-layout="1"] :where(.modal-content), html[data-braid-layout="1"] :where(.settings-modal), html[data-braid-layout="1"] :where(.dialog), html[data-braid-layout="1"] :where(.settings-panel){border-radius:1.125rem;border:1px solid var(--border)} html[data-braid-layout="1"] :where(.settings-tab), html[data-braid-layout="1"] :where(.settings-nav-item), html[data-braid-layout="1"] :where(.sound-tab){border-radius:.625rem} html[data-braid-layout="1"] .settings-tab.active, html[data-braid-layout="1"] .settings-nav-item.active, html[data-braid-layout="1"] .sound-tab.active{background:color-mix(in srgb,var(--accent) 16%,var(--bg-tertiary))!important;color:var(--accent)!important;border:1px solid color-mix(in srgb,var(--accent) 30%,transparent)!important} html[data-braid-layout="1"] :where(.context-menu), html[data-braid-layout="1"] :where(.dropdown-menu), html[data-braid-layout="1"] :where(.msg-toolbar){border-radius:.875rem;border:1px solid var(--border)} html[data-braid-layout="1"] :where(.profile-popup), html[data-braid-layout="1"] :where(.profile-card){border-radius:1rem;border:1px solid var(--border)} html[data-braid-layout="1"] :where(.theme-btn){border-radius:.75rem} html[data-braid-layout="1"] :where(.reaction), html[data-braid-layout="1"] :where(.reaction-chip), html[data-braid-layout="1"] :where(.reaction-badge){border-radius:999px} html[data-braid-layout="1"] :where(.toast), html[data-braid-layout="1"] :where(.notification-toast), html[data-braid-layout="1"] :where(.chip-toast){border-radius:999px;backdrop-filter:blur(10px)} html[data-braid-layout="1"] :where(.jump-to-bottom){border-radius:999px;border:1px solid var(--border-light)} html[data-braid-layout="1"] :where(.inline-code), html[data-braid-layout="1"] :where(code){border-radius:.375rem} html[data-braid-layout="1"] :where(.mention){border-radius:.375rem} html[data-braid-layout="1"] :where(.user-item), html[data-braid-layout="1"] :where(.member-item){margin:2px .625rem;padding:.625rem .75rem;border-radius:.75rem} html[data-braid-layout="1"] .user-item:hover, html[data-braid-layout="1"] .member-item:hover{background:var(--bg-hover)} html[data-braid-layout="1"] :where(.message-input-area textarea), html[data-braid-layout="1"] :where(.message-input-container textarea){border-radius:1.25rem;border:1px solid var(--border-light);transition:border-color .15s,box-shadow .15s} html[data-braid-layout="1"] .message-input-area textarea:focus, html[data-braid-layout="1"] .message-input-container textarea:focus{border-color:color-mix(in srgb,var(--accent) 40%,var(--border))!important;box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 9%,transparent)!important;outline:none!important} html[data-braid-layout="1"] :where(.music-panel-controls button), html[data-braid-layout="1"] :where(.music-btn){border-radius:.625rem} html[data-braid-layout="1"] .sidebar-bottom-btn svg{display:block} /* ── Modern settings ── the long scrolling column becomes cards, the nav becomes a pill rail, and the inline hairline separators between sections go away (the cards carry the separation). */ html[data-braid-layout="1"] .modal-settings{border-radius:1.25rem!important;overflow:hidden} html[data-braid-layout="1"] .settings-header{padding:.875rem 1.25rem!important;border-bottom:1px solid var(--border)!important;background:color-mix(in srgb,var(--bg-secondary) 92%,transparent)!important;backdrop-filter:saturate(180%) blur(16px);-webkit-backdrop-filter:saturate(180%) blur(16px);gap:.75rem} html[data-braid-layout="1"] .settings-header h3{font-size:1rem!important;font-weight:650!important;letter-spacing:-.02em!important} html[data-braid-layout="1"] .settings-tab-bar{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:.75rem!important;padding:3px!important;gap:2px!important} html[data-braid-layout="1"] .settings-tab{border-radius:.5625rem!important;border:0!important;padding:.375rem .875rem!important;font-weight:550!important} html[data-braid-layout="1"] .settings-close-btn{width:2.125rem;height:2.125rem;border-radius:.625rem!important;border:0!important;background:transparent!important;color:var(--text-muted)!important;font-size:1.125rem;display:grid;place-items:center} html[data-braid-layout="1"] .settings-close-btn:hover{background:var(--bg-hover)!important;color:var(--text-primary)!important} html[data-braid-layout="1"] .settings-nav{background:var(--bg-secondary)!important;border-right:1px solid var(--border)!important;padding:.625rem .5rem .625rem .625rem!important} html[data-braid-layout="1"] .settings-nav-group-label{font-size:.625rem!important;font-weight:650!important;letter-spacing:.12em!important;text-transform:uppercase!important;color:var(--text-muted)!important;margin:.875rem .375rem .25rem!important} /* Full-width pills. Stock uses a hanging-indent hack (padding-left 1.625rem + negative text-indent) so wrapped lines clear the leading emoji. With a real icon element that hack would push the first line left OUT of the pill, which is why highlights never covered the row. */ html[data-braid-layout="1"] .settings-nav-item{display:flex!important;align-items:center;gap:.5rem;width:100%;box-sizing:border-box;border-radius:.625rem!important;padding:.4375rem .625rem!important;text-indent:0!important;margin:1px 0!important;font-size:calc(.8125rem*var(--braid-ui-scale,1))!important;font-weight:550!important;border:1px solid transparent!important} html[data-braid-layout="1"] .settings-nav-item svg{flex:0 0 auto;opacity:.75} html[data-braid-layout="1"] .settings-nav-item:hover{background:var(--bg-hover)!important} html[data-braid-layout="1"] .settings-nav-item.active{background:color-mix(in srgb,var(--accent) 14%,var(--bg-tertiary))!important;color:var(--accent)!important;border:1px solid color-mix(in srgb,var(--accent) 28%,transparent)!important} html[data-braid-layout="1"] .settings-nav-item.active svg{opacity:1} html[data-braid-layout="1"] .settings-tab svg{margin-right:.375rem;vertical-align:-.1875rem} html[data-braid-layout="1"] .settings-section{background:var(--bg-card)!important;border:1px solid var(--border)!important;border-top:1px solid var(--border)!important;border-radius:1rem!important;padding:1rem 1.125rem!important;margin:0 0 .75rem!important} html[data-braid-layout="1"] .settings-section-subtitle{font-weight:650!important;letter-spacing:-.015em!important} html[data-braid-layout="1"] .settings-section select, html[data-braid-layout="1"] .settings-section input[type="text"], html[data-braid-layout="1"] .settings-section input[type="password"], html[data-braid-layout="1"] .settings-section input[type="number"]{border-radius:.625rem!important;border:1px solid var(--border)!important;background:var(--bg-input)!important;padding:.5rem .75rem!important} /* ── Haven logo glow-up ── the brand hexagon breathes in the accent */ @keyframes braid-logo-breathe{ 0%,100%{filter:drop-shadow(0 0 2px color-mix(in srgb,var(--accent) 45%,transparent)) drop-shadow(0 0 7px color-mix(in srgb,var(--accent) 22%,transparent))} 50%{filter:drop-shadow(0 0 4px color-mix(in srgb,var(--accent) 70%,transparent)) drop-shadow(0 0 14px color-mix(in srgb,var(--accent) 38%,transparent))} } html[data-braid-layout="1"] .brand .logo-sm, html[data-braid-layout="1"] .sidebar-header .logo-sm{color:var(--accent)!important;animation:braid-logo-breathe 3.6s ease-in-out infinite;transition:transform .45s cubic-bezier(.16,1,.3,1);display:inline-block} html[data-braid-layout="1"] .brand:hover .logo-sm{transform:rotate(120deg) scale(1.12)} @media (prefers-reduced-motion: reduce){ html[data-braid-layout="1"] .brand .logo-sm,html[data-braid-layout="1"] .sidebar-header .logo-sm{animation:none!important;filter:drop-shadow(0 0 4px color-mix(in srgb,var(--accent) 40%,transparent))} } /* ── Hexagonal loading indicator ── a mint hex ring that spins while a solid core hexagon pulses inside it; also reskins the setup wizard's stock circular spinner. */ @keyframes braid-hex-spin{to{transform:rotate(360deg)}} @keyframes braid-hex-pulse{0%,100%{transform:scale(.55);opacity:.5}50%{transform:scale(.8);opacity:1}} html[data-braid-layout="1"] .braid-hex-loader{position:relative;width:2.75rem;height:2.75rem;display:inline-block} html[data-braid-layout="1"] .braid-hex-loader::before{content:'';position:absolute;inset:0;background:conic-gradient(from 0deg,transparent 0 40%,var(--accent) 78%,transparent 78.5%);clip-path:polygon(50% 0,93.3% 25%,93.3% 75%,50% 100%,6.7% 75%,6.7% 25%,50% 0,50% 12%,17% 31%,17% 69%,50% 88%,83% 69%,83% 31%,50% 12%);animation:braid-hex-spin 1.1s linear infinite} html[data-braid-layout="1"] .braid-hex-loader::after{content:'';position:absolute;inset:0;background:var(--accent);clip-path:polygon(50% 0,93.3% 25%,93.3% 75%,50% 100%,6.7% 75%,6.7% 25%);animation:braid-hex-pulse 1.4s ease-in-out infinite} html[data-braid-layout="1"] #braid-hex-overlay{position:absolute;inset:0;z-index:30;display:grid;place-items:center;background:color-mix(in srgb,var(--bg-primary) 55%,transparent);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);opacity:1;transition:opacity .2s ease} html[data-braid-layout="1"] #braid-hex-overlay.braid-fade{opacity:0} html[data-braid-layout="1"] .wizard-spinner{border:0!important;border-radius:0!important;width:2.75rem!important;height:2.75rem!important;background:conic-gradient(from 0deg,transparent 0 40%,var(--accent) 78%,transparent 78.5%);clip-path:polygon(50% 0,93.3% 25%,93.3% 75%,50% 100%,6.7% 75%,6.7% 25%,50% 0,50% 12%,17% 31%,17% 69%,50% 88%,83% 69%,83% 31%,50% 12%);animation:braid-hex-spin 1.1s linear infinite!important} @media (prefers-reduced-motion: reduce){ html[data-braid-layout="1"] .braid-hex-loader::before,html[data-braid-layout="1"] .braid-hex-loader::after,html[data-braid-layout="1"] .wizard-spinner{animation:none!important} } /* ── Text size sliders ── two continuous scales layered over rem zoom: chat text and UI chrome text, both live-adjustable from Settings. */ html[data-braid-layout="1"] .message-text, html[data-braid-layout="1"] .message-content{font-size:calc(.9375rem*var(--braid-chat-scale,1))!important} html[data-braid-layout="1"] .message-header .message-author{font-size:calc(.875rem*var(--braid-chat-scale,1))!important} html[data-braid-layout="1"] .channel-name{font-size:calc(.875rem*var(--braid-ui-scale,1))!important} html[data-braid-layout="1"] .brand-text{font-size:calc(1rem*var(--braid-ui-scale,1))!important} html[data-braid-layout="1"] #channel-header-name{font-size:calc(.90625rem*var(--braid-ui-scale,1))!important} html[data-braid-layout="1"] .section-label{font-size:calc(.625rem*var(--braid-ui-scale,1))!important} html[data-braid-layout="1"] .settings-nav-item{font-size:calc(.8125rem*var(--braid-ui-scale,1))!important} html[data-braid-layout="1"] .current-user{font-size:calc(.8125rem*var(--braid-ui-scale,1))!important} html[data-braid-layout="1"] .user-item,html[data-braid-layout="1"] .member-item{font-size:calc(.8125rem*var(--braid-ui-scale,1))} `; BraidLayout._MOTION_CSS = ` @keyframes braid-msg-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}} @keyframes braid-pop{from{transform:scale(.82);opacity:0}to{transform:scale(1);opacity:1}} @keyframes braid-fade{from{opacity:0}to{opacity:1}} html[data-braid-layout="1"] .message,html[data-braid-layout="1"] .message-compact{animation:braid-msg-in .18s cubic-bezier(.16,1,.3,1) both} html[data-braid-layout="1"] .messages{animation:braid-fade .22s ease-out both} html[data-braid-layout="1"] .reaction,html[data-braid-layout="1"] .message-reactions>*{animation:braid-pop .16s cubic-bezier(.16,1,.3,1) both;transition:transform .12s ease,background .15s ease,border-color .15s ease} html[data-braid-layout="1"] .reaction:hover,html[data-braid-layout="1"] .message-reactions>*:hover{transform:translateY(-1px)} html[data-braid-layout="1"] .reaction:active,html[data-braid-layout="1"] .message-reactions>*:active{transform:scale(.94)} html[data-braid-layout="1"] .channel-item{transition:background .15s ease,border-color .15s ease,transform .12s ease} html[data-braid-layout="1"] .channel-item:active{transform:scale(.99)} html[data-braid-layout="1"] .btn-send,html[data-braid-layout="1"] .icon-btn{transition:transform .12s ease,background .15s ease,color .15s ease} html[data-braid-layout="1"] .btn-send:active,html[data-braid-layout="1"] .icon-btn:active{transform:scale(.92)} html[data-braid-layout="1"] .message-input-area textarea,html[data-braid-layout="1"] .message-input-container textarea{transition:border-color .15s ease,box-shadow .15s ease,background .15s ease} html[data-braid-layout="1"] .typing-indicator,html[data-braid-layout="1"] .typing-text{animation:braid-fade .2s ease-out both} html[data-braid-layout="1"] .msg-toolbar,html[data-braid-layout="1"] .context-menu,html[data-braid-layout="1"] .dropdown-menu{animation:braid-pop .13s cubic-bezier(.16,1,.3,1) both;transform-origin:top right} html[data-braid-layout="1"] .theme-popup,html[data-braid-layout="1"] .modal-content,html[data-braid-layout="1"] .settings-panel{animation:braid-msg-in .2s cubic-bezier(.16,1,.3,1) both} @media (prefers-reduced-motion: reduce){ html[data-braid-layout="1"] .message,html[data-braid-layout="1"] .message-compact,html[data-braid-layout="1"] .messages,html[data-braid-layout="1"] .reaction,html[data-braid-layout="1"] .message-reactions>*,html[data-braid-layout="1"] .typing-indicator,html[data-braid-layout="1"] .typing-text,html[data-braid-layout="1"] .msg-toolbar,html[data-braid-layout="1"] .context-menu,html[data-braid-layout="1"] .dropdown-menu,html[data-braid-layout="1"] .theme-popup,html[data-braid-layout="1"] .modal-content,html[data-braid-layout="1"] .settings-panel{animation:none!important} html[data-braid-layout="1"] .channel-item,html[data-braid-layout="1"] .btn-send,html[data-braid-layout="1"] .icon-btn,html[data-braid-layout="1"] .reaction{transition:none!important;transform:none!important} } `; // Self-contained (no braid keyframes/vars assumed): this sheet is the one // piece that stays loaded while the layout is disengaged, so the way back // exists even with every Braid layer torn down. BraidLayout._PILL_CSS = ` #braid-return-pill.braid-return-btn{color:var(--accent,#00ff9d)!important;border:1px solid color-mix(in srgb,var(--accent,#00ff9d) 35%,transparent)!important;border-radius:.625rem} #braid-return-pill.braid-return-btn:hover{background:color-mix(in srgb,var(--accent,#00ff9d) 14%,transparent)!important} #braid-return-pill.braid-return-btn svg{display:block;margin:auto} #braid-return-pill.braid-return-float{position:fixed;left:.875rem;bottom:.875rem;z-index:120;display:inline-flex;align-items:center;justify-content:center;width:2.5rem;height:2.5rem;border-radius:999px;border:1px solid var(--border,#333a46);background:var(--bg-card,var(--bg-secondary,#12151c));color:var(--accent,#00ff9d);cursor:pointer;box-shadow:0 10px 28px -10px rgba(0,0,0,.45)} html[data-braid-layout="1"] #braid-return-pill{display:none!important} body.mod-mode-on #braid-return-pill{display:none!important} #braid-mod-done{position:fixed;left:50%;bottom:5.75rem;transform:translateX(-50%);z-index:200;display:inline-flex;align-items:center;gap:.5rem;padding:.6875rem 1.25rem;border-radius:999px;border:1px solid color-mix(in srgb,var(--accent,#00ff9d) 55%,transparent);background:var(--accent,#00ff9d);color:var(--bg-primary,#0b0d12);font-family:inherit;font-size:.8125rem;font-weight:700;letter-spacing:-.01em;line-height:1;cursor:pointer;box-shadow:0 14px 36px -10px rgba(0,0,0,.5);transition:transform .12s,filter .15s} #braid-mod-done:hover{filter:brightness(1.08)} #braid-mod-done:active{transform:translateX(-50%) scale(.96)} `; // Register with the plugin loader's _win scope if (typeof module !== 'undefined') module.exports = BraidLayout; if (typeof _win !== 'undefined') _win.BraidLayout = BraidLayout;