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:
commit
c10cf59e67
7 changed files with 877 additions and 0 deletions
21
LICENSE
Normal file
21
LICENSE
Normal 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
39
PROVENANCE.md
Normal 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
99
README.md
Normal 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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
102
otter-dark.css
Normal 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
239
preview.html
Normal 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 · BINARY RANCH · CSASS@BINARYRANCH.COM · 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 · 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 · 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 · 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 · 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 · 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 · 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><html></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
BIN
preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
377
userscript/otter-dark-mode.user.js
Normal file
377
userscript/otter-dark-mode.user.js
Normal 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 });
|
||||||
|
})();
|
||||||
Loading…
Reference in a new issue