Created
October 1, 2026 13:55
-
-
Save Gabrielcarvfer/4674ec72afce9eb4df32c399ed2e8722 to your computer and use it in GitHub Desktop.
GitLab Color Transforms
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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