From 98d8a8a76d0ce66e12b822a6994b300850a33499 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 09:40:27 +0200 Subject: [PATCH] Add UI Token Extractor userscript --- README.md | 23 +- ui-token-extractor/README.md | 62 +++ ui-token-extractor/ui-token-extractor.user.js | 436 ++++++++++++++++++ 3 files changed, 519 insertions(+), 2 deletions(-) create mode 100644 ui-token-extractor/README.md create mode 100644 ui-token-extractor/ui-token-extractor.user.js diff --git a/README.md b/README.md index 3d63866..e15dfe3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,22 @@ -# userscripts +# Userscripts -Browser userscripts (Tampermonkey/Violentmonkey) — local-only, no telemetry, one script per tool \ No newline at end of file +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. diff --git a/ui-token-extractor/README.md b/ui-token-extractor/README.md new file mode 100644 index 0000000..0f4245f --- /dev/null +++ b/ui-token-extractor/README.md @@ -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. diff --git a/ui-token-extractor/ui-token-extractor.user.js b/ui-token-extractor/ui-token-extractor.user.js new file mode 100644 index 0000000..9bb1fc0 --- /dev/null +++ b/ui-token-extractor/ui-token-extractor.user.js @@ -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 = `UI Token Extractor`; + + 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(); + } + }); +})();