// ==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();
}
})();