Skip to content

Instantly share code, notes, and snippets.

@marijnvdwerf
Created September 8, 2026 09:38
Show Gist options
  • Select an option

  • Save marijnvdwerf/771ce3f867a943247bc952a17c3d2613 to your computer and use it in GitHub Desktop.

Select an option

Save marijnvdwerf/771ce3f867a943247bc952a17c3d2613 to your computer and use it in GitHub Desktop.
decomp.dev treemap userscript
// ==UserScript==
// @name decomp.dev treemap theme
// @namespace https://decomp.dev/
// @description Recolors the interactive progress treemap with light/dark palettes.
// @match https://decomp.dev/*
// @match http://localhost:3000/*
// @run-at document-start
// @inject-into page
// @grant none
// ==/UserScript==
(() => {
// Percentage of black mixed into the outer stop of each unit's radial
// gradient. 0 disables the gradient (flat fills).
const OUTER_DARKEN = 0;
// 0-99.9% ramp: 'gray' (perceptually matched to the green) or 'blue'.
const PROGRESS_SCHEME = 'blue';
const PROGRESS_END = {
gray: { dark: '#858c9c', light: '#898c94' },
blue: { dark: '#0094e8', light: '#3994d1' },
};
const PALETTES = {
dark: {
background: '#181c25',
border: '#000',
hoverBorder: '#fff',
progressStart: '#1c212c',
progressEnd: PROGRESS_END[PROGRESS_SCHEME].dark,
complete: '#00a66e',
filteredAlpha: 0.1,
},
light: {
background: '#fff',
border: '#fff',
hoverBorder: '#000',
progressStart: '#e7eaf0',
progressEnd: PROGRESS_END[PROGRESS_SCHEME].light,
complete: '#00a66e',
filteredAlpha: 0.1,
},
};
const darken = (color) =>
OUTER_DARKEN
? `color-mix(in srgb, ${color}, #000 ${OUTER_DARKEN}%)`
: color;
const palette = () => {
const attr = document.documentElement.getAttribute('data-theme');
if (attr === 'light' || attr === 'dark') {
return PALETTES[attr];
}
const dark =
window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? true;
return dark ? PALETTES.dark : PALETTES.light;
};
// Literals painted by js/treemap.ts on the site.
const SITE_BACKGROUND = '#181c25';
const SITE_BORDER = '#000';
const SITE_HOVER_BORDER = '#fff';
const SITE_COMPLETE_INNER = 'hsl(120 100% 39%)';
const SITE_COMPLETE_OUTER = 'hsl(120 100% 17%)';
const SITE_PROGRESS_INNER =
/^color-mix\(in srgb, hsl\(200 0% 21%\), hsl\(200 100% 35%\) ([\d.]+)%\)$/;
const SITE_PROGRESS_OUTER =
/^color-mix\(in srgb, hsl\(200 0% 15%\), hsl\(200 100% 15%\) ([\d.]+)%\)$/;
const SITE_FILTERED_ALPHA = 0.1;
const remapStop = (color) => {
const p = palette();
if (color === SITE_COMPLETE_INNER) return p.complete;
if (color === SITE_COMPLETE_OUTER) return darken(p.complete);
let m = SITE_PROGRESS_INNER.exec(color);
if (m) {
return `color-mix(in srgb, ${p.progressStart}, ${p.progressEnd} ${m[1]}%)`;
}
m = SITE_PROGRESS_OUTER.exec(color);
if (m) {
return darken(
`color-mix(in srgb, ${p.progressStart}, ${p.progressEnd} ${m[1]}%)`,
);
}
return color;
};
const patchSetter = (proto, prop, remap) => {
const desc = Object.getOwnPropertyDescriptor(proto, prop);
Object.defineProperty(proto, prop, {
...desc,
set(value) {
desc.set.call(this, remap(value));
},
});
};
const ctxProto = CanvasRenderingContext2D.prototype;
// The site clears the canvas with fillRect; the tooltip paints with fill().
// Only remap the former: in Pico's light theme the tooltip background is
// also #181c25, and remapping it would make the tooltip white-on-white.
const fillRect = ctxProto.fillRect;
ctxProto.fillRect = function (...args) {
if (this.fillStyle !== SITE_BACKGROUND) {
return fillRect.apply(this, args);
}
this.fillStyle = palette().background;
try {
return fillRect.apply(this, args);
} finally {
this.fillStyle = SITE_BACKGROUND;
}
};
patchSetter(ctxProto, 'strokeStyle', (v) => {
if (v === SITE_BORDER) return palette().border;
if (v === SITE_HOVER_BORDER) return palette().hoverBorder;
return v;
});
patchSetter(ctxProto, 'globalAlpha', (v) =>
v === SITE_FILTERED_ALPHA ? palette().filteredAlpha : v,
);
const addColorStop = CanvasGradient.prototype.addColorStop;
CanvasGradient.prototype.addColorStop = function (offset, color) {
return addColorStop.call(this, offset, remapStop(color));
};
// The site's unit cache is private; make it rebuild on theme changes by
// re-running its filter handler, or nudging the canvas size as a fallback.
const repaint = () => {
const filter = document.querySelector('input[name="filter"]');
if (filter instanceof HTMLInputElement) {
filter.dispatchEvent(new Event('input', { bubbles: true }));
return;
}
const canvas = document.getElementById('treemap');
if (!(canvas instanceof HTMLCanvasElement)) return;
const height = canvas.style.height;
canvas.style.height = `${canvas.getBoundingClientRect().height + 1}px`;
requestAnimationFrame(() => {
canvas.style.height = height;
});
};
const init = () => {
// Userscript injection can lose the race against the site's inline
// drawTreemap() call (e.g. cached bundle on a normal reload), leaving the
// unit cache painted in stock colors. Always repaint once on load.
repaint();
new MutationObserver(repaint).observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
window
.matchMedia?.('(prefers-color-scheme: dark)')
.addEventListener('change', repaint);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
@marijnvdwerf

Copy link
Copy Markdown
Author
image image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment