437 lines
16 KiB
JavaScript
437 lines
16 KiB
JavaScript
// ==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();
|
|
}
|
|
});
|
|
})();
|