Colours by Sam

Colorises hint name mentions and highlights mentioned cards on hover (Shadow DOM + MutationObserver safe).

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==UserScript==
// @name         Colours by Sam
// @license MIT
// @namespace    https://cluesbysam.com/userscripts
// @version      2025.08.11
// @description  Colorises hint name mentions and highlights mentioned cards on hover (Shadow DOM + MutationObserver safe).
// @author       @whi-tw
// @match        https://cluesbysam.com/*
// @match        https://www.cluesbysam.com/*
// @icon         https://cluesbysam.com/images/favicon.ico
// @grant        none
// @run-at       document-start
// @noframes
// ==/UserScript==

(function () {
    'use strict';

    let started = false;
    const state = { people: [], byName: new Map(), paletteByCoord: new Map() };
    let cardClickListenerAttached = false;
    let domObserver;

    // ----- config -----
    const PALETTE_20 = [
        '#F3C300','#875692','#F38400','#A1CAF1','#BE0032',
        '#C2B280','#848482','#008856','#E68FAC','#0067A5',
        '#F99379','#604E97','#F6A600','#B3446C','#DCD300',
        '#882D17','#8DB600','#654522','#E25822','#2B3D26'
    ];
    const COLOR_STRATEGY = 'coord'; // 'coord' | 'nameHash'

    // ---------- helpers ----------
    const normalize = (s) => (s ?? '').toString().normalize('NFKC').trim().toLowerCase();

    function tokenize(text) {
        const t = normalize(text).replace(/(['’]s)\b/g, ''); // helen's -> helen
        return t.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
    }
    function nameTokens(name) { return normalize(name).split(/\s+/).filter(Boolean); }
    function containsNameTokens(hintTokens, targetNameTokens) {
        if (!hintTokens.length || !targetNameTokens.length) return false;
        for (let i = 0; i <= hintTokens.length - targetNameTokens.length; i++) {
            let ok = true;
            for (let j = 0; j < targetNameTokens.length; j++) {
                if (hintTokens[i + j] !== targetNameTokens[j]) { ok = false; break; }
            }
            if (ok) return true;
        }
        return false;
    }
    function djb2(str) { let h = 5381; for (let i=0;i<str.length;i++) h=((h<<5)+h)^str.charCodeAt(i); return h>>>0; }
    function parseCoord(coord) {
        const m = String(coord).trim().match(/^([A-Za-z]+)\s*(\d+)$/);
        if (!m) return { colLabel: '', rowNumber: NaN };
        return { colLabel: m[1].toUpperCase(), rowNumber: parseInt(m[2], 10) };
    }
    function whenCardsReady(cb) {
        if (document.querySelector('.card')) return cb();
        const mo = new MutationObserver(() => {
            if (document.querySelector('.card')) { mo.disconnect(); cb(); }
        });
        mo.observe(document.documentElement, { childList: true, subtree: true });
    }

    // ---------- extraction & analysis ----------
    function extractPeople() {
        const cards = Array.from(document.querySelectorAll('.card'));
        return cards.map(card => {
            const name = normalize(card.querySelector('.name')?.textContent ?? '');
            const profession = normalize(card.querySelector('.profession')?.textContent ?? '');
            const coord = (card.querySelector('p.coord')?.textContent ?? '').trim();
            const flipped = card.classList.contains('flipped');
            const hintEl = card.querySelector('p.hint');
            const hint = flipped ? (hintEl?.textContent ?? '').trim() : '';
            return { el: card, name, profession, coord, hint, hintEl, flipped, mentions: [], color: '' };
        });
    }

    function buildNameIndex(people) {
        const m = new Map();
        for (const p of people) if (p.name) m.set(p.name, p);
        return m;
    }

    function annotateMentions(people) {
        const all = people.map(p => ({ name: p.name, tokens: nameTokens(p.name) }));
        for (const p of people) {
            p.mentions = [];
            if (!p.hint) continue;
            const htoks = tokenize(p.hint);
            for (const other of all) if (other.name && containsNameTokens(htoks, other.tokens)) p.mentions.push(other.name);
            p.mentions = [...new Set(p.mentions)];
        }
    }

    // ---------- contrast helpers ----------
    function hexToRgb(hex){ let h=hex.replace('#',''); if(h.length===3) h=[...h].map(c=>c+c).join(''); return [parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16),1]; }
    function parseCssColor(s){
        if(!s) return null;
        s = s.trim().toLowerCase();
        if (s === 'transparent') return [0,0,0,0];
        if (s.startsWith('#')) return hexToRgb(s);
        const m = s.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i);
        if (m) return [Number(m[1]), Number(m[2]), Number(m[3]), m[4] == null ? 1 : Number(m[4])];
        return null;
    }
    function isTransparentRGBA(rgba){ return !rgba || rgba[3] === 0; }
    function srgbToLin(c){ c/=255; return c<=0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); }
    function luminance([r,g,b]){ return 0.2126*srgbToLin(r)+0.7152*srgbToLin(g)+0.0722*srgbToLin(b); }
    function contrastRatio(fg,bg){
        const L1 = luminance(fg), L2 = luminance(bg);
        const [mx, mn] = [Math.max(L1,L2), Math.min(L1,L2)];
        return (mx + 0.05) / (mn + 0.05);
    }
    function mixRgb(a,b,t){ return [0,1,2].map(i => Math.round(a[i]*(1-t)+b[i]*t)).concat(1); }
    function rgbToHex([r,g,b]){ return '#'+[r,g,b].map(x=>x.toString(16).padStart(2,'0')).join('').toUpperCase(); }

    // climb DOM (and out of shadow) to find a non-transparent background
    function getEffectiveBgColor(el){
        let n = el;
        while (n) {
            const cs = getComputedStyle(n);
            const bg = parseCssColor(cs.backgroundColor);
            if (!isTransparentRGBA(bg)) return bg;
            // step up (handles shadow hosts too)
            const root = n.getRootNode && n.getRootNode();
            n = n.parentElement || (root && root.host) || null;
        }
        return parseCssColor(getComputedStyle(document.body).backgroundColor) || [255,255,255,1];
    }

    // nudge foreground towards white/black until reaching target contrast
    function adjustForContrast(fgHex, bgCssOrRgb, target=4.5){
        const bg = Array.isArray(bgCssOrRgb) ? bgCssOrRgb : (parseCssColor(bgCssOrRgb) || [0,0,0,1]);
        let fg = hexToRgb(fgHex);
        if (contrastRatio(fg, bg) >= target) return fgHex;

        const lighten = luminance(bg) < 0.5;
        const pole = lighten ? [255,255,255,1] : [0,0,0,1];
        let lo = 0, hi = 1, best = fg, bestR = contrastRatio(fg,bg);

        for (let i=0;i<22;i++){
            const t = (lo+hi)/2;
            const cand = mixRgb(fg, pole, t);
            const r = contrastRatio(cand, bg);
            if (r >= target){ best = cand; bestR = r; hi = t; } else { lo = t; }
        }
        // if still short, pick black/white with max contrast
        if (bestR < target){
            const cw = contrastRatio([255,255,255,1], bg);
            const cb = contrastRatio([0,0,0,1], bg);
            best = cw > cb ? [255,255,255,1] : [0,0,0,1];
        }
        return rgbToHex(best);
    }

    // ----- highlight helpers -----
    const highlighted = new Set();
    let currentHoverCard = null;

    function indexByElement(people) {
        const wm = new WeakMap();
        for (const p of people) wm.set(p.el, p);
        return wm;
    }

function clearHighlights() {
  for (const el of highlighted) {
    el.style.borderColor = '';
    el.style.boxShadow = '';
  }
  highlighted.clear();
}

function highlightMentionsFor(person) {
  clearHighlights();
  if (!person || !person.hintEl || !person.hint || !person.mentions?.length) return;

  const hostBg = getEffectiveBgColor(person.hintEl);

  for (const name of person.mentions) {
    const target = state.byName.get(name);
    if (!target) continue;
    const el = target.el;

    const base = target.color;
    const adjusted = adjustForContrast(base, hostBg, 4.5);

    // Keep border width unchanged (no reflow); just recolor it
    el.style.borderColor = adjusted;

    // Add a non-layout ring; preserve the site's drop shadow
    el.style.boxShadow = `0 0 0 2px ${adjusted}, 0 1px 6px var(--shadow-color-secondary)`;

    highlighted.add(el);
  }
}

    // ----- color assignment -----
    function computeGridOrdering(people) {
        const cols = new Set(), rows = new Set();
        for (const p of people) {
            const { colLabel, rowNumber } = parseCoord(p.coord);
            if (colLabel) cols.add(colLabel);
            if (!Number.isNaN(rowNumber)) rows.add(rowNumber);
        }
        const colsArr = [...cols].sort((a, b) => a.localeCompare(b));
        const rowsArr = [...rows].sort((a, b) => a - b);
        return { colsArr, rowsArr };
    }

    function assignColorsByCoord(people) {
        const { colsArr, rowsArr } = computeGridOrdering(people);
        const gridSlots = colsArr.length * rowsArr.length;
        if (PALETTE_20.length < gridSlots) return assignColorsByNameHash(people);

        state.paletteByCoord.clear();
        for (const p of people) {
            const { colLabel, rowNumber } = parseCoord(p.coord);
            const ci = colsArr.indexOf(colLabel);
            const ri = rowsArr.indexOf(rowNumber);
            if (ci === -1 || ri === -1) continue;
            const paletteIndex = ci * rowsArr.length + ri; // column-major
            const color = PALETTE_20[paletteIndex % PALETTE_20.length];
            state.paletteByCoord.set(p.coord, color);
            p.color = color;
            p.el.setAttribute('data-person-color', color);
        }
    }

    function assignColorsByNameHash(people) {
        const used = new Set();
        for (const p of people) {
            const idx = djb2(p.name || p.coord) % PALETTE_20.length;
            let k = idx, steps = 0;
            while (used.has(k) && steps < PALETTE_20.length) { k = (k + 7) % PALETTE_20.length; steps++; }
            used.add(k);
            p.color = PALETTE_20[k];
            p.el.setAttribute('data-person-color', p.color);
        }
    }

    function assignColors(people) {
        if (COLOR_STRATEGY === 'coord') assignColorsByCoord(people);
        else assignColorsByNameHash(people);
    }

    // Render colored names into a Shadow DOM so the app can't clobber what the user sees
    function paintHintMentions(people) {
        const names = people.map(p => p.name).filter(Boolean).sort((a, b) => b.length - a.length);
        if (!names.length) return;

        const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
        const alt = names.map(esc).join('|');
        const re = new RegExp(`(^|[^\\p{L}\\p{N}])(${alt})(['’]s)?(?=$|[^\\p{L}\\p{N}])`, 'giu');

        for (const p of people) {
            if (!p.hintEl || !p.hint) continue;

            // Get or create a shadow root on the hint element
            const host = p.hintEl;
            const hostBg = getEffectiveBgColor(host);
            host.style.cursor = 'pointer'; // make the whole hint show a hand
            const lightText = (host.textContent || '').trim();

            // If the element was replaced, we need to rebuild; the data attrs would be gone
            let sr = host.shadowRoot;
            if (!sr) sr = host.attachShadow({ mode: 'open' });

            // Skip if already painted for this exact text
            if (host.dataset._decoratedFor === lightText) continue;

            // Build shadow content fresh from the light DOM text
            const frag = document.createDocumentFragment();

            // Scoped styles inside the shadow root; site CSS can’t undo this
            const style = document.createElement('style');
            style.textContent = `
        :host { all: initial; display: block; white-space: pre-wrap; font: inherit; color: inherit; }
        .hint-name { font-weight: 600; }
      `;
            frag.appendChild(style);

            let lastIndex = 0;
            re.lastIndex = 0;
            let m;
            while ((m = re.exec(lightText)) !== null) {
                const [ , prefix, nameMatch, poss ] = m;
                const matchStart = m.index;
                const matchEnd = re.lastIndex;

                if (matchStart > lastIndex) frag.appendChild(document.createTextNode(lightText.slice(lastIndex, matchStart)));
                if (prefix) frag.appendChild(document.createTextNode(prefix));

                const normalized = normalize(nameMatch).replace(/\s+/g, ' ');
                const person = state.byName.get(normalized);
                const color = person?.color || '#000';

                const span = document.createElement('span');
                span.className = 'hint-name';
                span.textContent = nameMatch;
                const adjusted = adjustForContrast(color, hostBg, 4.5);
                span.style.color = adjusted;
                // optional extra safety for noisy backgrounds:
                span.style.textShadow = '0 1px 1px rgba(0,0,0,.6), 0 0 2px rgba(0,0,0,.4)';
                frag.appendChild(span);

                if (poss) frag.appendChild(document.createTextNode(poss));
                lastIndex = matchEnd;
            }
            if (lastIndex < lightText.length) frag.appendChild(document.createTextNode(lightText.slice(lastIndex)));

            // Swap visible content
            sr.replaceChildren(frag);
            host.dataset._decoratedFor = lightText; // idempotence key
        }
    }

    function debugPrint(people) {
        for (const p of people) {
            console.log(`${p.coord}: ${p.name} (${p.profession}) - ${p.hint}`);
            const mentions = p.hint ? (p.mentions.length ? p.mentions.join(', ') : '(none)') : '(no hint)';
            console.log(`   mentions: ${mentions} | color: ${p.color}`);
        }
    }

    function refreshState({ log = true } = {}) {
        state.people = extractPeople();
        state.byName = buildNameIndex(state.people);
        state.byEl = indexByElement(state.people);
        annotateMentions(state.people);
        assignColors(state.people);
        paintHintMentions(state.people);
        if (log) debugPrint(state.people);
    }

    // ---------- observers ----------
    function startDomObserver() {
        if (domObserver) return;

        let scheduled = false;
        const schedule = () => {
            if (scheduled) return;
            scheduled = true;
            // Let the app finish its render tick, then repaint once
            requestAnimationFrame(() => {
                scheduled = false;
                refreshState({ log: false });
            });
        };

        domObserver = new MutationObserver((mutations) => {
            for (const m of mutations) {
                if (m.type === 'characterData') {
                    if (m.target.parentElement?.matches?.('p.hint')) { schedule(); break; }
                } else if (m.type === 'childList') {
                    // New/changed cards or hints
                    const added = [...m.addedNodes].some(n =>
                                                         n.nodeType === 1 && (n.matches?.('.card, p.hint') || n.querySelector?.('.card, p.hint'))
                                                        );
                    const removed = [...m.removedNodes].some(n =>
                                                             n.nodeType === 1 && (n.matches?.('.card, p.hint') || n.querySelector?.('.card, p.hint'))
                                                            );
                    if (added || removed || m.target?.matches?.('.card, p.hint')) { schedule(); break; }
                }
            }
        });

        domObserver.observe(document.documentElement, {
            subtree: true,
            childList: true,
            characterData: true
        });
    }

    // ---------- lifecycle ----------
    let hoverListenerAttached = false;

    function main() {
        if (started) return;
        started = true;

        whenCardsReady(() => {
            refreshState({ log: true });
            startDomObserver();
        });

        if (!cardClickListenerAttached) {
            document.addEventListener('click', (e) => {
                const card = e.target.closest('.card');
                if (!card) return;
                queueMicrotask(() => refreshState({ log: true }));
            }, true);
            cardClickListenerAttached = true;
        }

        // --- hover highlighting ---
        if (!hoverListenerAttached) {
            document.addEventListener('pointerover', (e) => {
                const card = e.target.closest('.card');
                if (!card || card === currentHoverCard) return;

                currentHoverCard = card;
                const person = (state.byEl && state.byEl.get(card)) || state.people.find(p => p.el === card);
                if (person && person.hint) {
                    highlightMentionsFor(person);
                } else {
                    clearHighlights();
                }
            }, true);

            document.addEventListener('pointerout', (e) => {
                if (!currentHoverCard) return;
                // Ignore pointerout events that stay within the current card
                const toEl = e.relatedTarget;
                if (toEl && currentHoverCard.contains(toEl)) return;
                // Only clear when actually leaving the hovered card
                const fromCard = e.target.closest && e.target.closest('.card');
                if (fromCard && fromCard === currentHoverCard) {
                    currentHoverCard = null;
                    clearHighlights();
                }
            }, true);

            hoverListenerAttached = true;
        }
        const style = document.createElement('style');
        style.textContent = `.card{ transition: border-color .12s ease, border-width .12s ease, box-shadow .12s ease; }`;
        document.documentElement.appendChild(style);
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', main, { once: true });
    } else {
        main();
    }
})();