Color my Oomf

Colors @usernames you follow with stable huesr + profile-only add/remove UI.

// ==UserScript==
// @name         Color my Oomf
// @namespace    f_d_tools
// @version      1.1
// @description  Colors @usernames you follow with stable huesr + profile-only add/remove UI.
// @author       you
// @match        https://x.com/*
// @match        https://twitter.com/*
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  // ---- Config ----
  const STORAGE_KEY = 'fd_following_handles_v1';
  const HUE_BASE = 0;
  const HUE_SPREAD = 360;
  const SAT = 75; // %
  const LIT = 48; // %

  // ---- State ----
  let following = new Set(loadFollowing());
  const processedAttr = 'data-fd-colored';

  // ---- Utils ----
  function loadFollowing() {
    try {
      const raw = localStorage.getItem(STORAGE_KEY);
      if (!raw) return [];
      const arr = JSON.parse(raw);
      return Array.isArray(arr) ? arr : [];
    } catch {
      return [];
    }
  }
  function saveFollowing() {
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(following)));
    } catch {}
  }

  // FNV-1a 32-bit (fast & simple)
  function hash32(str) {
    let h = 0x811c9dc5 >>> 0;
    for (let i = 0; i < str.length; i++) {
      h ^= str.charCodeAt(i);
      h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
    }
    return h >>> 0;
  }
  function colorForHandle(handle) {
    const h = (hash32(handle.toLowerCase()) % HUE_SPREAD + HUE_BASE) % 360;
    return `hsl(${h} ${SAT}% ${LIT}%)`;
  }

  function isAtHandle(text) {
    const t = text?.trim();
    return !!t && t[0] === '@' && t.length > 1;
  }
  function extractHandleFromText(text) {
    return text.trim().replace(/^@/, '').split(/\s/)[0];
  }

  function markIfFollowed(span) {
    if (!span || span.getAttribute(processedAttr)) return;
    const txt = span.textContent;
    if (!isAtHandle(txt)) return;

    const handle = extractHandleFromText(txt);
    if (!handle || !following.has(handle.toLowerCase())) return;

    colorSpan(span, handle);
  }

  function colorSpan(span, handle) {
    span.style.color = colorForHandle(handle);
    span.style.textDecorationColor = 'currentColor';
    span.style.textDecorationThickness = 'from-font';
    span.style.textDecorationLine = 'none';
    span.setAttribute(processedAttr, '1');
  }

  function uncolorAllInstances(handle) {
    // Remove color from any spans with this @handle
    const at = '@' + handle;
    document.querySelectorAll('span').forEach(s => {
      const t = s.textContent?.trim();
      if (t === at) {
        s.style.color = '';
        s.style.textDecorationColor = '';
        s.style.textDecorationThickness = '';
        s.style.textDecorationLine = '';
        s.removeAttribute(processedAttr);
      }
    });
  }

  // ---- Safe scanner on Following page ----
  // Heuristic: only add handles from user cards that indicate a "Following" state,
  // which excludes "Follow" suggestions.
  function scanFollowingPageSafely() {
    let added = 0;

    // Candidates: user rows/cards
    const cards = document.querySelectorAll('[data-testid="UserCell"], [data-testid="cellInnerDiv"], article, div[role="listitem"]');

    cards.forEach(card => {
      // Find a "Following" button/label inside this card
      const hasFollowingState =
        !!card.querySelector('div[role="button"][data-testid*="unfollow"], div[aria-label*="Following"], div[dir="auto"]:has(span:contains("Following"))') ||
        textContains(card, /\bFollowing\b/);

      if (!hasFollowingState) return;

      // Extract @handle in this card
      const spans = card.querySelectorAll('span');
      for (const s of spans) {
        const txt = s.textContent;
        if (!isAtHandle(txt)) continue;
        const h = extractHandleFromText(txt).toLowerCase();
        // Sanity: link form /<handle> exists nearby (profile link)
        const profileLink = card.querySelector(`a[href^="/${cssEscape(h)}"]`);
        if (!profileLink) continue;

        if (!following.has(h)) {
          following.add(h);
          added++;
        }
        break; // one handle per card is enough
      }
    });

    if (added > 0) saveFollowing();
    return added;
  }

  // Fallback "visible scan" with extra guards; kept for the toolbar button
  function scanHandlesInFollowingPage() {
    // Prefer safe method
    return scanFollowingPageSafely();
  }

  // ---- Profile-only Add/Remove UI ----
  function onProfilePage() {
    // Heuristic: /handle or /handle/ with no further path segment
    // (exclude /home, /explore, etc.)
    const m = location.pathname.match(/^\/([A-Za-z0-9_]{1,15})(?:\/)?$/);
    return !!m && !['home', 'explore', 'notifications', 'messages', 'settings', 'compose', 'i'].includes(m[1]);
  }
  function currentProfileHandle() {
    // Try to read from header @handle
    const header = document.querySelector('[data-testid="UserName"]');
    if (header) {
      const at = header.querySelector('span');
      if (at && isAtHandle(at.textContent)) {
        return extractHandleFromText(at.textContent).toLowerCase();
      }
    }
    // Fallback to path
    const m = location.pathname.match(/^\/([A-Za-z0-9_]{1,15})(?:\/)?$/);
    return m ? m[1].toLowerCase() : null;
  }

  let profileBarEl = null;
  function injectProfileBar() {
    removeProfileBar(); // avoid duplicates
    if (!onProfilePage()) return;

    const handle = currentProfileHandle();
    if (!handle) return;

    const bar = document.createElement('div');
    bar.id = 'fd-profile-pill';
    bar.style.position = 'fixed';
    bar.style.zIndex = 999999;
    bar.style.bottom = '16px';
    bar.style.right = '16px';
    bar.style.padding = '8px 10px';
    bar.style.background = 'rgba(30,30,30,0.92)';
    bar.style.color = '#fff';
    bar.style.borderRadius = '9999px';
    bar.style.boxShadow = '0 6px 20px rgba(0,0,0,0.35)';
    bar.style.font = '12px system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif';
    bar.style.display = 'flex';
    bar.style.gap = '8px';
    bar.style.alignItems = 'center';

    const label = document.createElement('span');
    label.textContent = '@' + handle;
    label.style.opacity = '0.85';

    function btn(text, onclick) {
      const b = document.createElement('button');
      b.textContent = text;
      b.style.cursor = 'pointer';
      b.style.border = '1px solid rgba(255,255,255,0.25)';
      b.style.background = 'transparent';
      b.style.color = '#fff';
      b.style.padding = '4px 8px';
      b.style.borderRadius = '9999px';
      b.style.fontSize = '12px';
      b.addEventListener('click', onclick);
      b.addEventListener('mouseenter', () => (b.style.background = 'rgba(255,255,255,0.08)'));
      b.addEventListener('mouseleave', () => (b.style.background = 'transparent'));
      return b;
    }

    const inCache = following.has(handle);

    const addBtn = btn('Add', () => {
      if (!following.has(handle)) {
        following.add(handle);
        saveFollowing();
        // Recolor existing occurrences immediately
        document.querySelectorAll('span').forEach(s => {
          if (s.textContent?.trim() === '@' + handle) colorSpan(s, handle);
        });
        injectProfileBar(); // refresh buttons
      }
    });

    const removeBtn = btn('Remove', () => {
      if (following.delete(handle)) {
        saveFollowing();
        uncolorAllInstances(handle);
        injectProfileBar(); // refresh buttons
      }
    });

    bar.append(label, inCache ? removeBtn : addBtn);
    document.body.appendChild(bar);
    profileBarEl = bar;
  }
  function removeProfileBar() {
    if (profileBarEl && profileBarEl.parentNode) {
      profileBarEl.parentNode.removeChild(profileBarEl);
    }
    profileBarEl = null;
  }

  // ---- Following-page toolbar (unchanged UX) ----
  function onFollowingPage() {
    return /\/following\/?$/.test(location.pathname);
  }

  let followToolbarEl = null;
  function injectFollowingToolbar() {
    if (followToolbarEl || !onFollowingPage()) return;

    const bar = document.createElement('div');
    bar.id = 'fd-follow-toolbar';
    bar.style.position = 'fixed';
    bar.style.zIndex = 999999;
    bar.style.bottom = '16px';
    bar.style.right = '16px';
    bar.style.padding = '10px 12px';
    bar.style.background = 'rgba(30,30,30,0.9)';
    bar.style.color = '#fff';
    bar.style.borderRadius = '12px';
    bar.style.boxShadow = '0 6px 20px rgba(0,0,0,0.3)';
    bar.style.font = '13px system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif';
    bar.style.display = 'flex';
    bar.style.gap = '8px';
    bar.style.alignItems = 'center';

    function btn(label, onclick, title='') {
      const b = document.createElement('button');
      b.textContent = label;
      b.style.cursor = 'pointer';
      b.style.border = '1px solid rgba(255,255,255,0.2)';
      b.style.background = 'transparent';
      b.style.color = '#fff';
      b.style.padding = '6px 8px';
      b.style.borderRadius = '8px';
      b.style.fontSize = '12px';
      if (title) b.title = title;
      b.addEventListener('click', onclick);
      b.addEventListener('mouseenter', () => (b.style.background = 'rgba(255,255,255,0.08)'));
      b.addEventListener('mouseleave', () => (b.style.background = 'transparent'));
      return b;
    }

    const status = document.createElement('span');
    status.textContent = `Cached: ${following.size}`;
    status.style.opacity = '0.8';

    const scanBtn = btn('Scan visible (safe)', () => {
      const added = scanHandlesInFollowingPage();
      status.textContent = `Cached: ${following.size} (+${added})`;
    }, 'Adds @handles from cards that show a “Following” state, ignoring suggestions.');

    const clearBtn = btn('Clear', () => {
      if (!confirm('Clear all cached handles?')) return;
      following.clear();
      saveFollowing();
      status.textContent = `Cached: ${following.size}`;
    });

    const exportBtn = btn('Export', () => {
      const data = Array.from(following).join('\n');
      navigator.clipboard.writeText(data).then(() => {
        status.textContent = `Copied ${following.size} handles`;
        setTimeout(() => (status.textContent = `Cached: ${following.size}`), 1500);
      });
    });

    bar.append(scanBtn, clearBtn, exportBtn, status);
    document.body.appendChild(bar);
    followToolbarEl = bar;
  }
  function removeFollowingToolbar() {
    if (followToolbarEl && followToolbarEl.parentNode) {
      followToolbarEl.parentNode.removeChild(followToolbarEl);
    }
    followToolbarEl = null;
  }

  // ---- Helpers ----
  function textContains(root, regex) {
    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
    let n;
    while ((n = walker.nextNode())) {
      if (regex.test(n.nodeValue)) return true;
    }
    return false;
  }
  // Escape for CSS attribute selector usage
  function cssEscape(str) {
    return CSS?.escape ? CSS.escape(str) : str.replace(/[^a-zA-Z0-9_\-]/g, s => '\\' + s);
  }

  // ---- DOM observation ----
  const observer = new MutationObserver(mutations => {
    for (const m of mutations) {
      for (const node of m.addedNodes) {
        if (!(node instanceof HTMLElement)) continue;

        if (node.tagName === 'SPAN') {
          markIfFollowed(node);
        }
        const spans = node.querySelectorAll?.('span');
        if (spans?.length) {
          for (const s of spans) markIfFollowed(s);
        }
      }
    }
  });

  function primeExisting() {
    document.querySelectorAll('span').forEach(markIfFollowed);
  }

  function routeChangeHandlers() {
    // Called on SPA route changes
    primeExisting();
    removeFollowingToolbar();
    removeProfileBar();
    if (onFollowingPage()) injectFollowingToolbar();
    if (onProfilePage()) injectProfileBar();
  }

  // ---- Boot ----
  primeExisting();
  observer.observe(document.documentElement || document.body, { childList: true, subtree: true });

  // SPA navigation watcher
  let lastPath = location.pathname;
  setInterval(() => {
    if (location.pathname !== lastPath) {
      lastPath = location.pathname;
      routeChangeHandlers();
    }
  }, 800);

  // First-time page kind setup
  if (onFollowingPage()) injectFollowingToolbar();
  if (onProfilePage()) injectProfileBar();

})();