Lichess Custom Board Colors

Unique embedded UI with profile support that allows you to pick any board colors.

// ==UserScript==
// @name         Lichess Custom Board Colors
// @namespace    http://tampermonkey.net/
// @version      1.5
// @description  Unique embedded UI with profile support that allows you to pick any board colors.
// @author       ObnubiladO
// @match        https://lichess.org/*
// @icon         https://emoji-palette.com/wp-content/uploads/platform/microsoft-artist-palette-emoji.png
// @grant        GM_addStyle
// @grant        GM_addElement
// @license      GPL-3.0-or-later
// ==/UserScript==

(function () {
    'use strict';

    const colorTileMap = {};

    const profiles = [
        { value: 'profile1', label: 'Profile 1' },
        { value: 'profile2', label: 'Profile 2' },
        { value: 'profile3', label: 'Profile 3' }
    ];

    const defaultActiveProfile = profiles[0].value;
    const themeName = "brown";

    const defaultLightColor = "#eae9d2";
    const defaultDarkColor = "#4b7399";
    const defaultLastMoveColor = "#ffbf00";
    const defaultLastMoveOpacity = "0.5";

    function storageKey(profileValue, suffix) {
        return `tm.customTheme.${profileValue}.${suffix}`;
    }
    function getActiveProfile() {
        return localStorage.getItem("tm.customTheme.activeProfile") || defaultActiveProfile;
    }
    function setActiveProfile(profileValue) {
        localStorage.setItem("tm.customTheme.activeProfile", profileValue);
    }

    function loadColorSetting(profileValue, colorKey, defaultVal) {
        const key = storageKey(profileValue, colorKey);
        const val = localStorage.getItem(key);
        if (val === null) {
            localStorage.setItem(key, defaultVal);
            return defaultVal;
        } else {
            return val;
        }
    }

    function loadAllSettings(profileValue) {
        return {
            lightColor: loadColorSetting(profileValue, "lightColor", defaultLightColor),
            darkColor: loadColorSetting(profileValue, "darkColor", defaultDarkColor),
            lastMoveColor: loadColorSetting(profileValue, "lastMoveColor", defaultLastMoveColor),
            lastMoveOpacity: loadColorSetting(profileValue, "lastMoveOpacity", defaultLastMoveOpacity),
        };
    }

    function getThemeStyles(lightColor, darkColor, lastMoveColor, lastMoveOpacity) {
        const svg = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:x="http://www.w3.org/1999/xlink"
     viewBox="0 0 8 8" shape-rendering="crispEdges">
  <g id="a">
    <g id="b">
      <g id="c">
        <g id="d">
          <rect width="1" height="1" fill="${lightColor}" id="e"/>
          <use x="1" y="1" href="#e" x:href="#e"/>
          <rect y="1" width="1" height="1" fill="${darkColor}" id="f"/>
          <use x="1" y="-1" href="#f" x:href="#f"/>
        </g>
        <use x="2" href="#d" x:href="#d"/>
      </g>
      <use x="4" href="#c" x:href="#c"/>
    </g>
    <use y="2" href="#b" x:href="#b"/>
  </g>
  <use y="4" href="#a" x:href="#a"/>
</svg>`;

        let base64SVG = btoa(svg);

        return `
        body[data-board=${themeName}] .is2d cg-board::before {
            background-image: url("data:image/svg+xml;base64,${base64SVG}") !important;
        }
        #dasher_app .board.d2 .${themeName} {
            background-image: url("data:image/svg+xml;base64,${base64SVG}") !important;
            background-size: 256px !important;
        }
        body[data-board=${themeName}] .is2d cg-board .last-move:not(.current-premove) {
            background-color: ${lastMoveColor} !important;
            opacity: ${lastMoveOpacity} !important;
        }`;
    }

    let addedStyleElement = GM_addStyle("");
    applyInitialTheme();

    function applyInitialTheme() {
        const profile = getActiveProfile();
        const s = loadAllSettings(profile);
        const css = getThemeStyles(s.lightColor, s.darkColor, s.lastMoveColor, s.lastMoveOpacity);
        addedStyleElement.innerHTML = css;
    }

    function updateTheme() {
        const profile = getActiveProfile();
        const light = document.getElementById("tmLightColor");
        const dark = document.getElementById("tmDarkColor");
        const lastMove = document.getElementById("tmLastMoveColor");
        const opacityElem = document.getElementById("tmLastMoveOpacity");

        if (!light || !dark || !lastMove || !opacityElem) return;

        const lightColor = light.value;
        const darkColor = dark.value;
        const lastMoveColor = lastMove.value;
        const lastMoveOpacity = opacityElem.value;

        const css = getThemeStyles(lightColor, darkColor, lastMoveColor, lastMoveOpacity);
        addedStyleElement.innerHTML = css;

        localStorage.setItem(storageKey(profile, "lightColor"), light.value);
        localStorage.setItem(storageKey(profile, "darkColor"), dark.value);
        localStorage.setItem(storageKey(profile, "lastMoveColor"), lastMove.value);
        localStorage.setItem(storageKey(profile, "lastMoveOpacity"), opacityElem.value);
    }

    function createColorTile(id, defaultColor) {
        const container = document.createElement("div");
        container.style.width = "36px";
        container.style.height = "36px";
        container.style.borderRadius = "4px";
        container.style.cursor = "pointer";
        container.style.background = defaultColor;
        container.style.border = "2px solid transparent";
        container.style.boxSizing = "border-box";
        container.style.position = "relative";

        const input = document.createElement("input");
        input.type = "color";
        input.id = id;
        input.value = defaultColor;
        input.style.opacity = "0";
        input.style.position = "absolute";
        input.style.inset = "0";
        input.style.pointerEvents = "none";
        input.style.width = "100%";
        input.style.height = "100%";

        container.addEventListener("click", () => input.click());
        input.addEventListener("input", () => {
            container.style.background = input.value;
            updateTheme();
        });

        container.appendChild(input);
        container.dataset.colorTileId = id;
        colorTileMap[id] = container;

        return container;
    }

    function createLabeledTile(labelText, tileId, defaultColor, includeOpacity = false) {
        const container = document.createElement("div");
        container.style.display = "flex";
        container.style.flexDirection = "column";
        container.style.alignItems = "center";
        container.style.fontSize = "12px";
        container.style.color = "var(--text)";
        container.style.gap = "4px";

        const label = document.createElement("div");
        label.textContent = labelText;

        container.appendChild(label);

        if (includeOpacity) {
            // Special horizontal group for color tile + slider
            const horizontalGroup = document.createElement("div");
            horizontalGroup.style.display = "flex";
            horizontalGroup.style.flexDirection = "row";
            horizontalGroup.style.alignItems = "center";
            horizontalGroup.style.gap = "12px";

            const tile = createColorTile(tileId, defaultColor);

            const sliderGroup = document.createElement("div");
            sliderGroup.style.display = "flex";
            sliderGroup.style.flexDirection = "column";
            sliderGroup.style.alignItems = "center";

            const opacityLabel = document.createElement("div");
            opacityLabel.id = "tmLastMoveOpacityLabel";  // ADD ID so we can update it later
            opacityLabel.textContent = `Opacity: ${defaultLastMoveOpacity}`;
            opacityLabel.style.fontSize = "11px";
            opacityLabel.style.marginBottom = "2px";

            const slider = document.createElement("input");
            slider.type = "range";
            slider.id = "tmLastMoveOpacity";
            slider.min = "0";
            slider.max = "1";
            slider.step = "0.05";
            slider.value = defaultLastMoveOpacity;
            slider.style.width = "80px";

            slider.addEventListener("input", () => {
                opacityLabel.textContent = `Opacity: ${slider.value}`;
                updateTheme();
            });

            sliderGroup.appendChild(opacityLabel);
            sliderGroup.appendChild(slider);

            horizontalGroup.appendChild(tile);
            horizontalGroup.appendChild(sliderGroup);
            container.appendChild(horizontalGroup);
        } else {
            const tile = createColorTile(tileId, defaultColor);
            container.appendChild(tile);
        }

        return container;
    }

    function applyProfileToUI(profileValue) {
        const settings = loadAllSettings(profileValue);
        const updateTile = (id, value) => {
            const el = document.getElementById(id);
            if (el) el.value = value;
            if (colorTileMap[id]) colorTileMap[id].style.background = value;
        };

        updateTile("tmLightColor", settings.lightColor);
        updateTile("tmDarkColor", settings.darkColor);
        updateTile("tmLastMoveColor", settings.lastMoveColor);

        const opacitySlider = document.getElementById("tmLastMoveOpacity");
        const opacityLabel = document.getElementById("tmLastMoveOpacityLabel");

        if (opacitySlider) opacitySlider.value = settings.lastMoveOpacity;
        if (opacityLabel) opacityLabel.textContent = `Opacity: ${settings.lastMoveOpacity}`;
    }

    function buildUI(container) {
        if (!container) return;

        const wrapper = document.createElement("div");
        wrapper.style.display = "flex";
        wrapper.style.flexDirection = "column";
        wrapper.style.gap = "8px";
        container.appendChild(wrapper);

        const profileSelect = GM_addElement(wrapper, "select", {
            id: "tmProfileSelect",
            className: "select large",
            style: "width: 100%;"
        });

        profiles.forEach((p) => {
            const opt = document.createElement("option");
            opt.value = p.value;
            opt.textContent = p.label;
            profileSelect.appendChild(opt);
        });
        profileSelect.value = getActiveProfile();

        profileSelect.addEventListener("change", () => {
            const newProfile = profileSelect.value;
            setActiveProfile(newProfile);
            applyProfileToUI(newProfile);
            updateTheme();
        });

        const colorRow = document.createElement("div");
        colorRow.style.display = "flex";
        colorRow.style.gap = "12px";
        colorRow.style.marginBottom = "8px";

        colorRow.appendChild(createLabeledTile("Light", "tmLightColor", defaultLightColor));
        colorRow.appendChild(createLabeledTile("Dark", "tmDarkColor", defaultDarkColor));
        colorRow.appendChild(createLabeledTile("Last Move", "tmLastMoveColor", defaultLastMoveColor, true));
        wrapper.appendChild(colorRow);
    }

    function waitForElement(selector, callback) {
        const el = document.querySelector(selector);
        if (el) {
            callback(el);
        } else {
            const observer = new MutationObserver(() => {
                const el = document.querySelector(selector);
                if (el) {
                    observer.disconnect();
                    callback(el);
                }
            });
            observer.observe(document.body, { childList: true, subtree: true });
        }
    }

    function main() {
        const observer = new MutationObserver(() => {
            const boardSettings = document.querySelector("#dasher_app .sub.board.d2");
            if (boardSettings && !boardSettings.dataset.tmInjected) {
                boardSettings.dataset.tmInjected = "true";

                const uiContainer = document.createElement("div");
                uiContainer.style.marginTop = "10px";

                const title = document.createElement("div");
                title.textContent = "🎨 Board Color Controls";
                title.style.fontWeight = "bold";
                title.style.marginBottom = "6px";
                title.style.color = "var(--text)";
                uiContainer.appendChild(title);

                buildUI(uiContainer);

                const boardGrid = boardSettings.querySelector(".list");
                if (boardGrid) {
                    boardSettings.insertBefore(uiContainer, boardGrid);
                } else {
                    boardSettings.appendChild(uiContainer);
                }

                applyProfileToUI(getActiveProfile());
                updateTheme();
            }
        });

        waitForElement("#dasher_app", (dasherApp) => {
            observer.observe(dasherApp, { childList: true, subtree: true });
        });

        waitForElement("cg-board", (boardElem) => {
            const boardObserver = new MutationObserver(() => {
                updateTheme();
            });
            boardObserver.observe(boardElem.parentElement, { childList: true, subtree: true });
            updateTheme();
        });
    }

    if (document.readyState === "complete" || document.readyState === "interactive") {
        main();
    } else {
        document.addEventListener("DOMContentLoaded", main);
    }
})();