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>
This commit is contained in:
Christian Sass 2026-10-27 12:42:16 -05:00
commit c10cf59e67
7 changed files with 877 additions and 0 deletions

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Christian Sass, Binary Ranch
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

39
PROVENANCE.md Normal file
View file

@ -0,0 +1,39 @@
# Provenance
**Developed and first published:** 2026-10-27 by Christian Sass, Binary Ranch.
This repository is the public record of the work: the three-tier dark theme design, the
measured contrast analysis, and the working userscript were built on this date, before the
proposal was sent to Otter.ai. The git history and the file digests below are the evidence.
## What this claims
- The three-tier structure (Midnight / Dusk / Slate) as applied to a meeting-transcript
product, with contrast held roughly constant across surface lightness.
- The split between `--ot-accent` (text-safe accent) and `--ot-accent-fill` (solid button
fill) as the resolution of a brand colour that fails contrast on dark surfaces.
- The userscript technique of a shade-parameterised invert with an exact analytic inverse
applied to media elements, so photographs keep their true colour.
## What this does not claim
No affiliation with, endorsement by, or access to the internal codebase of Otter.ai
(AISense Inc.). This is an independent design proposal offered publicly, under the MIT
licence, in the hope that it is useful.
## File digests (SHA-256)
| File | SHA-256 |
|---|---|
| `README.md` | `859bc3ba8215ce1045da55266bb5c7bb75a04d61f8a1183db97bd8c0f5b0e11f` |
| `LICENSE` | `fa7a1b8b7526dbd4e866fef2f6b046ef7f24df004e4a3bb860730537b9e0ba1c` |
| `otter-dark.css` | `e584729f7770bb2729cbfdce570e60f0fb75abd7a91eab60f4d7b72607878266` |
| `preview.html` | `94b8fd8ce7f1189fd6d79f63ab04f25b64f34382b9e8f856536459b4a8358a78` |
| `preview.png` | `f93e9a370b0049be794480066e756404241cbd64cd3a5a368bb52718de1aeb4b` |
| `userscript/otter-dark-mode.user.js` | `af90dc3d5b174f174353a28de8054a92550cc9f1f043cafb6521d4be8962693d` |
Reproduce with:
```bash
sha256sum README.md LICENSE otter-dark.css preview.html preview.png userscript/otter-dark-mode.user.js
```

99
README.md Normal file
View file

@ -0,0 +1,99 @@
# Otter dark mode
A three-tier dark theme proposal for the [Otter.ai](https://otter.ai) web app, plus a working
userscript that gives you the same thing today.
Otter's web app has no dark mode. Heavy users read transcripts for hours, frequently at
night, and a full-white workspace at 11pm is genuinely painful. This is what a dark theme
for it should look like, in three shades rather than one, written so that Otter can adopt
it directly.
![Three tiers side by side](preview.png)
## Why three shades instead of one
"Dark mode" is not one design. A pure black UI that feels wonderful at 2am feels like a hole
punched in the room at 11am, and a lifted grey that is pleasant at noon reads as muddy at
night. Rather than pick one and be wrong half the time, ship the token set three times and
let the user choose. The tiers differ only in surface lightness, with text lightness moving
with the surface so contrast stays roughly constant across all three.
| Tier | Canvas | Best for |
|---|---|---|
| **Midnight** | `#000000` | OLED panels, late night |
| **Dusk** (default) | `#101216` | mixed lighting |
| **Slate** | `#1B1F24` | bright rooms, daytime |
## Measured contrast
WCAG 2.1 ratios computed from the token values, not estimated.
| Tier | Canvas | Body text | Secondary | Tertiary | Accent text | Button label |
|---|---|---|---|---|---|---|
| Midnight | `#000000` | 17.56:1 | 8.75:1 | 4.44:1 | 8.72:1 | 7.78:1 |
| Dusk | `#101216` | 15.39:1 | 8.29:1 | 4.38:1 | 8.39:1 | 8.39:1 |
| Slate | `#1B1F24` | 14.25:1 | 8.49:1 | 5.21:1 | 8.26:1 | 9.34:1 |
Every text pair clears AA (4.5:1) for normal-size text. Body and secondary clear AAA (7:1).
## The two things that catch dark themes
**The brand blue cannot be reused as text on dark.** `#0b57d0` measures 3.29:1 on Midnight
and 2.59:1 on Slate. It is correct as a *fill* (6.39:1 with white text) but unusable as link
or accent text on a dark canvas. The tokens split it: `--ot-accent` for text, `--ot-accent-fill`
for solid buttons. The brand blue stays untouched in the light theme.
**Borders are decorative, not text.** `--ot-border` lands around 1.4:1 against the canvas,
which is correct. A 1px separator only needs to be perceived, and the 3:1 requirement applies
to UI components that carry meaning, not hairline dividers. Focus rings use the accent and
clear 3:1.
## Files
| File | What it is |
|---|---|
| [`preview.html`](preview.html) | Self-contained demo. Any browser, no build, no network. |
| [`otter-dark.css`](otter-dark.css) | The tokens. Set `data-theme` on `<html>`, read them with `var(--ot-*)`. |
| `preview.png` | Screenshot of the demo, three tiers side by side. |
| [`userscript/`](userscript) | A Tampermonkey userscript, the stopgap that works today. See below. |
## Suggested implementation
1. Add the token block from `otter-dark.css`, scoped to `:root[data-theme]`.
2. Replace hard-coded colours in the workspace shell with `var(--ot-*)`, starting with the
three surfaces, text and borders. The accent is the only one that needs a real design
decision per component.
3. Persist the choice in `localStorage`, default it to `prefers-color-scheme`, and expose it
in Settings next to the existing display options.
4. Set `color-scheme: dark` so native scrollbars, form controls and autofill follow.
If a full token migration is too large to schedule, the same palettes can be applied as an
overlay stylesheet for the workspace shell only. That covers the bulk of the reading surface,
which is where the discomfort is.
## About the userscript
`userscript/otter-dark-mode.user.js` is a different beast, deliberately. It exists because
you cannot ship a fix to someone else's product on a Tuesday afternoon, and waiting for a
feature request to land is not a plan. It inverts the page at the compositor and hue-rotates
it back, with each shade being the same transform at a different invert strength.
Two honest caveats:
- Lifting the black point forces extreme values to clip, so very saturated colours inside
images read slightly muted at the lighter shades. Photos are barely affected because they
live in the midtones.
- The userscript shades are neutral greys, because an invert cannot produce a tinted surface.
The proposal's surfaces are slightly blue-tinted. Same idea, different floor.
The CSS in this repo has neither problem, because nothing in it is a filter.
## License
MIT. Take it, adapt it, ship it under your own design system. The tier structure and the
accent-versus-fill split are the parts worth keeping; the hex values are interchangeable.
---
Built by Christian Sass, Binary Ranch. See [`PROVENANCE.md`](PROVENANCE.md) for the
publication record.

102
otter-dark.css Normal file
View file

@ -0,0 +1,102 @@
/* =============================================================================
Otter dark theme tokens
Proposal by Christian Sass (csass@binaryranch.com), Binary Ranch. 2026-10-27.
Three tiers, one token set. Nothing here filters, inverts or transforms the
page, so avatars, screen captures and video keep their exact original colour.
Usage: set data-theme on <html>. That is the whole contract.
<html data-theme="dusk">
Values in comments are the measured WCAG 2.1 contrast ratio against --ot-canvas
unless stated otherwise.
============================================================================= */
/* ---- Midnight: OLED panels and late-night reading ---- */
:root[data-theme="midnight"] {
--ot-canvas: #000000;
--ot-surface-1: #0B0D10; /* sidebars, nav rails, player bar */
--ot-surface-2: #12151A; /* conversation cards, list rows, panels */
--ot-surface-3: #1A1E24; /* hover, selected row, menus */
--ot-border: #22272E; /* hairlines, card outlines (decorative) */
--ot-border-2: #303740; /* focused input, split-pane divider */
--ot-text: #E8EBEF; /* 17.56:1 on canvas */
--ot-text-2: #A0A8B4; /* 8.75:1 on canvas */
--ot-text-3: #6B7480; /* 4.44:1 on canvas */
--ot-accent: #6FA8FF; /* 8.72:1, links and active nav */
--ot-accent-fill:#6FA8FF; /* solid button / active pill background */
--ot-on-accent: #0A1220; /* 7.78:1 on the fill */
--ot-danger: #FF6B6B; /* 7.57:1 */
--ot-warn: #FFB020;
--ot-success: #4ADE80;
color-scheme: dark;
}
/* ---- Dusk: default, mixed lighting ---- */
:root[data-theme="dusk"] {
--ot-canvas: #101216;
--ot-surface-1: #16191E; /* sidebars, nav rails, player bar */
--ot-surface-2: #1C2027; /* conversation cards, list rows, panels */
--ot-surface-3: #242931; /* hover, selected row, menus */
--ot-border: #2A3038; /* hairlines, card outlines (decorative) */
--ot-border-2: #3A424C; /* focused input, split-pane divider */
--ot-text: #E6E9ED; /* 15.39:1 on canvas */
--ot-text-2: #A6ADB8; /* 8.29:1 on canvas */
--ot-text-3: #737B86; /* 4.38:1 on canvas */
--ot-accent: #7FAEFF; /* 8.39:1, links and active nav */
--ot-accent-fill:#7FAEFF; /* solid button / active pill background */
--ot-on-accent: #0A1220; /* 8.39:1 on the fill */
--ot-danger: #FF7B72; /* 7.43:1 */
--ot-warn: #FFC14D;
--ot-success: #5BD98A;
color-scheme: dark;
}
/* ---- Slate: bright rooms and daytime ---- */
:root[data-theme="slate"] {
--ot-canvas: #1B1F24;
--ot-surface-1: #22272D; /* sidebars, nav rails, player bar */
--ot-surface-2: #282E35; /* conversation cards, list rows, panels */
--ot-surface-3: #30373F; /* hover, selected row, menus */
--ot-border: #363E47; /* hairlines, card outlines (decorative) */
--ot-border-2: #48515C; /* focused input, split-pane divider */
--ot-text: #ECEEF1; /* 14.25:1 on canvas */
--ot-text-2: #B4BAC4; /* 8.49:1 on canvas */
--ot-text-3: #8A919C; /* 5.21:1 on canvas */
--ot-accent: #8FB8FF; /* 8.26:1, links and active nav */
--ot-accent-fill:#8FB8FF; /* solid button / active pill background */
--ot-on-accent: #0A1220; /* 9.34:1 on the fill */
--ot-danger: #FF8A80; /* 7.25:1 */
--ot-warn: #FFCB66;
--ot-success: #6FE39B;
color-scheme: dark;
}
/* Opt in to the system preference, defaulting to Dusk. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--ot-canvas: #101216; --ot-surface-1: #16191E; --ot-surface-2: #1C2027; --ot-surface-3: #242931;
--ot-border: #2A3038; --ot-border-2: #3A424C;
--ot-text: #E6E9ED; --ot-text-2: #A6ADB8; --ot-text-3: #737B86;
--ot-accent: #7FAEFF; --ot-accent-fill: #7FAEFF; --ot-on-accent: #0A1220;
--ot-danger: #FF7B72; --ot-warn: #FFC14D; --ot-success: #5BD98A;
color-scheme: dark;
}
}
/* Reference wiring. Otter's own selectors replace the generic ones; the token
names are the part worth keeping. */
[data-theme] body { background: var(--ot-canvas); color: var(--ot-text); }
[data-theme] a { color: var(--ot-accent); }
[data-theme] ::selection { background: color-mix(in srgb, var(--ot-accent) 30%, transparent); }

239
preview.html Normal file
View file

@ -0,0 +1,239 @@
<!doctype html>
<html lang="en" data-theme="dusk">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Otter dark theme - three tiers, preview</title>
<style>
:root {
--ink: #14161a; --paper: #f4f5f7; --rule: #dcdfe4; --muted: #6b7280;
--brand: #0b57d0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; padding: 34px 30px 46px; background: var(--paper); color: var(--ink); }
header.page { max-width: 1290px; margin: 0 auto 26px; }
header.page h1 { font-size: 21px; margin: 0 0 6px; letter-spacing: -.01em; }
header.page p { margin: 0; color: var(--muted); font-size: 13.5px; max-width: 74ch; line-height: 1.55; }
header.page .byline { margin-top: 10px; font-size: 11.5px; color: var(--muted); letter-spacing: .02em; }
.deck { display: flex; gap: 20px; justify-content: center; align-items: flex-start; flex-wrap: wrap; max-width: 1290px; margin: 0 auto; }
/* ---- tier frames ---- */
.tier { width: 396px; border-radius: 14px; overflow: hidden; box-shadow: 0 10px 26px rgba(15,20,30,.16); background: var(--canvas); }
.t-midnight { --canvas:#000000; --s1:#0B0D10; --s2:#12151A; --s3:#1A1E24; --bd:#22272E; --bd2:#303740;
--tx:#E8EBEF; --tx2:#A0A8B4; --tx3:#6B7480; --ac:#6FA8FF; --fill:#6FA8FF; --on:#0A1220; }
.t-dusk { --canvas:#101216; --s1:#16191E; --s2:#1C2027; --s3:#242931; --bd:#2A3038; --bd2:#3A424C;
--tx:#E6E9ED; --tx2:#A6ADB8; --tx3:#737B86; --ac:#7FAEFF; --fill:#7FAEFF; --on:#0A1220; }
.t-slate { --canvas:#1B1F24; --s1:#22272D; --s2:#282E35; --s3:#30373F; --bd:#363E47; --bd2:#48515C;
--tx:#ECEEF1; --tx2:#B4BAC4; --tx3:#8A919C; --ac:#8FB8FF; --fill:#8FB8FF; --on:#0A1220; }
.app { display: flex; height: 348px; color: var(--tx); }
.rail { width: 118px; background: var(--s1); border-right: 1px solid var(--bd); padding: 11px 9px; font-size: 10.5px; flex: none; }
.brandrow { display: flex; align-items: center; gap: 6px; margin-bottom: 13px; }
.logo { width: 15px; height: 15px; border-radius: 4px; background: var(--brand); }
.brandrow b { font-size: 11px; font-weight: 600; letter-spacing: -.01em; }
.nav { padding: 5px 7px; border-radius: 6px; color: var(--tx2); margin-bottom: 1px; }
.nav.on { background: var(--s3); color: var(--ac); font-weight: 600; }
.navdots { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--bd); }
.navdots .lbl { font-size: 9px; letter-spacing: .09em; text-transform: uppercase; color: var(--tx3); padding-left: 7px; margin-bottom: 5px; }
.pane { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.topbar { display: flex; align-items: center; gap: 6px; padding: 9px 11px; border-bottom: 1px solid var(--bd); background: var(--canvas); }
.search { flex: 1; background: var(--s2); border: 1px solid var(--bd); border-radius: 6px; padding: 4px 8px; font-size: 10px; color: var(--tx3); }
.rec { background: var(--fill); color: var(--on); font-size: 10px; font-weight: 600; padding: 4px 11px; border-radius: 12px; white-space: nowrap; }
.body { flex: 1; padding: 10px 11px; overflow: hidden; }
.when { font-size: 9.5px; color: var(--tx3); margin-bottom: 7px; }
.card { display: flex; gap: 8px; background: var(--s2); border: 1px solid var(--bd); border-radius: 9px; padding: 9px 10px; margin-bottom: 8px; }
.av { width: 21px; height: 21px; border-radius: 50%; flex: none; }
.a1 { background: linear-gradient(135deg, #f6b26b, #cc4125); }
.a2 { background: linear-gradient(135deg, #6fa8dc, #1c4587); }
.card h4 { margin: 0 0 2px; font-size: 11.5px; font-weight: 600; letter-spacing: -.005em; }
.card .meta { font-size: 9px; color: var(--tx3); margin-bottom: 4px; }
.card p { margin: 0; font-size: 10px; line-height: 1.45; color: var(--tx2); }
.hl { background: color-mix(in srgb, var(--ac) 22%, transparent); color: var(--tx); border-radius: 3px; padding: 0 2px; }
.who { font-size: 9px; font-weight: 600; color: var(--ac); margin-bottom: 2px; }
.bubble { background: var(--s3); border-radius: 8px; padding: 6px 8px; font-size: 10px; line-height: 1.45; color: var(--tx); }
.caption { background: var(--canvas); border-top: 1px solid var(--bd); padding: 12px 14px 14px; color: var(--tx); }
.caption .name { display: flex; align-items: baseline; gap: 8px; }
.caption .name b { font-size: 13.5px; }
.caption .name code { font-size: 10px; color: var(--tx2); }
.caption .use { font-size: 10.5px; color: var(--tx3); margin: 2px 0 9px; }
.sw { display: flex; gap: 4px; margin-bottom: 8px; }
.sw i { width: 22px; height: 12px; border-radius: 3px; border: 1px solid rgba(255,255,255,.08); }
.nums { display: flex; gap: 12px; font-size: 9.5px; color: var(--tx3); }
.nums b { color: var(--tx); font-weight: 600; }
footer.page { max-width: 1290px; margin: 30px auto 0; padding-top: 16px; border-top: 1px solid var(--rule);
font-size: 11.5px; color: var(--muted); line-height: 1.6; }
footer.page code { background: #e8ebef; padding: 1px 5px; border-radius: 3px; font-size: 11px; }
</style>
</head>
<body>
<header class="page">
<h1>Otter dark mode, three tiers</h1>
<p>Same component, same text sizes, three surface lightnesses. Nothing below inverts or filters
the page, so avatars and screen captures keep their true colour. Contrast figures are measured
from the token values with the WCAG 2.1 formula.</p>
<div class="byline">PREPARED BY CHRISTIAN SASS &middot; BINARY RANCH &middot; CSASS@BINARYRANCH.COM &middot; 2026-10-27</div>
</header>
<div class="deck">
<!-- MIDNIGHT -->
<section class="tier t-midnight">
<div class="app">
<div class="rail">
<div class="brandrow"><span class="logo"></span><b>Otter</b></div>
<div class="nav on">Home</div>
<div class="nav">AI Chat</div>
<div class="nav">Explore</div>
<div class="navdots">
<div class="lbl">Channels</div>
<div class="nav">Engineering</div>
<div class="nav">General</div>
</div>
</div>
<div class="pane">
<div class="topbar"><div class="search">Ask or search</div><div class="rec">Record</div></div>
<div class="body">
<div class="when">Today, Oct 27</div>
<div class="card">
<div class="av a1"></div>
<div>
<h4>Weekly product sync</h4>
<div class="meta">10:53 AM &middot; 38 min</div>
<p>The team walks through the revised onboarding flow and agrees to drop the second step, which was <span class="hl">tripping up first-time users</span> on mobile.</p>
</div>
</div>
<div class="card">
<div class="av a2"></div>
<div>
<h4>Design review</h4>
<div class="meta">9:15 AM &middot; 42 min</div>
<p>Shipping the client build Thursday. The remaining blocker is the mail server certificate.</p>
</div>
</div>
<div class="who">Priya</div>
<div class="bubble">The certificate is already staged in staging. I can rotate it before the demo if we get the window.</div>
</div>
</div>
</div>
<div class="caption">
<div class="name"><b>Midnight</b><code>#000000</code></div>
<div class="use">OLED panels and late-night reading</div>
<div class="sw"><i style="background:#000000"></i><i style="background:#0B0D10"></i><i style="background:#12151A"></i><i style="background:#1A1E24"></i><i style="background:#6FA8FF"></i></div>
<div class="nums"><span>Body <b>17.6:1</b></span><span>Secondary <b>8.8:1</b></span><span>Button <b>7.8:1</b></span></div>
</div>
</section>
<!-- DUSK -->
<section class="tier t-dusk">
<div class="app">
<div class="rail">
<div class="brandrow"><span class="logo"></span><b>Otter</b></div>
<div class="nav on">Home</div>
<div class="nav">AI Chat</div>
<div class="nav">Explore</div>
<div class="navdots">
<div class="lbl">Channels</div>
<div class="nav">Engineering</div>
<div class="nav">General</div>
</div>
</div>
<div class="pane">
<div class="topbar"><div class="search">Ask or search</div><div class="rec">Record</div></div>
<div class="body">
<div class="when">Today, Oct 27</div>
<div class="card">
<div class="av a1"></div>
<div>
<h4>Weekly product sync</h4>
<div class="meta">10:53 AM &middot; 38 min</div>
<p>The team walks through the revised onboarding flow and agrees to drop the second step, which was <span class="hl">tripping up first-time users</span> on mobile.</p>
</div>
</div>
<div class="card">
<div class="av a2"></div>
<div>
<h4>Design review</h4>
<div class="meta">9:15 AM &middot; 42 min</div>
<p>Shipping the client build Thursday. The remaining blocker is the mail server certificate.</p>
</div>
</div>
<div class="who">Priya</div>
<div class="bubble">The certificate is already staged in staging. I can rotate it before the demo if we get the window.</div>
</div>
</div>
</div>
<div class="caption">
<div class="name"><b>Dusk</b><code>#101216</code></div>
<div class="use">Default, mixed lighting</div>
<div class="sw"><i style="background:#101216"></i><i style="background:#16191E"></i><i style="background:#1C2027"></i><i style="background:#242931"></i><i style="background:#7FAEFF"></i></div>
<div class="nums"><span>Body <b>15.4:1</b></span><span>Secondary <b>8.3:1</b></span><span>Button <b>8.4:1</b></span></div>
</div>
</section>
<!-- SLATE -->
<section class="tier t-slate">
<div class="app">
<div class="rail">
<div class="brandrow"><span class="logo"></span><b>Otter</b></div>
<div class="nav on">Home</div>
<div class="nav">AI Chat</div>
<div class="nav">Explore</div>
<div class="navdots">
<div class="lbl">Channels</div>
<div class="nav">Engineering</div>
<div class="nav">General</div>
</div>
</div>
<div class="pane">
<div class="topbar"><div class="search">Ask or search</div><div class="rec">Record</div></div>
<div class="body">
<div class="when">Today, Oct 27</div>
<div class="card">
<div class="av a1"></div>
<div>
<h4>Weekly product sync</h4>
<div class="meta">10:53 AM &middot; 38 min</div>
<p>The team walks through the revised onboarding flow and agrees to drop the second step, which was <span class="hl">tripping up first-time users</span> on mobile.</p>
</div>
</div>
<div class="card">
<div class="av a2"></div>
<div>
<h4>Design review</h4>
<div class="meta">9:15 AM &middot; 42 min</div>
<p>Shipping the client build Thursday. The remaining blocker is the mail server certificate.</p>
</div>
</div>
<div class="who">Priya</div>
<div class="bubble">The certificate is already staged in staging. I can rotate it before the demo if we get the window.</div>
</div>
</div>
</div>
<div class="caption">
<div class="name"><b>Slate</b><code>#1B1F24</code></div>
<div class="use">Bright rooms and daytime</div>
<div class="sw"><i style="background:#1B1F24"></i><i style="background:#22272D"></i><i style="background:#282E35"></i><i style="background:#30373F"></i><i style="background:#8FB8FF"></i></div>
<div class="nums"><span>Body <b>14.3:1</b></span><span>Secondary <b>8.5:1</b></span><span>Button <b>9.3:1</b></span></div>
</div>
</section>
</div>
<footer class="page">
Token values live in <code>otter-dark.css</code>. Set <code>data-theme="midnight|dusk|slate"</code> on
<code>&lt;html&gt;</code> and read them with <code>var(--ot-*)</code>. The brand blue
<code>#0b57d0</code> is kept as a fill for the Otter mark and for the light theme; it measures
2.6:1 to 3.3:1 as text on these canvases, so links and accent text use the lightened accent tokens instead.
</footer>
</body>
</html>

BIN
preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

View file

@ -0,0 +1,377 @@
// ==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 });
})();