Logger that optionally mirror to a on-page UI overlay shared by every page match-common userscript
This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greasyfork.org/scripts/588114/1882370/Logging%20Handler%20%20UI%20Overlay.js
// ==UserScript==
// @name Logging Handler & UI Overlay
// @namespace 861ddd094884eac5bea7a3b12e074f34
// @description Logger that optionally mirror to a on-page UI overlay shared by every page match-common userscript
// @author Anonymous, Claude Opus 4.8
// @version 1.0.0
// @license BSD-0
// @grant none
// ==/UserScript==
(function () {
'use strict';
const PANEL_ID = 'logui-panel';
const STYLE_ID = 'logui-style';
const BODY_CLASS = 'logui-body';
const VISIBLE_ATTR = 'data-logui-visible';
const DEFAULT_MAX_LINES = 500;
const DEFAULT_PREFS_KEY = 'logui_prefs';
const LEVELS = ['debug', 'info', 'warn', 'error'];
const CSS = `
#${PANEL_ID} {
position: fixed;
top: 20px;
right: 40px;
width: 420px;
max-width: 22.5vw;
height: 20vh;
z-index: 2147483647;
display: flex;
flex-direction: column;
background: rgba(0, 0, 0, 0.10);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 6px;
font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
/* Click-through, so the panel never steals input from the page beneath. */
pointer-events: none;
overflow-y: auto;
scrollbar-width: none;
}
#${PANEL_ID}::-webkit-scrollbar { display: none; }
#${PANEL_ID}:hover { background: rgba(0, 0, 0, 0.30); }
#${PANEL_ID} .${BODY_CLASS} {
flex: 1 1 auto;
min-height: 0;
padding: 6px 8px;
word-break: break-word;
overflow-y: auto;
scrollbar-width: none;
/* Dimmed until hovered: legible on demand, unobtrusive otherwise. Opacity
lives on the body alone so per-level colours stay a single flat value.
Hover here still satisfies the :hover rule above via propagation, and
re-enables events so the log can be scrolled. */
opacity: 0.30;
pointer-events: auto;
transition: opacity 120ms ease;
}
#${PANEL_ID} .${BODY_CLASS}:hover { opacity: 1; }
#${PANEL_ID} .logui-line {
margin: 0 0 3px;
white-space: pre-wrap;
}
#${PANEL_ID} .logui-time { color: #9aa0a6; margin-right: 6px; }
#${PANEL_ID} .logui-tag { margin-right: 6px; font-weight: 600; }
#${PANEL_ID} .logui-debug { color: #e6e6e6; }
#${PANEL_ID} .logui-info { color: #6fb3ff; }
#${PANEL_ID} .logui-warn { color: #ffc857; }
#${PANEL_ID} .logui-error { color: #ff6b6b; }
`;
// storage
///////////
// The calling script owns the grants, so probe rather than assume. A script
// that granted neither still gets a working logger, just a forgetful one.
const hasGet = typeof GM_getValue === 'function';
const hasSet = typeof GM_setValue === 'function';
function readPrefs(key) {
if (!hasGet) return {};
try {
const raw = GM_getValue(key, null);
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (e) {
return {};
}
}
function writePrefs(key, value) {
if (!hasSet) return false;
try {
GM_setValue(key, JSON.stringify(value));
return true;
} catch (e) {
return false;
}
}
// panel
/////////
// Whichever instance logs first builds the panel; the rest adopt it. Both
// lookups are by document id, which is the only channel sandboxed scripts
// reliably share.
function ensurePanel() {
const host = document.body || document.documentElement;
if (!host) return null;
let panel = document.getElementById(PANEL_ID);
if (panel) return panel;
if (!document.getElementById(STYLE_ID)) {
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = CSS;
(document.head || host).appendChild(style);
}
panel = document.createElement('div');
panel.id = PANEL_ID;
const body = document.createElement('div');
body.className = BODY_CLASS;
panel.appendChild(body);
host.appendChild(panel);
return panel;
}
function panelBody(panel) {
return panel ? panel.querySelector('.' + BODY_CLASS) : null;
}
// Deterministic hue per tag: the same tag is the same colour in every
// script and on every page, with no shared allocation table. Saturation and
// lightness are fixed high enough to stay readable on the dark panel.
function tagColour(tag) {
let hash = 0;
for (let i = 0; i < tag.length; i++) {
hash = (hash * 31 + tag.charCodeAt(i)) >>> 0;
}
return `hsl(${hash % 360}, 65%, 70%)`;
}
function formatArg(value) {
if (typeof value === 'string') return value;
if (value instanceof Error) return value.stack || String(value);
try {
return JSON.stringify(value);
} catch (e) {
return String(value);
}
}
// instances
/////////////
function create(config) {
const cfg = config || {};
const tag = String(cfg.tag || cfg.name || 'log');
const consolePrefix = cfg.name ? `[${cfg.name}] ` : `[${tag}] `;
const prefsKey = cfg.prefsKey || DEFAULT_PREFS_KEY;
const maxLines = cfg.maxLines > 0 ? cfg.maxLines : DEFAULT_MAX_LINES;
// A surface where the panel makes no sense (a secondary @match, say) can
// opt this instance out of the panel sink while keeping the console one.
const panelSink = cfg.panelSink !== false;
const minLevel = LEVELS.indexOf(cfg.minLevel) >= 0
? LEVELS.indexOf(cfg.minLevel) : 0;
const colour = tagColour(tag);
const stored = readPrefs(prefsKey);
let consoleOn = typeof stored.console === 'boolean'
? stored.console
: cfg.console !== false;
let panelOn = typeof stored.panel === 'boolean'
? stored.panel
: cfg.panel !== false;
function persist() {
writePrefs(prefsKey, { console: consoleOn, panel: panelOn });
}
// Push this instance's notion of visibility onto the shared node, and
// stamp it so sibling instances can follow.
function applyPanel(build) {
const panel = build ? ensurePanel() : document.getElementById(PANEL_ID);
if (!panel) return null;
panel.style.display = panelOn ? 'flex' : 'none';
if (panel.getAttribute(VISIBLE_ATTR) !== String(panelOn)) {
panel.setAttribute(VISIBLE_ATTR, String(panelOn));
}
return panel;
}
// The converse: adopt a sibling's flip. Watching the attribute rather
// than the style keeps this from firing on our own display writes.
function watchPanel(panel) {
if (!panel || !window.MutationObserver) return;
new MutationObserver(() => {
const shared = panel.getAttribute(VISIBLE_ATTR) === 'true';
if (shared === panelOn) return;
panelOn = shared;
panel.style.display = panelOn ? 'flex' : 'none';
persist();
}).observe(panel, { attributes: true, attributeFilter: [VISIBLE_ATTR] });
}
function appendLine(level, args) {
const panel = applyPanel(true);
const body = panelBody(panel);
if (!body) return;
// Only chase the newest line when the reader is already parked at
// the bottom; otherwise leave a manual scroll-up undisturbed.
const atBottom =
body.scrollHeight - body.scrollTop - body.clientHeight < 4;
const line = document.createElement('div');
line.className = 'logui-line logui-' + level;
const time = document.createElement('span');
time.className = 'logui-time';
time.textContent = new Date().toLocaleTimeString();
line.appendChild(time);
const label = document.createElement('span');
label.className = 'logui-tag';
label.style.color = colour;
label.textContent = tag;
line.appendChild(label);
line.appendChild(document.createTextNode(args.map(formatArg).join(' ')));
body.appendChild(line);
// Newest lines settle at the bottom; older ones are trimmed so a
// long-lived session cannot grow the node list without bound.
while (body.childElementCount > maxLines) {
body.removeChild(body.firstChild);
}
if (atBottom) body.scrollTop = body.scrollHeight;
}
function emit(level, args) {
if (LEVELS.indexOf(level) < minLevel) return;
if (consoleOn) {
// Objects are handed to the console unstringified, so devtools
// keeps them inspectable; only the panel flattens them.
const sink = console[level] || console.log;
sink.call(console, consolePrefix, ...args);
}
if (panelOn && panelSink) appendLine(level, args);
}
const logger = {
debug: (...args) => emit('debug', args),
info: (...args) => emit('info', args),
warn: (...args) => emit('warn', args),
error: (...args) => emit('error', args),
clear() {
const body = panelBody(document.getElementById(PANEL_ID));
if (body) body.textContent = '';
},
toggleConsole() {
consoleOn = !consoleOn;
persist();
return consoleOn;
},
togglePanel() {
panelOn = !panelOn;
persist();
applyPanel(panelOn);
return panelOn;
},
get consoleEnabled() { return consoleOn; },
set consoleEnabled(on) {
consoleOn = !!on;
persist();
},
get panelEnabled() { return panelOn; },
set panelEnabled(on) {
panelOn = !!on;
persist();
applyPanel(panelOn);
},
get element() { return document.getElementById(PANEL_ID); },
// Convenience wiring for the two toggles, skipped silently when the
// caller did not grant GM_registerMenuCommand.
registerMenuCommands(opts) {
if (typeof GM_registerMenuCommand !== 'function') return false;
const o = opts || {};
GM_registerMenuCommand(o.consoleLabel || 'Toggle console logging', () => {
const on = logger.toggleConsole();
// Announce through whichever sink is still live.
logger.info(`console logging ${on ? 'enabled' : 'disabled'}`);
});
GM_registerMenuCommand(o.panelLabel || 'Toggle log panel', () => {
const on = logger.togglePanel();
logger.info(`log panel ${on ? 'enabled' : 'disabled'}`);
});
return true;
},
};
logger.log = logger.info;
// Build eagerly -- hidden if this instance's preference says so -- so
// there is always a node to watch for sibling flips, and so a later
// toggle has nothing to construct.
if (panelSink) {
const panel = applyPanel(true);
if (panel) {
watchPanel(panel);
} else {
document.addEventListener('DOMContentLoaded', () => {
watchPanel(applyPanel(true));
}, { once: true });
}
}
return logger;
}
window.LoggingUI = { create, LEVELS };
})();