Grok Theme Toggle

Adds a Light/Dark mode toggle at the bottom left

// ==UserScript==
// @name         Grok Theme Toggle
// @namespace    nisc
// @version      2025.06.24-A
// @description  Adds a Light/Dark mode toggle at the bottom left
// @homepageURL  https://github.com/nisc/grok-userscripts/
// @author       nisc
// @match        https://grok.com/*
// @icon         https://grok.com/images/favicon-light.png
// @run-at       document-end
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    const CONFIG = {
        THEME: {
            KEY: 'forcedTheme',
            MODES: {
                DARK: 'dark',
                LIGHT: 'light'
            },
            DEFAULT: 'dark',
            COLORS: {
                dark: { bg: 'var(--surface-l4, #3c3c3c)', text: 'var(--fg-primary, #fff)', border: 'var(--border-l1, #444)' },
                light: { bg: 'var(--surface-l4, #f8f8f8)', text: 'var(--fg-primary, #000)', border: 'var(--border-l1, #ddd)' }
            },
            ICONS: {
                dark: '🌕',
                light: '🌑'
            }
        },
        UI: {
            POSITION_MARGIN: 20,
            BUTTON_STYLES: {
                position: 'fixed',
                zIndex: '10000',
                width: '36px',
                height: '36px',
                padding: '4px',
                border: '1px solid var(--border-color, #333)',
                borderRadius: '50%',
                cursor: 'pointer',
                fontSize: '12px',
                boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
                transition: 'all 0.15s ease',
                userSelect: 'none',
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'center'
            },
            LIGHT_MODE_STYLES: `
                .light .text-xs.font-semibold {
                    color: #000 !important;
                }
            `
        }
    };

    // Track current theme state
    let currentTheme = localStorage.getItem(CONFIG.THEME.KEY) || CONFIG.THEME.DEFAULT;

    /**
     * Applies theme to document root only if it differs from current state
     * Handles both class names and color-scheme property
     */
    function applyTheme(theme) {
        const htmlElement = document.documentElement;
        const isDark = htmlElement.classList.contains(CONFIG.THEME.MODES.DARK);
        const isLight = htmlElement.classList.contains(CONFIG.THEME.MODES.LIGHT);

        if (theme === CONFIG.THEME.MODES.DARK && !isDark) {
            htmlElement.classList.add(CONFIG.THEME.MODES.DARK);
            htmlElement.classList.remove(CONFIG.THEME.MODES.LIGHT);
            htmlElement.style.setProperty('color-scheme', CONFIG.THEME.MODES.DARK, 'important');
        } else if (theme === CONFIG.THEME.MODES.LIGHT && !isLight) {
            htmlElement.classList.add(CONFIG.THEME.MODES.LIGHT);
            htmlElement.classList.remove(CONFIG.THEME.MODES.DARK);
            htmlElement.style.setProperty('color-scheme', CONFIG.THEME.MODES.LIGHT, 'important');
        }
    }

    // Create and inject custom style element
    const styleElement = document.createElement('style');
    document.head.appendChild(styleElement);

    /**
     * Updates custom styles based on current theme
     * Primarily handles special cases for light mode
     */
    function updateStyles(theme) {
        styleElement.textContent = theme === CONFIG.THEME.MODES.LIGHT ? CONFIG.UI.LIGHT_MODE_STYLES : '';
    }

    /**
     * Toggles between light and dark themes
     * Updates localStorage, applies theme, and updates UI
     */
    function toggleTheme() {
        currentTheme = currentTheme === CONFIG.THEME.MODES.DARK ?
            CONFIG.THEME.MODES.LIGHT : CONFIG.THEME.MODES.DARK;
        localStorage.setItem(CONFIG.THEME.KEY, currentTheme);
        applyTheme(currentTheme);
        updateStyles(currentTheme);
        updateButtonAppearance();
    }

    /**
     * Updates toggle button appearance based on current theme
     * Changes icon, background, text, and border colors
     */
    function updateButtonAppearance() {
        const themeColors = CONFIG.THEME.COLORS[currentTheme];
        toggleButton.textContent = CONFIG.THEME.ICONS[currentTheme];
        toggleButton.style.backgroundColor = themeColors.bg;
        toggleButton.style.color = themeColors.text;
        toggleButton.style.borderColor = themeColors.border;
    }

    // Create and configure theme toggle button
    const toggleButton = document.createElement('button');
    Object.assign(toggleButton.style, CONFIG.UI.BUTTON_STYLES);
    updateButtonAppearance();

    // Add hover effects
    toggleButton.addEventListener('mouseenter', () => {
        toggleButton.style.transform = 'scale(1.1)';
        toggleButton.style.boxShadow = '0 6px 16px rgba(0,0,0,0.4)';
    });
    toggleButton.addEventListener('mouseleave', () => {
        toggleButton.style.transform = 'scale(1)';
        toggleButton.style.boxShadow = '0 4px 12px rgba(0,0,0,0.3)';
    });

    toggleButton.addEventListener('click', toggleTheme);
    document.body.appendChild(toggleButton);

    // Function to update position based on JX button location
    function updatePositionRelativeToJX() {
        const jxButton = document.querySelector('button[id^="radix-"] span span[class*="bg-surface-l4"]')?.closest('div.absolute');
        if (jxButton) {
            const jxRect = jxButton.getBoundingClientRect();
            const jxLeft = jxRect.left;
            const jxBottom = window.innerHeight - jxRect.bottom;

            // Position our button above JX button with 10px gap
            toggleButton.style.left = `${jxLeft + 2}px`; // Center align (36px vs 40px = 2px offset)
            toggleButton.style.bottom = `${jxBottom + jxRect.height + 10}px`;
        } else {
            // Fallback position if JX button not found
            toggleButton.style.left = '8px';
            toggleButton.style.bottom = '100px';
        }
    }

    // Initial positioning
    updatePositionRelativeToJX();

    // Initialize theme
    applyTheme(currentTheme);
    updateStyles(currentTheme);

    // Watch for external theme changes and maintain chosen theme
    const themeObserver = new MutationObserver(mutations => {
        mutations.forEach(mutation => {
            if (mutation.type === 'attributes' &&
                (mutation.attributeName === 'class' || mutation.attributeName === 'style')) {
                applyTheme(currentTheme);
            }
        });
    });

    themeObserver.observe(document.documentElement, {
        attributes: true,
        attributeFilter: ['class', 'style']
    });

    // Throttle position updates to prevent infinite loops
    let positionUpdateTimeout;
    function throttledPositionUpdate() {
        if (positionUpdateTimeout) return;
        positionUpdateTimeout = setTimeout(() => {
            updatePositionRelativeToJX();
            positionUpdateTimeout = null;
        }, 16); // ~60fps for smoother following
    }

    // Watch for JX button movement and follow it
    const positionObserver = new MutationObserver((mutations) => {
        // Only update if mutations don't involve our theme button
        const shouldUpdate = mutations.some(mutation => {
            return mutation.target !== toggleButton &&
                !toggleButton.contains(mutation.target);
        });

        if (shouldUpdate) {
            throttledPositionUpdate();
        }
    });

    // Observe for JX button changes with broader scope
    positionObserver.observe(document.body, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ['style', 'class']
    });

    // Also update position on window events
    window.addEventListener('resize', throttledPositionUpdate);
    window.addEventListener('scroll', throttledPositionUpdate, { passive: true });
})();