otter-dark-mode/userscript/otter-dark-mode.user.js
Christian Sass c10cf59e67 feat: three-tier dark theme proposal for Otter.ai
Otter's web app ships no dark mode, and heavy users read transcripts for hours
at night. This proposes three shades rather than one, so the choice survives
changing lighting instead of being wrong half the day.

Contents:
- otter-dark.css   token set, one block per tier, scoped to :root[data-theme]
- preview.html     self-contained demo, no build, no network
- PREVIEW.png      rendered screenshot of the demo
- userscript/      Tampermonkey stopgap that works today

Contrast figures in the README are measured from the token values with the
WCAG 2.1 formula, not estimated. The notable finding is that Otter's brand blue
#0b57d0 fails as accent text on every dark surface (2.59:1 to 3.29:1) while
remaining correct as a solid fill (6.39:1), so the tokens split accent from
accent-fill.

MIT licensed. No affiliation with AISense Inc.

Co-authored-by: CLU <clu@binaryranch.com>
2026-10-27 12:42:16 -05:00

377 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 });
})();