// ==UserScript== // @name Otter.ai Dark Mode (CLU) // @namespace https://binaryranch.com/clu // @version 2.0.0 // @description Three dark shades for otter.ai with an on-page switcher. Alt+Shift+D cycles, Alt+Shift+0 turns it off. // @author Christian Sass (operator) - developed 2026-10-27 in collaboration with CLU. Co-developed with active AI pair-programming. Contact csass@binaryranch.com for intent or change history. // @match https://otter.ai/* // @match https://*.otter.ai/* // @run-at document-start // @noframes // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // ==/UserScript== /* * HOW THIS WORKS * Otter ships no dark mode and no CSS hooks a userscript can rely on, so instead of * guessing at class names that change every deploy this inverts the page and * hue-rotates it back so blue stays blue. Each shade is the same transform at a * different invert strength: invert(c) maps page white onto a grey, so a lighter * shade lifts the background and softens the text at the same time. * * invert(1.00) -> background #000000, text #ffffff (Midnight) 21.0:1 * invert(0.93) -> background #121212, text #ededed (Dusk) 16.0:1 * invert(0.86) -> background #242424, text #dbdbdb (Slate) 11.2:1 * * Everything photographic (img, video, canvas, background images) carries the exact * mathematical inverse of that transform, so photos and avatars keep their true color * instead of turning into negatives. * * KEYS: Alt+Shift+D cycles shades, Alt+Shift+0 turns it off, Alt+Shift+S shows or * hides the on-page switcher. */ (function () { 'use strict'; var ROOT_CLASS = 'clu-otter-dark'; var STYLE_ID = 'clu-otter-dark-style'; var PILL_ID = 'clu-otter-switch'; var K_THEME = 'clu:otter-theme'; var K_SWITCHER = 'clu:otter-switcher'; // shade = the CSS invert() strength. k = 1/(2*shade-1), the exact inverse of that // invert, used to un-invert media. Derived, not guessed. var SHADES = [ { id: 'midnight', label: 'Midnight', shade: 1.00, bg: '#000000', fg: '#ffffff', swatch: '#000000' }, { id: 'dusk', label: 'Dusk', shade: 0.93, bg: '#121212', fg: '#ededed', swatch: '#121212' }, { id: 'slate', label: 'Slate', shade: 0.86, bg: '#242424', fg: '#dbdbdb', swatch: '#242424' } ]; function byId(id) { for (var i = 0; i < SHADES.length; i++) if (SHADES[i].id === id) return SHADES[i]; return null; } // ---------- preference store ---------- function get(key, dflt) { try { if (typeof GM_getValue === 'function') { var v = GM_getValue(key, null); if (v !== null && v !== undefined) return v; } } catch (e) { /* fall through */ } try { var l = localStorage.getItem(key); if (l !== null) return l; } catch (e) { /* ignore */ } return dflt; } function set(key, val) { try { if (typeof GM_setValue === 'function') GM_setValue(key, val); } catch (e) { /* ignore */ } try { localStorage.setItem(key, String(val)); } catch (e) { /* ignore */ } } var themeId = String(get(K_THEME, 'dusk')); // shade id, or 'off' if (themeId !== 'off' && !byId(themeId)) themeId = 'dusk'; var switcherOn = String(get(K_SWITCHER, 'true')) === 'true'; // ---------- page stylesheet ---------- function buildCSS() { var rules = []; // Print: always legible on paper, never inverted. rules.push('@media print { html.' + ROOT_CLASS + ' { filter: none !important; background: #fff !important; } }'); var t = byId(themeId); if (!t) return rules.join('\n'); // off var k = 1 / (2 * t.shade - 1); rules.push( 'html.' + ROOT_CLASS + ' {' + // This background propagates to the browser canvas and is painted UNFILTERED, so it // must already be the final dark shade. Otherwise the canvas stays white and // scrolling or rubber-band overscroll reveals a white band around the content. ' background: ' + t.bg + ' !important;' + ' filter: invert(' + t.shade + ') hue-rotate(180deg) !important;' + '}' ); var mediaSel = [ 'html.' + ROOT_CLASS + ' img', 'html.' + ROOT_CLASS + ' picture', 'html.' + ROOT_CLASS + ' video', 'html.' + ROOT_CLASS + ' canvas', 'html.' + ROOT_CLASS + ' [style*="background-image"]', 'html.' + ROOT_CLASS + ' #' + PILL_ID ].join(',\n'); rules.push( mediaSel + ' {' + ' filter: hue-rotate(180deg) invert(1) contrast(' + k.toFixed(4) + ') !important;' + '}' ); rules.push( 'html.' + ROOT_CLASS + '::-webkit-scrollbar, html.' + ROOT_CLASS + ' ::-webkit-scrollbar { width: 12px; height: 12px; }', 'html.' + ROOT_CLASS + '::-webkit-scrollbar-track, html.' + ROOT_CLASS + ' ::-webkit-scrollbar-track { background: #f2f2f2; }', 'html.' + ROOT_CLASS + '::-webkit-scrollbar-thumb, html.' + ROOT_CLASS + ' ::-webkit-scrollbar-thumb { background: #c6c6c6; border-radius: 6px; }' ); return rules.join('\n'); } // Switcher chrome. Authored normally: the switcher carries the counter-filter above, // so these colors render as written instead of being inverted. // // position: absolute, not fixed. A filter on makes it the containing block for // fixed descendants, which makes "fixed" elements scroll away with the page. Absolute // plus a computed top is deterministic whether the document scrolls or an inner pane // does, so the switcher stays put on both the app shell and long marketing pages. var PILL_CSS = [ '#' + PILL_ID + ' {', ' position: absolute; right: 14px; top: 0; z-index: 2147483647;', ' display: flex; align-items: center; gap: 7px;', ' padding: 7px 9px; border-radius: 999px; box-sizing: border-box;', ' background: #14171b; border: 1px solid #2b3038;', ' box-shadow: 0 6px 22px rgba(0,0,0,.42);', ' font: 500 11px/1 -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;', ' color: #9aa2ad; -webkit-user-select: none; user-select: none;', '}', '#' + PILL_ID + ' .clu-dot {', ' width: 17px; height: 17px; border-radius: 50%; box-sizing: border-box;', ' border: 1px solid rgba(255,255,255,.18); cursor: pointer; padding: 0;', ' background-clip: padding-box;', '}', '#' + PILL_ID + ' .clu-dot:hover { border-color: rgba(255,255,255,.55); }', '#' + PILL_ID + ' .clu-dot.clu-on {', ' box-shadow: 0 0 0 2px #5a97ff; border-color: transparent;', '}', '#' + PILL_ID + ' .clu-dot.clu-off {', ' background: linear-gradient(135deg, transparent 44%, #d9534f 44%, #d9534f 56%, transparent 56%), #f4f4f4;', '}', '#' + PILL_ID + ' .clu-hide {', ' all: unset; cursor: pointer; color: #5c646f; font-size: 13px; line-height: 1;', ' padding: 0 2px; margin-left: 1px;', '}', '#' + PILL_ID + ' .clu-hide:hover { color: #c9d1da; }', '@media print { #' + PILL_ID + ' { display: none !important; } }' ].join('\n'); function injectStyle() { var css = buildCSS(); var el = document.getElementById(STYLE_ID); if (!el) { el = document.createElement('style'); el.id = STYLE_ID; (document.head || document.documentElement || document).appendChild(el); } if (el.textContent !== css) el.textContent = css; } function injectPillStyle() { var id = STYLE_ID + '-pill'; if (document.getElementById(id)) return; var el = document.createElement('style'); el.id = id; el.textContent = PILL_CSS; (document.head || document.documentElement || document).appendChild(el); } function apply() { var root = document.documentElement; if (!root) return; if (themeId === 'off') root.classList.remove(ROOT_CLASS); else root.classList.add(ROOT_CLASS); } // ---------- on-page switcher ---------- function buildPill() { if (!document.body) return; var pill = document.getElementById(PILL_ID); if (!pill) { pill = document.createElement('div'); pill.id = PILL_ID; pill.setAttribute('role', 'group'); pill.setAttribute('aria-label', 'Otter dark mode shade'); SHADES.forEach(function (t) { var b = document.createElement('button'); b.className = 'clu-dot'; b.type = 'button'; b.dataset.theme = t.id; b.title = t.label + ' (' + t.bg + ')'; b.style.background = t.swatch; b.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); choose(t.id); }); pill.appendChild(b); }); var off = document.createElement('button'); off.className = 'clu-dot clu-off'; off.type = 'button'; off.dataset.theme = 'off'; off.title = 'Turn dark mode off'; off.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); choose('off'); }); pill.appendChild(off); var hide = document.createElement('button'); hide.className = 'clu-hide'; hide.type = 'button'; hide.textContent = '\u00d7'; hide.title = 'Hide this switcher (Alt+Shift+S brings it back)'; hide.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); setSwitcher(false); }); pill.appendChild(hide); document.body.appendChild(pill); } syncPill(); } // Keep the switcher at the visual bottom-right of the viewport without relying on // position: fixed, which a root filter breaks. function pinPill() { var pill = document.getElementById(PILL_ID); if (!pill || pill.style.display === 'none') return; var vh = window.innerHeight || document.documentElement.clientHeight || 0; var sy = window.pageYOffset; if (sy === undefined) sy = document.documentElement.scrollTop || document.body.scrollTop || 0; var h = pill.offsetHeight || 32; pill.style.top = Math.max(0, sy + vh - h - 14) + 'px'; } function syncPill() { var pill = document.getElementById(PILL_ID); if (!pill) return; if (!switcherOn) { pill.style.display = 'none'; return; } pill.style.display = 'flex'; var dots = pill.querySelectorAll('.clu-dot'); for (var i = 0; i < dots.length; i++) { if (dots[i].dataset.theme === themeId) dots[i].classList.add('clu-on'); else dots[i].classList.remove('clu-on'); } pinPill(); } function setSwitcher(on) { switcherOn = on; set(K_SWITCHER, on ? 'true' : 'false'); syncPill(); registerMenu(); } function choose(id) { themeId = (id === 'off' || byId(id)) ? id : 'dusk'; set(K_THEME, themeId); injectStyle(); apply(); syncPill(); registerMenu(); } // ---------- menu ---------- var menuIds = []; function registerMenu() { if (typeof GM_registerMenuCommand !== 'function') return; try { if (typeof GM_unregisterMenuCommand === 'function') { menuIds.forEach(function (id) { GM_unregisterMenuCommand(id); }); } } catch (e) { /* ignore */ } menuIds = []; try { SHADES.forEach(function (t) { var mark = themeId === t.id ? '\u2713 ' : '\u2003'; menuIds.push(GM_registerMenuCommand(mark + t.label + ' (' + t.bg + ')', function () { choose(t.id); })); }); menuIds.push(GM_registerMenuCommand((themeId === 'off' ? '\u2713 ' : '\u2003') + 'Off', function () { choose('off'); })); menuIds.push(GM_registerMenuCommand(switcherOn ? 'Hide on-page switcher' : 'Show on-page switcher', function () { setSwitcher(!switcherOn); })); } catch (e) { /* menu is a nicety, not a requirement */ } } // ---------- keys ---------- function typing(e) { var t = e.target; return !!(t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName || ''))); } function onKey(e) { if (!e.altKey || !e.shiftKey) return; if (typing(e)) return; if (e.code === 'KeyD' || e.key === 'D' || e.key === 'd') { e.preventDefault(); e.stopPropagation(); var order = ['midnight', 'dusk', 'slate', 'off']; choose(order[(order.indexOf(themeId) + 1) % order.length]); return; } if (e.code === 'Digit0' || e.key === '0') { e.preventDefault(); e.stopPropagation(); choose(themeId === 'off' ? 'dusk' : 'off'); return; } if (e.code === 'KeyS' || e.key === 'S' || e.key === 's') { e.preventDefault(); e.stopPropagation(); setSwitcher(!switcherOn); } } // ---------- boot ---------- var booted = false; function boot() { if (booted || !document.documentElement) return; booted = true; injectStyle(); injectPillStyle(); apply(); registerMenu(); window.addEventListener('keydown', onKey, true); // capture:true so it also fires for inner scroll panes, not just the document. window.addEventListener('scroll', pinPill, { capture: true, passive: true }); window.addEventListener('resize', pinPill); // Some SPAs rewrite 's class attribute, and some re-render . var root = document.documentElement; try { new MutationObserver(function () { if (themeId !== 'off' && !root.classList.contains(ROOT_CLASS)) root.classList.add(ROOT_CLASS); if (themeId === 'off' && root.classList.contains(ROOT_CLASS)) root.classList.remove(ROOT_CLASS); if (switcherOn && document.body && !document.getElementById(PILL_ID)) buildPill(); }).observe(root, { attributes: true, childList: true, subtree: false, attributeFilter: ['class'] }); } catch (e) { /* ignore */ } } boot(); function repair() { if (!document.documentElement) return; injectStyle(); injectPillStyle(); apply(); buildPill(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', repair, { once: true }); } else { repair(); } window.addEventListener('load', repair, { once: true }); })();