Solarized themes for Hacker News

ChatGPT wrote this lol.
 avatar
unknown
javascript
9 months ago
13 kB
13
No Index
// ==UserScript==
// @name         Hacker News Dark Theme (Dark + Solarized + Auto + Header Band)
// @namespace    https://news.ycombinator.com/
// @version      1.3.0
// @description  Clean themes for Hacker News: Dark, Solarized Dark, and Solarized Light. Header controls, keyboard shortcuts, auto-follow system, and a full-width background band behind the top menu for readability.
// @author       you
// @match        https://news.ycombinator.com/*
// @run-at       document-start
// @grant        GM_addStyle
// ==/UserScript==

(function () {
  'use strict';

  // --- Storage keys
  const ENABLE_KEY = 'hn_dark_enabled_v1';
  const AUTO_KEY = 'hn_auto_follow_system_v1';
  const DARK_PREF_KEY = 'hn_pref_dark_theme_v1';      // 'dark' | 'solarized-dark'
  const LIGHT_PREF_KEY = 'hn_pref_light_theme_v1';     // 'solarized-light'

  // --- Themes
  const THEMES = ['dark', 'solarized-dark', 'solarized-light'];
  const isDarkTheme = (t) => t === 'dark' || t === 'solarized-dark';

  // --- Initial state
  const mql = matchMedia('(prefers-color-scheme: dark)');
  const prefersDark = mql.matches;
  const storedEnabled = localStorage.getItem(ENABLE_KEY);
  const storedAuto = localStorage.getItem(AUTO_KEY);
  let enabled = storedEnabled === null ? prefersDark : storedEnabled === 'true';
  let autoFollow = storedAuto === 'true';
  let darkPref = (localStorage.getItem(DARK_PREF_KEY) || 'dark');
  if (!isDarkTheme(darkPref)) darkPref = 'dark';
  let lightPref = (localStorage.getItem(LIGHT_PREF_KEY) || 'solarized-light');
  if (isDarkTheme(lightPref)) lightPref = 'solarized-light';
  let manualTheme = darkPref; // when autoFollow is off

  // --- CSS (scoped by data attributes)
  const css = `
    /* Base wiring */
    html[data-hn-enabled='1'], html[data-hn-enabled='1'] body {
      background: var(--bg) !important;
      color: var(--text) !important;
    }
    html[data-hn-enabled='1'] body,
    html[data-hn-enabled='1'] table,
    html[data-hn-enabled='1'] tr,
    html[data-hn-enabled='1'] td {
      background: transparent !important;
      color: var(--text) !important;
      border-color: var(--border) !important;
    }

    /* Palette: Modern Dark */
    html[data-hn-enabled='1'][data-hn-theme='dark'] {
      --bg: #0f1115;
      --bg-elev: #161922;
      --bg-soft: #141824;
      --text: #e7eaf0;
      --text-dim: #b7c0d1;
      --link: #9ec1ff;
      --link-visited: #c7a0ff;
      --accent: #ff8a3d;
      --muted: #2a2f3a;
      --border: #262b36;
      --upvote: #ffbd7a;
    }

    /* Palette: Solarized Dark */
    html[data-hn-enabled='1'][data-hn-theme='solarized-dark'] {
      --bg: #002b36;        /* base03 */
      --bg-elev: #073642;   /* base02 */
      --bg-soft: #073642;
      --text: #93a1a1;      /* base1 */
      --text-dim: #839496;  /* base0 */
      --link: #268bd2;      /* blue */
      --link-visited: #6c71c4; /* violet */
      --accent: #b58900;    /* yellow */
      --muted: #0a3942;
      --border: #0c3f49;
      --upvote: #b58900;
    }

    /* Palette: Solarized Light */
    html[data-hn-enabled='1'][data-hn-theme='solarized-light'] {
      --bg: #fdf6e3;        /* base3 */
      --bg-elev: #eee8d5;   /* base2 */
      --bg-soft: #efe9d9;
      --text: #586e75;      /* base00 */
      --text-dim: #657b83;  /* base0 */
      --link: #268bd2;      /* blue */
      --link-visited: #6c71c4; /* violet */
      --accent: #cb4b16;    /* orange */
      --muted: #e6dfcb;
      --border: #e1d9c5;
      --upvote: #b58900;
    }

    /* Top bar + full-width background band */
    #hn-header-band {
      position: fixed;
      top: 0; left: 0; right: 0; height: 34px; /* fits HN header height */
      background: var(--bg-elev);
      border-bottom: 1px solid var(--border);
      z-index: 999; /* beneath controls, above page bg */
      pointer-events: none; /* don't block clicks */
    }
    /* make the actual header sit above the band */
    html[data-hn-enabled='1'] .pagetop,
    html[data-hn-enabled='1'] .topcolor,
    html[data-hn-enabled='1'] table[bgcolor='#ff6600'] { position: relative; z-index: 1000; background: transparent !important; }

    /* Story rows */
    html[data-hn-enabled='1'] .athing { background: transparent !important; }
    html[data-hn-enabled='1'] .titleline a { color: var(--text) !important; }
    html[data-hn-enabled='1'] .titleline a:hover { text-decoration: underline; }
    html[data-hn-enabled='1'] .subtext,
    html[data-hn-enabled='1'] .subline,
    html[data-hn-enabled='1'] .age { color: var(--text-dim) !important; }

    /* Links */
    html[data-hn-enabled='1'] a { color: var(--link) !important; }
    html[data-hn-enabled='1'] a:visited { color: var(--link-visited) !important; }

    /* Comment pages */
    html[data-hn-enabled='1'] .comment-tree td { border-color: var(--muted) !important; }
    html[data-hn-enabled='1'] .commtext { color: var(--text) !important; }
    html[data-hn-enabled='1'] .commtext a { color: var(--link) !important; }
    html[data-hn-enabled='1'] .comhead { color: var(--text-dim) !important; }

    /* Inputs (login, search, reply, submit) */
    html[data-hn-enabled='1'] input,
    html[data-hn-enabled='1'] textarea,
    html[data-hn-enabled='1'] select {
      background: var(--bg-elev) !important;
      color: var(--text) !important;
      border: 1px solid var(--border) !important;
    }
    html[data-hn-enabled='1'] input[type='submit'],
    html[data-hn-enabled='1'] button {
      background: var(--bg-soft) !important;
      color: var(--text) !important;
      border: 1px solid var(--border) !important;
      cursor: pointer;
    }

    /* Misc */
    html[data-hn-enabled='1'] .yclinks { color: var(--text-dim) !important; }
    html[data-hn-enabled='1'] .morelink:link,
    html[data-hn-enabled='1'] a.morelink { color: var(--accent) !important; }
    html[data-hn-enabled='1'] .votelinks a[href^='vote'] { color: var(--upvote) !important; }

    /* Light borders for structure */
    html[data-hn-enabled='1'] td[bgcolor='#f6f6ef'] { background: var(--bg) !important; }
    html[data-hn-enabled='1'] td[bgcolor='#ffffff'] { background: var(--bg-elev) !important; }
    html[data-hn-enabled='1'] .spacer,
    html[data-hn-enabled='1'] .morespace { background: transparent !important; }
    html[data-hn-enabled='1'] img { background: transparent !important; }

    /* Header buttons */
    #hn-dark-toggle, #hn-theme-toggle, #hn-auto-toggle {
      font: 12px system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, sans-serif;
      padding: 2px 6px;
      border-radius: 6px;
      border: 1px solid var(--border);
      margin-left: 8px;
      background: var(--bg-soft);
      color: var(--text);
      text-decoration: none !important;
      display: inline-block;
      line-height: 1.4;
    }
    #hn-dark-toggle:hover, #hn-theme-toggle:hover, #hn-auto-toggle:hover { filter: brightness(1.05); }
  `;

  let styleNode = null;

  // --- Helpers
  const effectiveTheme = () => {
    if (autoFollow) return mql.matches ? darkPref : lightPref;
    return manualTheme;
  };

  const setAttrs = () => {
    const html = document.documentElement;
    if (enabled) {
      html.setAttribute('data-hn-enabled', '1');
      html.setAttribute('data-hn-theme', effectiveTheme());
    } else {
      html.removeAttribute('data-hn-enabled');
      html.removeAttribute('data-hn-theme');
    }
  };

  const persist = () => {
    localStorage.setItem(ENABLE_KEY, String(enabled));
    localStorage.setItem(AUTO_KEY, String(autoFollow));
    localStorage.setItem(DARK_PREF_KEY, darkPref);
    localStorage.setItem(LIGHT_PREF_KEY, lightPref);
  };

  const apply = () => {
    if (enabled) {
      if (!styleNode) {
        try { styleNode = GM_addStyle ? GM_addStyle(css) : null; } catch (_) { styleNode = null; }
        if (!styleNode) {
          styleNode = document.createElement('style');
          styleNode.id = 'hn-dark-style';
          styleNode.textContent = css;
          (document.head || document.documentElement).appendChild(styleNode);
        }
      }
    } else {
      if (styleNode && styleNode.parentNode) styleNode.parentNode.removeChild(styleNode);
      styleNode = null;
    }
    setAttrs();
    persist();
    updateToggleLabels();
    ensureHeaderBand();
  };

  const cycleTheme = () => {
    const list = THEMES;
    const current = effectiveTheme();
    const idx = Math.max(0, list.indexOf(current));
    const next = list[(idx + 1) % list.length];
    if (autoFollow) {
      if (mql.matches) { darkPref = isDarkTheme(next) ? next : 'dark'; }
      else { lightPref = isDarkTheme(next) ? 'solarized-light' : next; }
    } else {
      manualTheme = next;
    }
    apply();
  };

  const toggleEnabled = () => { enabled = !enabled; apply(); };
  const toggleAuto = () => { autoFollow = !autoFollow; apply(); };

  const labelTheme = (t) => t.replace('-', ' ');

  const updateToggleLabels = () => {
    const onOff = enabled ? 'on' : 'off';
    const btn = document.getElementById('hn-dark-toggle');
    if (btn) btn.textContent = `dark: ${onOff}`;

    const themeBtn = document.getElementById('hn-theme-toggle');
    if (themeBtn) themeBtn.textContent = `theme: ${labelTheme(effectiveTheme())}`;

    const autoBtn = document.getElementById('hn-auto-toggle');
    if (autoBtn) autoBtn.textContent = `auto: ${autoFollow ? 'system' : 'off'}`;
  };

  const ensureHeaderToggle = () => {
    const haveAll = document.getElementById('hn-dark-toggle') && document.getElementById('hn-theme-toggle') && document.getElementById('hn-auto-toggle');
    if (haveAll) { updateToggleLabels(); return true; }

    const candidates = [
      document.querySelector('span.pagetop'),
      document.querySelector('td.pagetop'),
      document.querySelector('table tr td span > b')?.parentElement,
    ].filter(Boolean);

    const host = candidates[0];
    if (!host) return false;

    const sep = () => document.createTextNode(' | ');

    const btn = document.createElement('a');
    btn.id = 'hn-dark-toggle';
    btn.href = '#';
    btn.addEventListener('click', (e) => { e.preventDefault(); toggleEnabled(); });

    const themeBtn = document.createElement('a');
    themeBtn.id = 'hn-theme-toggle';
    themeBtn.href = '#';
    themeBtn.addEventListener('click', (e) => { e.preventDefault(); cycleTheme(); });

    const autoBtn = document.createElement('a');
    autoBtn.id = 'hn-auto-toggle';
    autoBtn.href = '#';
    autoBtn.addEventListener('click', (e) => { e.preventDefault(); toggleAuto(); });

    host.appendChild(sep()); host.appendChild(btn);
    host.appendChild(sep()); host.appendChild(themeBtn);
    host.appendChild(sep()); host.appendChild(autoBtn);

    updateToggleLabels();
    return true;
  };

  // Full-width header background band
  const ensureHeaderBand = () => {
    // Only show band when theming is enabled
    let band = document.getElementById('hn-header-band');
    if (!enabled) { if (band) band.remove(); return; }
    if (!band) {
      band = document.createElement('div');
      band.id = 'hn-header-band';
      (document.body || document.documentElement).appendChild(band);
    }
  };

  // Keyboard shortcuts
  window.addEventListener('keydown', (e) => {
    const key = e.key.toLowerCase();
    if (!e.metaKey && !e.ctrlKey && !e.altKey) {
      const t = e.target;
      if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
      if (key === 'd') toggleEnabled();
      if (key === 't') cycleTheme();
      if (key === 'a') toggleAuto();
    }
  }, true);

  // React to system theme changes when auto is on
  mql.addEventListener?.('change', () => { if (autoFollow) apply(); });

  // Apply ASAP
  apply();

  // Once DOM is ready, inject header toggle + band
  const ready = () => { ensureHeaderToggle(); ensureHeaderBand(); };
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', ready);
  } else {
    ready();
  }

  // Mutation observer (throttled) — stops once buttons are present
  let mo;
  let pending = false;
  const installObserver = () => {
    if (mo) mo.disconnect();
    mo = new MutationObserver(() => {
      if (pending) return;
      pending = true;
      requestAnimationFrame(() => {
        pending = false;
        const ok = ensureHeaderToggle();
        ensureHeaderBand();
        if (ok && mo) mo.disconnect();
      });
    });
    mo.observe(document.documentElement, { childList: true, subtree: true });
  };
  if (!ensureHeaderToggle()) installObserver(); else ensureHeaderBand();
})();
Editor is loading...
Leave a Comment