commit 672d3d51e18cd633b53ebcd9e81b267494c254fe Author: Evan Reichard Date: Wed Jul 8 11:59:50 2026 -0400 Initial commit: userscripts collection with oxlint tooling diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b0a26a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +_scratch/ +.direnv/ diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..fb19115 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json", + "env": { + "browser": true, + "es2024": true, + "greasemonkey": true + }, + "globals": { + "GM": "readonly", + "GM_getValue": "readonly", + "GM_setValue": "readonly", + "GM_deleteValue": "readonly", + "GM_addStyle": "readonly", + "GM_openInTab": "readonly", + "GM_xmlhttpRequest": "readonly", + "GM_registerMenuCommand": "readonly", + "GM_unregisterMenuCommand": "readonly", + "unsafeWindow": "readonly" + }, + "rules": { + "no-unused-vars": ["warn", { "caughtErrors": "none", "args": "none" }], + "no-debugger": "warn" + }, + "ignorePatterns": ["_scratch/**"] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fe77f4e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,29 @@ +# Userscripts + +A flat collection of standalone browser userscripts (Tampermonkey / Violentmonkey / Greasemonkey). + +## Core Rule: One Self-Contained File Per Script + +Each userscript is a single `*.user.js` file at the repo root. A script owns everything it needs — metadata header, styles, and logic — in that one file. + +- No shared modules, no build step, no bundler. A file must install and run as-is by pasting it into a userscript manager. +- New script → new `name.user.js` file. Do not factor common code into helpers across files; duplication across scripts is acceptable and expected here. +- Every file starts with a complete `==UserScript==` metadata block (`@name`, `@namespace`, `@version`, `@description`, `@match`, `@run-at`, and any `@grant`/`@connect`). + +## Linting + +[oxlint](https://oxc.rs) lints all scripts. It's a pnpm devDependency (pinned in `package.json` / `pnpm-lock.yaml`), not a Nix package. Config: `.oxlintrc.json` (browser + greasemonkey env, GM globals declared, `_scratch/` ignored). + +```bash +pnpm lint # lint every *.user.js +``` + +Warnings on third-party/imported scripts are acceptable signal, not blockers. Keep your own scripts warning-clean. + +## Dev Environment + +The Nix flake provides `nodejs` + `pnpm`; its `shellHook` runs `pnpm install` and puts `node_modules/.bin` on `PATH`, so `oxlint` is ready automatically. + +```bash +nix develop # or: direnv allow (uses .envrc → `use flake`) +``` diff --git a/archive-redirect.user.js b/archive-redirect.user.js new file mode 100644 index 0000000..9b16758 --- /dev/null +++ b/archive-redirect.user.js @@ -0,0 +1,48 @@ +// ==UserScript== +// @name Auto Archive Redirect +// @namespace Evan Reichard +// @version 0.0.3 +// @match *://*/* +// @grant GM.xmlhttpRequest +// @grant GM.openInTab +// @noframes +// @run-at document-start +// ==/UserScript== + +GM.xmlhttpRequest({ + url: 'https://archive.is/' + document.location.href, + method: 'GET', + responseType: 'document', + onloadend: processArchiveResponse +}); + +function processArchiveResponse(response){ + const { response: archiveDocument } = response; + const archiveURL = archiveDocument.querySelector('#CONTENT #row0 a')?.href; + if (archiveURL) createArchiveButton(archiveURL); +} + +function createArchiveButton(archiveURL){ + let myDiv = document.createElement('div') + myDiv.innerHTML = ` +

A

+
`; + let spanEl = myDiv.children[0]; + document.body.append(spanEl) + + spanEl.onclick = () => { + document.location.href = archiveURL; + } + + setTimeout(() => { + spanEl.style.opacity = '1'; + }, 0); + + setTimeout(() => { + spanEl.style.opacity = '0'; + }, 5000); + + setTimeout(() => { + spanEl.remove(); + }, 6000); +} \ No newline at end of file diff --git a/darkreader.user.js b/darkreader.user.js new file mode 100644 index 0000000..13d4571 --- /dev/null +++ b/darkreader.user.js @@ -0,0 +1,108 @@ +// ==UserScript== +// @name DarkReader +// @version 0.0.7 +// @match *://*/* +// @downloadURL https://gist.github.com/evanreichard/f983ca26836d4af6ca0f6fb602b9dcaf/raw/darkreader.user.js +// @require https://unpkg.com/darkreader@4.9.46/darkreader.js +// @grant GM.xmlhttpRequest +// @grant GM.registerMenuCommand +// @grant GM.unregisterMenuCommand +// @grant GM_getValue +// @grant GM_setValue +// @inject-into content +// @noframes +// @run-at document-start +// ==/UserScript== + +/* globals DarkReader */ + +let options = { + brightness: 70, + contrast: 110, + sepia: 50, +}; + +let prefersDark = + window.matchMedia && + window.matchMedia("(prefers-color-scheme: dark)").matches; + +function toggleHost() { + let disabledHosts = GM_getValue("disabledHosts", []); + let isDisabled = disabledHosts.includes(window.location.hostname); + + if (isDisabled) { + disabledHosts = disabledHosts.filter( + (item) => item !== window.location.hostname, + ); + } else { + disabledHosts.push(window.location.hostname); + } + + GM_setValue("disabledHosts", disabledHosts); + applySettings(); +} + +function toggleSystem() { + let useSystem = GM_getValue("useSystem", true); + GM_setValue("useSystem", !useSystem); + applySettings(); +} + +/** + * This is necessary for CORS related requests. GM.xmlhttpRequest + * bypasses that restriction. + **/ +async function extensionFetch(url) { + let responseData = await new Promise((resolve, reject) => { + GM.xmlhttpRequest({ + url, + responseType: "blob", + onerror: reject, + onload: resolve, + }); + }); + + return new Response(responseData.response); +} + +DarkReader.setFetchMethod(extensionFetch); + +function applySettings() { + GM.unregisterMenuCommand("System Mode - Enable"); + GM.unregisterMenuCommand("System Mode - Disable"); + GM.unregisterMenuCommand("DarkReader - Disable"); + GM.unregisterMenuCommand("DarkReader - Enable"); + + let disabledHosts = GM_getValue("disabledHosts", []); + let useSystem = GM_getValue("useSystem", true); + + if (useSystem) { + GM.registerMenuCommand("System Mode - Disable", toggleSystem); + } else { + GM.registerMenuCommand("System Mode - Enable", toggleSystem); + } + + let isDisabled = disabledHosts.includes(window.location.hostname); + if (isDisabled) { + GM.registerMenuCommand("DarkReader - Enable", toggleHost); + } else { + GM.registerMenuCommand("DarkReader - Disable", toggleHost); + } + + if (useSystem && prefersDark && !isDisabled) + return DarkReader.enable(options); + if (!useSystem && !isDisabled) return DarkReader.enable(options); + + DarkReader.disable(); +} + +// System Change +window + .matchMedia("(prefers-color-scheme: dark)") + .addEventListener("change", (event) => { + prefersDark = event.matches; + applySettings(); + }); + +// Initial Load +applySettings(); \ No newline at end of file diff --git a/emdash-highlighter.user.js b/emdash-highlighter.user.js new file mode 100644 index 0000000..ea6a99a --- /dev/null +++ b/emdash-highlighter.user.js @@ -0,0 +1,250 @@ +// ==UserScript== +// @name Em Dash Highlighter +// @namespace Reichard +// @version 1.0.0 +// @description Detects em dashes on the page, highlights them, and shows a popup on hover/tap +// @author Evan Reichard +// @match *://*/* +// @grant GM_addStyle +// @run-at document-idle +// ==/UserScript== + +(function () { + 'use strict'; + + // ─── Constants ──────────────────────────────────────────────────────────── + + const EM_DASH = '\u2014'; + const EMDASH_REGEX = /\u2014/g; + const PROCESSED_ATTR = 'data-emdash-processed'; + const POPUP_ID = 'emdash-popup'; + + // Tags whose text content we should never touch + const SKIP_TAGS = new Set([ + 'SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', + 'SELECT', 'BUTTON', 'PRE', 'SVG', 'MATH', + ]); + + // ─── Styles ─────────────────────────────────────────────────────────────── + + GM_addStyle(` + .emdash-highlight { + display: inline; + padding: 0 1px; + border-radius: 2px; + cursor: help; + animation: emdash-hue 4s linear infinite; + background-color: hsl(0, 80%, 70%); + position: relative; + } + + @keyframes emdash-hue { + 0% { background-color: hsl(0, 80%, 70%); } + 25% { background-color: hsl(90, 80%, 70%); } + 50% { background-color: hsl(180, 80%, 70%); } + 75% { background-color: hsl(270, 80%, 70%); } + 100% { background-color: hsl(360, 80%, 70%); } + } + + #${POPUP_ID} { + position: fixed; + z-index: 2147483647; + background: #1e1e2e; + color: #cdd6f4; + border: 1px solid #89b4fa; + border-radius: 6px; + padding: 6px 10px; + font: 13px/1.4 monospace; + pointer-events: none; + white-space: nowrap; + box-shadow: 0 4px 12px rgba(0,0,0,0.5); + opacity: 0; + transition: opacity 0.15s ease; + max-width: 280px; + white-space: normal; + } + + #${POPUP_ID}.visible { + opacity: 1; + } + `); + + // ─── Popup singleton ────────────────────────────────────────────────────── + + const popup = document.createElement('div'); + popup.id = POPUP_ID; + popup.innerHTML = ` + Em Dash — U+2014 + `; + document.documentElement.appendChild(popup); + + let hideTimer = null; + + function showPopup(anchorEl) { + clearTimeout(hideTimer); + const rect = anchorEl.getBoundingClientRect(); + const popupHeight = popup.offsetHeight || 80; + const popupWidth = popup.offsetWidth || 220; + + let top = rect.top - popupHeight - 8; + let left = rect.left + rect.width / 2 - popupWidth / 2; + + // Flip below if not enough room above + if (top < 4) top = rect.bottom + 8; + + // Clamp horizontally + left = Math.max(6, Math.min(left, window.innerWidth - popupWidth - 6)); + + popup.style.top = `${top}px`; + popup.style.left = `${left}px`; + popup.classList.add('visible'); + } + + function hidePopup(delay = 120) { + hideTimer = setTimeout(() => popup.classList.remove('visible'), delay); + } + + // ─── Span factory ───────────────────────────────────────────────────────── + + function createSpan() { + const span = document.createElement('span'); + span.className = 'emdash-highlight'; + span.setAttribute('aria-label', 'Em dash character'); + span.textContent = EM_DASH; + + // Desktop hover + span.addEventListener('mouseenter', () => showPopup(span)); + span.addEventListener('mouseleave', () => hidePopup()); + + // Mobile tap — show/hide without bubbling + span.addEventListener('touchend', (e) => { + e.stopPropagation(); + e.preventDefault(); + if (popup.classList.contains('visible')) { + hidePopup(0); + } else { + showPopup(span); + // Auto-hide after 3 s on mobile + hideTimer = setTimeout(() => popup.classList.remove('visible'), 3000); + } + }, { passive: false }); + + // Prevent click bubbling (e.g. accordion toggles, links) + span.addEventListener('click', (e) => { + e.stopPropagation(); + }); + + return span; + } + + // ─── Core processor ─────────────────────────────────────────────────────── + + /** + * Walk a DOM subtree and wrap every em dash in a text node with a span. + * We operate on Text nodes directly to avoid serialising/deserialising HTML. + */ + function processNode(root) { + // TreeWalker is faster than querySelectorAll for text nodes + const walker = document.createTreeWalker( + root, + NodeFilter.SHOW_TEXT, + { + acceptNode(node) { + const parent = node.parentElement; + if (!parent) return NodeFilter.FILTER_REJECT; + if (SKIP_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT; + if (parent.isContentEditable) return NodeFilter.FILTER_REJECT; + if (parent.classList.contains('emdash-highlight')) return NodeFilter.FILTER_REJECT; + if (!node.nodeValue.includes(EM_DASH)) return NodeFilter.FILTER_SKIP; + return NodeFilter.FILTER_ACCEPT; + }, + } + ); + + // Collect first — mutating the DOM while walking breaks the walker + const textNodes = []; + let node; + while ((node = walker.nextNode())) textNodes.push(node); + + for (const textNode of textNodes) { + splitAndWrap(textNode); + } + } + + /** + * Split a text node around em dashes and replace it with a fragment + * containing plain text nodes and highlight spans. + */ + function splitAndWrap(textNode) { + const text = textNode.nodeValue; + const parts = text.split(EMDASH_REGEX); + + if (parts.length <= 1) return; // no em dash found + + const fragment = document.createDocumentFragment(); + + parts.forEach((part, i) => { + if (part) fragment.appendChild(document.createTextNode(part)); + if (i < parts.length - 1) fragment.appendChild(createSpan()); + }); + + textNode.parentNode.replaceChild(fragment, textNode); + } + + // ─── Mutation observer ──────────────────────────────────────────────────── + + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'characterData') { + // Text node changed in place + const node = mutation.target; + if ( + node.nodeValue?.includes(EM_DASH) && + node.parentElement && + !SKIP_TAGS.has(node.parentElement.tagName) && + !node.parentElement.classList.contains('emdash-highlight') + ) { + splitAndWrap(node); + } + } else if (mutation.type === 'childList') { + for (const added of mutation.addedNodes) { + if (added.nodeType === Node.TEXT_NODE) { + if ( + added.nodeValue?.includes(EM_DASH) && + added.parentElement && + !SKIP_TAGS.has(added.parentElement.tagName) && + !added.parentElement.classList.contains('emdash-highlight') + ) { + splitAndWrap(added); + } + } else if (added.nodeType === Node.ELEMENT_NODE) { + processNode(added); + } + } + } + } + }); + + // ─── Global tap-outside to dismiss popup ────────────────────────────────── + + document.addEventListener('touchstart', () => hidePopup(0), { passive: true }); + + // ─── Init ───────────────────────────────────────────────────────────────── + + function init() { + processNode(document.body); + + observer.observe(document.body, { + childList: true, + subtree: true, + characterData: true, + }); + } + + // document-idle fires after DOMContentLoaded, but guard anyway + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); \ No newline at end of file diff --git a/enable-paste.user.js b/enable-paste.user.js new file mode 100644 index 0000000..6ea215c --- /dev/null +++ b/enable-paste.user.js @@ -0,0 +1,34 @@ +// ==UserScript== +// @name Paste Enabler +// @version 0.0.1 +// @description Re-enables paste functionality on websites +// @author Evan Reichard +// @match *://*/* +// @grant GM.registerMenuCommand +// @noframes +// @run-at document-start +// ==/UserScript== + +(function() { + 'use strict'; + + const enablePasteFunction = function() { + const events = ['paste', 'copy', 'cut']; + events.forEach(function(event) { + document.addEventListener(event, function(e) { + e.stopImmediatePropagation(); + return true; + }, true); + }); + + document.querySelectorAll('input, textarea').forEach(function(el) { + el.setAttribute('oncopy', 'return true'); + el.setAttribute('onpaste', 'return true'); + el.setAttribute('oncut', 'return true'); + el.removeAttribute('readonly'); + }); + }; + + // Register the menu command + GM.registerMenuCommand("Enable Paste", enablePasteFunction, "p"); +})(); \ No newline at end of file diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..615c7a5 --- /dev/null +++ b/flake.nix @@ -0,0 +1,34 @@ +{ + description = "Userscripts dev environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { self + , nixpkgs + , flake-utils + , + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { system = system; }; + in + { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + nodejs + pnpm + ]; + + shellHook = '' + pnpm install --silent + export PATH="$PWD/node_modules/.bin:$PATH" + ''; + }; + } + ); +} diff --git a/hn-tweaks.user.js b/hn-tweaks.user.js new file mode 100644 index 0000000..c0fbd54 --- /dev/null +++ b/hn-tweaks.user.js @@ -0,0 +1,139 @@ +// ==UserScript== +// @name Hacker News Tweaks +// @version 0.0.4 +// @downloadURL https://gist.githubusercontent.com/evanreichard/96d89512cbfe23e5d7a81479c482e6da/raw/hn-tweaks.user.js +// @match *://news.ycombinator.com/* +// @grant GM.addStyle +// @grant GM.registerMenuCommand +// @grant GM.unregisterMenuCommand +// @grant GM_getValue +// @grant GM.setValue +// @noframes +// @run-at document-idle +// ==/UserScript== + +/** + * This is a hacked together UserScript to add comment collapsing like old reddit. + * It also adds the following CSS tweak: https://news.ycombinator.com/item?id=29494475 + **/ + +let activeStyleElement; + +async function toggleStyle() { + let isEnabled = GM_getValue("enabled", true); + if (isEnabled === true) { + GM.unregisterMenuCommand("Disable Style"); + GM.registerMenuCommand("Enable Style", toggleStyle); + activeStyleElement?.remove(); + } else { + GM.unregisterMenuCommand("Enable Style"); + GM.registerMenuCommand("Disable Style", toggleStyle); + applyStyle(); + } + await GM.setValue("enabled", !isEnabled); +} + +function applyStyle() { + activeStyleElement = GM.addStyle(`.comhead { + font-size: 12pt; + line-height: 20pt; + } + @media (prefers-color-scheme: dark) { body { background-color: #1F2430; } + #hnmain { background-color: #232834; } + + a:link.storylink, + #hnmain > tbody > tr:first-child > td a, + .commtext, .commtext a:link, + td { + color: #fafafa; + } + + #hnmain > tbody > tr:first-child > td { background-color: #333a4a; } + + a:link, + .sitebit, .subtext, .sitestr, .subtext a:link, + .rank, a.morelink, + .pagetop, .yclinks a, + .comhead a:link, .comhead, + .hnmore a:link, .reply a:link { + color: #8c96ac; + } + + a:visited { + color: #979cf4; + } + + .votearrow { + overflow: visible; + position: relative; + } + + .votearrow::before { + content: ""; + width: 0; + height: 0; + left: 1px; + top: 3px; + display: block; + position: absolute; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-bottom: 7px solid #bebfd1; + } + + input[type=text], + textarea { + background-color: #333a4a; + color: #fafafa; + border: 1px solid #1F2430; + } +}`); +} + +function findParent(el, index) { + let previousSibling = el.previousElementSibling; + let foundElem = previousSibling.querySelector(`[indent="${index}"]`); + return foundElem + ? foundElem.closest(".athing") + : findParent(previousSibling, index); +} + +let isEnabled = GM_getValue("enabled", true); +if (isEnabled === true) { + GM.registerMenuCommand("Disable Style", toggleStyle); + applyStyle(); +} else { + GM.registerMenuCommand("Enable Style", toggleStyle); +} + +Array.from(document.querySelectorAll(".ind")).forEach((indent) => { + let imgEl = indent.querySelector("img"); + if (!imgEl) return; + let colWidth = imgEl.offsetWidth; + + indent.style.position = "relative"; + let indCount = parseInt(indent.getAttribute("indent")); + + let voteOffset = + indent.nextElementSibling.querySelector("center").offsetHeight; + + for (let i = 0; i <= indCount; i += 1) { + let marginLeft = i === 0 ? "10px" : (colWidth / indCount) * i + 10 + "px"; + let topOffset = i === indCount ? voteOffset + 15 : -10; + let newElem = document.createElement("div"); + newElem.setAttribute( + "style", + `cursor: pointer; height: calc(100% - ${topOffset + 2}px); width: 0px; position: absolute; margin-left: ${marginLeft}; top: ${topOffset}px; border-left: 2px solid green; padding: 0px min(${colWidth / 2}px, 5px);`, + ); + newElem.onclick = () => { + let commentBlock = newElem.closest(".athing"); + if (i === indCount) { + commentBlock.querySelector(".togg.clicky").click(); + } else { + let parentComment = findParent(commentBlock, i); + parentComment.querySelector(".togg.clicky").click(); + } + }; + indent.appendChild(newElem); + } +}); diff --git a/kagi_llm.user.js b/kagi_llm.user.js new file mode 100644 index 0000000..c29c261 --- /dev/null +++ b/kagi_llm.user.js @@ -0,0 +1,45 @@ +// ==UserScript== +// @name Auto Kagi LLM +// @match https://kagi.com/assistant?mode=4* +// @grant none +// @version 1.0 +// @author Evan Reichard +// ==/UserScript== + +function doAutoLLM() { + let hashLocation = window.location.hash; + if (!hashLocation.startsWith("#query=")) return; + + let query = decodeURIComponent(hashLocation.replace(/^#query=/, '')); + let inputEl = document.querySelector("textarea"); + + inputEl.value = query; + const keyboardEvent = new KeyboardEvent('keydown', { + code: 'Enter', + key: 'Enter', + charCode: 13, + keyCode: 13, + view: window, + bubbles: true + }); + + setTimeout(() => { + inputEl.dispatchEvent(keyboardEvent); + }, 100); +} + +doAutoLLM() + +// Example Usage: +// https://kagi.com/assistant?mode=4&sub_mode=1#query=%s + +// LLMs: +// GPT35Turbo - https://kagi.com/assistant?mode=4&sub_mode=1 +// GPT4 - https://kagi.com/assistant?mode=4&sub_mode=2 +// GPT4Turbo - https://kagi.com/assistant?mode=4&sub_mode=7 +// Claude3Haiku - https://kagi.com/assistant?mode=4&sub_mode=13 +// Claude3Sonnet - https://kagi.com/assistant?mode=4&sub_mode=14 +// Claude3Opus - https://kagi.com/assistant?mode=4&sub_mode=12 +// MistralSmall - https://kagi.com/assistant?mode=4&sub_mode=10 +// MistralLarge - https://kagi.com/assistant?mode=4&sub_mode=11 +// Gemini15Pro - https://kagi.com/assistant?mode=4&sub_mode=8 \ No newline at end of file diff --git a/old-reddit-mobile.user.js b/old-reddit-mobile.user.js new file mode 100644 index 0000000..a085f7f --- /dev/null +++ b/old-reddit-mobile.user.js @@ -0,0 +1,854 @@ +// ==UserScript== +// @name Reddit Tweaks (Apollo) +// @version 0.3.0 +// @match *://old.reddit.com/* +// @match *://www.reddit.com/* +// @downloadURL https://gist.githubusercontent.com/evanreichard/968eee1cf83da38c81c155fb775cf3c6/raw/old-reddit-mobile.user.js +// @grant GM.addStyle +// @grant GM.registerMenuCommand +// @grant GM.unregisterMenuCommand +// @grant GM.getValue +// @grant GM.setValue +// @grant GM_getValue +// @noframes +// @run-at document-start +// ==/UserScript== + +/* ============================================================================ + * REDIRECT: www.reddit.com -> old.reddit.com + * ---------------------------------------------------------------------------- + * Run before anything else so we don't waste cycles styling the new design. + * The /media path is a special case: Reddit's image viewer wraps the image + * in heavy chrome, so we strip everything down to just the tag. + * ========================================================================== */ +if (document.location.host === "www.reddit.com") { + if (document.location.pathname === "/media") { + document.addEventListener("DOMContentLoaded", () => { + const img = document.querySelector("img"); + if (img) document.body.innerHTML = img.outerHTML; + }); + } else { + window.location.href = + "https://old.reddit.com" + + window.location.pathname + + window.location.search + + window.location.hash; + } +} + +/* ============================================================================ + * STATE + * ---------------------------------------------------------------------------- + * `isEnabled` is read synchronously at script start so we can decide whether + * to inject CSS at document-start (before paint). Toggling the script ALWAYS + * reloads the page — see toggleStyle() for the reasoning. + * ========================================================================== */ +let isEnabled = GM_getValue("enabled", true); + +/* ============================================================================ + * STYLES + * ---------------------------------------------------------------------------- + * All visual styling lives here. Uses CSS custom properties for theming so + * dark mode is just a media query override on the :root tokens. + * ========================================================================== */ +function applyStyle() { + GM.addStyle(` + /* -------------------------------------------------------------- + DESIGN TOKENS + -------------------------------------------------------------- */ + :root { + --bg: #ffffff; + --bg-elev: #f7f8fa; + --bg-hover: #eef0f3; + --surface: #ffffff; + --surface-2: #f4f5f7; + --border: #e3e6ea; + --border-soft: #ecedef; + + --fg: #1c1c1e; + --fg-2: #3c3c43; + --fg-dim: #6e6e73; + --fg-faint: #b0b3b8; + + --accent: #ff4500; + --accent-2: #ff6a33; + --link: #2f6feb; + --visited: #8a8d96; + + --upvote: #ff4500; + --downvote: #5b8def; + + /* Apollo-style thread depth colors (cycle every 6) */ + --depth-1: #4f9cf9; /* blue */ + --depth-2: #34c759; /* green */ + --depth-3: #ffcc00; /* yellow */ + --depth-4: #ff9f0a; /* orange */ + --depth-5: #ff453a; /* red */ + --depth-6: #bf5af2; /* purple */ + + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; + + --font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", + "Segoe UI", "Inter", Roboto, system-ui, sans-serif; + --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + + --shadow-sm: 0 1px 2px rgba(0,0,0,0.04); + --shadow: 0 2px 8px rgba(0,0,0,0.06); + + --tap: 44px; + } + + @media (prefers-color-scheme: dark) { + :root { + --bg: #0e0f12; + --bg-elev: #16181d; + --bg-hover: #1e2128; + --surface: #16181d; + --surface-2: #1c1f25; + --border: #26292f; + --border-soft: #1e2128; + + --fg: #f2f2f7; + --fg-2: #d1d1d6; + --fg-dim: #9b9ba1; + --fg-faint: #5a5d63; + + --link: #5b8def; + --visited: #6e6f78; + + --shadow-sm: 0 1px 2px rgba(0,0,0,0.3); + --shadow: 0 4px 12px rgba(0,0,0,0.4); + } + } + + /* -------------------------------------------------------------- + GLOBAL RESET / BASE + -------------------------------------------------------------- */ + html, body { + background: var(--bg) !important; + color: var(--fg) !important; + font-family: var(--font-sans) !important; + font-size: 15px !important; + line-height: 1.4 !important; + -webkit-font-smoothing: antialiased; + -webkit-text-size-adjust: 100%; + } + + a { color: var(--link); text-decoration: none; } + a:visited { color: var(--visited); } + a:hover { color: var(--fg); } + + /* Hide all the cruft old.reddit ships with by default */ + .side, + .footer-parent, + .rank, + .rank-spacer, + #sr-header-area, + .mobile-web-redirect-bar, + .link.promotedlink, + .infobar.listingsignupbar, + .infobar.welcome, + .listingsignupbar, + .promoted, + [data-promoted="true"], + .res-floatingSideBar, + .bottommenu, + .sr-list, + .sr-bar, + .sr-interest-bar, + .leavemoderator, + .titlebox, + .login-form-side, + .icon-menu .gold-buy, + .reportform, + .panestack-title .res-comments-sortMenu, + .link .flat-list.buttons li.share, + .link .flat-list.buttons .post-sharing-button, + .link .flat-list.buttons .reportbtn { + display: none !important; + } + + /* -------------------------------------------------------------- + HEADER + -------------------------------------------------------------- */ + #header { + background: var(--bg-elev) !important; + border-bottom: 1px solid var(--border) !important; + box-shadow: var(--shadow-sm); + padding: 0 12px !important; + min-height: 48px !important; + height: auto !important; + position: sticky; + top: 0; + z-index: 1000; + backdrop-filter: saturate(180%) blur(12px); + -webkit-backdrop-filter: saturate(180%) blur(12px); + } + #header-bottom-left { + display: flex !important; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 6px 14px; + padding: 8px 0 !important; + } + #header-img, .pagename a { + color: var(--fg) !important; + font-weight: 700 !important; + font-size: 17px !important; + letter-spacing: -0.01em; + } + .pagename { padding: 0 !important; } + + .tabmenu { + width: 100%; + display: flex !important; + gap: 4px; + margin: 0 !important; + overflow-x: auto; + scrollbar-width: none; + } + .tabmenu::-webkit-scrollbar { display: none; } + .tabmenu li { margin: 0 !important; } + .tabmenu li a { + display: inline-block; + padding: 6px 12px !important; + border-radius: 999px; + font-size: 13px !important; + font-weight: 500; + color: var(--fg-dim) !important; + background: transparent !important; + border: 1px solid transparent !important; + transition: background 0.15s ease, color 0.15s ease; + } + .tabmenu li.selected a { + background: var(--accent) !important; + color: #fff !important; + } + .tabmenu li:not(.selected) a:hover { + background: var(--bg-hover) !important; + color: var(--fg) !important; + } + + #header-bottom-right { + top: 0; + bottom: unset; + border-radius: 0 0 var(--radius-sm) var(--radius-sm); + background: var(--bg-elev) !important; + color: var(--fg-dim) !important; + font-size: 12px !important; + padding: 6px 10px !important; + border-left: 1px solid var(--border); + border-bottom: 1px solid var(--border); + } + #header-bottom-right a { color: var(--fg-dim) !important; } + + /* -------------------------------------------------------------- + CONTENT WRAPPER + -------------------------------------------------------------- */ + .content[role="main"] { + margin: 0 !important; + padding: 0 !important; + max-width: 100% !important; + background: var(--bg) !important; + color: var(--fg) !important; + } + #siteTable { margin: 0 !important; } + + /* -------------------------------------------------------------- + POSTS + ---------------------------------------------------------------- + Flex layout that preserves Reddit's native expando behavior. + Order: thumbnail | entry | (votes hidden) | (wrap) expando + -------------------------------------------------------------- */ + .link { + position: relative; + display: flex !important; + flex-wrap: wrap; + align-items: flex-start; + column-gap: 12px; + row-gap: 4px; + padding: 12px 14px !important; + margin: 0 !important; + background: var(--surface) !important; + color: var(--fg) !important; + border: 0 !important; + border-bottom: 1px solid var(--border-soft) !important; + transition: background 0.12s ease; + } + .link:active { background: var(--bg-hover) !important; } + .link.last-clicked { + background: var(--bg-elev) !important; + border-bottom: 1px solid var(--border-soft) !important; + } + + .link > .thumbnail { order: 1; flex: 0 0 auto; } + .link > .entry { order: 2; flex: 1 1 0; min-width: 0; } + .link > .midcol { order: 3; flex: 0 0 auto; } + .link > .child, + .link > .clearleft { display: none !important; } + + /* Hide post-level vote arrows and native expando button (we tap thumbnail instead) */ + .link > .midcol { display: none !important; } + .link .expando-button { display: none !important; } + + /* Thumbnail container — shared by real and synthetic thumbs */ + .link > .thumbnail { + width: 64px !important; + height: 64px !important; + margin: 0 !important; + border-radius: var(--radius); + overflow: hidden; + background: var(--bg-elev); + border: 1px solid var(--border-soft); + display: flex !important; + align-items: center; + justify-content: center; + cursor: pointer; + } + .link > .thumbnail img { + width: 100% !important; + height: 100% !important; + object-fit: cover !important; + display: block; + } + + /* Placeholder labels when there's no real image */ + .link > .thumbnail.self::after, + .link > .thumbnail.default::after, + .link > .thumbnail.spoiler::after, + .link > .thumbnail.nsfw::after { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.05em; + color: var(--fg-dim); + } + .link > .thumbnail.self::after { content: "TEXT"; } + .link > .thumbnail.default::after { content: "LINK"; } + .link > .thumbnail.spoiler::after { content: "⚠"; font-size: 18px; } + .link.over18 > .thumbnail::after { content: "NSFW"; color: #ff453a; } + + /* Entry / title / meta */ + .link .entry { margin: 0 !important; color: var(--fg) !important; } + .link .top-matter { padding: 0 !important; margin: 0 !important; } + + .link .title { + margin: 0 0 4px 0 !important; + padding: 0 !important; + font-size: 15px !important; + line-height: 1.3 !important; + font-weight: 500; + letter-spacing: -0.01em; + } + .link .title a.title { color: var(--fg) !important; } + .link.visited .title a.title, + .link .title a.title:visited { color: var(--fg-dim) !important; } + .link .title .domain { + display: inline-block; + font-size: 11px !important; + color: var(--fg-dim) !important; + font-weight: 400; + margin-left: 4px; + vertical-align: middle; + } + .link .title .domain a { color: var(--fg-dim) !important; } + + .link .linkflairlabel { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 10.5px !important; + font-weight: 600; + padding: 2px 7px !important; + border-radius: 999px; + margin-right: 6px; + vertical-align: middle; + line-height: 1.4; + border: 0 !important; + } + .link .flairemoji { width: 12px !important; height: 12px !important; } + + .link .tagline { + font-size: 12px !important; + color: var(--fg-dim) !important; + margin: 0 !important; + line-height: 1.3 !important; + } + .link .tagline a, + .link .tagline time { color: var(--fg-dim) !important; } + .link .tagline .author { color: var(--fg-2) !important; font-weight: 500; } + + .link .flat-list.buttons { + display: flex; + gap: 12px; + margin: 4px 0 0 0 !important; + padding: 0 !important; + font-size: 12px !important; + list-style: none; + } + .link .flat-list.buttons li { display: inline-block; margin: 0; padding: 0; } + .link .flat-list.buttons a { color: var(--fg-dim) !important; font-weight: 500; } + .link .flat-list.buttons .comments { + color: var(--accent) !important; + font-weight: 600; + } + + /* -------------------------------------------------------------- + EXPANDO (full-width row beneath the post) + -------------------------------------------------------------- */ + .link > .expando { + order: 4; + flex: 0 0 100%; + width: 100% !important; + max-width: 100% !important; + margin: 8px 0 0 0 !important; + padding: 0 !important; + background: transparent !important; + border: 0 !important; + color: var(--fg) !important; + box-sizing: border-box; + } + .link > .expando .media-preview, + .link > .expando .media-preview-content, + .link > .expando .res-expando-box, + .link > .expando .res-expando-box div { + max-width: 100% !important; + width: 100% !important; + } + .link .expando img, + .link .expando video, + .link .expando iframe, + .link .expando .preview, + .link .expando .media-preview-content > * { + max-width: 100% !important; + height: auto !important; + border-radius: var(--radius); + display: block; + } + + /* Self-text body */ + .link .expando .usertext-body, + .link .usertext-body, + .commentarea .link .usertext-body { + background: var(--surface-2) !important; + color: var(--fg) !important; + border: 1px solid var(--border-soft) !important; + border-radius: var(--radius); + padding: 10px 14px !important; + margin: 0 !important; + } + .link .expando .md, + .link .usertext-body .md { + background: transparent !important; + color: var(--fg) !important; + max-width: unset !important; + font-size: 14px !important; + line-height: 1.55 !important; + } + .link .expando .md p, + .link .expando .md li, + .link .expando .md h1, + .link .expando .md h2, + .link .expando .md h3, + .link .expando .md blockquote { + color: var(--fg) !important; + } + + /* -------------------------------------------------------------- + COMMENTS — bubble-in-bubble with depth-colored left border + -------------------------------------------------------------- */ + .commentarea { + background: var(--bg) !important; + color: var(--fg) !important; + padding: 8px 10px !important; + margin: 0 !important; + } + .commentarea > .menuarea { + background: transparent !important; + border: 0 !important; + padding: 8px 4px !important; + margin: 0 0 4px 0 !important; + font-size: 12px; + color: var(--fg-dim) !important; + } + + .comment { + background: var(--surface) !important; + color: var(--fg) !important; + border: 1px solid var(--border-soft) !important; + border-left: 3px solid var(--depth-1) !important; + border-radius: var(--radius); + padding: 10px 12px !important; + margin: 6px 0 !important; + box-shadow: var(--shadow-sm); + } + .comment .child, + .comment .showreplies { + border-left: 0 !important; + margin: 6px 0 0 6px !important; + padding: 0 !important; + } + + /* Cycle border colors by nesting depth */ + .comment .comment { border-left-color: var(--depth-2) !important; } + .comment .comment .comment { border-left-color: var(--depth-3) !important; } + .comment .comment .comment .comment { border-left-color: var(--depth-4) !important; } + .comment .comment .comment .comment .comment { border-left-color: var(--depth-5) !important; } + .comment .comment .comment .comment .comment .comment { border-left-color: var(--depth-6) !important; } + .comment .comment .comment .comment .comment .comment .comment { border-left-color: var(--depth-1) !important; } + .comment .comment .comment .comment .comment .comment .comment .comment { border-left-color: var(--depth-2) !important; } + .comment .comment .comment .comment .comment .comment .comment .comment .comment { border-left-color: var(--depth-3) !important; } + + /* Alternate bg shades to emphasize nesting */ + .comment .comment { background: var(--surface-2) !important; } + .comment .comment .comment { background: var(--surface) !important; } + + .comment .entry { padding: 0 !important; } + .comment .tagline { + font-size: 12px !important; + color: var(--fg-dim) !important; + margin-bottom: 4px; + } + .comment .tagline .author { color: var(--fg) !important; font-weight: 600; } + .comment .tagline .submitter { color: var(--link) !important; font-weight: 600; } + .comment .tagline .score { color: var(--fg-dim) !important; font-weight: 500; } + + .comment .usertext-body, + .comment .usertext-body .md, + .comment .md { + font-size: 14px !important; + line-height: 1.5 !important; + color: var(--fg) !important; + background: transparent !important; + } + .comment .md p { margin: 4px 0 !important; color: var(--fg) !important; } + .comment .md a { color: var(--link) !important; } + .comment .md blockquote { + border-left: 3px solid var(--border); + padding-left: 10px; + color: var(--fg-2) !important; + margin: 6px 0; + } + .comment .md code { + background: var(--bg-elev) !important; + color: var(--fg) !important; + border-radius: 4px; + padding: 1px 5px; + font-family: var(--font-mono); + font-size: 12.5px; + } + .comment .md pre { + background: var(--bg-elev) !important; + color: var(--fg) !important; + padding: 8px 10px; + border-radius: var(--radius-sm); + overflow-x: auto; + } + + .comment .midcol { + width: 16px !important; + margin-right: 4px !important; + } + .comment .arrow { + width: 18px !important; + height: 18px !important; + background-size: 14px !important; + border-radius: 4px; + } + .comment .arrow:hover { background-color: var(--bg-hover); } + + .comment .flat-list.buttons { + font-size: 12px !important; + margin-top: 4px !important; + } + .comment .flat-list.buttons a { color: var(--fg-dim) !important; font-weight: 500; } + .comment .flat-list.buttons .reply-button a { color: var(--link) !important; } + + .comment .expand { + color: var(--fg-dim) !important; + font-weight: 600; + padding: 0 4px; + } + .comment.collapsed { + opacity: 0.7; + padding: 6px 12px !important; + } + + /* "load more" buttons */ + .morechildren a, .morecomments a { + display: inline-block; + padding: 6px 12px; + border-radius: 999px; + background: var(--bg-elev) !important; + color: var(--link) !important; + font-weight: 600; + font-size: 13px; + border: 1px solid var(--border) !important; + } + + /* -------------------------------------------------------------- + PAGINATION + -------------------------------------------------------------- */ + .nextprev { + display: flex; + justify-content: space-between; + align-items: stretch; + gap: 10px; + padding: 16px 14px 28px; + margin: 0; + } + .nextprev .separator { display: none; } + .nextprev a { + flex: 1 1 0; + min-height: var(--tap); + display: flex; + align-items: center; + justify-content: center; + padding: 12px 16px; + border-radius: var(--radius); + font-weight: 600; + font-size: 15px; + text-decoration: none; + background: var(--bg-elev) !important; + color: var(--fg) !important; + border: 1px solid var(--border); + transition: transform 0.06s ease, background 0.15s ease; + } + .nextprev a:hover { background: var(--bg-hover) !important; } + .nextprev a:active { transform: scale(0.98); } + .nextprev a[rel*="next"] { + background: var(--accent) !important; + color: #fff !important; + border-color: transparent; + } + .nextprev a[rel*="next"]:hover { background: var(--accent-2) !important; } + + /* -------------------------------------------------------------- + FORMS / MENUS / MISC + -------------------------------------------------------------- */ + input[type=text], + input[type=password], + input[type=email], + textarea { + background: var(--bg-elev) !important; + color: var(--fg) !important; + border: 1px solid var(--border) !important; + border-radius: var(--radius-sm) !important; + padding: 8px 10px !important; + font-family: var(--font-sans) !important; + font-size: 14px !important; + } + .btn, button, .morelink a { + background: var(--bg-elev) !important; + color: var(--fg) !important; + border: 1px solid var(--border) !important; + border-radius: var(--radius-sm) !important; + padding: 6px 12px !important; + font-weight: 600; + font-family: var(--font-sans) !important; + } + .menuarea, .panestack-title { + background: transparent !important; + border: 0 !important; + color: var(--fg-dim) !important; + padding: 8px 12px !important; + } + .dropdown, .drop-choices { + background: var(--surface) !important; + color: var(--fg) !important; + border: 1px solid var(--border) !important; + border-radius: var(--radius-sm) !important; + box-shadow: var(--shadow); + } + + table, th, td { + background: var(--surface) !important; + color: var(--fg) !important; + border-color: var(--border) !important; + } + pre, code { + background: var(--bg-elev) !important; + color: var(--fg) !important; + font-family: var(--font-mono) !important; + } + + /* Dim Reddit's default vote arrows in dark mode (they're black PNGs) */ + @media (prefers-color-scheme: dark) { + .arrow:not(.upmod):not(.downmod) { filter: brightness(0.85) invert(0.9); } + } + + a, button { touch-action: manipulation; } + body > .content .link .midcol { width: auto !important; } + body > .content .link .rank, .rank-spacer { width: 0 !important; } + `); + + // Mobile viewport — Reddit's default doesn't include viewport-fit=cover, + // which we want for safe-area handling on notched devices. + if (document.head) { + const meta = document.createElement("meta"); + meta.setAttribute("name", "viewport"); + meta.setAttribute( + "content", + "width=device-width, initial-scale=1, viewport-fit=cover", + ); + document.head.appendChild(meta); + } + + // Nuke subreddit-specific stylesheet ASAP so it can't fight ours + document.head?.querySelector("[ref=applied_subreddit_stylesheet]")?.remove(); +} + +/* ============================================================================ + * DOM TWEAKS + * ---------------------------------------------------------------------------- + * Things CSS alone can't do: rewriting nodes, injecting placeholders, + * relocating elements within the post layout. + * ========================================================================== */ +function applyDOMTweaks() { + // Belt-and-suspenders subreddit stylesheet removal (in case it loaded after us) + document.querySelector('link[title="applied_subreddit_stylesheet"]')?.remove(); + + // Replace the subreddit logo/banner anchor with a plain "reddit" home link + const headerAnchor = document.querySelector("#header-bottom-left a"); + if (headerAnchor) { + headerAnchor.outerHTML = + `reddit`; + } + + // Process every post on the page + document.querySelectorAll(".link").forEach(processLink); + + // Pagination polish — shorten "view more" wording and add arrows + const nextprev = document.querySelector(".nextprev"); + if (nextprev) { + // The first text node is often "view more:" — drop it + const first = nextprev.childNodes[0]; + if (first && first.textContent.trim().toLowerCase().startsWith("view more")) { + first.remove(); + } + nextprev.querySelectorAll("a").forEach((a) => { + const rel = (a.getAttribute("rel") || "").toLowerCase(); + if (rel.includes("next")) a.textContent = "Next ›"; + else if (rel.includes("prev")) a.textContent = "‹ Prev"; + }); + // Center the lone button if there's only one direction available + if (nextprev.querySelectorAll("a").length === 1) { + nextprev.style.justifyContent = "center"; + nextprev.querySelector("a").style.flex = "0 1 280px"; + } + } +} + +/** + * Per-post tweaks: + * 1. Move .expando out of .entry so it can become a full-width flex row + * (flex order: thumbnail | entry | expando-on-new-line). + * 2. Inject a synthetic .thumbnail
when the subreddit suppresses + * thumbnails server-side (e.g. r/cars). This guarantees every post + * has a tappable thumb area showing "TEXT", "LINK", "NSFW", etc. + */ +function processLink(link) { + // --- 1. Promote .expando to be a direct child of .link --- + const expando = link.querySelector(":scope > .entry > .expando"); + if (expando) link.appendChild(expando); + + // --- 2. Inject synthetic thumbnail if missing --- + if (!link.querySelector(":scope > .thumbnail")) { + const thumb = document.createElement("div"); + + // Pick the right placeholder class so CSS ::after shows the right label + if (link.classList.contains("over18")) { + thumb.className = "thumbnail nsfw"; + } else if (link.classList.contains("spoiler")) { + thumb.className = "thumbnail spoiler"; + } else if (link.classList.contains("self")) { + thumb.className = "thumbnail self"; + } else { + thumb.className = "thumbnail default"; + } + + link.prepend(thumb); + } +} + +/* ============================================================================ + * INTERACTION HANDLERS + * ---------------------------------------------------------------------------- + * Delegated click handlers — attached once at script start, gated on + * `isEnabled` so they no-op when the user disables the script live. + * ========================================================================== */ + +// Tap a comment body to collapse/expand it (Apollo-style) +document.addEventListener("click", (evt) => { + if (!isEnabled) return; + + // Ignore clicks on interactive descendants + if (evt.target.closest("a")) return; + if (evt.target.classList.contains("expand")) return; + if (evt.target.closest(".flat-list.buttons")) return; + if (evt.target.closest(".usertext-edit")) return; + if (evt.target.closest(".midcol")) return; + + const comment = evt.target.closest(".comment"); + if (!comment) return; + + const expander = comment.querySelector(":scope > .entry .expand"); + if (!expander) return; + expander.click(); +}); + +// Tap a thumbnail to toggle the post's expando. +// On synthetic thumbnails (link posts with no expando) this is a no-op. +document.addEventListener("click", (evt) => { + if (!isEnabled) return; + + let thumb; + if (evt.target.classList?.contains("thumbnail")) { + thumb = evt.target; + } else if (evt.target.parentElement?.classList?.contains("thumbnail")) { + thumb = evt.target.parentElement; + } + if (!thumb) return; + + const link = thumb.closest(".link"); + if (!link) return; + + const btn = link.querySelector(".expando-button"); + evt.preventDefault(); + evt.stopPropagation(); + if (!btn) return; // synthetic thumb, nothing to expand + + btn.click(); +}); + +/* ============================================================================ + * MENU / TOGGLE + * ---------------------------------------------------------------------------- + * Toggling always reloads the page. Why: + * - GM.addStyle injects a