otter-dark-mode/userscript/otter-dark-mode.user.js

378 lines
14 KiB
JavaScript
Raw Permalink Normal View History

// ==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 <csass@binaryranch.com> (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 <html> 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 <html>'s class attribute, and some re-render <body>.
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 });
})();