Add UI Token Extractor userscript
This commit is contained in:
@@ -1,3 +1,22 @@
|
||||
# userscripts
|
||||
# Userscripts
|
||||
|
||||
Browser userscripts (Tampermonkey/Violentmonkey) — local-only, no telemetry, one script per tool
|
||||
Browser userscripts (Tampermonkey / Violentmonkey / Greasemonkey
|
||||
compatible). One directory per script. Every script here is local-only
|
||||
by policy — no telemetry, no third-party network calls, no AI hand-off —
|
||||
unless a script's own README explicitly says otherwise.
|
||||
|
||||
## Scripts
|
||||
|
||||
- [**UI Token Extractor**](ui-token-extractor/) — extracts colors,
|
||||
typography, spacing, effects, and component styles from the current
|
||||
page's live DOM and shows them on-screen as selectable text. Inspired
|
||||
by [uidrop.site](https://uidrop.site)'s extraction categories, without
|
||||
its AI hand-off — nothing is ever sent anywhere.
|
||||
|
||||
## Installing a script
|
||||
|
||||
1. Install [Tampermonkey](https://www.tampermonkey.net/) (Chrome/Edge/
|
||||
Firefox/Safari) or [Violentmonkey](https://violentmonkey.github.io/).
|
||||
2. Open the script's `.user.js` file's raw content and the extension
|
||||
will offer to install it, or use the extension's "Create new script"
|
||||
/ import-from-file option and paste the contents.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# UI Token Extractor
|
||||
|
||||
A userscript that reads the **live, computed** DOM of whatever page
|
||||
you're on and extracts design tokens — colors, typography, spacing,
|
||||
effects, component styles — into an on-screen panel you can select and
|
||||
copy from. No AI, no network requests, nothing sent anywhere. Inspired by
|
||||
[uidrop.site](https://uidrop.site)'s extraction categories; deliberately
|
||||
without its "send to Claude/ChatGPT/etc." step.
|
||||
|
||||
## What it captures
|
||||
|
||||
- **Colors** — the most-used text and background colors on the page
|
||||
(by computed value, ranked by frequency), plus any `:root` CSS custom
|
||||
properties it can read from same-origin stylesheets (the usual home
|
||||
for a site's real design tokens).
|
||||
- **Typography** — distinct font families, sizes, and weights in use,
|
||||
ranked by frequency.
|
||||
- **Spacing** — distinct padding and margin values in use.
|
||||
- **Border radius** — distinct values in use.
|
||||
- **Effects** — box-shadows, CSS gradients, and any non-default opacity
|
||||
values.
|
||||
- **Components** — up to 5 examples each of buttons, inputs, cards, and
|
||||
chip/badge/tag-like elements, with their full style snapshot (colors,
|
||||
padding, radius, border, shadow, font).
|
||||
- **Color scheme** — whether the page matches `prefers-color-scheme:
|
||||
dark`, and whether the body background itself reads as dark.
|
||||
|
||||
## How to use it
|
||||
|
||||
1. Install the script (see the top-level repo README).
|
||||
2. On any page, click the floating 🎨 button in the bottom-right corner,
|
||||
or press **Alt+Shift+U**.
|
||||
3. A panel opens with everything formatted as plain text in a read-only
|
||||
textarea. Click the text to select all of it, then copy normally
|
||||
(Ctrl/Cmd+C) — or use the **Copy all** button, which uses the
|
||||
browser's local clipboard API and never touches the network either.
|
||||
4. Press **Esc**, click outside the panel, or hit **Close** to dismiss.
|
||||
|
||||
## Guarantees
|
||||
|
||||
- **No `fetch`/`XMLHttpRequest`/`WebSocket`/`sendBeacon`/`GM_xmlhttpRequest`
|
||||
anywhere in the script.** Extraction only reads `getComputedStyle()`
|
||||
and same-origin stylesheet rules already loaded by the page you're
|
||||
viewing.
|
||||
- **No AI integration.** This is deliberately a strict subset of what
|
||||
uidrop.site does — the "send to an AI tool" step from the original is
|
||||
not implemented here at all.
|
||||
- **`@grant none`** — the script doesn't request any special userscript-
|
||||
manager permissions.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Cross-origin stylesheets can't be read for `:root` custom properties
|
||||
(a browser security restriction, not something this script can work
|
||||
around) — the color/typography/spacing extraction from computed
|
||||
styles still works fine regardless, since that doesn't depend on
|
||||
reading stylesheet source.
|
||||
- Component detection (buttons/cards/chips) uses heuristics (tag name,
|
||||
`class` name patterns, `role="button"`) — it won't catch every custom
|
||||
component naming convention, and may occasionally mis-tag something.
|
||||
- Capped at scanning 4,000 elements for performance on very large pages;
|
||||
the report notes when a page was truncated.
|
||||
@@ -0,0 +1,436 @@
|
||||
// ==UserScript==
|
||||
// @name UI Token Extractor
|
||||
// @namespace https://devops.cloudhost.es/Malin/userscripts
|
||||
// @version 1.0.0
|
||||
// @description Extracts colors, typography, spacing, effects, and component styles from the current page's live DOM and shows them on-screen as selectable text. No AI, no network requests of any kind.
|
||||
// @author Malin
|
||||
// @match *://*/*
|
||||
// @grant none
|
||||
// @run-at document-idle
|
||||
// ==/UserScript==
|
||||
|
||||
/*
|
||||
* UI Token Extractor
|
||||
* -------------------
|
||||
* Inspired by uidrop.site's live-DOM design-token extraction, minus the
|
||||
* AI hand-off. This script never sends data anywhere -- it only reads
|
||||
* computed styles already present in the page you're viewing and renders
|
||||
* them in a local overlay panel. No fetch/XHR/WebSocket/GM_xmlhttpRequest
|
||||
* calls exist anywhere in this file. The one non-essential convenience
|
||||
* (a "Copy" button using navigator.clipboard.writeText) is 100% local
|
||||
* and can be deleted without affecting extraction if you'd rather rely
|
||||
* purely on manual select-and-copy from the readonly textareas.
|
||||
*
|
||||
* Trigger: floating button (bottom-right) or Alt+Shift+U.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const MAX_SAMPLE_ELEMENTS = 4000; // cap DOM walk cost on huge pages
|
||||
const TOP_N = 12; // how many distinct values to keep per category
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Extraction
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function tally(map, key) {
|
||||
if (!key) return;
|
||||
map.set(key, (map.get(key) || 0) + 1);
|
||||
}
|
||||
|
||||
function topEntries(map, n) {
|
||||
return Array.from(map.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, n);
|
||||
}
|
||||
|
||||
function isNoiseColor(c) {
|
||||
if (!c) return true;
|
||||
return c === 'rgba(0, 0, 0, 0)' || c === 'transparent';
|
||||
}
|
||||
|
||||
function detectColorScheme() {
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const bodyBg = getComputedStyle(document.body).backgroundColor;
|
||||
const m = bodyBg.match(/\d+(\.\d+)?/g);
|
||||
let bodyIsDark = null;
|
||||
if (m && m.length >= 3) {
|
||||
const [r, g, b] = m.map(Number);
|
||||
// Perceived luminance
|
||||
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
bodyIsDark = luminance < 128;
|
||||
}
|
||||
return { prefersDark, bodyBackground: bodyBg, bodyAppearsDark: bodyIsDark };
|
||||
}
|
||||
|
||||
function getRootCustomProperties() {
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
const props = [];
|
||||
// documentElement's computed style doesn't enumerate custom props
|
||||
// directly in all browsers, so also scan inline :root declarations
|
||||
// from same-origin stylesheets where accessible.
|
||||
try {
|
||||
for (const sheet of document.styleSheets) {
|
||||
let rules;
|
||||
try {
|
||||
rules = sheet.cssRules;
|
||||
} catch (e) {
|
||||
continue; // cross-origin stylesheet, can't read rules -- skip silently
|
||||
}
|
||||
if (!rules) continue;
|
||||
for (const rule of rules) {
|
||||
if (rule.selectorText === ':root' && rule.style) {
|
||||
for (let i = 0; i < rule.style.length; i++) {
|
||||
const prop = rule.style[i];
|
||||
if (prop.startsWith('--')) {
|
||||
props.push([prop, rule.style.getPropertyValue(prop).trim()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore -- best-effort only
|
||||
}
|
||||
return props;
|
||||
}
|
||||
|
||||
function looksLikeButton(el) {
|
||||
if (el.tagName === 'BUTTON') return true;
|
||||
if (el.tagName === 'A' && /\bbtn\b|\bbutton\b/i.test(el.className || '')) return true;
|
||||
if (el.getAttribute && el.getAttribute('role') === 'button') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function looksLikeCard(el) {
|
||||
return /\bcard\b/i.test(el.className || '');
|
||||
}
|
||||
|
||||
function looksLikeChip(el) {
|
||||
return /\bchip\b|\bbadge\b|\btag\b/i.test(el.className || '');
|
||||
}
|
||||
|
||||
function snapshotComponent(el) {
|
||||
const cs = getComputedStyle(el);
|
||||
return {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
class: (el.className && typeof el.className === 'string') ? el.className.slice(0, 80) : '',
|
||||
background: cs.backgroundColor,
|
||||
color: cs.color,
|
||||
padding: cs.padding,
|
||||
borderRadius: cs.borderRadius,
|
||||
border: cs.border,
|
||||
boxShadow: cs.boxShadow === 'none' ? null : cs.boxShadow,
|
||||
fontSize: cs.fontSize,
|
||||
fontWeight: cs.fontWeight,
|
||||
};
|
||||
}
|
||||
|
||||
function extract() {
|
||||
const colorTally = new Map();
|
||||
const bgTally = new Map();
|
||||
const fontFamilyTally = new Map();
|
||||
const fontSizeTally = new Map();
|
||||
const fontWeightTally = new Map();
|
||||
const paddingTally = new Map();
|
||||
const marginTally = new Map();
|
||||
const radiusTally = new Map();
|
||||
const shadowTally = new Map();
|
||||
const gradientTally = new Map();
|
||||
const opacityTally = new Map();
|
||||
|
||||
const buttons = [];
|
||||
const cards = [];
|
||||
const chips = [];
|
||||
const inputs = [];
|
||||
|
||||
const all = document.querySelectorAll('*');
|
||||
const limit = Math.min(all.length, MAX_SAMPLE_ELEMENTS);
|
||||
|
||||
for (let i = 0; i < limit; i++) {
|
||||
const el = all[i];
|
||||
if (el.tagName === 'SCRIPT' || el.tagName === 'STYLE' || el.tagName === 'NOSCRIPT') continue;
|
||||
const cs = getComputedStyle(el);
|
||||
|
||||
if (!isNoiseColor(cs.color)) tally(colorTally, cs.color);
|
||||
if (!isNoiseColor(cs.backgroundColor)) tally(bgTally, cs.backgroundColor);
|
||||
|
||||
tally(fontFamilyTally, cs.fontFamily);
|
||||
tally(fontSizeTally, cs.fontSize);
|
||||
tally(fontWeightTally, cs.fontWeight);
|
||||
|
||||
if (cs.padding && cs.padding !== '0px') tally(paddingTally, cs.padding);
|
||||
if (cs.margin && cs.margin !== '0px') tally(marginTally, cs.margin);
|
||||
if (cs.borderRadius && cs.borderRadius !== '0px') tally(radiusTally, cs.borderRadius);
|
||||
if (cs.boxShadow && cs.boxShadow !== 'none') tally(shadowTally, cs.boxShadow);
|
||||
if (cs.backgroundImage && cs.backgroundImage.includes('gradient')) tally(gradientTally, cs.backgroundImage);
|
||||
|
||||
const op = parseFloat(cs.opacity);
|
||||
if (!isNaN(op) && op !== 1 && op !== 0) tally(opacityTally, cs.opacity);
|
||||
|
||||
if (looksLikeButton(el) && buttons.length < 5) buttons.push(snapshotComponent(el));
|
||||
else if (el.tagName === 'INPUT' && inputs.length < 5) inputs.push(snapshotComponent(el));
|
||||
else if (looksLikeCard(el) && cards.length < 5) cards.push(snapshotComponent(el));
|
||||
else if (looksLikeChip(el) && chips.length < 5) chips.push(snapshotComponent(el));
|
||||
}
|
||||
|
||||
return {
|
||||
url: location.href,
|
||||
extractedAt: new Date().toISOString(),
|
||||
truncated: all.length > MAX_SAMPLE_ELEMENTS,
|
||||
elementsScanned: limit,
|
||||
colorScheme: detectColorScheme(),
|
||||
colors: {
|
||||
textColors: topEntries(colorTally, TOP_N),
|
||||
backgroundColors: topEntries(bgTally, TOP_N),
|
||||
rootCustomProperties: getRootCustomProperties().slice(0, 40),
|
||||
},
|
||||
typography: {
|
||||
fontFamilies: topEntries(fontFamilyTally, TOP_N),
|
||||
fontSizes: topEntries(fontSizeTally, TOP_N),
|
||||
fontWeights: topEntries(fontWeightTally, TOP_N),
|
||||
},
|
||||
spacing: {
|
||||
paddings: topEntries(paddingTally, TOP_N),
|
||||
margins: topEntries(marginTally, TOP_N),
|
||||
},
|
||||
radius: topEntries(radiusTally, TOP_N),
|
||||
effects: {
|
||||
shadows: topEntries(shadowTally, TOP_N),
|
||||
gradients: topEntries(gradientTally, TOP_N),
|
||||
opacities: topEntries(opacityTally, TOP_N),
|
||||
},
|
||||
components: { buttons, inputs, cards, chips },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Formatting
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function fmtEntries(entries) {
|
||||
if (!entries.length) return ' (none found)';
|
||||
return entries.map(([v, count]) => ` ${v} (x${count})`).join('\n');
|
||||
}
|
||||
|
||||
function fmtComponent(c) {
|
||||
return (
|
||||
` <${c.tag}${c.class ? ` class="${c.class}"` : ''}>\n` +
|
||||
` background: ${c.background}\n` +
|
||||
` color: ${c.color}\n` +
|
||||
` padding: ${c.padding}\n` +
|
||||
` border-radius: ${c.borderRadius}\n` +
|
||||
` border: ${c.border}\n` +
|
||||
(c.boxShadow ? ` box-shadow: ${c.boxShadow}\n` : '') +
|
||||
` font: ${c.fontWeight} ${c.fontSize}\n`
|
||||
);
|
||||
}
|
||||
|
||||
function fmtComponents(list, label) {
|
||||
if (!list.length) return `${label}:\n (none found)`;
|
||||
return `${label}:\n` + list.map(fmtComponent).join('\n');
|
||||
}
|
||||
|
||||
function toReport(data) {
|
||||
const lines = [];
|
||||
lines.push(`UI Token Extractor — ${data.url}`);
|
||||
lines.push(`Extracted: ${data.extractedAt}`);
|
||||
lines.push(`Elements scanned: ${data.elementsScanned}${data.truncated ? ' (capped, page has more)' : ''}`);
|
||||
lines.push('');
|
||||
lines.push('=== COLOR SCHEME ===');
|
||||
lines.push(`prefers-color-scheme: dark => ${data.colorScheme.prefersDark}`);
|
||||
lines.push(`body background => ${data.colorScheme.bodyBackground}`);
|
||||
lines.push(`body appears dark => ${data.colorScheme.bodyAppearsDark}`);
|
||||
lines.push('');
|
||||
lines.push('=== COLORS: text ===');
|
||||
lines.push(fmtEntries(data.colors.textColors));
|
||||
lines.push('');
|
||||
lines.push('=== COLORS: background ===');
|
||||
lines.push(fmtEntries(data.colors.backgroundColors));
|
||||
lines.push('');
|
||||
lines.push('=== COLORS: :root custom properties (design tokens) ===');
|
||||
if (!data.colors.rootCustomProperties.length) {
|
||||
lines.push(' (none found or stylesheets not readable)');
|
||||
} else {
|
||||
data.colors.rootCustomProperties.forEach(([k, v]) => lines.push(` ${k}: ${v}`));
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('=== TYPOGRAPHY: font families ===');
|
||||
lines.push(fmtEntries(data.typography.fontFamilies));
|
||||
lines.push('');
|
||||
lines.push('=== TYPOGRAPHY: font sizes ===');
|
||||
lines.push(fmtEntries(data.typography.fontSizes));
|
||||
lines.push('');
|
||||
lines.push('=== TYPOGRAPHY: font weights ===');
|
||||
lines.push(fmtEntries(data.typography.fontWeights));
|
||||
lines.push('');
|
||||
lines.push('=== SPACING: padding values ===');
|
||||
lines.push(fmtEntries(data.spacing.paddings));
|
||||
lines.push('');
|
||||
lines.push('=== SPACING: margin values ===');
|
||||
lines.push(fmtEntries(data.spacing.margins));
|
||||
lines.push('');
|
||||
lines.push('=== BORDER RADIUS ===');
|
||||
lines.push(fmtEntries(data.radius));
|
||||
lines.push('');
|
||||
lines.push('=== EFFECTS: shadows ===');
|
||||
lines.push(fmtEntries(data.effects.shadows));
|
||||
lines.push('');
|
||||
lines.push('=== EFFECTS: gradients ===');
|
||||
lines.push(fmtEntries(data.effects.gradients));
|
||||
lines.push('');
|
||||
lines.push('=== EFFECTS: non-default opacities ===');
|
||||
lines.push(fmtEntries(data.effects.opacities));
|
||||
lines.push('');
|
||||
lines.push('=== COMPONENTS ===');
|
||||
lines.push(fmtComponents(data.components.buttons, 'Buttons'));
|
||||
lines.push('');
|
||||
lines.push(fmtComponents(data.components.inputs, 'Inputs'));
|
||||
lines.push('');
|
||||
lines.push(fmtComponents(data.components.cards, 'Cards'));
|
||||
lines.push('');
|
||||
lines.push(fmtComponents(data.components.chips, 'Chips / badges / tags'));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// UI overlay
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const STYLE = `
|
||||
#uite-trigger {
|
||||
position: fixed; bottom: 20px; right: 20px; z-index: 2147483000;
|
||||
width: 48px; height: 48px; border-radius: 50%;
|
||||
background: #1a1a1a; color: #fff; border: 2px solid #444;
|
||||
font: 20px/48px system-ui, sans-serif; text-align: center;
|
||||
cursor: pointer; box-shadow: 0 2px 10px rgba(0,0,0,.4);
|
||||
user-select: none;
|
||||
}
|
||||
#uite-trigger:hover { background: #333; }
|
||||
#uite-overlay {
|
||||
position: fixed; inset: 0; z-index: 2147483001;
|
||||
background: rgba(0,0,0,.55);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
#uite-panel {
|
||||
width: min(880px, 92vw); height: min(720px, 88vh);
|
||||
background: #161616; color: #e6e6e6; border-radius: 10px;
|
||||
border: 1px solid #333; box-shadow: 0 10px 40px rgba(0,0,0,.5);
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
font: 13px/1.5 ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
|
||||
}
|
||||
#uite-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 14px; border-bottom: 1px solid #333;
|
||||
font: 600 14px/1 system-ui, sans-serif; flex-shrink: 0;
|
||||
}
|
||||
#uite-header .uite-actions { display: flex; gap: 8px; }
|
||||
#uite-header button {
|
||||
font: 600 12px/1 system-ui, sans-serif; padding: 6px 10px;
|
||||
border-radius: 6px; border: 1px solid #444; background: #222; color: #eee;
|
||||
cursor: pointer;
|
||||
}
|
||||
#uite-header button:hover { background: #2c2c2c; }
|
||||
#uite-textarea {
|
||||
flex: 1; border: none; outline: none; resize: none;
|
||||
background: #0f0f0f; color: #d8d8d8; padding: 14px;
|
||||
white-space: pre; overflow: auto;
|
||||
}
|
||||
#uite-hint { padding: 6px 14px; font: 11px/1.4 system-ui, sans-serif; color: #888; border-top: 1px solid #262626; }
|
||||
`;
|
||||
|
||||
function injectStyle() {
|
||||
const s = document.createElement('style');
|
||||
s.textContent = STYLE;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function buildOverlay(reportText) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'uite-overlay';
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'uite-panel';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.id = 'uite-header';
|
||||
header.innerHTML = `<span>UI Token Extractor</span>`;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'uite-actions';
|
||||
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.textContent = 'Copy all';
|
||||
copyBtn.onclick = () => {
|
||||
navigator.clipboard.writeText(reportText).then(() => {
|
||||
copyBtn.textContent = 'Copied!';
|
||||
setTimeout(() => (copyBtn.textContent = 'Copy all'), 1200);
|
||||
}).catch(() => {
|
||||
// Clipboard API can fail silently in some contexts (e.g. insecure
|
||||
// origin) -- the textarea below is still fully selectable manually.
|
||||
});
|
||||
};
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.textContent = 'Close (Esc)';
|
||||
closeBtn.onclick = () => overlay.remove();
|
||||
|
||||
actions.appendChild(copyBtn);
|
||||
actions.appendChild(closeBtn);
|
||||
header.appendChild(actions);
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.id = 'uite-textarea';
|
||||
textarea.readOnly = true;
|
||||
textarea.value = reportText;
|
||||
textarea.addEventListener('click', () => textarea.select());
|
||||
|
||||
const hint = document.createElement('div');
|
||||
hint.id = 'uite-hint';
|
||||
hint.textContent = 'Click the text to select all, then Ctrl/Cmd+C. Nothing here is sent anywhere -- extraction is entirely local.';
|
||||
|
||||
panel.appendChild(header);
|
||||
panel.appendChild(textarea);
|
||||
panel.appendChild(hint);
|
||||
overlay.appendChild(panel);
|
||||
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) overlay.remove();
|
||||
});
|
||||
document.addEventListener('keydown', function escHandler(e) {
|
||||
if (e.key === 'Escape') {
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
}
|
||||
|
||||
function run() {
|
||||
const data = extract();
|
||||
const report = toReport(data);
|
||||
buildOverlay(report);
|
||||
}
|
||||
|
||||
function addTrigger() {
|
||||
const btn = document.createElement('div');
|
||||
btn.id = 'uite-trigger';
|
||||
btn.title = 'Extract UI tokens (Alt+Shift+U)';
|
||||
btn.textContent = '🎨';
|
||||
btn.addEventListener('click', run);
|
||||
document.body.appendChild(btn);
|
||||
}
|
||||
|
||||
injectStyle();
|
||||
if (document.body) addTrigger();
|
||||
else document.addEventListener('DOMContentLoaded', addTrigger);
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.altKey && e.shiftKey && (e.key === 'U' || e.key === 'u')) {
|
||||
run();
|
||||
}
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user