Skip to content

Instantly share code, notes, and snippets.

@Gabrielcarvfer
Created October 1, 2026 13:55
Show Gist options
  • Select an option

  • Save Gabrielcarvfer/4674ec72afce9eb4df32c399ed2e8722 to your computer and use it in GitHub Desktop.

Select an option

Save Gabrielcarvfer/4674ec72afce9eb4df32c399ed2e8722 to your computer and use it in GitHub Desktop.
GitLab Color Transforms
// ==UserScript==
// @name GitLab Color Transforms
// @namespace gitlab-color-transforms
// @version 1.3.0
// @description Modular, registry-based value transforms (color swaps, CSS overrides) for GitLab pages
// @match https://gitlab.com/*
// @match https://gitlab.example.org/*
// @run-at document-idle
// @grant GM_xmlhttpRequest
// @connect *
// ==/UserScript==
(function () {
'use strict';
/* ======================================================================
* 0. SETTINGS
* ==================================================================== */
const DEBUG = false; // logs what was replaced, per stage, in the console
const COMPUTED_FALLBACK = true; // check each element's final (computed) colors
const LATE_PASSES_MS = [1000, 3000]; // extra full passes after load, for late UI
/* ======================================================================
* 1. YOUR TRANSFORMS
* Two kinds of modules:
* colorSwap(name, [[from, to], ...]) rewrites matching colors anywhere
* cssOverride(name, cssText) injects a stylesheet
* Custom value transforms: register({ name, apply: (v) => v }).
* Disable any module with: register({ ...module, enabled: false }).
* ==================================================================== */
function defineTransforms({ register, colorSwap, cssOverride }) {
/* ---------------- Your own swaps ---------------- */
register(colorSwap('Red -> coral', [
['rgb(252, 121, 136)', 'rgb(245, 127, 108)'],
['rgb(100, 25, 37)', 'rgb(102, 14, 0)'], // dark red background
]));
register(colorSwap('Teal green -> green', [
['rgb(53, 181, 151)', 'rgb(82, 184, 122)'],
['rgb(0, 60, 54)', 'rgb(10, 64, 32)'], // dark teal background
]));
register(colorSwap('Faint red -> transparent', [
['rgba(241, 81, 104, 0.16)', 'transparent'], // only this exact alpha
]));
/* ---------------- Undo GitLab's 2026 palette rebrand ----------------
* Restores every design token to its value before design.gitlab.com !5931
* (base commit 8071e4ef), which also covers the later ramp changes:
* blue, green (teal), orange, purple, red, neutral greys, danger buttons.
* https://gitlab.com/gitlab-org/gitlab-services/design.gitlab.com/-/merge_requests/5931
* Each part can be disabled on its own. */
const OLD = {
// base color ramps (same in light and dark)
ramps: {
'--gl-color-blue-50': '#e9f3fc',
'--gl-color-blue-100': '#cbe2f9',
'--gl-color-blue-200': '#9dc7f1',
'--gl-color-blue-300': '#63a6e9',
'--gl-color-blue-400': '#428fdc',
'--gl-color-blue-500': '#1f75cb',
'--gl-color-blue-600': '#2f68b4',
'--gl-color-blue-700': '#2f5ca0',
'--gl-color-blue-800': '#284779',
'--gl-color-blue-900': '#213454',
'--gl-color-blue-950': '#1d283e',
'--gl-color-green-50': '#ecf4ee',
'--gl-color-green-100': '#c3e6cd',
'--gl-color-green-200': '#91d4a8',
'--gl-color-green-300': '#52b87a',
'--gl-color-green-400': '#2da160',
'--gl-color-green-500': '#108548',
'--gl-color-green-600': '#2f7549',
'--gl-color-green-700': '#306440',
'--gl-color-green-800': '#225131',
'--gl-color-green-900': '#1e3e28',
'--gl-color-green-950': '#17291c',
'--gl-color-orange-50': '#fdf1dd',
'--gl-color-orange-100': '#f5d9a8',
'--gl-color-orange-200': '#e9be74',
'--gl-color-orange-300': '#d99530',
'--gl-color-orange-400': '#c17d10',
'--gl-color-orange-500': '#ab6100',
'--gl-color-orange-600': '#995715',
'--gl-color-orange-700': '#894b16',
'--gl-color-orange-800': '#693c14',
'--gl-color-orange-900': '#532e16',
'--gl-color-orange-950': '#382315',
'--gl-color-purple-50': '#f4f0ff',
'--gl-color-purple-100': '#e1d8f9',
'--gl-color-purple-200': '#cbbbf2',
'--gl-color-purple-300': '#ac93e6',
'--gl-color-purple-400': '#9475db',
'--gl-color-purple-500': '#7b58cf',
'--gl-color-purple-600': '#6a4fb4',
'--gl-color-purple-700': '#5c47a6',
'--gl-color-purple-800': '#493c83',
'--gl-color-purple-900': '#342d59',
'--gl-color-purple-950': '#27243e',
'--gl-color-red-50': '#fcf1ef',
'--gl-color-red-100': '#fdd4cd',
'--gl-color-red-200': '#fcb5aa',
'--gl-color-red-300': '#f6806d',
'--gl-color-red-400': '#ec5941',
'--gl-color-red-500': '#dd2b0e',
'--gl-color-red-600': '#c02f12',
'--gl-color-red-700': '#a32c12',
'--gl-color-red-800': '#812713',
'--gl-color-red-900': '#582014',
'--gl-color-red-950': '#3e1a14',
},
// grey ramp (left alone when a tinted UI theme is selected)
neutral: {
'--gl-color-neutral-10': '#fbfafd',
'--gl-color-neutral-50': '#ececef',
'--gl-color-neutral-100': '#dcdcde',
'--gl-color-neutral-200': '#bfbfc3',
'--gl-color-neutral-300': '#a4a3a8',
'--gl-color-neutral-400': '#89888d',
'--gl-color-neutral-500': '#737278',
'--gl-color-neutral-600': '#626168',
'--gl-color-neutral-700': '#4c4b51',
'--gl-color-neutral-800': '#3a383f',
'--gl-color-neutral-1000': '#050506',
},
// semantic tokens, both modes
both: {
'--gl-avatar-fallback-background-color-blue': 'rgba(157, 199, 241, 0.23921568627450981)',
'--gl-avatar-fallback-background-color-green': 'rgba(145, 212, 168, 0.23921568627450981)',
'--gl-avatar-fallback-background-color-orange': 'rgba(233, 190, 116, 0.23921568627450981)',
'--gl-avatar-fallback-background-color-purple': 'rgba(203, 187, 242, 0.23921568627450981)',
'--gl-avatar-fallback-background-color-red': 'rgba(252, 181, 170, 0.23921568627450981)',
'--gl-chart-threshold-area-color': 'rgba(221, 43, 14, 0.1)',
},
// semantic tokens, light mode only
light: {
'--gl-action-danger-background-color-active': 'rgba(174, 24, 0, 0.24)',
'--gl-action-danger-background-color-default': 'rgba(245, 127, 108, 0)',
'--gl-action-danger-background-color-hover': 'rgba(245, 127, 108, 0.16)',
'--gl-action-danger-foreground-color-active': 'var(--gl-color-red-900)',
'--gl-action-danger-foreground-color-default': 'var(--gl-color-red-500)',
'--gl-action-danger-foreground-color-hover': 'var(--gl-color-red-700)',
'--gl-button-danger-primary-background-color-active': 'var(--gl-color-red-800)',
'--gl-button-danger-primary-background-color-default': 'var(--gl-color-red-500)',
'--gl-button-danger-primary-background-color-hover': 'var(--gl-color-red-600)',
'--gl-button-danger-primary-border-color-active': 'var(--gl-color-red-900)',
'--gl-button-danger-primary-border-color-default': 'var(--gl-color-red-600)',
'--gl-button-danger-primary-border-color-hover': 'var(--gl-color-red-800)',
'--gl-button-danger-secondary-border-color-default': 'var(--gl-color-red-500)',
'--gl-button-danger-secondary-border-color-hover': 'var(--gl-color-red-700)',
'--gl-button-danger-secondary-foreground-color-default': 'var(--gl-color-red-500)',
'--gl-button-danger-secondary-foreground-color-hover': 'var(--gl-color-red-700)',
},
// semantic tokens, dark mode only
dark: {
'--gl-action-danger-background-color-active': 'rgba(236, 89, 65, 0.16)',
'--gl-action-danger-background-color-default': 'rgba(236, 89, 65, 0)',
'--gl-action-danger-background-color-hover': 'rgba(236, 89, 65, 0.4)',
'--gl-alert-danger-background-color': 'var(--gl-background-color-subtle)',
'--gl-alert-danger-title-color': 'var(--gl-color-red-300)',
'--gl-alert-info-background-color': 'var(--gl-background-color-subtle)',
'--gl-alert-info-title-color': 'var(--gl-color-blue-300)',
'--gl-alert-neutral-background-color': 'var(--gl-background-color-subtle)',
'--gl-alert-success-background-color': 'var(--gl-background-color-subtle)',
'--gl-alert-success-title-color': 'var(--gl-color-green-300)',
'--gl-alert-warning-background-color': 'var(--gl-background-color-subtle)',
'--gl-alert-warning-title-color': 'var(--gl-color-orange-300)',
'--gl-button-danger-primary-background-color-active': 'var(--gl-color-red-400)',
'--gl-button-danger-primary-background-color-default': 'var(--gl-color-red-300)',
'--gl-button-danger-primary-background-color-hover': 'var(--gl-color-red-200)',
'--gl-button-danger-secondary-background-color-active': 'rgba(236, 89, 65, 0.32)',
'--gl-button-danger-secondary-background-color-default': 'rgba(236, 89, 65, 0.4)',
'--gl-button-danger-secondary-background-color-hover': 'rgba(236, 89, 65, 0.64)',
'--gl-feedback-brand-icon-color': 'var(--gl-color-purple-300)',
'--gl-feedback-danger-icon-color': 'var(--gl-color-red-300)',
'--gl-feedback-info-icon-color': 'var(--gl-color-blue-300)',
'--gl-feedback-neutral-icon-color': 'var(--gl-color-neutral-300)',
'--gl-feedback-success-icon-color': 'var(--gl-color-green-300)',
'--gl-feedback-warning-icon-color': 'var(--gl-color-orange-300)',
},
};
// Extra ID-level specificity so these win over GitLab's own :root rules.
const ROOT = ':root:not(#color-transforms-none)';
const TINTS = ['indigo', 'blue', 'green', 'red', 'gray'];
const DEFAULT_THEME = ROOT + TINTS.map((t) => `:not(.ui-${t}):not(.tint-neutral-${t})`).join('');
const block = (sel, vars) =>
`${sel} {\n${Object.entries(vars).map(([k, v]) => ` ${k}: ${v};`).join('\n')}\n}`;
// (a) Variables: the reliable part, fixes everything that uses tokens.
register(cssOverride('Old palette: color ramps', block(ROOT, OLD.ramps)));
register(cssOverride('Old palette: neutral greys', block(DEFAULT_THEME, OLD.neutral)));
register(cssOverride('Old semantic tokens (both modes)', block(ROOT, OLD.both)));
register(cssOverride('Old semantic tokens (light)', block(`${ROOT}:not(.gl-dark)`, OLD.light)));
register(cssOverride('Old semantic tokens (dark)', block(`${ROOT}.gl-dark`, OLD.dark)));
// (b) Hardcoded colors: new ramp value -> old ramp value, for CSS that
// GitLab compiled with literal colors instead of variables.
register(colorSwap('Old palette: hardcoded colors', [
['#e8f2fd', '#e9f3fc'],
['#c8e0f9', '#cbe2f9'],
['#9dc8f2', '#9dc7f1'],
['#64a7e9', '#63a6e9'],
['#3f8edd', '#428fdc'],
['#1f73ca', '#1f75cb'],
['#2566b5', '#2f68b4'],
['#27599f', '#2f5ca0'],
['#254581', '#284779'],
['#1f3166', '#213454'],
['#1d2153', '#1d283e'],
['#e6f5ea', '#ecf4ee'],
['#bee6d0', '#c3e6cd'],
['#84d4b6', '#91d4a8'],
['#35b597', '#52b87a'],
['#129c84', '#2da160'],
['#0d8070', '#108548'],
['#0c7265', '#2f7549'],
['#0b645a', '#306440'],
['#075048', '#225131'],
['#003c36', '#1e3e28'],
['#002c26', '#17291c'],
['#fdf0d5', '#fdf1dd'],
['#fada9c', '#f5d9a8'],
['#f7b951', '#e9be74'],
['#fb8017', '#d99530'],
['#e95f21', '#c17d10'],
['#c74816', '#ab6100'],
['#b34013', '#995715'],
['#9e3811', '#894b16'],
['#7d2d0e', '#693c14'],
['#5d220d', '#532e16'],
['#461807', '#382315'],
['#f4f0fc', '#f4f0ff'],
['#e1daf7', '#e1d8f9'],
['#c9beef', '#cbbbf2'],
['#a997e1', '#ac93e6'],
['#927cd7', '#9475db'],
['#7862c8', '#7b58cf'],
['#6a54c0', '#6a4fb4'],
['#5b46b8', '#5c47a6'],
['#483698', '#493c83'],
['#362676', '#342d59'],
['#291a5c', '#27243e'],
['#feeef0', '#fcf1ef'],
['#ffd3d7', '#fdd4cd'],
['#ffafb8', '#fcb5aa'],
['#fc7988', '#f6806d'],
['#f15168', '#ec5941'],
['#de1d4b', '#dd2b0e'],
['#c52042', '#c02f12'],
['#ac203a', '#a32c12'],
['#891c2f', '#812713'],
['#641925', '#582014'],
['#48151d', '#3e1a14'],
['#f7f7f5', '#fbfafd'],
['#ecebea', '#ececef'],
['#dcdbd9', '#dcdcde'],
['#c1bfbe', '#bfbfc3'],
['#a5a3a3', '#a4a3a8'],
['#8a8888', '#89888d'],
['#747273', '#737278'],
['#646163', '#626168'],
['#4d4b4e', '#4c4b51'],
['#3a383d', '#3a383f'],
['#050408', '#050506'],
]));
// (c) Alert shape: full 1px colored border -> old 2px gradient top line
// + bottom border, with the old icon / dismiss-button offsets.
const ALERT_VARIANTS = { danger: 'danger', warning: 'warning', tip: 'neutral', info: 'info', success: 'success' };
register(cssOverride('MR5931 alert style', `
.gl-alert.gl-alert { border: 0; }
@media (forced-colors: active) {
.gl-alert.gl-alert { border: 1px solid transparent; }
}
.gl-alert.gl-alert::before {
content: "";
display: block;
width: 100%;
height: 2px;
position: absolute;
top: -1px;
left: 0;
}
${Object.entries(ALERT_VARIANTS).map(([cls, tok]) => `
.gl-alert.gl-alert-${cls} {
border-bottom: 1px solid var(--gl-alert-${tok}-border-bottom-color);
}
.gl-alert.gl-alert-${cls}::before {
background: linear-gradient(to right, transparent 0%,
var(--gl-alert-${tok}-border-top-color) 50%, transparent 100%);
}`).join('')}
.gl-alert-icon-container {
top: var(--gl-spacing-scale-5, 1rem);
height: var(--gl-line-height-20, 1.25rem);
}
.gl-alert-has-title .gl-alert-icon-container {
top: calc(var(--gl-spacing-scale-4, 0.75rem) + 2px);
}
.gl-dismiss-btn {
top: calc(var(--gl-spacing-scale-4, 0.75rem) + 2px);
}
`));
}
/* ======================================================================
* 2. COLOR UTILITIES
* ==================================================================== */
const TOKEN_RE =
/rgba?\(\s*(\d{1,3})[\s,]+(\d{1,3})[\s,]+(\d{1,3})\s*(?:[,/]\s*([\d.]+%?)\s*)?\)|#([0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})\b/gi;
const key = (r, g, b) => `${r},${g},${b}`;
function parseHex(h) {
if (h.length <= 4) h = [...h].map((c) => c + c).join('');
const n = [0, 2, 4, 6].map((i) => parseInt(h.slice(i, i + 2), 16));
return { r: n[0], g: n[1], b: n[2], a: h.length === 8 ? n[3] : null };
}
function toHex(r, g, b, a) {
const hx = (v) => v.toString(16).padStart(2, '0');
return '#' + hx(r) + hx(g) + hx(b) + (a != null ? hx(a) : '');
}
function parseAlpha(s) {
if (s == null) return null;
return s.endsWith('%') ? parseFloat(s) / 100 : parseFloat(s);
}
// -> { rgb: [r,g,b], a: alpha 0..1 or null if not written, transparent: bool }
function parseColor(str) {
if (/^\s*transparent\s*$/i.test(str)) return { rgb: [0, 0, 0], a: 0, transparent: true };
const m = new RegExp(TOKEN_RE.source, 'i').exec(str.trim());
if (!m) throw new Error(`[color-transforms] Unparseable color: ${str}`);
if (m[5]) {
const c = parseHex(m[5]);
return { rgb: [c.r, c.g, c.b], a: c.a != null ? c.a / 255 : null, transparent: false };
}
return { rgb: [+m[1], +m[2], +m[3]], a: parseAlpha(m[4]), transparent: false };
}
/**
* Swap colors. Rules:
* - `from` without alpha (rgb(...), #rrggbb) matches that RGB at ANY alpha.
* - `from` with alpha (rgba(..., 0.16)) matches ONLY that alpha (takes precedence).
* - `to` may be 'transparent'. If `to` has an alpha it is used; otherwise the
* source alpha is kept.
*/
function colorSwap(name, pairs) {
const table = new Map(); // "r,g,b" -> [{ a, to }]
for (const [from, to] of pairs) {
const f = parseColor(from);
const k = key(...f.rgb);
if (!table.has(k)) table.set(k, []);
table.get(k).push({ a: f.a, to: parseColor(to) });
}
const lookup = (r, g, b, srcA) => {
const list = table.get(key(r, g, b));
if (!list) return null;
const a = srcA == null ? 1 : srcA;
return (
list.find((e) => e.a != null && Math.abs(e.a - a) < 0.005) ||
list.find((e) => e.a == null) ||
null
);
};
const render = (to, srcA, asHex) => {
if (to.transparent) return 'transparent';
const [r, g, b] = to.rgb;
const a = to.a != null ? to.a : srcA;
if (asHex) return toHex(r, g, b, a != null ? Math.round(a * 255) : null);
return a != null ? `rgba(${r}, ${g}, ${b}, ${+a.toFixed(4)})` : `rgb(${r}, ${g}, ${b})`;
};
return {
name,
apply(value) {
return value.replace(TOKEN_RE, (tok, r, g, b, alpha, hex) => {
if (hex) {
const c = parseHex(hex);
const srcA = c.a != null ? c.a / 255 : null;
const e = lookup(c.r, c.g, c.b, srcA);
return e ? render(e.to, srcA, true) : tok;
}
const srcA = parseAlpha(alpha);
const e = lookup(+r, +g, +b, srcA);
return e ? render(e.to, srcA, false) : tok;
});
},
};
}
/** A module that injects CSS instead of rewriting values. */
function cssOverride(name, css) {
return { name, css };
}
/* ======================================================================
* 3. REGISTRY / PIPELINE
* ==================================================================== */
const TRANSFORMS = []; // value transforms (have apply)
const OVERRIDES = []; // css modules (have css)
const stats = { sheetDecls: 0, crossOriginRules: 0, inline: 0, computed: 0 };
const log = (...a) => DEBUG && console.info('[color-transforms]', ...a);
function register(t) {
if (!t || (typeof t.apply !== 'function' && typeof t.css !== 'string')) {
throw new Error('Module needs apply() or css');
}
if (t.enabled === false) return;
if (typeof t.apply === 'function') TRANSFORMS.push(t);
if (typeof t.css === 'string') OVERRIDES.push(t);
}
function transformValue(v) {
if (!v) return v;
let out = v;
for (const t of TRANSFORMS) {
try {
out = t.apply(out);
} catch (e) {
console.warn(`[color-transforms] "${t.name}" failed:`, e);
}
}
return out;
}
defineTransforms({ register, colorSwap, cssOverride });
if (!TRANSFORMS.length && !OVERRIDES.length) return;
/* ======================================================================
* 3b. CSS OVERRIDES (one <style>, kept at the end of <head>)
* ==================================================================== */
const OVERRIDE_ID = 'color-transforms-overrides';
const overrideCss = OVERRIDES.map((o) => `/* ${o.name} */\n${o.css}`).join('\n');
function ensureOverrides(moveToEnd) {
if (!OVERRIDES.length) return;
const parent = document.head || document.documentElement;
let el = document.getElementById(OVERRIDE_ID);
if (!el) {
el = document.createElement('style');
el.id = OVERRIDE_ID;
el.textContent = overrideCss;
parent.appendChild(el);
} else if (moveToEnd && el !== parent.lastElementChild) {
parent.appendChild(el); // later in source order wins ties
}
}
ensureOverrides(true);
/* ======================================================================
* 4a. STYLESHEETS (rewrites rules in place, incl. pseudo-elements)
* ==================================================================== */
const doneSheets = new WeakSet();
function processDeclarations(style) {
for (const prop of Array.from(style)) {
const v = style.getPropertyValue(prop);
if (!v) continue;
const n = transformValue(v);
if (n !== v) {
style.setProperty(prop, n, style.getPropertyPriority(prop));
stats.sheetDecls++;
}
}
}
function processRules(rules) {
for (const rule of rules) {
if (rule.style) processDeclarations(rule.style);
if (rule.styleSheet) processSheet(rule.styleSheet);
if (rule.cssRules) processRules(rule.cssRules);
}
}
function processSheet(sheet) {
if (!sheet || doneSheets.has(sheet)) return;
if (sheet.ownerNode && sheet.ownerNode.id === OVERRIDE_ID) return;
let rules;
try {
rules = sheet.cssRules;
} catch (e) {
if (e.name === 'SecurityError') {
doneSheets.add(sheet);
patchCrossOriginSheet(sheet);
}
return; // not loaded yet: retried on a later pass
}
if (!rules) return;
doneSheets.add(sheet);
processRules(rules);
}
function scanSheets() {
if (!TRANSFORMS.length) return;
for (const s of document.styleSheets) processSheet(s);
for (const s of document.adoptedStyleSheets || []) processSheet(s);
}
function absolutizeUrls(css, base) {
return css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, (m, q, u) =>
/^(data:|#|[a-z][a-z0-9+.-]*:)/i.test(u) ? m : `url(${q}${new URL(u, base).href}${q})`);
}
function patchCrossOriginSheet(sheet) {
const href = sheet.href;
if (!href || typeof GM_xmlhttpRequest !== 'function') return;
GM_xmlhttpRequest({
method: 'GET',
url: href,
onload(res) {
if (res.status < 200 || res.status >= 300) return;
let parsed;
try {
parsed = new CSSStyleSheet();
parsed.replaceSync(res.responseText);
} catch (e) {
return;
}
const changed = [];
for (const rule of parsed.cssRules) {
const t = rule.cssText;
const n = transformValue(t);
if (n !== t) changed.push(n);
}
if (!changed.length) return;
stats.crossOriginRules += changed.length;
const style = document.createElement('style');
style.dataset.colorTransformsFor = href;
style.textContent = absolutizeUrls(changed.join('\n'), href);
const owner = sheet.ownerNode;
if (owner && owner.parentNode) owner.after(style);
else (document.head || document.documentElement).appendChild(style);
ensureOverrides(true);
log(`patched ${changed.length} rules from`, href);
queue(document.body); // re-check computed colors after the override lands
},
});
}
/* ======================================================================
* 4b. ELEMENTS: inline style, SVG attributes, computed-style fallback
* ==================================================================== */
const SVG_ATTRS = ['fill', 'stroke', 'stop-color', 'flood-color', 'lighting-color', 'color'];
const COMPUTED_PROPS = [
'color', 'background-color',
'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color',
'outline-color', 'text-decoration-color', 'caret-color', 'column-rule-color',
'fill', 'stroke', 'stop-color', 'box-shadow', 'text-shadow', 'background-image',
];
function processAttributes(el) {
const style = el.getAttribute('style');
if (style) {
const n = transformValue(style);
if (n !== style) {
el.setAttribute('style', n);
stats.inline++;
}
}
if (el instanceof SVGElement) {
for (const a of SVG_ATTRS) {
const v = el.getAttribute(a);
if (!v) continue;
const n = transformValue(v);
if (n !== v) {
el.setAttribute(a, n);
stats.inline++;
}
}
}
}
function processComputed(el) {
const cs = getComputedStyle(el);
for (const p of COMPUTED_PROPS) {
const v = cs.getPropertyValue(p);
if (!v || v === 'none') continue;
const n = transformValue(v);
if (n !== v) {
el.style.setProperty(p, n, 'important');
stats.computed++;
}
}
}
function processElement(el) {
if (!el || el.nodeType !== 1 || !el.isConnected) return;
processAttributes(el);
if (COMPUTED_FALLBACK) processComputed(el);
}
/* ======================================================================
* 5. WORK QUEUE (batched, chunked so big diffs don't freeze the tab)
* ==================================================================== */
const pendingRoots = new Set();
let scheduled = false;
let sheetsDirty = true;
function queue(root) {
if (!TRANSFORMS.length) return;
if (!root || root.nodeType !== 1) return;
pendingRoots.add(root);
if (!scheduled) {
scheduled = true;
setTimeout(flush, 50);
}
}
function flush() {
scheduled = false;
if (sheetsDirty) {
sheetsDirty = false;
scanSheets();
}
const els = new Set();
for (const r of pendingRoots) {
if (!r.isConnected) continue;
els.add(r);
r.querySelectorAll('*').forEach((e) => els.add(e));
}
pendingRoots.clear();
const list = [...els];
let i = 0;
(function chunk() {
const end = Math.min(i + 500, list.length);
for (; i < end; i++) processElement(list[i]);
if (i < list.length) setTimeout(chunk, 0);
else log({ ...stats });
})();
}
/* ======================================================================
* 6. START (after the page has fully loaded) + keep watching
* ==================================================================== */
function start() {
log(
`starting with ${TRANSFORMS.length} value transform(s) and ${OVERRIDES.length} CSS override(s):`,
[...TRANSFORMS, ...OVERRIDES].map((t) => t.name),
);
ensureOverrides(true);
document.addEventListener('load', (e) => {
if (e.target && e.target.tagName === 'LINK') {
ensureOverrides(true);
sheetsDirty = true;
queue(document.body);
}
}, true); // capture: catches late <link> loads
new MutationObserver((mutations) => {
let headChanged = false;
for (const m of mutations) {
if (m.type === 'attributes') {
// class changes alter computed colors; style/SVG attrs may carry colors
queue(m.target);
continue;
}
if (m.target.nodeName === 'STYLE') sheetsDirty = true;
if (m.target === document.head) headChanged = true;
for (const n of m.addedNodes) {
if (n.nodeType !== 1 || n.id === OVERRIDE_ID) continue;
if (n.tagName === 'STYLE' || n.tagName === 'LINK' || n.querySelector?.('style, link')) {
sheetsDirty = true;
}
queue(n);
}
}
if (headChanged) ensureOverrides(true);
}).observe(document.documentElement, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['style', 'class', ...SVG_ATTRS],
});
queue(document.documentElement);
for (const ms of LATE_PASSES_MS) {
setTimeout(() => {
ensureOverrides(true);
sheetsDirty = true;
queue(document.documentElement);
}, ms);
}
}
if (document.readyState === 'complete') start();
else window.addEventListener('load', start, { once: true });
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment