378 lines
14 KiB
JavaScript
378 lines
14 KiB
JavaScript
|
|
// ==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 });
|
||
|
|
})();
|