Add UI Token Extractor userscript

This commit is contained in:
Claude
2026-08-17 09:40:27 +02:00
parent 0df29e6f60
commit 98d8a8a76d
3 changed files with 519 additions and 2 deletions
+21 -2
View File
@@ -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.
+62
View File
@@ -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();
}
});
})();